Compose Multiplatform 三方库 Calendar(kizitonwose)的 OpenHarmony 鸿蒙化适配实战
Compose Multiplatform 三方库 Calendar(kizitonwose)的 OpenHarmony 鸿蒙化适配实战(上游源码零修改 + 3 个 shim + 单入口 JSON 桥接 + ArkUI 四种日历视图)
库版本:kizitonwose/Calendar compose-multiplatform 2.9.1(main@0023d3e)|验证环境:Kotlin 2.2.21-1.0.0(鸿蒙定制版)|kotlinx-datetime 0.7.1-1.0.0|DevEco Studio 26.0.0|DevEco 模拟器|HarmonyOS 7.0.0(API 26)
日历几乎是每个业务 App 都绕不开的组件:酒店和机票要选入住离店区间,打卡和健身要看月度热力图,日程要有周历条,会议室预订要能跨月翻页。kizitonwose/Calendar 是 Android 与 Compose 生态里星数最高的日历库(5.6k★),它的 CMP 模块一口气提供了月历、周历、年历、热力图四种 Composable。我这次想把它带上鸿蒙,但查了 CPF-KMP-CMP 官方三方库清单(37 个已适配库)和 AtomGit 搜索,都没有它的鸿蒙版本,于是动手做了一遍。
结论先说:上游 32 个源文件一字未改,只补了 3 个十几行的 Compose shim 和 2 个鸿蒙平台 actual,上游自带的 51 个单测在 shim 之上全部通过;Kotlin/Native 编出双 ABI .so,通过一个 JSON 单入口桥给 ArkTS,模拟器上四种日历视图和 15 个接口用例全部真实跑通。
*先睹为快:DevEco 模拟器实测,月历网格由 Kotlin/Native 侧的上游算法生成,ArkUI 渲染,点选 09-22 → 09-26 区间*

