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/14CalendarDay、CalendarMonth、Week、Year 等数据模型,daysOfWeek()、firstDayOfWeekFromLocale()、日期加减扩展、Year 序列化器只有 @Immutable 注解和 Locale 类
data/7月/周/年/热力图的网格生成、索引计算、范围校验、DataStore 缓存只有 @Immutable
compose/20四个 Composable 和对应 StateLazyRow、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 assignabletabIndex 是组件自带的属性方法名,不能当 @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-065
首日周日 / EndOfRow日一二三四五六08-30 ~ 09-055
首日周六 / EndOfRow六日一二三四五08-29 ~ 09-045
首日周一 / EndOfGrid一二三四五六日08-31 ~ 09-066
首日切周日 首日切周六 *首日切周日:整个网格右移一列,首格从 08-31 变 08-30;切周六:再右移一列,首格 08-29* EndOfGrid *`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,包括三条异常路径:

API 用例 1 *`firstDayOfWeekFromLocale`:zh-Hans-CN → MONDAY(region=CN)、en-US → SUNDAY、ar-EG → SATURDAY;`now()` 系列;日期加减(2024-01-31 plusMonths(1) = 2024-02-29,月末钳制)* API 用例 2 *`checkRange` 异常;以及 4 种"首日 × 补位方式"组合的网格签名* API 用例 3 *每行给出 `rows` / `cells` / `firstRow` / `lastRow` / `firstRowPositions`(I=InDate、M=MonthDate)/ `daysOfWeek`,避免 42 个日期的长 JSON 刷屏*
接口模拟器返回
firstDayOfWeekFromLocale ×3MONDAY(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}
越界 getScrollIndexCalendarState : Attempting to scroll out of range: 2027-03
checkRangeIllegalStateException: start: 2026-10 is greater than end: 2026-01
网格签名 2026-09 / SUNDAY / EndOfRowrows=5, firstRow=2026-08-30…2026-09-05, daysOfWeek=日一二三四五六
网格签名 2026-09 / MONDAY / EndOfGridrows=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 循环点击而不是 ArkUI Select(原因见踩坑记录),这只影响示例页的交互形式,不影响库能力;
  • 只在 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 表就是现成的答案。


Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