本文记录 Compose-Pickers 接入 OpenHarmony 的完整过程,覆盖约束感知滚轮选择器库的盘点、鸿蒙工具链选型、约束内核提取、ohosArm64 / ohosX64 目标、Kotlin/Native 动态库、C ABI/N-API 桥接、ArkTS 自绘滚轮真机页面、HAP 构建和真机验收。

Compose-Pickers 是基于 Compose Multiplatform 的约束感知滚轮选择器库:一个通用 WheelPicker<T> 加上时间 / 日期 / 年月 / 日期区间 / 时长预设,多列联动时始终保持一个合法的逻辑选中值——item 列表随闭区间约束过滤、天数随年月联动钳制、12/24 小时自动换算、列停稳后把非法组合修复成最近的合法值。本次适配属于社区早期路线(qrose / haze / kmp-wheel-picker / compose-datetime-wheel-picker 的"零依赖内核提取"):社区 Maven 上确有 kotlinx-datetime 的 ohos 制品,但它们携带 Kotlin 2.4.20 元数据、HarmonyOS Kotlin 2.2.21-1.0.0 编译器读不了,因此Compose UI 不编译(原样移入 composeMain 休眠),约束决策内核(item 生成 / 过滤 / 钳制 / 修复 / 归一化 / 校验)被提取为零依赖纯 Kotlin 编译成 OpenHarmony KLIB,历法数学手写,ArkTS 自绘滚轮作为验收宿主。

项目地址: AtomGit/oh-tpc/Compose-Pickers.git

上游仓库: kez-lab/Compose-Pickers

开发工具: 华为云码道

环境安装: 环境安装指南

一、背景

1.1 为什么做 OpenHarmony 平台 KMP/CMP 适配

Compose-Pickers 为 Compose Multiplatform 应用提供"多列联动选择"这一件事的完整答案:WheelPicker(items, selectedItem) 一行起滚轮;TimePicker / DatePicker / YearMonthPicker / DateRangePicker / DurationPicker 五个预设各自持有可保存的逻辑状态;*PickerItems + *PickerConstraints 把可选值与闭区间约束建模成不可变配置;列与列之间的依赖(年→月→日、时→分、12 小时的 AM/PM)由 repaired*After 系列函数在一个逻辑值上修复,coerce* 系列负责把应用侧的越界值归一化成最近合法值,contains 系列做成员校验。KMP 项目把这些能力放在 commonMain,由 Android、iOS、桌面、Web 共享。OpenHarmony 应用同样需要这套选择语义,但上游构建(Kotlin 2.4.10 / Gradle 9.7 / AGP 9.3.1 / Compose 1.11.1)不会生成 OpenHarmony 变体。

如果只把示例页面改成 ArkTS,页面可以画出滚轮,却无法证明约束内核在 OpenHarmony ARM64 设备上完成编译、链接和执行。适配需要同时验证约束内核、历法数学、Native 动态库、C ABI、N-API、ArkTS 滚轮交互和 HAP 交付链路。

障碍具体问题
目标缺失必须增加 ohosArm64() / ohosX64() 才能生成 OpenHarmony KLIB。
工具链不一致上游 Kotlin 2.4.10 / Gradle 9.7.0 / AGP 9.3.1 与鸿蒙工具链无法共存于一个 Gradle 构建;鸿蒙 CMP 制品线只有 1.9.2。
依赖矩阵内核类型 LocalDate / LocalTime / Month 来自 kotlinx-datetime,其 ohos 制品元数据 2.4.0,Kotlin 2.2.21 只能读到 2.2.x;ImmutableList 来自 kotlinx-collections-immutable,同样没有可解析的 ohos 制品。
语言边界不同ArkTS 不能直接持有 Kotlin 对象,需要 C ABI、C++ N-API 和 JSON。
状态语义跨不了桥remember*State 的 saveable 恢复、mutableStateOf 的快照订阅是 Compose Runtime 概念,C ABI 只能同步一来一回。
交付链路复杂.so、CMake、N-API、HAP、签名和 hdc 都要单独验收。

因此,本项目把边界放在:约束内核的零依赖提取与 ohos 编译、根构建替换(cmp_qrose 模式)和 C ABI/N-API 桥接层。选择语义完全由上游逻辑的移植版负责,ArkTS 只负责调用 Native、解析 JSON 和渲染滚轮。

1.2 库提供的能力

Demo 提供五个真实的选择会话,对应桥接的全部入口:

  • 解析(resolve):传入 kind、约束与当前选择,内核先 coerce* 归一化,再返回每一列经约束过滤后的可选行(rows,即 Compose 渲染前从 selectable* 拿到的同一份列内容);
  • 提交(commit):一列滚轮停稳后提交单列变化,内核走 repaired*After 把依赖列修复成最近合法值(如 1 月 31 日滚到 2 月自动钳到 29 日);
  • 归一化(coerce):把应用侧越界值(如 2026-02-31)钳到最近合法 selection;
  • 校验(contains):membership 检查,返回布尔;
  • 内核自检(selftest):设备上现场运行 9 组内核断言(列过滤、AM/PM 修复、闰年天数、月份切换钳日、年月修复、时长修复与归一化),页头显示通过数。

响应包含:

字段作用
ok操作是否成功,失败时 error 带原文。
rows[{key, items:[{text, value}]}],列顺序即渲染顺序。
selection修复 / 归一化后的逻辑选中值(时间附 displayHour / period)。
changedcommit 时选中值是否发生变化(非法列值被拒绝时为 false)。
passed / failed / casesselftest 的逐用例结果。

内核侧完整能力(约束构造期校验、等距取小、整值距离比较、DateRange 区间运算、12/24 小时换算)全部随编译进入 .so,JVM 侧保留 40 项测试交叉验证(38 项内核语义 + 2 项桥契约),证明 ohos .so 里执行的选择逻辑没有漂移。

1.3 实现适配

维度要求
代码复用决策逻辑(PickerItems 四件套、约束、repaired*After、coerce*、contains)从上游 commonMain 逐行移植(只替换值类型),控制流零改动;Compose UI / 状态容器 / 样式语义原样移入 composeMain 不编译。
平台目标根构建直接声明 ohosArm64 + ohosX64,保留 jvm() 作交叉验证,库模块出 KLIB,example/nativeApp 出双 ABI 动态库。
构建隔离根构建替换为极简 ohos 构建(cmp_qrose 模式);sample / benchmark / screenshot-tests 等上游工程留盘不进构建。
桥接稳定使用 2 个 C ABI 和 UTF-8 JSON,不传递 Kotlin 对象地址;异常永远不跨 FFI。
依赖纪律commonMain 零第三方依赖:kotlinx-datetime 不可用即手写历法(daysInMonth 从上游逐字复制、epoch-day 用标准 civil-days 算法);kotlin.time.Duration 是标准库,ohos 可用,原样保留。
UI 完整单页五 Tab 覆盖全部桥入口:时间 / 日期 / 年月 / 时长 / 通用+自检,每次桥调用日志留痕。
仓库规范项目说明、文章、效果图和源码链接统一使用 AtomGit。

本项目的 ArkTS 页面不是 Compose Multiplatform UI 实现。滚轮"形态"(中心高亮条、文字缩放渐隐、吸附动画)由 ArkTS 自绘复刻;“语义”(显示什么、修到哪)全部出自 Kotlin 内核——过桥的只有语义,不过桥的只有形态。

二、实现路线图

第 1 阶段:项目初始化     ── 盘点模块与依赖边界、确认 ohos 制品线不可用、确定交付物
第 2 阶段:目标与依赖打通 ── 根构建替换、eazytec Maven 置前、Kotlin 2.2.21-1.0.0 钉版、Gradle 8.14.1
第 3 阶段:内核提取       ── 手写历法、PickerTime/PickerDate 值类型、约束/修复/coerce 逐行移植、列枚举下沉
第 4 阶段:原生桥接       ── @CName C ABI、C++ N-API、参数校验和内存释放
第 5 阶段:ArkUI 宿主     ── 自绘滚轮、resolve/commit 双跳、约束切换 chips、操作日志和版本握手
第 6 阶段:示例与验证     ── HAP、签名、设备安装、真机运行和效果图

三、逐步实现过程

第 1 阶段:项目初始化

1.1 工程边界
pickers/src/commonMain/            约束内核(进 ohos 构建,jvm + ohosArm64 + ohosX64,零依赖)
pickers/src/composeMain/           上游 Compose UI 原样存放(不编译,等 CMP for ohos 对齐后接回)
pickers/src/commonTest/            内核 JVM 交叉验证 + 桥契约测试(40 项)
pickers/src/composeTest/           上游 commonTest 原样存放(不编译)
sample/ benchmark/ iosApp/ ...     上游示例与基准工程,留盘不进 settings
example/nativeApp/                 Kotlin/Native 动态库(Bridge + MiniJson)
example/harmonyApp/                DevEco Stage、CMake N-API 和 ArkTS 页面
README.OpenHarmony.md / _CN.md     鸿蒙适配说明(中英双份)
README.upstream.md                 上游原文保存

构建拓扑走 cmp_qrose 模式(根构建即 ohos 构建):settings.gradle.kts 只 include :pickers 与 :example:nativeApp,rootProject.name = "ohos_compose_pickers"。上游的 compose / android-library / vanniktech / dokka / abi-validation 插件、AGP classpath、Maven Central 发布段全部随根构建替换剥离——本 fork 不发 Maven Central。

1.2 工具链矩阵
项目配置
Kotlin MultiplatformHarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven)
JDK21(org.gradle.java.home 指向 Adoptium 21.0.12)
OpenHarmony 目标ohosArm64 + ohosX64(jvm() 保留作交叉验证)
Gradle8.14.1(wrapper 替换;2.2.21 线经验值,不沿用上游 9.7.0)
第三方依赖无(commonMain 零依赖;kotlin.time.Duration 走标准库)
DevEco product5.1.0(18) 兼容
DevEco Studio5.1.0 Release
ABIarm64-v8a + x86_64
Bundleorg.terminator.ohos.composepickers
export JAVA_HOME="/path/to/jdk-21"
java -version
1.3 源码依赖度盘点

对上游 commonMain 的 26 个 Kotlin 文件逐一 grep androidx.compose,盘点结果:

文件组依赖形态处置
Picker.kt、PickerDefaults.kt、样式 / 语义 / 格式化 / 布局 7 个、组件 Composable 与 *PickerState 11 个androidx.compose.* + ImmutableList + Compose Runtime移入 composeMain(不编译)
PickerItems.kt、三个 *Selection.kt、MultiWheelSelection.kt、TimeFormat.kt、YearMonth.kt纯 Kotlin + kotlinx-datetime留在 commonMain 重写类型(LocalTime/LocalDate/Month → 内核类型)
PickerClock.ktkotlin.time.Clock + kotlinx TimeZone移入 composeMain(桥不读时钟,ArkTS 传设备当前值)
DurationPickerOptions.kt一半纯 Kotlin(约束 / items / 标量帮助)、一半 Compose(Format / Semantics / Layout)拆分:内核部分留 commonMain,Compose 部分移 composeMain

expect/actual 声明全库为零——这决定了鸿蒙侧不需要任何 actual 补位,适配面落在"换值类型 + 提升可见性"上。两个跨文件公共体也被顺手下沉:DatePickerColumn / DurationPickerColumn / YearMonthPickerColumn 枚举从 PickerDefaults.kt 移到 commonMain(内核提交函数要用),12/24 小时换算的三处私有副本合并为公共 TimeConversion.kt。