一、先看清楚:这个库的"值钱部分"在哪
拿到上游代码后,我没有急着写 Gradle,而是先把 `compose-multiplatform/library/src/commonMain` 下的 41 个文件按目录分了一下类:
| 目录 | 文件数 | 内容 | 对 Compose 的依赖 |
|---|---|---|---|
core/ | 14 | CalendarDay、CalendarMonth、Week、Year 等数据模型,daysOfWeek()、firstDayOfWeekFromLocale()、日期加减扩展、Year 序列化器 | 只有 @Immutable 注解和 Locale 类 |
data/ | 7 | 月/周/年/热力图的网格生成、索引计算、范围校验、DataStore 缓存 | 只有 @Immutable |
compose/ | 20 | 四个 Composable 和对应 State | LazyRow、Modifier、Saver 等,重度依赖 |
这个分布很关键。日历组件里真正容易写错的,是"这个月第一格该显示几号、前面补几天上个月、末尾补到行尾还是补满 6 行、周首日换成周日后怎么重排、周历的起止日期如何对齐到整周、热力图跨月时列怎么拼接"。这些全在 core/ + data/ 里,只依赖 kotlinx-datetime,而 CPF-KMP-CMP 已经发布了 kotlinx-datetime 0.7.1-1.0.0 的鸿蒙切片(ohosArm64/ohosX64 都有,我在 eazytec 仓库的 maven-metadata 里确认过)。compose/ 那 20 个文件做的事情,本质上是把 7 列网格摆出来,ArkUI 的 Row 和 Grid 就能胜任。
所以路线就定了:算法原样复用,UI 用 ArkUI 重做。这和社区里 compose-icons、MaterialKolor、qrose 等 CMP 库的鸿蒙化思路一致。我也考虑过把 compose/ 整体编进鸿蒙,但上游用的是 Kotlin 2.3.0 + CMP 1.10.0,社区基线是 Kotlin 2.2.21 + CMP 1.9.2,Compose 编译器与 Kotlin 版本强绑定,降版本要逐个排查 1.10 新增的 API,收益和风险不成比例。
二、整体链路
ArkTS (Index.ets / CalendarApi.ets)
│ import calendarNative from 'libcalendar.so'
▼ calendarNative.call('{"op":"monthRange", ...}')
libcalendar.so ← C++ NAPI 薄层:字符串进、字符串出
│ extern "C" OhosCalendarCall / OhosCalendarFree
▼
libohoscalendar.so ← Kotlin/Native(ohosArm64 / ohosX64)
│ CalendarBridge:解析 op → 调用门面层 → JSON 序列化
▼
CalendarEngine(门面) → 上游 core/ + data/(零修改)
▼
kotlinx-datetime 0.7.1-1.0.0 / kotlinx-serialization 1.9.1-1.0.0(鸿蒙 klib)
工程结构:
calendar-ohos-demo/
├── calendar/ # 库模块
│ └── src/commonMain/kotlin/
│ ├── com/kizitonwose/calendar/core/ ← 上游原样
│ ├── com/kizitonwose/calendar/data/ ← 上游原样
│ ├── com/kizitonwose/calendar/ohos/CalendarEngine.kt ← 新增门面
│ └── androidx/compose/… ← 新增 3 个 shim
├── example/nativeApp/ # 产出 libohoscalendar.so
├── example/ohosApp/ # DevEco 工程
├── scripts/ # build-so / copy-so / shot / layout
└── docs/images/ # 本文所有截图,shot.ps1 一键复现
三、适配过程
3.1 Gradle:复用本地已跑通的工具链
settings.gradle.kts 照旧把 eazytec Nexus 放在 pluginManagement 第一位,Kotlin 插件钉 2.2.21-1.0.0。库模块比之前做的 compose-icons 多了两个依赖,版本号全部按 CPF-KMP-CMP 清单里的推荐值:
// calendar/build.gradle.kts
plugins {
kotlin("multiplatform")
kotlin("plugin.serialization")
}
kotlin {
ohosArm64()
ohosX64()
jvm() // 本地快速验证 + 跑上游 commonTest
applyDefaultHierarchyTemplate()
sourceSets {
commonMain.dependencies {
// CPF-KMP-CMP 官方推荐的鸿蒙切片版本
api("org.jetbrains.kotlinx:kotlinx-datetime:0.7.1-1.0.0")
api("org.jetbrains.kotlinx:kotlinx-serialization-core:1.9.1-1.0.0")
}
commonTest.dependencies {
implementation(kotlin("test"))
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.1-1.0.0")
}
all {
languageSettings.optIn("kotlin.time.ExperimentalTime")
}
}
compilerOptions {
// 上游 firstDayOfWeekFromLocale / log 是 expect fun,实际实现在各平台源集
freeCompilerArgs.add("-Xexpect-actual-classes")
}
}
保留 jvm() target 是我这几次适配下来的固定习惯:先在 JVM 上把上游单测跑绿,再去编 .so,出了问题能立刻分清是 shim 写错了还是鸿蒙工具链的问题。
3.2 三个 shim:让上游文件一个字都不用改
`core/` 和 `data/` 对 Compose 只有三处引用,我全部用同包名的最小实现顶上。
@Immutable。8 个数据类标了它,这只是给 Compose 编译器看的稳定性提示,运行时没有任何作用,shim 就是一个空注解:
package androidx.compose.runtime
@MustBeDocumented
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.BINARY)
public annotation class Immutable
Locale。firstDayOfWeekFromLocale(locale: Locale = Locale.current) 需要它。我翻了上游 Android、iOS、web 三个平台的 actual,发现只用到 current、toLanguageTag()、region 三个成员,于是按 BCP-47 语言标签实现这三个成员,再加一个 setCurrentLanguageTag(),让 ArkTS 把系统区域注入进来:
package androidx.compose.ui.text.intl
public class Locale(languageTag: String) {
private val tag: String = languageTag.replace('_', '-')
public val region: String
get() = tag.split('-').drop(1).firstOrNull { part ->
(part.length == 2 && part.all { it.isLetter() }) ||
(part.length == 3 && part.all { it.isDigit() })
}?.uppercase() ?: ""
public fun toLanguageTag(): String = tag
public companion object {
private var currentTag: String = "en-US"
public val current: Locale get() = Locale(currentTag)
public fun setCurrentLanguageTag(languageTag: String) {
if (languageTag.isNotBlank()) currentTag = languageTag
}
}
}
region 那段判断是为了正确处理 zh-Hans-CN 这种带文字子标签的写法:Hans 是 4 个字母,不能被当成区域。
String.toLowerCase(Locale)。这个是第三步才冒出来的,下一节讲。
3.3 鸿蒙 actual:直接复用上游自己的 CLDR 表
firstDayOfWeekFromLocale 是 expect 函数。Android/JVM 用 java.time.WeekFields,iOS 用 NSCalendar,web 用浏览器的 Intl.Locale.getWeekInfo()。Kotlin/Native 的 ohos 目标这三样都没有。
一开始我打算自己整理一份"国家 → 周首日"的表,翻 web 源集时发现上游已经带了:webMain/FirstDayFromMap.kt 是 Firefox 不支持 getWeekInfo() 时的兜底,里面是从 Unicode CLDR weekData.json 抄来的 150 多个区域。于是我把这个文件原样挪进 commonMain,鸿蒙 actual 只写一行:
public actual fun firstDayOfWeekFromLocale(locale: Locale): DayOfWeek = firstDayFromMap(locale)
挪过来之后编译报了 toLowerCase 找不到,原来这个文件用 it.name.toLowerCase(enLocale).take(3) 把 MONDAY 转成 mon,引用的是 androidx.compose.ui.text.toLowerCase。这就是第三个 shim 的来历,只涉及 ASCII,直接转发给标准库 lowercase()。
另一个 expect 是 log(tag, message),上游在滚动越界时用它打告警。Kotlin/Native 这边没有现成的 hilog 绑定,我让它把最后一条日志存在内存里,桥接层读出来随 JSON 一起返回,再由 C++ 层打进 hilog。
写完这些后跑 :calendar:jvmTest,上游 7 个测试类共 51 个用例全部通过,说明 shim 没有改变任何行为。我又逐个比对了拷进来的 32 个上游文件(core/ 14 + data/ 7 + web 的 FirstDayFromMap.kt + jvm actual 2 + 测试 7 + 测试工具 1)的哈希,和上游仓库完全一致。
3.4 门面层:把 State 里的非 UI 部分搬出来
上游 data/ 的函数都是 internal,只被 compose/ 下的四个 State 调用。我在同一个模块里加了 CalendarEngine.kt,照着 State 的写法组装出四个公开类:
public class MonthCalendarEngine(
public val startMonth: YearMonth,
public val endMonth: YearMonth,
public val firstDayOfWeek: DayOfWeek,
public val outDateStyle: OutDateStyle,
) {
init { checkRange(startMonth, endMonth) }
private val store = DataStore { offset ->
getCalendarMonthData(startMonth, offset, firstDayOfWeek, outDateStyle).calendarMonth
}
/** 等价于 CalendarState.calendarInfo.indexCount */
public val monthCount: Int = getMonthIndicesCount(startMonth, endMonth)
public fun monthAt(index: Int): CalendarMonth = store[index]
/** 等价于 CalendarState.getScrollIndex(month) */
public fun indexOf(month: YearMonth): Int? {
if (month !in startMonth..endMonth) {
log("CalendarState", "Attempting to scroll out of range: $month")
return null
}
return getMonthIndex(startMonth, month)
}
}
周历、年历、热力图三个类同理。这里刻意没有写任何日期计算,连越界日志的文案都和上游 State 保持一致,目的是让"鸿蒙上的行为"和"Android 上的行为"可以直接对照。
3.5 桥接:一个 C 函数走天下
之前做 compose-icons 时我为每个接口单独导出一个 C 函数,C++ 层写了不少参数解析。日历的接口更多、返回结构还是"月 → 周 → 日"三层嵌套,照老办法写会很啰嗦。这次改成单入口:
@CName("OhosCalendarCall")
public fun ohosCalendarCall(request: CPointer<ByteVar>?): CPointer<ByteVar> {
val req = request?.toKString() ?: "{}"
return toCString(CalendarBridge.handle(req))
}
@CName("OhosCalendarFree")
public fun ohosCalendarFree(ptr: CPointer<ByteVar>?) {
ptr?.let { nativeHeap.free(it.rawValue) }
}
请求是 JSON,用 op 字段区分接口,日期一律 ISO-8601 字符串,枚举用名字。CalendarBridge 放在 commonMain,用鸿蒙版 kotlinx-serialization-json 解析和生成,这样我在 JVM 上又补了 6 个桥接测试(比如 2019-05 周一为首日时首格必须是 04-29、EndOfGrid 必须 6 行、ar-EG 的周首日必须是周六)。异常一律在 Kotlin 侧捕获,包成 {"error":"..."} 返回,不让它穿越语言边界。
C++ 层因此只剩字符串搬运:
static napi_value NapiCall(napi_env env, napi_callback_info info)
{
// ... 取出 args[0]
size_t len = 0;
napi_get_value_string_utf8(env, args[0], nullptr, 0, &len);
std::vector<char> buf(len + 1, '\0');
napi_get_value_string_utf8(env, args[0], buf.data(), len + 1, &len);
const std::string req(buf.data(), len);
const char *raw = OhosCalendarCall(req.c_str());
OH_LOG_INFO(LOG_APP, "call req=%{public}.80s resp.len=%{public}zu head=%{public}.80s",
req.c_str(), std::char_traits<char>::length(raw), raw);
napi_value out = nullptr;
napi_create_string_utf8(env, raw, NAPI_AUTO_LENGTH, &out);
OhosCalendarFree(raw); // 谁分配谁释放
return out;
}
ArkTS 侧封装成 CalendarApi,启动时先把系统区域注入 Kotlin。这里有个必须踩的细节:CLDR 那张表是按区域(region)查的,所以注入的标签必须带区域,而 i18n.System.getSystemLocale() 在模拟器上只返回 zh-Hans,查不到区域就会回落成默认的周一(而且这个接口自 API 20 起已废弃)。改用 getSystemLocaleInstance() 直接拿到 Intl.Locale:
import calendarNative from 'libcalendar.so'
import { i18n } from '@kit.LocalizationKit'
static systemLocaleTag(): string {
let tag = 'en-US'
try {
const locale: Intl.Locale = i18n.System.getSystemLocaleInstance()
tag = locale.toString() // zh-Hans-CN
if (locale.region === undefined || locale.region.length === 0) {
tag = `${locale.language}-${locale.script}-${locale.maximize().region}`
}
} catch (e) { /* 回落 en-US */ }
return tag
}
static initLocale(): LocaleJson {
const req: Req = { 'op': 'setLocale', 'tag': CalendarApi.systemLocaleTag() }
return JSON.parse(calendarNative.call(JSON.stringify(req))) as LocaleJson
}
改完之后 setLocale 的返回从 {"region":"","firstDayOfWeek":"MONDAY"} 变成 {"region":"CN","firstDayOfWeek":"MONDAY"},才算真正命中了 CLDR 表而不是走默认值。
3.6 编译与部署
# 1. JVM 单测 + 双 ABI release .so
.\gradlew.bat :calendar:jvmTest :example:nativeApp:jvmTest `
:example:nativeApp:linkReleaseSharedOhosArm64 :example:nativeApp:linkReleaseSharedOhosX64
# 2. 拷到 entry/libs/<abi>:libohoscalendar.so + SDK 自带 BiSheng 的 libc++_shared.so
# 3. hvigor 打包(JAVA_HOME 指向 DevEco jbr,DEVECO_SDK_HOME 指向 sdk 根目录)
hvigorw.bat assembleHap --mode module -p product=default --no-daemon
# 4. 安装启动
hdc -t 127.0.0.1:5555 install -r entry\build\default\outputs\default\entry-default-unsigned.hap
hdc -t 127.0.0.1:5555 shell aa start -a EntryAbility -b com.kizitonwose.calendar.ohosdemo
首次编译需要从 eazytec 拉 kotlinx-datetime 的鸿蒙 klib,用时 2 分 25 秒;.so 产物 arm64 4.14 MB、x86_64 4.05 MB。
四、踩坑记录
| 坑 | 现象 | 解法 |
|---|---|---|
libc++_shared.so 选错来源 | ~/.konan 里 cpf-llvm 附带的版本有 1.85 MB,和之前跑通的 kmpalette 用的不一样 | 按 MD5 反查,kmpalette 用的是 sdk/default/hms/native/BiSheng/lib/<triple>/libc++_shared.so,统一改用这个 |
| NAPI 编译报 “would lose const qualifier” | NDK 默认 C++ 标准下 std::string::data() 返回 const char* | 先读进 std::vector<char> |
ArkTS 报 Property 'tabIndex' ... not assignable | tabIndex 是组件自带的属性方法名,不能当 @State 变量名 | 改名 curTab |
| ArkTS 严格模式拒绝无类型对象字面量 | arkts-no-untyped-obj-literals | 请求统一声明为 Record<string, string | number>,ForEach 回调参数用 interface |
模拟器系统区域是 zh-Hans,没有区域 | getSystemLocale() 只返回语言 + 文字,且 API 20 起已废弃;CLDR 表按 region 查,查不到就回落周一 | 改用 getSystemLocaleInstance() 取 Intl.Locale,toString() 得到 zh-Hans-CN,区域仍缺失时用 maximize() 补 |
| 切换"首日"后网格不重排,而且整页从此不再响应点击 | 两件事叠在一起:一是 ArkUI 的 Select 浮层关闭后偶发残留一层不可见遮罩,把后续所有触摸事件都吞掉了(连切 Tab 都没反应,日志里也再没有新的 NAPI 调用);二是 @Builder 多参数按值传递,状态变化不会刷新它内部 UI | 选项栏换成 Button 循环点击(首日:一 → 日 → 六、EndOfRow ⇄ EndOfGrid),并把 weekHeader / monthGrid / dayCell 改成只收一个参数对象,走 ArkUI 的"按引用传递" |
| 定位点击坐标全靠目测,屡屡点空 | 截图里看到的像素和真机坐标对不上,估算 Tab 栏 y 坐标连续错三次 | 用 hdc shell uitest dumpLayout 导出节点树,直接读每个节点的 bounds,再据此写出 shot.ps1 里的常量表 |
uitest dumpLayout 的 JSON 用 ConvertFrom-Json 解析失败 | 它输出的是"宽松 JSON",空字段写成 "",不是合法 JSON | 坐标提取脚本改用正则从文本里抽 bounds / text / type |
| PowerShell 脚本里的中文报"字符串缺少终止符" | Windows PowerShell 5 把无 BOM 的 UTF-8 当 GBK 读 | 脚本存成 UTF-8 with BOM |
五、运行效果(DevEco 模拟器实测)
冷启动时 hilog 里能看到 6 次真实的 NAPI 调用,单次 1–4 ms,请求和响应都打在 CalendarNapi 这个 tag 上,ArkTS 侧另有一条 CalendarDemo 记录耗时:
CalendarNapi: call req={"op":"setLocale","tag":"zh-Hans-CN"} resp.len=149 head={"locale":"zh-Hans-CN","region":"CN","firstDayOfWeek":"MONDAY","daysOfWeek":["MO
CalendarNapi: call req={"op":"today"} resp.len=57 head={"date":"2026-09-29","yearMonth":"2026-09","year":"2026"}
CalendarNapi: call req={"op":"monthRange","start":"2025-09","end":"2027-09","firstDayOfWeek":"MONDAY",... resp.len=2121 head={"monthCount":25,"index":12,"indexOfTarget":12,...
CalendarNapi: call req={"op":"weekRange","start":"2025-09-01","end":"2027-09-28","firstDayOfWeek":"MOND... resp.len=639 head={"startDateAdjusted":"2025-09-01","endDateAdjusted":"2027-10-03","weekCount":109
CalendarNapi: call req={"op":"year","year":"2026","firstDayOfWeek":"MONDAY"} resp.len=25963 ...
CalendarNapi: call req={"op":"heatMap","start":"2026-04","end":"2026-09","firstDayOfWeek":"MONDAY"} resp.len=10473 ...
5.1 月历:CalendarState + 区间选择
范围是前后各 12 个月共 25 页,indexOf(2026-09) 返回 12,所以标题显示"第 13 / 25 页"。页面顶部两个 Button 用来切配置,点一次日期设起点、再点一次设终点,中间的日期填浅色背景,这是酒店预订最常见的交互:
*左:默认状态(首日周一、EndOfRow,5 行,红点标今天);右:点选 09-22 → 09-26 区间*
月历网格最容易出错的就是"周首日"和"补位方式"这两个正交维度,我把它们做成了可切换的,用来肉眼确认算法正确:
| 配置 | 表头 | 首行 | 行数 |
|---|---|---|---|
| 首日周一 / EndOfRow | 一二三四五六日 | 08-31 ~ 09-06 | 5 |
| 首日周日 / EndOfRow | 日一二三四五六 | 08-30 ~ 09-05 | 5 |
| 首日周六 / EndOfRow | 六日一二三四五 | 08-29 ~ 09-04 | 5 |
| 首日周一 / EndOfGrid | 一二三四五六日 | 08-31 ~ 09-06 | 6 |
*首日切周日:整个网格右移一列,首格从 08-31 变 08-30;切周六:再右移一列,首格 08-29*
*`OutDateStyle.EndOfGrid`:不再补到行尾,而是补满 6 行,末行 10-05 ~ 10-11 整行置灰*
这两个选项在上游是 State 的构造参数,鸿蒙这边直接透传给 getCalendarMonthData,所以切配置就是重新调一次接口拿新网格,Kotlin 侧不需要任何额外分支。
5.2 周历:WeekCalendarState
周历范围设为 2025-09-01 ~ 2027-09-28,上游的 getWeekCalendarAdjustedRange 会把终点对齐到整周,变成 2027-10-03,共 109 周;今天所在的是第 57 周。范围外的日期(InDate/OutDate)置灰不可选:
*第 57 / 109 周:09-28 ~ 10-04,下方显示 adjustDateRange 的结果*
5.3 年历:YearCalendarState
一次请求返回 12 个月的完整网格(约 26 KB JSON),ArkUI 用 3 列 Grid 摆成 12 宫格,标题处显示 Year.isLeap() 和 Year.length():
*2026 年:平年 · 365 天,每格独立补位*
5.4 热力图:HeatMapCalendarState
热力图和月历的网格规则不同:除第一个月外,后续月份的 inDays 是负数,意思是这个月的第一周与上个月最后一周共用一列,这样多个月拼起来才是连续的周列。这段逻辑在上游 getHeatMapCalendarMonthData 里,我直接用;活跃度是按日期算的演示数据:
*近 6 个月活跃度,纵轴为周一到周日,可横向滚动*
5.5 API 验收页:每个接口一张图
API 页一键执行 15 个用例,直接展示 Kotlin 侧返回的原始 JSON,包括三条异常路径:
*`firstDayOfWeekFromLocale`:zh-Hans-CN → MONDAY(region=CN)、en-US → SUNDAY、ar-EG → SATURDAY;`now()` 系列;日期加减(2024-01-31 plusMonths(1) = 2024-02-29,月末钳制)*
*`checkRange` 异常;以及 4 种"首日 × 补位方式"组合的网格签名*
*每行给出 `rows` / `cells` / `firstRow` / `lastRow` / `firstRowPositions`(I=InDate、M=MonthDate)/ `daysOfWeek`,避免 42 个日期的长 JSON 刷屏*
| 接口 | 模拟器返回 |
|---|---|
firstDayOfWeekFromLocale ×3 | MONDAY(CN)/ SUNDAY(US)/ SATURDAY(EG) |
LocalDate.plusMonths(1)(2024-01-31) | 2024-02-29 |
Year.onDay(60)(2024) | 2024-02-29 |
Year.onDay(366)(2025) | Invalid dayOfYear value '366' for year '2025 |
YearIso8601Serializer / YearComponentSerializer | "2024" / {"year":2024} |
越界 getScrollIndex | CalendarState : Attempting to scroll out of range: 2027-03 |
checkRange | IllegalStateException: start: 2026-10 is greater than end: 2026-01 |
网格签名 2026-09 / SUNDAY / EndOfRow | rows=5, firstRow=2026-08-30…2026-09-05, daysOfWeek=日一二三四五六 |
网格签名 2026-09 / MONDAY / EndOfGrid | rows=6, cells=42, firstRowPositions=IMMMMMM |
六、已适配 / 未适配 / 已知限制
已适配:月历、周历、年历、热力图四类网格数据;OutDateStyle 两种模式;任意周首日;分页总数与目标索引;firstDayOfWeekFromLocale / daysOfWeek;now() 系列;日期加减扩展;Year 的闰年、天数、onDay 与两种序列化器;范围校验与越界日志。
未适配:compose/ 下的 Composable 和 State 的滚动、动画 API(scrollToMonth、animateScrollToWeek 等)。鸿蒙端 UI 用 ArkUI 实现,索引计算仍然走上游。
已知限制:
firstDayOfWeekFromLocale在鸿蒙上走 CLDR 静态表(按 region 查),不读取用户在系统里设置的"每周第一天";- 每次调用是一次 JSON 往返,年历约 26 KB / 4 ms,日历场景够用,长列表可以按
index分页请求; - Demo 的配置切换用
Button循环点击而不是 ArkUISelect(原因见踩坑记录),这只影响示例页的交互形式,不影响库能力; - 只在 x86_64 模拟器上完成了运行验证,arm64
.so已编译并打进 HAP,尚未在真机上跑。
七、FAQ
Q1:为什么不直接在鸿蒙上跑上游的 Composable?
上游是 Kotlin 2.3.0 + CMP 1.10.0,社区鸿蒙基线是 Kotlin 2.2.21 + CMP 1.9.2。Compose 编译器插件和 Kotlin 版本绑定,降级要排查一批 1.10 新 API;而日历 UI 本身就是 7 列网格,ArkUI 表达起来不费力。把算法原样复用、UI 用 ArkUI 重做,改动最小、最容易验收。
Q2:shim 会不会悄悄改变上游行为?
@Immutable 本来就没有运行时语义;Locale 只实现了上游用到的三个成员。判断依据有两条:上游 51 个单测在 shim 之上全部通过;桥接测试在 JVM 上跑的是上游 java.time.WeekFields 版 actual,ar-EG、en-US、zh-Hans-CN 三个区域的周首日结果,与鸿蒙上 CLDR 表版 actual 的返回完全一致。
Q3:为什么用 JSON 单入口,而不是每个接口一个 C 函数?
日历接口参数多、返回是三层嵌套,一对一导出的话 C++ 层要写大量参数解析和结构体转换。单入口让 C++ 只负责搬字符串,所有解析都在 Kotlin 的 commonMain 里,能在 JVM 上直接单测。代价是一次序列化,实测年历 26 KB 也只要 4 ms。
Q5:切配置之后网格不刷新、页面还失去响应,是库的问题吗?
不是。这是两个 ArkUI 侧的坑叠在一起:Select 浮层关闭后残留的不可见遮罩吞掉了后续所有触摸事件(表现为整页"死"了,连切 Tab 都没反应,而且 hilog 里确实不再有新的 NAPI 调用,可以据此和库本身的卡死区分开);以及 @Builder 多参数按值传递不随状态刷新。改成 Button + 单参数对象即可,Kotlin 侧一行都不用动。
Q4:库本身有问题怎么提 Issue / PR?
这次适配没有发现上游缺陷,鸿蒙侧的改动全在新增文件里。如果后续发现问题:上游算法问题到 kizitonwose/Calendar 的 Issues 提交,附上最小复现(可以直接用本工程 JVM 单测的写法);鸿蒙适配层的问题,在适配仓库推到 AtomGit 后到对应仓库的 Issue / PR 页提交。
八、总结
这次适配把我之前的"纯算法 CMP 库"经验往前推了一步:上游不是纯算法库,而是一个带着 20 个 Composable 的 UI 组件库。关键在于先按目录判断"值钱的部分"在哪里,发现数据层对 Compose 的依赖只剩注解和 Locale,就可以用同包名 shim 把上游源码完整保住。遇到平台 expect 时,优先去上游其他平台的源集里找现成实现,这次 web 源集里的 CLDR 表就是现成的答案。
- 上游库:https://github.com/kizitonwose/Calendar
- 鸿蒙定制仓库:https://maven.eazytec-cloud.com/nexus/repository/maven-public/
- CPF-KMP-CMP 三方库清单:https://atomgit.com/CPF-KMP-CMP/docs
- OpenHarmony 三方库社区:https://atomgit.com/oh-tpc
- 欢迎加入 KMP&CMP 鸿蒙社区:https://atomgit.com/CPF-KMP-CMP
- 本项目适配仓库:https://atomgit.com/oh-tpc/kizitonwose
更多推荐



所有评论(0)