开源鸿蒙平台 CMP 三方库「Compose-Pickers」适配全流程
本文记录
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 Kotlin2.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)。 |
changed | commit 时选中值是否发生变化(非法列值被拒绝时为 false)。 |
passed / failed / cases | selftest 的逐用例结果。 |
内核侧完整能力(约束构造期校验、等距取小、整值距离比较、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 Multiplatform | HarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven) |
| JDK | 21(org.gradle.java.home 指向 Adoptium 21.0.12) |
| OpenHarmony 目标 | ohosArm64 + ohosX64(jvm() 保留作交叉验证) |
| Gradle | 8.14.1(wrapper 替换;2.2.21 线经验值,不沿用上游 9.7.0) |
| 第三方依赖 | 无(commonMain 零依赖;kotlin.time.Duration 走标准库) |
| DevEco product | 5.1.0(18) 兼容 |
| DevEco Studio | 5.1.0 Release |
| ABI | arm64-v8a + x86_64 |
| Bundle | org.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.kt | kotlin.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 方法 | 覆盖内核 |
|---|---|---|
PickersDispatch | pickersDispatch(req) | selectable* 列派生 + repaired*After + coerce* + contains + selftest |
PickersVersion | pickersVersion() | 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.kt | PickerTime / PickerDate 值类型 |
pickers/src/commonMain/.../date/PickerCalendar.kt | daysInMonth / isLeapYear / epochDaysFor(手写历法) |
pickers/src/commonMain/.../PickerItems.kt | 四类 items + 约束 + coerce/contains(类型移植版) |
pickers/src/commonMain/.../{time,date,duration}/*Selection.kt | repaired*After(public,含新增 yearMonth 版) |
pickers/src/commonTest/ | 38 项内核语义测试 + 2 项桥契约测试 |
example/nativeApp/build.gradle.kts | 双 ABI sharedLib、cxxShared 定位 libc++、copyKnSo* 回填 |
nativeApp/.../Bridge.kt | 2 个 C ABI、5 个 op、MiniJson、错误通道 |
harmonyApp/.../cpp/napi_init.cpp | N-API 导出、参数检查、DisposeString |
harmonyApp/.../cpp/CMakeLists.txt | IMPORTED 引入 KN so、$ORIGIN rpath、POST_BUILD 三件套 |
harmonyApp/.../ets/pages/Index.ets | ArkUI 五 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 / hourOfDayFor | 12/24 小时换算 |
| 内核 | DateRange.ordered / contains / overlaps / intersection | 区间运算 |
| Native | PickersDispatch / PickersVersion | C ABI JSON 导出 |
| N-API | pickersDispatch(req) / pickersVersion() | 向 ArkTS 暴露能力 |
| ArkTS | resolve() / 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 项用例全部通过:
| 测试类 | 用例数 | 钉住什么 |
|---|---|---|
PickerCalendarTest | 9 | epoch-day 已知值(含世纪闰年)、跨月/跨年边界、daysInMonth |
PickerValuesTest | 6 | PickerTime / PickerDate 校验与排序、12/24 换算 |
TimePickerItemsTest | 12 | 约束过滤(小时列 9…18、18 点只剩 minute 0)、步长列、coerce、AM↔PM 修复 |
YearMonthPickerItemsTest + DateRangeTest | 6 | 整 YearMonth 约束、修复、区间 ordered / dayCount / intersection |
DatePickerItemsTest | 5 | 天数联动、约束窗口、整值 coerce、月切换钳日、区间 coerce |
DurationPickerItemsTest | 4 | 标量约束、列过滤、等距取小、修复 |
BridgeContractTest | 2 | 桥 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 功能用例
- 启动后页头显示
版本握手 0.8.0-ohos.1与selftest 9/9 通过(来自真实 Native 调用); - 时间 Tab 切 12 小时,hour 列变为 1…12、period 列出现,selection 附
displayHour/period; - 时间 Tab 加 09:00~18:00 约束,hour 列收缩为 9…18、18 点的 minute 列只剩 0,日志逐次留痕;
- 分钟步长切 15,minute 列变为 0/15/30/45;
- 日期 Tab 把 1 月 31 日的月份滚到 2 月,day 自动钳到 29(2024)/ 28(2023),年滚到约束外被拒绝(changed=false);
- 年月 Tab 加 2020-06~2030-06 约束,年份滚出边界时月份被修复到最近合法对;
- 时长 Tab 加 30~90 分钟约束,小时 0 的 minute 列只剩 30…59;
- 通用 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 踩坑复盘
- 嵌套原始字符串的大括号数错一个,真机上所有 resolve 全挂:
row()的外层"""...${values.joinToString { """...}""" }}]}“”“多写了一个},产物value”:23}}]}是非法 JSON;Kotlin 编译期不校验字符串内容,JVM 内核测试也碰不到桥。真机现象是 ArkTSJSON.parse抛 SyntaxError、日志统一显示exception: {}(Error 对象 stringify 后只剩空壳),而 selftest 响应不走row()` 所以正常——这个"部分 op 正常、部分全挂"的组合一度把怀疑引向 NAPI 层。解法是把桥逻辑拷进 JVM 契约测试、用 node 定位到第 658 个字符,删一个花括号修复。手拼 JSON 的桥必须有逐字符校验的契约测试; java.lang.Math.floorDiv在 Kotlin/Native 不存在:epoch-day 算法初版 JVM 编译通过、compileKotlinOhosArm64才报Unresolved reference 'Math'——JVM 绿不等于 ohos 绿,内核每个文件都要过 ohos 编译;settings.gradle.kts里 eazytec 双仓必须排在pluginManagement与dependencyResolutionManagement最前,否则配置期直接Unresolved reference: ohosArm64;- kotlinx-datetime 的 ohos 制品(
0.8.0-0.1.0-*)元数据 2.4.0,HarmonyOS Kotlin 2.2.21 读不了——零依赖 + 手写历法是唯一路线,不要逐版本试错; - 上游
YearMonth.atDay()/from()绑着 kotlinxLocalDate,内核版换成PickerDate后,Compose 侧 interop 移到composeMain/YearMonthInterop.kt扩展函数——接回时成员与扩展二选一收敛; - 根构建的
/build/与.idea/*是根锚定模式,盖不住嵌套目录——example/harmonyApp/{oh_modules,.hvigor,.idea,entry/build,entry/libs,entry/.cxx}与example/nativeApp/build/要单独追加 gitignore; build-profile.json5的signingConfigs条目缺material字段会被 hvigor schema 拒绝(报00303038)——仓库内留空数组且 product 不引用签名,勾选自动签名后由 IDE 回填完整配置;- HAP 缺当前 ABI 的 so 会报安装失败
9568347,abiFilters必须双 ABI,每个 ABI 三件套缺一不可; - KN 返回字符串必须
DisposeString归还运行时,N-API 层不能free(); - 签名材料由 DevEco 自动写入
build-profile.json5,提交前用git update-index --skip-worktree保护,证书绝不进入 AtomGit; - HAP 构建必须用 DevEco 自带 JBR,用系统 JDK 8 会让
hvigorw assembleHap报不相干的错; - 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 用 ArkTSanimateTo等价表达;无障碍(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 三条经验
- UI 库适配的价值切割点在"语义/形态"边界:滚轮长什么样(形态)ArkTS 复刻成本固定且不会漂移;显示什么、修到哪(语义)必须留在 Kotlin——这条边界划清后,
selectable*与repaired*After两个函数族就是桥的全部职责面; - 制品线可达性决定路线,且要当天验证:同为 kotlinx,coroutines/serialization 的 ohos 线让 KStore 走"真编译整库",kotlinx-datetime 的元数据墙让本库走"内核提取"——两条路都对,依据是当天的 Maven 实测而不是历史结论;
- 手拼 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。
参考文档
- AtomGit/oh-tpc/Compose-Pickers
- KMP&CMP 鸿蒙社区
- Kotlin Multiplatform
- OpenHarmony N-API
- DevEco Studio
- OpenHarmony ArkUI
本文记录
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 Kotlin2.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)。 |
changed | commit 时选中值是否发生变化(非法列值被拒绝时为 false)。 |
passed / failed / cases | selftest 的逐用例结果。 |
内核侧完整能力(约束构造期校验、等距取小、整值距离比较、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 Multiplatform | HarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven) |
| JDK | 21(org.gradle.java.home 指向 Adoptium 21.0.12) |
| OpenHarmony 目标 | ohosArm64 + ohosX64(jvm() 保留作交叉验证) |
| Gradle | 8.14.1(wrapper 替换;2.2.21 线经验值,不沿用上游 9.7.0) |
| 第三方依赖 | 无(commonMain 零依赖;kotlin.time.Duration 走标准库) |
| DevEco product | 5.1.0(18) 兼容 |
| DevEco Studio | 5.1.0 Release |
| ABI | arm64-v8a + x86_64 |
| Bundle | org.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.kt | kotlin.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 方法 | 覆盖内核 |
|---|---|---|
PickersDispatch | pickersDispatch(req) | selectable* 列派生 + repaired*After + coerce* + contains + selftest |
PickersVersion | pickersVersion() | 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.kt | PickerTime / PickerDate 值类型 |
pickers/src/commonMain/.../date/PickerCalendar.kt | daysInMonth / isLeapYear / epochDaysFor(手写历法) |
pickers/src/commonMain/.../PickerItems.kt | 四类 items + 约束 + coerce/contains(类型移植版) |
pickers/src/commonMain/.../{time,date,duration}/*Selection.kt | repaired*After(public,含新增 yearMonth 版) |
pickers/src/commonTest/ | 38 项内核语义测试 + 2 项桥契约测试 |
example/nativeApp/build.gradle.kts | 双 ABI sharedLib、cxxShared 定位 libc++、copyKnSo* 回填 |
nativeApp/.../Bridge.kt | 2 个 C ABI、5 个 op、MiniJson、错误通道 |
harmonyApp/.../cpp/napi_init.cpp | N-API 导出、参数检查、DisposeString |
harmonyApp/.../cpp/CMakeLists.txt | IMPORTED 引入 KN so、$ORIGIN rpath、POST_BUILD 三件套 |
harmonyApp/.../ets/pages/Index.ets | ArkUI 五 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 / hourOfDayFor | 12/24 小时换算 |
| 内核 | DateRange.ordered / contains / overlaps / intersection | 区间运算 |
| Native | PickersDispatch / PickersVersion | C ABI JSON 导出 |
| N-API | pickersDispatch(req) / pickersVersion() | 向 ArkTS 暴露能力 |
| ArkTS | resolve() / 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 项用例全部通过:
| 测试类 | 用例数 | 钉住什么 |
|---|---|---|
PickerCalendarTest | 9 | epoch-day 已知值(含世纪闰年)、跨月/跨年边界、daysInMonth |
PickerValuesTest | 6 | PickerTime / PickerDate 校验与排序、12/24 换算 |
TimePickerItemsTest | 12 | 约束过滤(小时列 9…18、18 点只剩 minute 0)、步长列、coerce、AM↔PM 修复 |
YearMonthPickerItemsTest + DateRangeTest | 6 | 整 YearMonth 约束、修复、区间 ordered / dayCount / intersection |
DatePickerItemsTest | 5 | 天数联动、约束窗口、整值 coerce、月切换钳日、区间 coerce |
DurationPickerItemsTest | 4 | 标量约束、列过滤、等距取小、修复 |
BridgeContractTest | 2 | 桥 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 功能用例
- 启动后页头显示
版本握手 0.8.0-ohos.1与selftest 9/9 通过(来自真实 Native 调用); - 时间 Tab 切 12 小时,hour 列变为 1…12、period 列出现,selection 附
displayHour/period; - 时间 Tab 加 09:00~18:00 约束,hour 列收缩为 9…18、18 点的 minute 列只剩 0,日志逐次留痕;
- 分钟步长切 15,minute 列变为 0/15/30/45;
- 日期 Tab 把 1 月 31 日的月份滚到 2 月,day 自动钳到 29(2024)/ 28(2023),年滚到约束外被拒绝(changed=false);
- 年月 Tab 加 2020-06~2030-06 约束,年份滚出边界时月份被修复到最近合法对;
- 时长 Tab 加 30~90 分钟约束,小时 0 的 minute 列只剩 30…59;
- 通用 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 踩坑复盘
- 嵌套原始字符串的大括号数错一个,真机上所有 resolve 全挂:
row()的外层"""...${values.joinToString { """...}""" }}]}“”“多写了一个},产物value”:23}}]}是非法 JSON;Kotlin 编译期不校验字符串内容,JVM 内核测试也碰不到桥。真机现象是 ArkTSJSON.parse抛 SyntaxError、日志统一显示exception: {}(Error 对象 stringify 后只剩空壳),而 selftest 响应不走row()` 所以正常——这个"部分 op 正常、部分全挂"的组合一度把怀疑引向 NAPI 层。解法是把桥逻辑拷进 JVM 契约测试、用 node 定位到第 658 个字符,删一个花括号修复。手拼 JSON 的桥必须有逐字符校验的契约测试; java.lang.Math.floorDiv在 Kotlin/Native 不存在:epoch-day 算法初版 JVM 编译通过、compileKotlinOhosArm64才报Unresolved reference 'Math'——JVM 绿不等于 ohos 绿,内核每个文件都要过 ohos 编译;settings.gradle.kts里 eazytec 双仓必须排在pluginManagement与dependencyResolutionManagement最前,否则配置期直接Unresolved reference: ohosArm64;- kotlinx-datetime 的 ohos 制品(
0.8.0-0.1.0-*)元数据 2.4.0,HarmonyOS Kotlin 2.2.21 读不了——零依赖 + 手写历法是唯一路线,不要逐版本试错; - 上游
YearMonth.atDay()/from()绑着 kotlinxLocalDate,内核版换成PickerDate后,Compose 侧 interop 移到composeMain/YearMonthInterop.kt扩展函数——接回时成员与扩展二选一收敛; - 根构建的
/build/与.idea/*是根锚定模式,盖不住嵌套目录——example/harmonyApp/{oh_modules,.hvigor,.idea,entry/build,entry/libs,entry/.cxx}与example/nativeApp/build/要单独追加 gitignore; build-profile.json5的signingConfigs条目缺material字段会被 hvigor schema 拒绝(报00303038)——仓库内留空数组且 product 不引用签名,勾选自动签名后由 IDE 回填完整配置;- HAP 缺当前 ABI 的 so 会报安装失败
9568347,abiFilters必须双 ABI,每个 ABI 三件套缺一不可; - KN 返回字符串必须
DisposeString归还运行时,N-API 层不能free(); - 签名材料由 DevEco 自动写入
build-profile.json5,提交前用git update-index --skip-worktree保护,证书绝不进入 AtomGit; - HAP 构建必须用 DevEco 自带 JBR,用系统 JDK 8 会让
hvigorw assembleHap报不相干的错; - 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 用 ArkTSanimateTo等价表达;无障碍(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 三条经验
- UI 库适配的价值切割点在"语义/形态"边界:滚轮长什么样(形态)ArkTS 复刻成本固定且不会漂移;显示什么、修到哪(语义)必须留在 Kotlin——这条边界划清后,
selectable*与repaired*After两个函数族就是桥的全部职责面; - 制品线可达性决定路线,且要当天验证:同为 kotlinx,coroutines/serialization 的 ohos 线让 KStore 走"真编译整库",kotlinx-datetime 的元数据墙让本库走"内核提取"——两条路都对,依据是当天的 Maven 实测而不是历史结论;
- 手拼 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。
参考文档
更多推荐



所有评论(0)