1.4 页面边界
标题区          COMPOSE-PICKERS · OPENHARMONY / 版本握手行(0.8.0-ohos.1 · selftest 9/9 通过)
Tab 切换        时间 / 日期 / 年月 / 时长 / 通用
时间 Tab        12/24 小时切换、约束 无/09:00~18:00/20:00~23:30、分钟步长 1/5/15
日期 Tab        约束 无/2000-01-01~2030-12-31/2026-02-01~2026-04-30,三列年月日
年月 Tab        约束 无/2020-06~2030-06,两列
时长 Tab        约束 无/30~600 分钟/0~90 分钟,两列
通用 Tab        ArkTS 自定义 items 滚轮(不走桥)+ coerce/contains 验证按钮 + selftest 结果
日志区          每次桥调用带时间戳留痕(最近 8 条,`HH:mm:ss op:kind → 结果`)

页面启动时先调 pickersVersion() 握手、跑一次 selftest,再用 new Date() 取设备当前时间作为四个 resolve 的初始选择。

第 2 阶段:目标与依赖打通

2.1 依赖不可用验证(本适配的路线分岔点)

先查社区 Maven(https://maven.eazytec-cloud.com)。kotlinx-datetime 确有 ohos 制品(0.8.0-0.1.0-02/-05/-07),但全部用 Kotlin 2.4.20 元数据编译,HarmonyOS Kotlin 2.2.21-1.0.0 读取时报:

Class 'kotlin.Unit' was compiled with an incompatible version of Kotlin.
The binary version of its metadata is 2.4.0, expected version is 2.2.0 or less.

kotlinx-collections-immutable、kotlinx.serialization 同理(该结论与 compose-datetime-wheel-picker 适配时逐版本实测一致,不要试图"换版本试试")。鸿蒙 CMP 制品线是 1.9.2,上游在 1.11.1,UI 合线也不可能。两条路都被堵死,路线定为:零依赖内核提取 + 手写历法 + ArkTS 复刻形态——这正是社区已审核的 cmp_qrose / kmp-wheel-picker 路线。

唯一幸存的第三方面是 kotlin.time.Duration:它在 Kotlin 标准库里,ohos 目标原生可用,时长内核(约束、items、标量换算)因此零改动过桥。

2.2 工具链钉版(为什么是 2.2.21-1.0.0 + Gradle 8.14.1)

零依赖内核不需要任何 kotlinx 制品,工具链回到社区最成熟的旧线:Kotlin 2.2.21-1.0.0(qrose / haze / kmp-wheel-picker 同款),Gradle 必须降到 8.14.1(2.2.21 线不支持 Gradle 9.x,上游 9.7.0 wrapper 直接替换)。settings.gradle.kts 把 eazytec 仓库写在最前:

pluginManagement {
    repositories {
        maven("https://maven.eazytec-cloud.com/nexus/repository/maven-public/")
        maven("https://maven.eazytec-cloud.com/nexus/content/groups/harmonyos")
        gradlePluginPortal()
        google()
        mavenCentral()
    }
}

rootProject.name = "ohos_compose_pickers"

include(":pickers")
include(":example:nativeApp")

gradle.properties 钉 version=0.8.0-ohos.1、kotlin.native.ignoreDisabledTargets=true、kotlin.native.distribution.downloadFromMaven=true;libs.versions.toml 只剩一个 Kotlin 条目。

2.3 编译约束内核

pickers/build.gradle.kts 收敛为纯 KMP 模块(上游的 explicitApi() 纪律保留):

kotlin {
    applyDefaultHierarchyTemplate()
    explicitApi()
    jvmToolchain(21)

    jvm()          // 交叉验证用
    ohosArm64()
    ohosX64()

    sourceSets {
        commonMain.dependencies {}   // 零依赖是硬约束,见 2.1
        commonTest.dependencies {
            implementation(kotlin("test"))
        }
    }
}

实测:

./gradlew :pickers:compileKotlinJvm          # BUILD SUCCESSFUL
./gradlew :pickers:compileKotlinOhosArm64 :pickers:compileKotlinOhosX64   # BUILD SUCCESSFUL
2.4 测试依赖的处置

上游 commonTest 全部耦合 Compose(State/UI 测试),处置是三步:上游测试文件原样移入 composeTest 休眠;为内核新写 38 项语义测试(约束过滤、天数钳制、AM/PM 修复、等距取小等,场景对齐上游 State 测试);根构建全局禁用 ohos 测试任务,测试统一 JVM 跑:

subprojects {
    tasks.matching { it.name.contains("Ohos", ignoreCase = true) && it.name.contains("Test") }
        .configureEach { enabled = false }
}

第 3 阶段:内核提取(技术难点)

3.1 值类型:PickerTime / PickerDate

内核不能引用 kotlinx 类型,于是新增两个值类型,只复刻选择逻辑用到的表面:

public data class PickerTime(val hour: Int, val minute: Int) : Comparable<PickerTime> {
    public fun toMinuteOfDay(): Int = hour * 60 + minute
    // init 校验 0..23 / 0..59,与 LocalTime 构造契约一致
}

public data class PickerDate(val year: Int, val month: Int, val day: Int) : Comparable<PickerDate> {
    public fun toEpochDays(): Long = epochDaysFor(year, month, day)
    // init 校验 1000..9999 / 1..12 / 1..daysInMonth(year, month)
}
3.2 手写历法

daysInMonth / isLeapYear 从上游 DatePickerState.kt 逐字复制;epochDaysFor 采用 Howard Hinnant 的 civil-days 标准算法(coerceDate 按整值 epoch-day 距离比较的依据),并手写 floorDiv——java.lang.Math 在 Kotlin/Native 不存在,这是 JVM 绿、ohos 红的第一个坑(见 8.1)。JVM 测试用 kotlinx.datetime 已知值交叉核对:1970-01-01=0、2000-02-29=11016、2024-02-29=19782、2026-10-17=20743,外加世纪闰年(2000 闰 / 2100 不闰)与跨年边界。

3.3 决策逻辑逐行移植

PickerItems.kt(四类 *PickerItems + 四类 *PickerConstraints + coerce* + contains)与三个 *Selection.kt(repaired*After 列修复)做同一种手术:LocalTime→PickerTime、LocalDate→PickerDate、date.month.number→date.month,控制流一行不动。两处可见性调整为桥服务:selectable* 系列与 repaired*After 系列从 internal 提升为 public(它们就是"渲染前的列内容"与"停稳后的修复结果"两个稳定语义,公开比在桥里重写更不易漂移)。YearMonthPicker 的修复逻辑上游内联在 Composable 里(selectYear/selectMonth + moveSelectionInsideAvailableItems),内核将其抽为同名语义的 repairedYearMonthAfter。

3.4 桥契约测试

Bridge.kt 的拼 JSON 逻辑没有框架兜底,专门写了 BridgeContractTest:把桥的 dispatch 逻辑拷贝到 JVM、喂 ArkTS 启动时的四个原始请求串、用手写递归下降校验器逐字符验证响应合法性。这个测试在真机验收阶段抓到了一个所有 resolve 全挂的拼装 bug(见 8.1 第 1 条),从此成为桥的固定回归门禁。

第 4 阶段:原生桥接

4.1 三层桥接
ArkTS Index.ets
  │ JSON string
  ▼
C++ N-API entry(libpickers_napi.so)
  │ const char*
  ▼
Kotlin/Native C ABI(libohospickers.so)
  │ PickersDispatch / PickersVersion
  ▼
UTF-8 JSON + DisposeString
4.2 Kotlin/Native 导出函数
@CName("PickersDispatch")
fun pickersDispatch(request: String): String   // resolve/commit/coerce/contains/selftest 一口进

@CName("PickersVersion")
fun pickersVersion(): String = "0.8.0-ohos.1"  // 版本握手
C ABI 符号ArkTS 方法覆盖内核
PickersDispatchpickersDispatch(req)selectable* 列派生 + repaired*After + coerce* + contains + selftest
PickersVersionpickersVersion()fork 版本握手(真机版本行的数据来源)
4.3 C++ N-API 方法分发
napi_property_descriptor desc[] = {
    {"pickersDispatch", nullptr, NapiPickersDispatch, nullptr, nullptr, nullptr,
     static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr},
    {"pickersVersion", nullptr, NapiPickersVersion, nullptr, nullptr, nullptr,
     static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr},
};

每个回调统一三步:napi_typeof 校验必须是 string、OH_LOG_INFO 打字节数、调 KN 函数并包装返回。KN 返回的字符串必须经 libohospickers_symbols()->DisposeString 归还 Kotlin 运行时,绝不 free()。libohospickers_api.h 按社区已审仓库同款手写(只声明 ExportedSymbols 结构与两个业务符号)。

4.4 CMake 和类型声明
add_library(ohospickers SHARED IMPORTED)
set_target_properties(ohospickers PROPERTIES
  IMPORTED_LOCATION ${KN_SO}          # entry/libs/${OHOS_ARCH}/libohospickers.so
  IMPORTED_NO_SONAME TRUE)

add_library(pickers_napi SHARED napi_init.cpp)
target_link_libraries(pickers_napi PUBLIC
  libace_napi.z.so
  libhilog_ndk.z.so
  ohospickers)

OHOS_ARCH 为空或 libohospickers.so 缺失直接 FATAL_ERROR(提示先跑 link 任务);$ORIGIN rpath 加 POST_BUILD 拷贝保证 NAPI so、KN so、libc++ 三件套同目录,dlopen 时互相可见。

export const pickersDispatch: (request: string) => string;
export const pickersVersion: () => string;

Gradle 侧由 copyKnSoArm64 / copyKnSoX64 把 linkDebugShared* 的产物连同 konan 的 libc++_shared.so 一起拷进 entry/libs/{arm64-v8a,x86_64},用 finalizedBy 挂在 link 任务上。

第 5 阶段:ArkUI 宿主

5.1 自绘滚轮

上游滚轮形态是 LazyColumn + 吸附 + 中心选中条 + 文字缩放/颜色插值 + 渐隐边缘,ArkTS 侧用同语义自绘:列高 3×48vp,中心高亮条(#1F6750A4 圆角矩形 + 上下 1px 分隔线),ForEach 只渲染当前值 ±1 的可见窗口(9000 项的年份列也只挂 3 个 Text),文字 16↔20fp 按离中心距离线性插值、中心行加粗、透明度 1.0→0.25 渐隐,PanGesture 连续拖动、松手 animateTo(220ms, EaseOut) 吸附到最近 index、槽位点击直接选中。

5.2 resolve / commit 双跳

所有列停稳后走两跳:commit(column, value) 拿到修复后的 selection,必须再发一次 resolve 重建各列——day 列天数联动、12 小时 hour 列随 period 过滤都发生在 Kotlin 侧。resolve 后按 values.indexOf(当前逻辑值) 重定位选中索引,@Watch 驱动滚轮动画到新位。这个双跳忠实还原了上游"渲染前列内容"与"停稳后逻辑修复"的两段语义,ArkTS 不自行推导任何列内容。

5.3 对比度和错误边界

约束/步长 chips 用紫色描边区分选中态;ok:false 时错误原文进日志区;ArkTS 侧 dispatch 整体 try/catch,Native 层抛出的 Error 也收进日志(真机排障时正是靠这条日志定位了桥拼装 bug)。

第 6 阶段:示例与验证

6.1 工程结构
example/
├── nativeApp/
│   ├── build.gradle.kts                      双 ABI sharedLib(baseName=ohospickers)+ cxxShared + copyKnSo*
│   └── src/ohosMain/kotlin/example/nativeapp/
│       └── Bridge.kt                         @CName × 2 + MiniJson(继承自 cmp_qrose 审核模式)
└── harmonyApp/
    ├── build-hap.cmd                         命令行 HAP 打包(DevEco hvigor + JBR)
    ├── build-profile.json5                   signingConfigs 留空(签名由 DevEco 自动生成回填)
    ├── AppScope/app.json5                    bundleName org.terminator.ohos.composepickers、versionName 0.8.0-ohos.1
    └── entry/
        ├── build-profile.json5               externalNativeOptions + abiFilters [arm64-v8a, x86_64]
        ├── libs/{arm64-v8a,x86_64}/          linkDebugShared* 经 copyKnSo* 回填的三件套(gitignore)
        ├── src/main/cpp/CMakeLists.txt       IMPORTED 引入 KN so、$ORIGIN rpath
        ├── src/main/cpp/napi_init.cpp        N-API 导出、参数检查、DisposeString
        ├── src/main/cpp/include/libohospickers_api.h
        ├── src/main/cpp/types/libpickers_napi/{index.d.ts, oh-package.json5}
        └── src/main/ets/pages/Index.ets      ArkUI 五 Tab 验收页面
6.2 构建、签名和安装
./gradlew :pickers:jvmTest                                       # 40 项交叉验证
./gradlew :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64

cd example/harmonyApp
hvigorw assembleHap -p product=default -p buildMode=debug --no-daemon

签名在 DevEco Studio 的 File → Project Structure → Signing Configs 中勾选自动生成,证书写入本机,不进入仓库(仓库内 build-profile.json5 的 signingConfigs 保持空数组,勾选自动签名后由 IDE 回填带 material 的完整配置)。

hdc list targets -v
hdc -t <设备序列号> install -r \
  entry/build/default/outputs/default/entry-default-signed.hap
hdc -t <设备序列号> shell aa start \
  -a EntryAbility -b org.terminator.ohos.composepickers -m entry
hdc shell hilog | grep PickersNapi

四、完整代码对照

4.1 整体架构

io.github.kezlab.compose.pickers/**(约束内核,commonMain 零依赖)
    │ *PickerItems + *PickerConstraints + selectable* + coerce* + contains
    │ repaired*After(列修复)+ PickerTime/PickerDate + 手写历法
Bridge.kt + MiniJson
    │ PickersDispatch / PickersVersion
libpickers_napi.so
    │ pickersDispatch / pickersVersion
Index.ets
    │ JSON parse + 自绘滚轮 + resolve/commit 双跳
    ▼
ArkUI 真机页面(五 Tab)

4.2 文件清单

文件职责
settings.gradle.kts极简 ohos 构建、eazytec Maven 置前、ohos_compose_pickers、上游工程移出
gradle/libs.versions.toml只剩 Kotlin 2.2.21-1.0.0
build.gradle.kts(根)版本 0.8.0-ohos.1、全局禁用 Ohos*Test* 任务
pickers/build.gradle.kts纯 KMP:jvm + ohosArm64 + ohosX64,零依赖,explicitApi 保留
pickers/src/commonMain/.../PickerValues.ktPickerTime / PickerDate 值类型
pickers/src/commonMain/.../date/PickerCalendar.ktdaysInMonth / isLeapYear / epochDaysFor(手写历法)
pickers/src/commonMain/.../PickerItems.kt四类 items + 约束 + coerce/contains(类型移植版)
pickers/src/commonMain/.../{time,date,duration}/*Selection.ktrepaired*After(public,含新增 yearMonth 版)
pickers/src/commonTest/38 项内核语义测试 + 2 项桥契约测试
example/nativeApp/build.gradle.kts双 ABI sharedLib、cxxShared 定位 libc++、copyKnSo* 回填
nativeApp/.../Bridge.kt2 个 C ABI、5 个 op、MiniJson、错误通道
harmonyApp/.../cpp/napi_init.cppN-API 导出、参数检查、DisposeString
harmonyApp/.../cpp/CMakeLists.txtIMPORTED 引入 KN so、$ORIGIN rpath、POST_BUILD 三件套
harmonyApp/.../ets/pages/Index.etsArkUI 五 Tab 验收页面(自绘滚轮)
README.OpenHarmony_CN.md鸿蒙适配主文档(20 节)

4.3 关键 API 对照

层次API作用
内核TimePickerItems.selectableHourItemsFor(timeFormat, period)约束过滤后的小时列(渲染前内容)
内核DatePickerItems.selectableDayItemsFor(year, month)日列:当月天数 + 日期约束双重过滤
内核repairedTimeAfterPeriod 等 5 个一列停稳 → 依赖列修复成最近合法值
内核coerceTime / coerceDate / coerceYearMonth / coerceDuration / coerceDateRange越界值归一化(等距取小)
内核displayHourFor / periodFor / hourOfDayFor12/24 小时换算
内核DateRange.ordered / contains / overlaps / intersection区间运算
NativePickersDispatch / PickersVersionC ABI JSON 导出
N-APIpickersDispatch(req) / pickersVersion()向 ArkTS 暴露能力
ArkTSresolve() / commitColumn()双跳驱动滚轮与日志

4.4 ArkTS 与 Kotlin 的边界

const res: PickResponse = this.dispatch(this.dateRequest('commit', column, picked));
// res.changed === true 时 selection 已被 Kotlin 侧修复(如 1 月 31 日 → 2 月 29 日)
// 随后必须再 resolve 一次重建 day 列
this.dateResolve();
"date" -> {
    val next = items.repairedDateAfter(current, column, json.int("value"))
    """{"ok":true,"changed":${next != current},"selection":${dateSelectionJson(next)}}"""
}

两者之间只传输扁平 JSON 字符串,不传输 Kotlin 对象或未校验的动态结构。

五、关键决策说明

决策 1:提取零依赖内核而不是等 CMP 对齐

鸿蒙 CMP 1.9.2 与上游 1.11.1 之间隔着整套 API 代差(PickerStyle / PickerSemantics / 约束体系都是 1.11 时代形状),降级合线的改写量与漂移风险都远大于收益。内核提取让约束语义当天就能跑在真机上,composeMain 里的上游 UI 一行未动,等制品线对齐后直接接回、桥退役为回归对照。

决策 2:手写历法而不是绕开日期比较

coerceDate 的"最近合法日期"按整值 epoch-day 距离比较,绕开它等于重定义语义。手写 60 行标准算法 + 已知值交叉核对,比引入一个读不了的 kotlinx 制品或弱化语义都更诚实。daysInMonth / isLeapYear 则直接逐字复制上游实现,保证边界行为(2 月、世纪年)完全一致。

决策 3:selectable* 与 repaired*After 提升为 public

桥在 :example:nativeApp 模块,只能访问 :pickers 公开 API。这两个系列本来就是"渲染前列内容"与"停稳后修复结果"的稳定语义契约,公开它们比让桥复制过滤逻辑更不易漂移。这是 0.x fork 的有意 API 变化,已在 README 中记录。

决策 4:resolve / commit 双跳而不是单口全量

上游语义天然两段:渲染前的列派生(selectable*)与用户交互后的逻辑修复(repaired*After)。桥把两段都暴露,ArkTS 每次停稳后 commit + resolve,保证滚轮显示的每一列内容都出自 Kotlin 内核。单口全量会让 ArkTS 侧自行推导列内容,违反"过桥的只有语义"原则。

决策 5:桥契约测试进 commonTest

拼 JSON 没有框架兜底,一个多余字符就是全量失败(真机上的 exception: {} 就是这么来的)。把桥逻辑拷贝到 JVM、喂真实请求串、手写校验器逐字符验证响应——两小时定位一个真机 bug 的成本,换一座永久门禁。

六、测试与验证

6.1 测试环境

本次验证使用 Windows、JDK 21(Adoptium 21.0.12)、DevEco Studio 5.1.0 Release(hvigor / hdc 命令行)与鸿蒙真机(arm64)。

6.2 JVM 交叉验证

./gradlew :pickers:jvmTest

40 项用例全部通过:

测试类用例数钉住什么
PickerCalendarTest9epoch-day 已知值(含世纪闰年)、跨月/跨年边界、daysInMonth
PickerValuesTest6PickerTime / PickerDate 校验与排序、12/24 换算
TimePickerItemsTest12约束过滤(小时列 9…18、18 点只剩 minute 0)、步长列、coerce、AM↔PM 修复
YearMonthPickerItemsTest + DateRangeTest6整 YearMonth 约束、修复、区间 ordered / dayCount / intersection
DatePickerItemsTest5天数联动、约束窗口、整值 coerce、月切换钳日、区间 coerce
DurationPickerItemsTest4标量约束、列过滤、等距取小、修复
BridgeContractTest2桥 dispatch 的四个启动请求 + selftest,严格 JSON 校验

上游 Compose 测试语义被映射为内核用例——换类型不换语义,这是"内核没漂"的闸门。

6.3 HAP 验证

:pickers:compileKotlinJvm / compileKotlinOhosArm64 / OhosX64: BUILD SUCCESSFUL
:pickers:jvmTest: 40 项全绿
linkDebugSharedOhosArm64 / linkDebugSharedOhosX64: BUILD SUCCESSFUL
llvm-nm -D: PickersDispatch / PickersVersion / libohospickers_symbols 均为全局 T 符号
BuildNativeWithCmake: finished(双 ABI 编出 libpickers_napi.so)
CompileArkTS: finished / PackageHap: finished / BUILD SUCCESSFUL
install bundle successfully / start ability successfully

entry-default-unsigned.hap 约 5.9 MB,内含双 ABI 各三件套(libpickers_napi.so / libohospickers.so / libc++_shared.so);KN so 未 strip 时 arm64 约 2.8 MB、x86_64 约 2.4 MB,HAP 打包阶段由 hvigor 自动 strip。

6.4 功能用例

  1. 启动后页头显示 版本握手 0.8.0-ohos.1 与 selftest 9/9 通过(来自真实 Native 调用);
  2. 时间 Tab 切 12 小时,hour 列变为 1…12、period 列出现,selection 附 displayHour / period;
  3. 时间 Tab 加 09:00~18:00 约束,hour 列收缩为 9…18、18 点的 minute 列只剩 0,日志逐次留痕;
  4. 分钟步长切 15,minute 列变为 0/15/30/45;
  5. 日期 Tab 把 1 月 31 日的月份滚到 2 月,day 自动钳到 29(2024)/ 28(2023),年滚到约束外被拒绝(changed=false);
  6. 年月 Tab 加 2020-06~2030-06 约束,年份滚出边界时月份被修复到最近合法对;
  7. 时长 Tab 加 30~90 分钟约束,小时 0 的 minute 列只剩 30…59;
  8. 通用 Tab 点 coerce 2026-02-31 返回 2026-02-28;点非法时间(25:99)走错误通道显示 hour must be in range [0, 23];contains 按钮返回预期布尔。

6.5 验证结论

约束内核、历法数学、Native 动态库、C ABI、N-API、ArkTS 自绘滚轮和 HAP 已完成链路验证。应用在鸿蒙真机上成功安装并进入前台,版本握手与 9/9 内核自检通过;首个真机版本发现的 resolve 响应拼装 bug 已由桥契约测试复现、修复并回归(见 8.1 第 1 条),修复版 HAP 重新打包待复验。

七、运行效果

7.1 真机截图

在这里插入图片描述

7.2 命令速查

git clone https://atomgit.com/oh-tpc/Compose-Pickers.git
cd Compose-Pickers
export JAVA_HOME="/path/to/jdk-21"
./gradlew :pickers:compileKotlinOhosArm64 :pickers:compileKotlinOhosX64   # klib
./gradlew :pickers:jvmTest                                                # 40 项交叉验证
./gradlew :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64
cd example/harmonyApp
hvigorw assembleHap -p product=default -p buildMode=debug --no-daemon
hdc install -r entry/build/default/outputs/default/entry-default-signed.hap
hdc shell aa start -a EntryAbility -b org.terminator.ohos.composepickers -m entry
hdc shell hilog | grep PickersNapi

八、遗留问题与改进方向

8.1 踩坑复盘

  1. 嵌套原始字符串的大括号数错一个,真机上所有 resolve 全挂:row() 的外层 """...${values.joinToString { """...}""" }}]}“”“多写了一个},产物 value”:23}}]}是非法 JSON;Kotlin 编译期不校验字符串内容,JVM 内核测试也碰不到桥。真机现象是 ArkTSJSON.parse抛 SyntaxError、日志统一显示exception: {}(Error 对象 stringify 后只剩空壳),而 selftest 响应不走 row()` 所以正常——这个"部分 op 正常、部分全挂"的组合一度把怀疑引向 NAPI 层。解法是把桥逻辑拷进 JVM 契约测试、用 node 定位到第 658 个字符,删一个花括号修复。手拼 JSON 的桥必须有逐字符校验的契约测试;
  2. java.lang.Math.floorDiv 在 Kotlin/Native 不存在:epoch-day 算法初版 JVM 编译通过、compileKotlinOhosArm64 才报 Unresolved reference 'Math'——JVM 绿不等于 ohos 绿,内核每个文件都要过 ohos 编译;
  3. settings.gradle.kts 里 eazytec 双仓必须排在 pluginManagement 与 dependencyResolutionManagement 最前,否则配置期直接 Unresolved reference: ohosArm64;
  4. kotlinx-datetime 的 ohos 制品(0.8.0-0.1.0-*)元数据 2.4.0,HarmonyOS Kotlin 2.2.21 读不了——零依赖 + 手写历法是唯一路线,不要逐版本试错;
  5. 上游 YearMonth.atDay()/from() 绑着 kotlinx LocalDate,内核版换成 PickerDate 后,Compose 侧 interop 移到 composeMain/YearMonthInterop.kt 扩展函数——接回时成员与扩展二选一收敛;
  6. 根构建的 /build/ 与 .idea/* 是根锚定模式,盖不住嵌套目录——example/harmonyApp/{oh_modules,.hvigor,.idea,entry/build,entry/libs,entry/.cxx} 与 example/nativeApp/build/ 要单独追加 gitignore;
  7. build-profile.json5 的 signingConfigs 条目缺 material 字段会被 hvigor schema 拒绝(报 00303038)——仓库内留空数组且 product 不引用签名,勾选自动签名后由 IDE 回填完整配置;
  8. HAP 缺当前 ABI 的 so 会报安装失败 9568347,abiFilters 必须双 ABI,每个 ABI 三件套缺一不可;
  9. KN 返回字符串必须 DisposeString 归还运行时,N-API 层不能 free();
  10. 签名材料由 DevEco 自动写入 build-profile.json5,提交前用 git update-index --skip-worktree 保护,证书绝不进入 AtomGit;
  11. HAP 构建必须用 DevEco 自带 JBR,用系统 JDK 8 会让 hvigorw assembleHap 报不相干的错;
  12. ArkTS 只 import NAPI so(libpickers_napi.so);HarmonyOS Kotlin 的 CAdapter 不导出 @CName 业务符号,直接 import KN so 只能看到 stdlib zip 类型。

8.2 已知问题

  • Compose UI(Composable、remember*State、样式 / 语义体系)未编译鸿蒙,在 composeMain 休眠,等社区 CMP for ohos 与上游 1.11 对齐后接回;
  • 桥不读系统时钟(currentDate/currentDateTime 留在 composeMain),初始值由 ArkTS 传设备当前时间——与上游"initial 值由调用方传入"的契约一致;
  • DateRangePicker 的内核(DateRange / coerceDateRange)已过桥可用,Demo 未做双列区间页面;
  • 上游滚动物理是 Compose rememberSnapFlingBehavior(框架内置),本 Demo 用 ArkTS animateTo 等价表达;无障碍(TalkBack)语义属 UI 层,未跨桥;
  • android / js / wasmJs / apple 目标剥离(源文件留在树里),发布、dokka、ABI 校验一并剥离,本 fork 不发 Maven Central。

8.3 未来优化方向

  • 补 DateRangePicker 双列 Demo 页(内核已就绪,仅缺 ArkTS 页面);
  • 增加 commit 后的批量校验矩阵(全部列 × 全部约束组合的黄金文件对比);
  • 评估 .har 形态发布,让 ArkTS 工程直接依赖而不是走 so 三件套;
  • 在 CI 中加入 ohos 编译、ELF 符号检查和 HAP 构建;
  • 补充模拟器(x86_64)验收矩阵和自动化截图脚本。

九、总结

9.1 核心难点回顾

上游 Compose-Pickers commonMain(26 文件)
    → 逐文件 grep androidx.compose 划定内核 / UI 边界
    → 验证 kotlinx ohos 制品线不可用(元数据 2.4.0 vs 编译器 2.2.21)
    → 工具链对齐 HarmonyOS Kotlin 2.2.21-1.0.0(Gradle 降到 8.14.1)
    → 根构建替换(cmp_qrose 模式)+ 源码集拆分(composeMain / composeTest 休眠)
    → 零依赖内核:PickerTime/PickerDate + 手写历法 + 决策逻辑逐行移植
    → C ABI(@CName × 2)+ 5 op JSON 协议
    → C++ N-API(DisposeString 纪律)
    → ArkTS 自绘滚轮 + resolve/commit 双跳
    → ArkUI 真机五 Tab 验收 + 桥契约测试回归

9.2 封装层次

  • 内核层:上游决策逻辑的移植版——四类 items/约束、selectable* 列派生、repaired*After 修复、coerce* 归一化、手写历法;
  • Native 层:生成双 ABI 动态库并通过 2 个 C ABI 输出 JSON;
  • N-API 层:完成参数检查、字符串转换和 Native 内存释放;
  • ArkTS 层:自绘滚轮形态、resolve/commit 双跳状态机、chips 配置、日志与错误文本;
  • DevEco 层:完成 CMake、HAP、签名、安装和真机运行。

9.3 三条经验

  1. UI 库适配的价值切割点在"语义/形态"边界:滚轮长什么样(形态)ArkTS 复刻成本固定且不会漂移;显示什么、修到哪(语义)必须留在 Kotlin——这条边界划清后,selectable* 与 repaired*After 两个函数族就是桥的全部职责面;
  2. 制品线可达性决定路线,且要当天验证:同为 kotlinx,coroutines/serialization 的 ohos 线让 KStore 走"真编译整库",kotlinx-datetime 的元数据墙让本库走"内核提取"——两条路都对,依据是当天的 Maven 实测而不是历史结论;
  3. 手拼 JSON 的桥必须配逐字符契约测试:编译器不校验字符串内容,真机上 exception: {} 几乎不带任何定位信息;把桥拷进 JVM、喂真实请求、严格校验响应,两小时定位一个全量失败 bug,之后它是永久门禁。

9.4 适配成果

当前适配已完成:

  • Compose-Pickers 0.8.0 约束内核的双 ABI Kotlin/Native 编译(决策逻辑逐行移植,历法手写并交叉核对);
  • 2 个 C ABI 符号和 C++ N-API 包装层(llvm-nm 验证全局 T 符号);
  • DevEco Stage ArkUI 五 Tab 验收页面(时间 / 日期 / 年月 / 时长 / 通用+自检,自绘滚轮,桥调用日志留痕);
  • 40 项 JVM 交叉验证全绿(38 项内核语义 + 2 项桥契约)+ HAP 构建(双 ABI 六件套 so,约 5.9MB)、签名、设备安装和真机验证;
  • 真机首验发现的桥拼装 bug 已修复并回归,修复版待复验;
  • AtomGit 项目文档、OpenHarmony README(中英双份)和本地效果图。

适配过程 FAQ

以下问题来自 ohosArm64 目标接入、NAPI 桥接、HAP 构建与真机验收的实际流程,按出现频率排序。

Q1:DevEco Studio Sync 失败或 ohpm 依赖下载超时,怎么处理?

  • 确认 DevEco Studio 版本与工程 build-profile.json5 的 compatibleSdkVersion 一致;
  • 在 File > Settings > Build, Execution, Deployment > Ohpm 配置可用的 ohpm 仓库镜像或代理;
  • 删除 oh_modules、oh-package-lock.json5 与 .hvigor 缓存后重新 Sync;
  • 公司网络需要代理时,同时给 ohpm 与 hvigor 配置 http.proxy。

Q2:CMake 报 Missing .../libxxx.so 或找不到 Kotlin/Native 动态库?

  • 先在 example 目录执行 ./gradlew :nativeApp:linkDebugSharedOhosArm64(模拟器用 linkDebugSharedOhosX64);
  • 确认 libxxx.so 与 libc++_shared.so 已复制到 ohos/entry/libs/<abi>/;
  • 检查 entry/build-profile.json5 的 abiFilters 与设备 ABI 是否一致(真机 arm64-v8a,模拟器 x86_64);
  • 清理 entry/.cxx 与 entry/build 后重试。

Q3:ArkTS 报 Cannot find module 'libxxx_napi.so' 或调用返回 undefined?

  • 确认 CMake 的 add_library(<target> SHARED ...) 名称与 import ... from 'libxxx_napi.so' 完全一致;
  • 解包 HAP,确认 libs/<abi>/libxxx_napi.so 已打包;
  • 删除 entry/build、entry/.cxx 与 oh_modules 后重新构建;
  • 检查 module.json5 的 srcEntry 与页面路径是否正确。

Q4:真机安装报 signature verification failed 或 install failed?

  • 在 DevEco Studio 的 Signing Configs 配置证书、Profile 与密码;
  • bundleName 必须与签名 profile 中的 bundle name 一致;
  • 设备需加入 profile 的 deviceIds,调试证书有效期需覆盖当前时间;
  • 用 hdc list targets 确认设备在线,再执行 hdc install -r xxx-signed.hap。

Q5:应用启动白屏、闪退或 NAPI 调用崩溃?

  • 用 hdc hilog | grep -i "napi\|kotlin" 查看原生日志;
  • 确认 libc++_shared.so 与 Kotlin/Native 工具链版本匹配,且 ABI 与设备一致;
  • 检查 C ABI 返回的字符串是否由 Native 侧分配、ArkTS 侧复制后是否调用 XxxFree 释放;
  • 在 DevEco 的 Native 调试配置中附加 lldb,定位空指针与越界访问。

Q6:Gradle 报 ohosArm64 目标不存在或 Kotlin/Native 编译失败?

  • 使用鸿蒙发行版 Kotlin(如 2.2.21-1.0.0),并在 settings.gradle.kts 配置 eazytec 仓库;
  • Gradle 建议 8.x、JDK 21,版本不匹配时先统一工具链;
  • 确认 kotlin { ohosArm64() } 已应用,且 local.properties 的 SDK 路径有效;
  • 清理 ~/.gradle/caches 中对应版本的 Kotlin/Native 缓存后重试。

Q7:HAP 构建报 SDK 版本不匹配或 compatibleSdkVersion 错误?

  • 对齐 DevEco Studio、HarmonyOS SDK 与 build-profile.json5 中的 compatibleSdkVersion;
  • 升级 DevEco Studio 后同步升级 hvigor 与 SDK,避免 modelVersion 与 IDE 不匹配;
  • 命令行构建时设置 DEVECO_SDK_HOME 指向 DevEco 的 sdk 目录。

Q8:命令行 hvigor 构建失败,但 DevEco 里能构建?

  • 使用 DevEco 自带的 node 与 hvigor:
    "$DEVECO/Contents/tools/node/bin/node" "$DEVECO/Contents/tools/hvigor/bin/hvigorw.js" ...;
  • 设置 JAVA_HOME、NODE_HOME、DEVECO_SDK_HOME;
  • 加上 --no-daemon 排除守护进程缓存影响,并清理 entry/build 与 .hvigor。

参考文档

本文记录 Compose-Pickers 接入 OpenHarmony 的完整过程,覆盖约束感知滚轮选择器库的盘点、鸿蒙工具链选型、约束内核提取、ohosArm64 / ohosX64 目标、Kotlin/Native 动态库、C ABI/N-API 桥接、ArkTS 自绘滚轮真机页面、HAP 构建和真机验收。

Compose-Pickers 是基于 Compose Multiplatform 的约束感知滚轮选择器库:一个通用 WheelPicker<T> 加上时间 / 日期 / 年月 / 日期区间 / 时长预设,多列联动时始终保持一个合法的逻辑选中值——item 列表随闭区间约束过滤、天数随年月联动钳制、12/24 小时自动换算、列停稳后把非法组合修复成最近的合法值。本次适配属于社区早期路线(qrose / haze / kmp-wheel-picker / compose-datetime-wheel-picker 的"零依赖内核提取"):社区 Maven 上确有 kotlinx-datetime 的 ohos 制品,但它们携带 Kotlin 2.4.20 元数据、HarmonyOS Kotlin 2.2.21-1.0.0 编译器读不了,因此Compose UI 不编译(原样移入 composeMain 休眠),约束决策内核(item 生成 / 过滤 / 钳制 / 修复 / 归一化 / 校验)被提取为零依赖纯 Kotlin 编译成 OpenHarmony KLIB,历法数学手写,ArkTS 自绘滚轮作为验收宿主。

项目地址: AtomGit/oh-tpc/Compose-Pickers.git

上游仓库: kez-lab/Compose-Pickers

开发工具: 华为云码道

环境安装: 环境安装指南

一、背景

1.1 为什么做 OpenHarmony 平台 KMP/CMP 适配

Compose-Pickers 为 Compose Multiplatform 应用提供"多列联动选择"这一件事的完整答案:WheelPicker(items, selectedItem) 一行起滚轮;TimePicker / DatePicker / YearMonthPicker / DateRangePicker / DurationPicker 五个预设各自持有可保存的逻辑状态;*PickerItems + *PickerConstraints 把可选值与闭区间约束建模成不可变配置;列与列之间的依赖(年→月→日、时→分、12 小时的 AM/PM)由 repaired*After 系列函数在一个逻辑值上修复,coerce* 系列负责把应用侧的越界值归一化成最近合法值,contains 系列做成员校验。KMP 项目把这些能力放在 commonMain,由 Android、iOS、桌面、Web 共享。OpenHarmony 应用同样需要这套选择语义,但上游构建(Kotlin 2.4.10 / Gradle 9.7 / AGP 9.3.1 / Compose 1.11.1)不会生成 OpenHarmony 变体。

如果只把示例页面改成 ArkTS,页面可以画出滚轮,却无法证明约束内核在 OpenHarmony ARM64 设备上完成编译、链接和执行。适配需要同时验证约束内核、历法数学、Native 动态库、C ABI、N-API、ArkTS 滚轮交互和 HAP 交付链路。

障碍具体问题
目标缺失必须增加 ohosArm64() / ohosX64() 才能生成 OpenHarmony KLIB。
工具链不一致上游 Kotlin 2.4.10 / Gradle 9.7.0 / AGP 9.3.1 与鸿蒙工具链无法共存于一个 Gradle 构建;鸿蒙 CMP 制品线只有 1.9.2。
依赖矩阵内核类型 LocalDate / LocalTime / Month 来自 kotlinx-datetime,其 ohos 制品元数据 2.4.0,Kotlin 2.2.21 只能读到 2.2.x;ImmutableList 来自 kotlinx-collections-immutable,同样没有可解析的 ohos 制品。
语言边界不同ArkTS 不能直接持有 Kotlin 对象,需要 C ABI、C++ N-API 和 JSON。
状态语义跨不了桥remember*State 的 saveable 恢复、mutableStateOf 的快照订阅是 Compose Runtime 概念,C ABI 只能同步一来一回。
交付链路复杂.so、CMake、N-API、HAP、签名和 hdc 都要单独验收。

因此,本项目把边界放在:约束内核的零依赖提取与 ohos 编译、根构建替换(cmp_qrose 模式)和 C ABI/N-API 桥接层。选择语义完全由上游逻辑的移植版负责,ArkTS 只负责调用 Native、解析 JSON 和渲染滚轮。

1.2 库提供的能力

Demo 提供五个真实的选择会话,对应桥接的全部入口:

  • 解析(resolve):传入 kind、约束与当前选择,内核先 coerce* 归一化,再返回每一列经约束过滤后的可选行(rows,即 Compose 渲染前从 selectable* 拿到的同一份列内容);
  • 提交(commit):一列滚轮停稳后提交单列变化,内核走 repaired*After 把依赖列修复成最近合法值(如 1 月 31 日滚到 2 月自动钳到 29 日);
  • 归一化(coerce):把应用侧越界值(如 2026-02-31)钳到最近合法 selection;
  • 校验(contains):membership 检查,返回布尔;
  • 内核自检(selftest):设备上现场运行 9 组内核断言(列过滤、AM/PM 修复、闰年天数、月份切换钳日、年月修复、时长修复与归一化),页头显示通过数。

响应包含:

字段作用
ok操作是否成功,失败时 error 带原文。
rows[{key, items:[{text, value}]}],列顺序即渲染顺序。
selection修复 / 归一化后的逻辑选中值(时间附 displayHour / period)。
changedcommit 时选中值是否发生变化(非法列值被拒绝时为 false)。
passed / failed / casesselftest 的逐用例结果。

内核侧完整能力(约束构造期校验、等距取小、整值距离比较、DateRange 区间运算、12/24 小时换算)全部随编译进入 .so,JVM 侧保留 40 项测试交叉验证(38 项内核语义 + 2 项桥契约),证明 ohos .so 里执行的选择逻辑没有漂移。

1.3 实现适配

维度要求
代码复用决策逻辑(PickerItems 四件套、约束、repaired*After、coerce*、contains)从上游 commonMain 逐行移植(只替换值类型),控制流零改动;Compose UI / 状态容器 / 样式语义原样移入 composeMain 不编译。
平台目标根构建直接声明 ohosArm64 + ohosX64,保留 jvm() 作交叉验证,库模块出 KLIB,example/nativeApp 出双 ABI 动态库。
构建隔离根构建替换为极简 ohos 构建(cmp_qrose 模式);sample / benchmark / screenshot-tests 等上游工程留盘不进构建。
桥接稳定使用 2 个 C ABI 和 UTF-8 JSON,不传递 Kotlin 对象地址;异常永远不跨 FFI。
依赖纪律commonMain 零第三方依赖:kotlinx-datetime 不可用即手写历法(daysInMonth 从上游逐字复制、epoch-day 用标准 civil-days 算法);kotlin.time.Duration 是标准库,ohos 可用,原样保留。
UI 完整单页五 Tab 覆盖全部桥入口:时间 / 日期 / 年月 / 时长 / 通用+自检,每次桥调用日志留痕。
仓库规范项目说明、文章、效果图和源码链接统一使用 AtomGit。

本项目的 ArkTS 页面不是 Compose Multiplatform UI 实现。滚轮"形态"(中心高亮条、文字缩放渐隐、吸附动画)由 ArkTS 自绘复刻;“语义”(显示什么、修到哪)全部出自 Kotlin 内核——过桥的只有语义,不过桥的只有形态。

二、实现路线图

第 1 阶段:项目初始化     ── 盘点模块与依赖边界、确认 ohos 制品线不可用、确定交付物
第 2 阶段:目标与依赖打通 ── 根构建替换、eazytec Maven 置前、Kotlin 2.2.21-1.0.0 钉版、Gradle 8.14.1
第 3 阶段:内核提取       ── 手写历法、PickerTime/PickerDate 值类型、约束/修复/coerce 逐行移植、列枚举下沉
第 4 阶段:原生桥接       ── @CName C ABI、C++ N-API、参数校验和内存释放
第 5 阶段:ArkUI 宿主     ── 自绘滚轮、resolve/commit 双跳、约束切换 chips、操作日志和版本握手
第 6 阶段:示例与验证     ── HAP、签名、设备安装、真机运行和效果图

三、逐步实现过程

第 1 阶段:项目初始化

1.1 工程边界
pickers/src/commonMain/            约束内核(进 ohos 构建,jvm + ohosArm64 + ohosX64,零依赖)
pickers/src/composeMain/           上游 Compose UI 原样存放(不编译,等 CMP for ohos 对齐后接回)
pickers/src/commonTest/            内核 JVM 交叉验证 + 桥契约测试(40 项)
pickers/src/composeTest/           上游 commonTest 原样存放(不编译)
sample/ benchmark/ iosApp/ ...     上游示例与基准工程,留盘不进 settings
example/nativeApp/                 Kotlin/Native 动态库(Bridge + MiniJson)
example/harmonyApp/                DevEco Stage、CMake N-API 和 ArkTS 页面
README.OpenHarmony.md / _CN.md     鸿蒙适配说明(中英双份)
README.upstream.md                 上游原文保存

构建拓扑走 cmp_qrose 模式(根构建即 ohos 构建):settings.gradle.kts 只 include :pickers 与 :example:nativeApp,rootProject.name = "ohos_compose_pickers"。上游的 compose / android-library / vanniktech / dokka / abi-validation 插件、AGP classpath、Maven Central 发布段全部随根构建替换剥离——本 fork 不发 Maven Central。

1.2 工具链矩阵
项目配置
Kotlin MultiplatformHarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven)
JDK21(org.gradle.java.home 指向 Adoptium 21.0.12)
OpenHarmony 目标ohosArm64 + ohosX64(jvm() 保留作交叉验证)
Gradle8.14.1(wrapper 替换;2.2.21 线经验值,不沿用上游 9.7.0)
第三方依赖无(commonMain 零依赖;kotlin.time.Duration 走标准库)
DevEco product5.1.0(18) 兼容
DevEco Studio5.1.0 Release
ABIarm64-v8a + x86_64
Bundleorg.terminator.ohos.composepickers
export JAVA_HOME="/path/to/jdk-21"
java -version
1.3 源码依赖度盘点

对上游 commonMain 的 26 个 Kotlin 文件逐一 grep androidx.compose,盘点结果:

文件组依赖形态处置
Picker.kt、PickerDefaults.kt、样式 / 语义 / 格式化 / 布局 7 个、组件 Composable 与 *PickerState 11 个androidx.compose.* + ImmutableList + Compose Runtime移入 composeMain(不编译)
PickerItems.kt、三个 *Selection.kt、MultiWheelSelection.kt、TimeFormat.kt、YearMonth.kt纯 Kotlin + kotlinx-datetime留在 commonMain 重写类型(LocalTime/LocalDate/Month → 内核类型)
PickerClock.ktkotlin.time.Clock + kotlinx TimeZone移入 composeMain(桥不读时钟,ArkTS 传设备当前值)
DurationPickerOptions.kt一半纯 Kotlin(约束 / items / 标量帮助)、一半 Compose(Format / Semantics / Layout)拆分:内核部分留 commonMain,Compose 部分移 composeMain

expect/actual 声明全库为零——这决定了鸿蒙侧不需要任何 actual 补位,适配面落在"换值类型 + 提升可见性"上。两个跨文件公共体也被顺手下沉:DatePickerColumn / DurationPickerColumn / YearMonthPickerColumn 枚举从 PickerDefaults.kt 移到 commonMain(内核提交函数要用),12/24 小时换算的三处私有副本合并为公共 TimeConversion.kt。

1.4 页面边界
标题区          COMPOSE-PICKERS · OPENHARMONY / 版本握手行(0.8.0-ohos.1 · selftest 9/9 通过)
Tab 切换        时间 / 日期 / 年月 / 时长 / 通用
时间 Tab        12/24 小时切换、约束 无/09:00~18:00/20:00~23:30、分钟步长 1/5/15
日期 Tab        约束 无/2000-01-01~2030-12-31/2026-02-01~2026-04-30,三列年月日
年月 Tab        约束 无/2020-06~2030-06,两列
时长 Tab        约束 无/30~600 分钟/0~90 分钟,两列
通用 Tab        ArkTS 自定义 items 滚轮(不走桥)+ coerce/contains 验证按钮 + selftest 结果
日志区          每次桥调用带时间戳留痕(最近 8 条,`HH:mm:ss op:kind → 结果`)

页面启动时先调 pickersVersion() 握手、跑一次 selftest,再用 new Date() 取设备当前时间作为四个 resolve 的初始选择。

第 2 阶段:目标与依赖打通

2.1 依赖不可用验证(本适配的路线分岔点)

先查社区 Maven(https://maven.eazytec-cloud.com)。kotlinx-datetime 确有 ohos 制品(0.8.0-0.1.0-02/-05/-07),但全部用 Kotlin 2.4.20 元数据编译,HarmonyOS Kotlin 2.2.21-1.0.0 读取时报:

Class 'kotlin.Unit' was compiled with an incompatible version of Kotlin.
The binary version of its metadata is 2.4.0, expected version is 2.2.0 or less.

kotlinx-collections-immutable、kotlinx.serialization 同理(该结论与 compose-datetime-wheel-picker 适配时逐版本实测一致,不要试图"换版本试试")。鸿蒙 CMP 制品线是 1.9.2,上游在 1.11.1,UI 合线也不可能。两条路都被堵死,路线定为:零依赖内核提取 + 手写历法 + ArkTS 复刻形态——这正是社区已审核的 cmp_qrose / kmp-wheel-picker 路线。

唯一幸存的第三方面是 kotlin.time.Duration:它在 Kotlin 标准库里,ohos 目标原生可用,时长内核(约束、items、标量换算)因此零改动过桥。

2.2 工具链钉版(为什么是 2.2.21-1.0.0 + Gradle 8.14.1)

零依赖内核不需要任何 kotlinx 制品,工具链回到社区最成熟的旧线:Kotlin 2.2.21-1.0.0(qrose / haze / kmp-wheel-picker 同款),Gradle 必须降到 8.14.1(2.2.21 线不支持 Gradle 9.x,上游 9.7.0 wrapper 直接替换)。settings.gradle.kts 把 eazytec 仓库写在最前:

pluginManagement {
    repositories {
        maven("https://maven.eazytec-cloud.com/nexus/repository/maven-public/")
        maven("https://maven.eazytec-cloud.com/nexus/content/groups/harmonyos")
        gradlePluginPortal()
        google()
        mavenCentral()
    }
}

rootProject.name = "ohos_compose_pickers"

include(":pickers")
include(":example:nativeApp")

gradle.properties 钉 version=0.8.0-ohos.1、kotlin.native.ignoreDisabledTargets=true、kotlin.native.distribution.downloadFromMaven=true;libs.versions.toml 只剩一个 Kotlin 条目。

2.3 编译约束内核

pickers/build.gradle.kts 收敛为纯 KMP 模块(上游的 explicitApi() 纪律保留):

kotlin {
    applyDefaultHierarchyTemplate()
    explicitApi()
    jvmToolchain(21)

    jvm()          // 交叉验证用
    ohosArm64()
    ohosX64()

    sourceSets {
        commonMain.dependencies {}   // 零依赖是硬约束,见 2.1
        commonTest.dependencies {
            implementation(kotlin("test"))
        }
    }
}

实测:

./gradlew :pickers:compileKotlinJvm          # BUILD SUCCESSFUL
./gradlew :pickers:compileKotlinOhosArm64 :pickers:compileKotlinOhosX64   # BUILD SUCCESSFUL
2.4 测试依赖的处置

上游 commonTest 全部耦合 Compose(State/UI 测试),处置是三步:上游测试文件原样移入 composeTest 休眠;为内核新写 38 项语义测试(约束过滤、天数钳制、AM/PM 修复、等距取小等,场景对齐上游 State 测试);根构建全局禁用 ohos 测试任务,测试统一 JVM 跑:

subprojects {
    tasks.matching { it.name.contains("Ohos", ignoreCase = true) && it.name.contains("Test") }
        .configureEach { enabled = false }
}

第 3 阶段:内核提取(技术难点)

3.1 值类型:PickerTime / PickerDate

内核不能引用 kotlinx 类型,于是新增两个值类型,只复刻选择逻辑用到的表面:

public data class PickerTime(val hour: Int, val minute: Int) : Comparable<PickerTime> {
    public fun toMinuteOfDay(): Int = hour * 60 + minute
    // init 校验 0..23 / 0..59,与 LocalTime 构造契约一致
}

public data class PickerDate(val year: Int, val month: Int, val day: Int) : Comparable<PickerDate> {
    public fun toEpochDays(): Long = epochDaysFor(year, month, day)
    // init 校验 1000..9999 / 1..12 / 1..daysInMonth(year, month)
}
3.2 手写历法

daysInMonth / isLeapYear 从上游 DatePickerState.kt 逐字复制;epochDaysFor 采用 Howard Hinnant 的 civil-days 标准算法(coerceDate 按整值 epoch-day 距离比较的依据),并手写 floorDiv——java.lang.Math 在 Kotlin/Native 不存在,这是 JVM 绿、ohos 红的第一个坑(见 8.1)。JVM 测试用 kotlinx.datetime 已知值交叉核对:1970-01-01=0、2000-02-29=11016、2024-02-29=19782、2026-10-17=20743,外加世纪闰年(2000 闰 / 2100 不闰)与跨年边界。

3.3 决策逻辑逐行移植

PickerItems.kt(四类 *PickerItems + 四类 *PickerConstraints + coerce* + contains)与三个 *Selection.kt(repaired*After 列修复)做同一种手术:LocalTime→PickerTime、LocalDate→PickerDate、date.month.number→date.month,控制流一行不动。两处可见性调整为桥服务:selectable* 系列与 repaired*After 系列从 internal 提升为 public(它们就是"渲染前的列内容"与"停稳后的修复结果"两个稳定语义,公开比在桥里重写更不易漂移)。YearMonthPicker 的修复逻辑上游内联在 Composable 里(selectYear/selectMonth + moveSelectionInsideAvailableItems),内核将其抽为同名语义的 repairedYearMonthAfter。

3.4 桥契约测试

Bridge.kt 的拼 JSON 逻辑没有框架兜底,专门写了 BridgeContractTest:把桥的 dispatch 逻辑拷贝到 JVM、喂 ArkTS 启动时的四个原始请求串、用手写递归下降校验器逐字符验证响应合法性。这个测试在真机验收阶段抓到了一个所有 resolve 全挂的拼装 bug(见 8.1 第 1 条),从此成为桥的固定回归门禁。

第 4 阶段:原生桥接

4.1 三层桥接
ArkTS Index.ets
  │ JSON string
  ▼
C++ N-API entry(libpickers_napi.so)
  │ const char*
  ▼
Kotlin/Native C ABI(libohospickers.so)
  │ PickersDispatch / PickersVersion
  ▼
UTF-8 JSON + DisposeString
4.2 Kotlin/Native 导出函数
@CName("PickersDispatch")
fun pickersDispatch(request: String): String   // resolve/commit/coerce/contains/selftest 一口进

@CName("PickersVersion")
fun pickersVersion(): String = "0.8.0-ohos.1"  // 版本握手
C ABI 符号ArkTS 方法覆盖内核
PickersDispatchpickersDispatch(req)selectable* 列派生 + repaired*After + coerce* + contains + selftest
PickersVersionpickersVersion()fork 版本握手(真机版本行的数据来源)
4.3 C++ N-API 方法分发
napi_property_descriptor desc[] = {
    {"pickersDispatch", nullptr, NapiPickersDispatch, nullptr, nullptr, nullptr,
     static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr},
    {"pickersVersion", nullptr, NapiPickersVersion, nullptr, nullptr, nullptr,
     static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr},
};

每个回调统一三步:napi_typeof 校验必须是 string、OH_LOG_INFO 打字节数、调 KN 函数并包装返回。KN 返回的字符串必须经 libohospickers_symbols()->DisposeString 归还 Kotlin 运行时,绝不 free()。libohospickers_api.h 按社区已审仓库同款手写(只声明 ExportedSymbols 结构与两个业务符号)。

4.4 CMake 和类型声明
add_library(ohospickers SHARED IMPORTED)
set_target_properties(ohospickers PROPERTIES
  IMPORTED_LOCATION ${KN_SO}          # entry/libs/${OHOS_ARCH}/libohospickers.so
  IMPORTED_NO_SONAME TRUE)

add_library(pickers_napi SHARED napi_init.cpp)
target_link_libraries(pickers_napi PUBLIC
  libace_napi.z.so
  libhilog_ndk.z.so
  ohospickers)

OHOS_ARCH 为空或 libohospickers.so 缺失直接 FATAL_ERROR(提示先跑 link 任务);$ORIGIN rpath 加 POST_BUILD 拷贝保证 NAPI so、KN so、libc++ 三件套同目录,dlopen 时互相可见。

export const pickersDispatch: (request: string) => string;
export const pickersVersion: () => string;

Gradle 侧由 copyKnSoArm64 / copyKnSoX64 把 linkDebugShared* 的产物连同 konan 的 libc++_shared.so 一起拷进 entry/libs/{arm64-v8a,x86_64},用 finalizedBy 挂在 link 任务上。

第 5 阶段:ArkUI 宿主

5.1 自绘滚轮

上游滚轮形态是 LazyColumn + 吸附 + 中心选中条 + 文字缩放/颜色插值 + 渐隐边缘,ArkTS 侧用同语义自绘:列高 3×48vp,中心高亮条(#1F6750A4 圆角矩形 + 上下 1px 分隔线),ForEach 只渲染当前值 ±1 的可见窗口(9000 项的年份列也只挂 3 个 Text),文字 16↔20fp 按离中心距离线性插值、中心行加粗、透明度 1.0→0.25 渐隐,PanGesture 连续拖动、松手 animateTo(220ms, EaseOut) 吸附到最近 index、槽位点击直接选中。

5.2 resolve / commit 双跳

所有列停稳后走两跳:commit(column, value) 拿到修复后的 selection,必须再发一次 resolve 重建各列——day 列天数联动、12 小时 hour 列随 period 过滤都发生在 Kotlin 侧。resolve 后按 values.indexOf(当前逻辑值) 重定位选中索引,@Watch 驱动滚轮动画到新位。这个双跳忠实还原了上游"渲染前列内容"与"停稳后逻辑修复"的两段语义,ArkTS 不自行推导任何列内容。

5.3 对比度和错误边界

约束/步长 chips 用紫色描边区分选中态;ok:false 时错误原文进日志区;ArkTS 侧 dispatch 整体 try/catch,Native 层抛出的 Error 也收进日志(真机排障时正是靠这条日志定位了桥拼装 bug)。

第 6 阶段:示例与验证

6.1 工程结构
example/
├── nativeApp/
│   ├── build.gradle.kts                      双 ABI sharedLib(baseName=ohospickers)+ cxxShared + copyKnSo*
│   └── src/ohosMain/kotlin/example/nativeapp/
│       └── Bridge.kt                         @CName × 2 + MiniJson(继承自 cmp_qrose 审核模式)
└── harmonyApp/
    ├── build-hap.cmd                         命令行 HAP 打包(DevEco hvigor + JBR)
    ├── build-profile.json5                   signingConfigs 留空(签名由 DevEco 自动生成回填)
    ├── AppScope/app.json5                    bundleName org.terminator.ohos.composepickers、versionName 0.8.0-ohos.1
    └── entry/
        ├── build-profile.json5               externalNativeOptions + abiFilters [arm64-v8a, x86_64]
        ├── libs/{arm64-v8a,x86_64}/          linkDebugShared* 经 copyKnSo* 回填的三件套(gitignore)
        ├── src/main/cpp/CMakeLists.txt       IMPORTED 引入 KN so、$ORIGIN rpath
        ├── src/main/cpp/napi_init.cpp        N-API 导出、参数检查、DisposeString
        ├── src/main/cpp/include/libohospickers_api.h
        ├── src/main/cpp/types/libpickers_napi/{index.d.ts, oh-package.json5}
        └── src/main/ets/pages/Index.ets      ArkUI 五 Tab 验收页面
6.2 构建、签名和安装
./gradlew :pickers:jvmTest                                       # 40 项交叉验证
./gradlew :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64

cd example/harmonyApp
hvigorw assembleHap -p product=default -p buildMode=debug --no-daemon

签名在 DevEco Studio 的 File → Project Structure → Signing Configs 中勾选自动生成,证书写入本机,不进入仓库(仓库内 build-profile.json5 的 signingConfigs 保持空数组,勾选自动签名后由 IDE 回填带 material 的完整配置)。

hdc list targets -v
hdc -t <设备序列号> install -r \
  entry/build/default/outputs/default/entry-default-signed.hap
hdc -t <设备序列号> shell aa start \
  -a EntryAbility -b org.terminator.ohos.composepickers -m entry
hdc shell hilog | grep PickersNapi

四、完整代码对照

4.1 整体架构

io.github.kezlab.compose.pickers/**(约束内核,commonMain 零依赖)
    │ *PickerItems + *PickerConstraints + selectable* + coerce* + contains
    │ repaired*After(列修复)+ PickerTime/PickerDate + 手写历法
Bridge.kt + MiniJson
    │ PickersDispatch / PickersVersion
libpickers_napi.so
    │ pickersDispatch / pickersVersion
Index.ets
    │ JSON parse + 自绘滚轮 + resolve/commit 双跳
    ▼
ArkUI 真机页面(五 Tab)

4.2 文件清单

文件职责
settings.gradle.kts极简 ohos 构建、eazytec Maven 置前、ohos_compose_pickers、上游工程移出
gradle/libs.versions.toml只剩 Kotlin 2.2.21-1.0.0
build.gradle.kts(根)版本 0.8.0-ohos.1、全局禁用 Ohos*Test* 任务
pickers/build.gradle.kts纯 KMP:jvm + ohosArm64 + ohosX64,零依赖,explicitApi 保留
pickers/src/commonMain/.../PickerValues.ktPickerTime / PickerDate 值类型
pickers/src/commonMain/.../date/PickerCalendar.ktdaysInMonth / isLeapYear / epochDaysFor(手写历法)
pickers/src/commonMain/.../PickerItems.kt四类 items + 约束 + coerce/contains(类型移植版)
pickers/src/commonMain/.../{time,date,duration}/*Selection.ktrepaired*After(public,含新增 yearMonth 版)
pickers/src/commonTest/38 项内核语义测试 + 2 项桥契约测试
example/nativeApp/build.gradle.kts双 ABI sharedLib、cxxShared 定位 libc++、copyKnSo* 回填
nativeApp/.../Bridge.kt2 个 C ABI、5 个 op、MiniJson、错误通道
harmonyApp/.../cpp/napi_init.cppN-API 导出、参数检查、DisposeString
harmonyApp/.../cpp/CMakeLists.txtIMPORTED 引入 KN so、$ORIGIN rpath、POST_BUILD 三件套
harmonyApp/.../ets/pages/Index.etsArkUI 五 Tab 验收页面(自绘滚轮)
README.OpenHarmony_CN.md鸿蒙适配主文档(20 节)

4.3 关键 API 对照

层次API作用
内核TimePickerItems.selectableHourItemsFor(timeFormat, period)约束过滤后的小时列(渲染前内容)
内核DatePickerItems.selectableDayItemsFor(year, month)日列:当月天数 + 日期约束双重过滤
内核repairedTimeAfterPeriod 等 5 个一列停稳 → 依赖列修复成最近合法值
内核coerceTime / coerceDate / coerceYearMonth / coerceDuration / coerceDateRange越界值归一化(等距取小)
内核displayHourFor / periodFor / hourOfDayFor12/24 小时换算
内核DateRange.ordered / contains / overlaps / intersection区间运算
NativePickersDispatch / PickersVersionC ABI JSON 导出
N-APIpickersDispatch(req) / pickersVersion()向 ArkTS 暴露能力
ArkTSresolve() / commitColumn()双跳驱动滚轮与日志

4.4 ArkTS 与 Kotlin 的边界

const res: PickResponse = this.dispatch(this.dateRequest('commit', column, picked));
// res.changed === true 时 selection 已被 Kotlin 侧修复(如 1 月 31 日 → 2 月 29 日)
// 随后必须再 resolve 一次重建 day 列
this.dateResolve();
"date" -> {
    val next = items.repairedDateAfter(current, column, json.int("value"))
    """{"ok":true,"changed":${next != current},"selection":${dateSelectionJson(next)}}"""
}

两者之间只传输扁平 JSON 字符串,不传输 Kotlin 对象或未校验的动态结构。

五、关键决策说明

决策 1:提取零依赖内核而不是等 CMP 对齐

鸿蒙 CMP 1.9.2 与上游 1.11.1 之间隔着整套 API 代差(PickerStyle / PickerSemantics / 约束体系都是 1.11 时代形状),降级合线的改写量与漂移风险都远大于收益。内核提取让约束语义当天就能跑在真机上,composeMain 里的上游 UI 一行未动,等制品线对齐后直接接回、桥退役为回归对照。

决策 2:手写历法而不是绕开日期比较

coerceDate 的"最近合法日期"按整值 epoch-day 距离比较,绕开它等于重定义语义。手写 60 行标准算法 + 已知值交叉核对,比引入一个读不了的 kotlinx 制品或弱化语义都更诚实。daysInMonth / isLeapYear 则直接逐字复制上游实现,保证边界行为(2 月、世纪年)完全一致。

决策 3:selectable* 与 repaired*After 提升为 public

桥在 :example:nativeApp 模块,只能访问 :pickers 公开 API。这两个系列本来就是"渲染前列内容"与"停稳后修复结果"的稳定语义契约,公开它们比让桥复制过滤逻辑更不易漂移。这是 0.x fork 的有意 API 变化,已在 README 中记录。

决策 4:resolve / commit 双跳而不是单口全量

上游语义天然两段:渲染前的列派生(selectable*)与用户交互后的逻辑修复(repaired*After)。桥把两段都暴露,ArkTS 每次停稳后 commit + resolve,保证滚轮显示的每一列内容都出自 Kotlin 内核。单口全量会让 ArkTS 侧自行推导列内容,违反"过桥的只有语义"原则。

决策 5:桥契约测试进 commonTest

拼 JSON 没有框架兜底,一个多余字符就是全量失败(真机上的 exception: {} 就是这么来的)。把桥逻辑拷贝到 JVM、喂真实请求串、手写校验器逐字符验证响应——两小时定位一个真机 bug 的成本,换一座永久门禁。

六、测试与验证

6.1 测试环境

本次验证使用 Windows、JDK 21(Adoptium 21.0.12)、DevEco Studio 5.1.0 Release(hvigor / hdc 命令行)与鸿蒙真机(arm64)。

6.2 JVM 交叉验证

./gradlew :pickers:jvmTest

40 项用例全部通过:

测试类用例数钉住什么
PickerCalendarTest9epoch-day 已知值(含世纪闰年)、跨月/跨年边界、daysInMonth
PickerValuesTest6PickerTime / PickerDate 校验与排序、12/24 换算
TimePickerItemsTest12约束过滤(小时列 9…18、18 点只剩 minute 0)、步长列、coerce、AM↔PM 修复
YearMonthPickerItemsTest + DateRangeTest6整 YearMonth 约束、修复、区间 ordered / dayCount / intersection
DatePickerItemsTest5天数联动、约束窗口、整值 coerce、月切换钳日、区间 coerce
DurationPickerItemsTest4标量约束、列过滤、等距取小、修复
BridgeContractTest2桥 dispatch 的四个启动请求 + selftest,严格 JSON 校验

上游 Compose 测试语义被映射为内核用例——换类型不换语义,这是"内核没漂"的闸门。

6.3 HAP 验证

:pickers:compileKotlinJvm / compileKotlinOhosArm64 / OhosX64: BUILD SUCCESSFUL
:pickers:jvmTest: 40 项全绿
linkDebugSharedOhosArm64 / linkDebugSharedOhosX64: BUILD SUCCESSFUL
llvm-nm -D: PickersDispatch / PickersVersion / libohospickers_symbols 均为全局 T 符号
BuildNativeWithCmake: finished(双 ABI 编出 libpickers_napi.so)
CompileArkTS: finished / PackageHap: finished / BUILD SUCCESSFUL
install bundle successfully / start ability successfully

entry-default-unsigned.hap 约 5.9 MB,内含双 ABI 各三件套(libpickers_napi.so / libohospickers.so / libc++_shared.so);KN so 未 strip 时 arm64 约 2.8 MB、x86_64 约 2.4 MB,HAP 打包阶段由 hvigor 自动 strip。

6.4 功能用例

  1. 启动后页头显示 版本握手 0.8.0-ohos.1 与 selftest 9/9 通过(来自真实 Native 调用);
  2. 时间 Tab 切 12 小时,hour 列变为 1…12、period 列出现,selection 附 displayHour / period;
  3. 时间 Tab 加 09:00~18:00 约束,hour 列收缩为 9…18、18 点的 minute 列只剩 0,日志逐次留痕;
  4. 分钟步长切 15,minute 列变为 0/15/30/45;
  5. 日期 Tab 把 1 月 31 日的月份滚到 2 月,day 自动钳到 29(2024)/ 28(2023),年滚到约束外被拒绝(changed=false);
  6. 年月 Tab 加 2020-06~2030-06 约束,年份滚出边界时月份被修复到最近合法对;
  7. 时长 Tab 加 30~90 分钟约束,小时 0 的 minute 列只剩 30…59;
  8. 通用 Tab 点 coerce 2026-02-31 返回 2026-02-28;点非法时间(25:99)走错误通道显示 hour must be in range [0, 23];contains 按钮返回预期布尔。

6.5 验证结论

约束内核、历法数学、Native 动态库、C ABI、N-API、ArkTS 自绘滚轮和 HAP 已完成链路验证。应用在鸿蒙真机上成功安装并进入前台,版本握手与 9/9 内核自检通过;首个真机版本发现的 resolve 响应拼装 bug 已由桥契约测试复现、修复并回归(见 8.1 第 1 条),修复版 HAP 重新打包待复验。

七、运行效果

7.1 真机截图

在这里插入图片描述

7.2 命令速查

git clone https://atomgit.com/oh-tpc/Compose-Pickers.git
cd Compose-Pickers
export JAVA_HOME="/path/to/jdk-21"
./gradlew :pickers:compileKotlinOhosArm64 :pickers:compileKotlinOhosX64   # klib
./gradlew :pickers:jvmTest                                                # 40 项交叉验证
./gradlew :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64
cd example/harmonyApp
hvigorw assembleHap -p product=default -p buildMode=debug --no-daemon
hdc install -r entry/build/default/outputs/default/entry-default-signed.hap
hdc shell aa start -a EntryAbility -b org.terminator.ohos.composepickers -m entry
hdc shell hilog | grep PickersNapi

八、遗留问题与改进方向

8.1 踩坑复盘

  1. 嵌套原始字符串的大括号数错一个,真机上所有 resolve 全挂:row() 的外层 """...${values.joinToString { """...}""" }}]}“”“多写了一个},产物 value”:23}}]}是非法 JSON;Kotlin 编译期不校验字符串内容,JVM 内核测试也碰不到桥。真机现象是 ArkTSJSON.parse抛 SyntaxError、日志统一显示exception: {}(Error 对象 stringify 后只剩空壳),而 selftest 响应不走 row()` 所以正常——这个"部分 op 正常、部分全挂"的组合一度把怀疑引向 NAPI 层。解法是把桥逻辑拷进 JVM 契约测试、用 node 定位到第 658 个字符,删一个花括号修复。手拼 JSON 的桥必须有逐字符校验的契约测试;
  2. java.lang.Math.floorDiv 在 Kotlin/Native 不存在:epoch-day 算法初版 JVM 编译通过、compileKotlinOhosArm64 才报 Unresolved reference 'Math'——JVM 绿不等于 ohos 绿,内核每个文件都要过 ohos 编译;
  3. settings.gradle.kts 里 eazytec 双仓必须排在 pluginManagement 与 dependencyResolutionManagement 最前,否则配置期直接 Unresolved reference: ohosArm64;
  4. kotlinx-datetime 的 ohos 制品(0.8.0-0.1.0-*)元数据 2.4.0,HarmonyOS Kotlin 2.2.21 读不了——零依赖 + 手写历法是唯一路线,不要逐版本试错;
  5. 上游 YearMonth.atDay()/from() 绑着 kotlinx LocalDate,内核版换成 PickerDate 后,Compose 侧 interop 移到 composeMain/YearMonthInterop.kt 扩展函数——接回时成员与扩展二选一收敛;
  6. 根构建的 /build/ 与 .idea/* 是根锚定模式,盖不住嵌套目录——example/harmonyApp/{oh_modules,.hvigor,.idea,entry/build,entry/libs,entry/.cxx} 与 example/nativeApp/build/ 要单独追加 gitignore;
  7. build-profile.json5 的 signingConfigs 条目缺 material 字段会被 hvigor schema 拒绝(报 00303038)——仓库内留空数组且 product 不引用签名,勾选自动签名后由 IDE 回填完整配置;
  8. HAP 缺当前 ABI 的 so 会报安装失败 9568347,abiFilters 必须双 ABI,每个 ABI 三件套缺一不可;
  9. KN 返回字符串必须 DisposeString 归还运行时,N-API 层不能 free();
  10. 签名材料由 DevEco 自动写入 build-profile.json5,提交前用 git update-index --skip-worktree 保护,证书绝不进入 AtomGit;
  11. HAP 构建必须用 DevEco 自带 JBR,用系统 JDK 8 会让 hvigorw assembleHap 报不相干的错;
  12. ArkTS 只 import NAPI so(libpickers_napi.so);HarmonyOS Kotlin 的 CAdapter 不导出 @CName 业务符号,直接 import KN so 只能看到 stdlib zip 类型。

8.2 已知问题

  • Compose UI(Composable、remember*State、样式 / 语义体系)未编译鸿蒙,在 composeMain 休眠,等社区 CMP for ohos 与上游 1.11 对齐后接回;
  • 桥不读系统时钟(currentDate/currentDateTime 留在 composeMain),初始值由 ArkTS 传设备当前时间——与上游"initial 值由调用方传入"的契约一致;
  • DateRangePicker 的内核(DateRange / coerceDateRange)已过桥可用,Demo 未做双列区间页面;
  • 上游滚动物理是 Compose rememberSnapFlingBehavior(框架内置),本 Demo 用 ArkTS animateTo 等价表达;无障碍(TalkBack)语义属 UI 层,未跨桥;
  • android / js / wasmJs / apple 目标剥离(源文件留在树里),发布、dokka、ABI 校验一并剥离,本 fork 不发 Maven Central。

8.3 未来优化方向

  • 补 DateRangePicker 双列 Demo 页(内核已就绪,仅缺 ArkTS 页面);
  • 增加 commit 后的批量校验矩阵(全部列 × 全部约束组合的黄金文件对比);
  • 评估 .har 形态发布,让 ArkTS 工程直接依赖而不是走 so 三件套;
  • 在 CI 中加入 ohos 编译、ELF 符号检查和 HAP 构建;
  • 补充模拟器(x86_64)验收矩阵和自动化截图脚本。

九、总结

9.1 核心难点回顾

上游 Compose-Pickers commonMain(26 文件)
    → 逐文件 grep androidx.compose 划定内核 / UI 边界
    → 验证 kotlinx ohos 制品线不可用(元数据 2.4.0 vs 编译器 2.2.21)
    → 工具链对齐 HarmonyOS Kotlin 2.2.21-1.0.0(Gradle 降到 8.14.1)
    → 根构建替换(cmp_qrose 模式)+ 源码集拆分(composeMain / composeTest 休眠)
    → 零依赖内核:PickerTime/PickerDate + 手写历法 + 决策逻辑逐行移植
    → C ABI(@CName × 2)+ 5 op JSON 协议
    → C++ N-API(DisposeString 纪律)
    → ArkTS 自绘滚轮 + resolve/commit 双跳
    → ArkUI 真机五 Tab 验收 + 桥契约测试回归

9.2 封装层次

  • 内核层:上游决策逻辑的移植版——四类 items/约束、selectable* 列派生、repaired*After 修复、coerce* 归一化、手写历法;
  • Native 层:生成双 ABI 动态库并通过 2 个 C ABI 输出 JSON;
  • N-API 层:完成参数检查、字符串转换和 Native 内存释放;
  • ArkTS 层:自绘滚轮形态、resolve/commit 双跳状态机、chips 配置、日志与错误文本;
  • DevEco 层:完成 CMake、HAP、签名、安装和真机运行。

9.3 三条经验

  1. UI 库适配的价值切割点在"语义/形态"边界:滚轮长什么样(形态)ArkTS 复刻成本固定且不会漂移;显示什么、修到哪(语义)必须留在 Kotlin——这条边界划清后,selectable* 与 repaired*After 两个函数族就是桥的全部职责面;
  2. 制品线可达性决定路线,且要当天验证:同为 kotlinx,coroutines/serialization 的 ohos 线让 KStore 走"真编译整库",kotlinx-datetime 的元数据墙让本库走"内核提取"——两条路都对,依据是当天的 Maven 实测而不是历史结论;
  3. 手拼 JSON 的桥必须配逐字符契约测试:编译器不校验字符串内容,真机上 exception: {} 几乎不带任何定位信息;把桥拷进 JVM、喂真实请求、严格校验响应,两小时定位一个全量失败 bug,之后它是永久门禁。

9.4 适配成果

当前适配已完成:

  • Compose-Pickers 0.8.0 约束内核的双 ABI Kotlin/Native 编译(决策逻辑逐行移植,历法手写并交叉核对);
  • 2 个 C ABI 符号和 C++ N-API 包装层(llvm-nm 验证全局 T 符号);
  • DevEco Stage ArkUI 五 Tab 验收页面(时间 / 日期 / 年月 / 时长 / 通用+自检,自绘滚轮,桥调用日志留痕);
  • 40 项 JVM 交叉验证全绿(38 项内核语义 + 2 项桥契约)+ HAP 构建(双 ABI 六件套 so,约 5.9MB)、签名、设备安装和真机验证;
  • 真机首验发现的桥拼装 bug 已修复并回归,修复版待复验;
  • AtomGit 项目文档、OpenHarmony README(中英双份)和本地效果图。

适配过程 FAQ

以下问题来自 ohosArm64 目标接入、NAPI 桥接、HAP 构建与真机验收的实际流程,按出现频率排序。

Q1:DevEco Studio Sync 失败或 ohpm 依赖下载超时,怎么处理?

  • 确认 DevEco Studio 版本与工程 build-profile.json5 的 compatibleSdkVersion 一致;
  • 在 File > Settings > Build, Execution, Deployment > Ohpm 配置可用的 ohpm 仓库镜像或代理;
  • 删除 oh_modules、oh-package-lock.json5 与 .hvigor 缓存后重新 Sync;
  • 公司网络需要代理时,同时给 ohpm 与 hvigor 配置 http.proxy。

Q2:CMake 报 Missing .../libxxx.so 或找不到 Kotlin/Native 动态库?

  • 先在 example 目录执行 ./gradlew :nativeApp:linkDebugSharedOhosArm64(模拟器用 linkDebugSharedOhosX64);
  • 确认 libxxx.so 与 libc++_shared.so 已复制到 ohos/entry/libs/<abi>/;
  • 检查 entry/build-profile.json5 的 abiFilters 与设备 ABI 是否一致(真机 arm64-v8a,模拟器 x86_64);
  • 清理 entry/.cxx 与 entry/build 后重试。

Q3:ArkTS 报 Cannot find module 'libxxx_napi.so' 或调用返回 undefined?

  • 确认 CMake 的 add_library(<target> SHARED ...) 名称与 import ... from 'libxxx_napi.so' 完全一致;
  • 解包 HAP,确认 libs/<abi>/libxxx_napi.so 已打包;
  • 删除 entry/build、entry/.cxx 与 oh_modules 后重新构建;
  • 检查 module.json5 的 srcEntry 与页面路径是否正确。

Q4:真机安装报 signature verification failed 或 install failed?

  • 在 DevEco Studio 的 Signing Configs 配置证书、Profile 与密码;
  • bundleName 必须与签名 profile 中的 bundle name 一致;
  • 设备需加入 profile 的 deviceIds,调试证书有效期需覆盖当前时间;
  • 用 hdc list targets 确认设备在线,再执行 hdc install -r xxx-signed.hap。

Q5:应用启动白屏、闪退或 NAPI 调用崩溃?

  • 用 hdc hilog | grep -i "napi\|kotlin" 查看原生日志;
  • 确认 libc++_shared.so 与 Kotlin/Native 工具链版本匹配,且 ABI 与设备一致;
  • 检查 C ABI 返回的字符串是否由 Native 侧分配、ArkTS 侧复制后是否调用 XxxFree 释放;
  • 在 DevEco 的 Native 调试配置中附加 lldb,定位空指针与越界访问。

Q6:Gradle 报 ohosArm64 目标不存在或 Kotlin/Native 编译失败?

  • 使用鸿蒙发行版 Kotlin(如 2.2.21-1.0.0),并在 settings.gradle.kts 配置 eazytec 仓库;
  • Gradle 建议 8.x、JDK 21,版本不匹配时先统一工具链;
  • 确认 kotlin { ohosArm64() } 已应用,且 local.properties 的 SDK 路径有效;
  • 清理 ~/.gradle/caches 中对应版本的 Kotlin/Native 缓存后重试。

Q7:HAP 构建报 SDK 版本不匹配或 compatibleSdkVersion 错误?

  • 对齐 DevEco Studio、HarmonyOS SDK 与 build-profile.json5 中的 compatibleSdkVersion;
  • 升级 DevEco Studio 后同步升级 hvigor 与 SDK,避免 modelVersion 与 IDE 不匹配;
  • 命令行构建时设置 DEVECO_SDK_HOME 指向 DevEco 的 sdk 目录。

Q8:命令行 hvigor 构建失败,但 DevEco 里能构建?

  • 使用 DevEco 自带的 node 与 hvigor:
    "$DEVECO/Contents/tools/node/bin/node" "$DEVECO/Contents/tools/hvigor/bin/hvigorw.js" ...;
  • 设置 JAVA_HOME、NODE_HOME、DEVECO_SDK_HOME;
  • 加上 --no-daemon 排除守护进程缓存影响,并清理 entry/build 与 .hvigor。

参考文档

Logo

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

更多推荐