开源鸿蒙平台 CMP 三方库「DateTime Wheel Picker」适配全流程
本文记录
datetime-wheel-picker接入 OpenHarmony 的完整过程,覆盖滚轮选择器库的依赖盘点、源码集拆分、零依赖内核提取、ohosArm64/ohosX64目标、Kotlin/Native 动态库、C ABI/N-API 桥接、ArkUI 真机页面、HAP 构建和真机验收。
datetime-wheel-picker是基于 Compose Multiplatform 的日期时间滚轮选择器:WheelDatePicker/WheelTimePicker/WheelDateTimePicker三个@Composable入口,底层是 LazyColumn 滚轮 + 圆柱投影(barrel)+ Material3 主题,再往下是一套覆盖 33 种语言的本地化格式引擎(月名全称/缩写/数字、AM/PM、CJK 年/月/日后缀、本地数字系统、日期字段顺序、12/24 小时制、闰年历法)
项目地址: AtomGit/oh-tpc/compose-datetime-wheel-picker
上游仓库: darkokoa/compose-datetime-wheel-picker
开发工具: 华为云码道
环境安装: 环境安装指南
一、背景
1.1 为什么做 OpenHarmony 平台 KMP/CMP 适配
datetime-wheel-picker 为 Kotlin Multiplatform 应用提供"滚轮选日期时间"这一件事的完整答案:WheelDatePicker(startDate, minDate, maxDate, yearsRange, …) 一行入场,年/月/日三列滚轮按 locale 自动排序(en-US → MDY、zh/ja/ko → YMD、欧洲 → DMY),月份随宽度在全称/缩写间切换,CJK 语言自动带"年/月/日"后缀,阿拉伯语用 ٠-٩、波斯语和中亚维吾尔文用 ۰-۹ 数字系统,时间列按 locale 切 12/24 小时制,2 月天数跟着闰年走、选了 2 月 31 日自动收敛到月末。这套行为全部长在 commonMain 的纯逻辑 + Compose UI 上,由 Android、iOS、桌面、Web 共享。OpenHarmony 应用同样需要这套滚轮语义,但上游构建(Kotlin 2.4.20 / CMP 1.12.1 / Gradle 9.6.1 / AGP 9.3.1)不会生成 OpenHarmony 变体。
如果只把示例页面改成 ArkTS,页面可以转起来,却无法证明公共格式引擎在 OpenHarmony ARM64 设备上完成编译、链接和执行。适配需要同时验证本地化引擎、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 交付链路。
| 障碍 | 具体问题 |
|---|---|
| 目标缺失 | 必须增加 ohosArm64() / ohosX64() 才能生成 OpenHarmony KLIB。 |
| Compose 不可用 | 上游 CMP 1.12.1 / Material3 1.9.0;社区 CMP for HarmonyOS 仅 1.9.2——@Composable、LazyColumn、圆柱投影全部无法在 ohos target 上解析。 |
| kotlinx-datetime 断供 | eazytec 制品线上的 kotlinx-datetime 0.8.0-0.1.0-02/-05/-07 全部以 Kotlin 2.4.20 元数据构建,已审核钉法 Kotlin 2.2.21-1.0.0 读不了 2.4 元数据。 |
| Lyricist/KSP 断供 | 33 个语言文件的 @LyricistStrings 注解与 KSP 生成的聚合表在鸿蒙工具链上不可用。 |
| 语言边界不同 | ArkTS 不能直接持有 Kotlin 对象,需要 C ABI、C++ N-API 和 JSON。 |
| 交付链路复杂 | .so、CMake、N-API、HAP、签名和 hdc 都要单独验收。 |
因此,本项目把边界放在:格式引擎的零依赖提取与 ohos 编译、根构建替换(cmp_qrose 模式)和 C ABI/N-API 桥接层。滚轮文案与历法语义完全由上游原版实现负责,ArkTS 只负责调用 Native、解析 JSON 和渲染滚轮。
1.2 库提供的能力
Demo 提供一个真实的滚轮选择会话,覆盖桥接的全部入口:
- 滚轮数据(rows):给定 locale 与样式,返回年/月/日/时/分每列的
{text, value}数组——文案全部来自上游 33 语言资源表; - 字段顺序(dateOrder):
["YEAR","MONTH","DAY"]/["MONTH","DAY","YEAR"]/["DAY","MONTH","YEAR"],ArkTS 据此排列滚轮列; - 制式解析(timeFormat):
AM_PM/HOUR_24,AM/PM 模式追加上午/下午列并把小时行换成 1…12; - 闰年钳制:年/月变化时天数列实时重算,2024-02 出 29 行、选 31 自动钳到 29;
- 版本握手:响应携带
libraryVersion: "1.5.0-ohos.1",来自真实 Native 调用而非静态文字。
响应字段:
| 字段 | 作用 |
|---|---|
ok | 是否成功,失败时 error 带原文。 |
languageTag / cjk | locale 解析结果(zh-Hans-CN → zh)与 CJK 判定。 |
timeFormat | AM_PM / HOUR_24。 |
dateOrder | 日期列顺序数组;kind=time 时为 null。 |
rows | 每列 {text, value} 数组:year / month / day / hour / minute / ampm。 |
selection | 归一化后的当前选中值(含钳制结果与 ampm 下标)。 |
引擎侧完整能力(月名、数字系统、后缀、顺序、制式、闰年)全部来自上游 commonMain 的资源表与纯函数,随编译进入 .so;JVM 侧保留 47 项 jvmTest 交叉验证用例,证明 ohos .so 里执行的格式化逻辑没有漂移。
1.3 实现适配
| 维度 | 要求 |
|---|---|
| 代码复用 | 格式引擎(33 语言表 + 顺序/制式/数字/历法纯函数)原样保留在 commonMain 编译;Compose UI 原样移入 composeMain 留存不编译;新增公共门面 WheelPickerL10n 作为桥的唯一入口。 |
| 平台目标 | 根构建直接声明 ohosArm64 + ohosX64,保留 jvm 作交叉验证,库模块出 KLIB,example/nativeApp 出双 ABI 动态库。 |
| 零依赖纪律 | commonMain 不依赖任何第三方库(kotlinx-datetime / Lyricist / Compose 全部隔离),历法数学由 CalendarMath.kt 以纯 Kotlin 补齐。 |
| 桥接稳定 | 1 个 C ABI、UTF-8 JSON 进出一趟;异常永远不跨 FFI。 |
| UI 完整 | 单页覆盖全部引擎维度:11 种语言、3 种选择器类型、月份样式、CJK 后缀、时间制式实时切换。 |
| 仓库规范 | 项目说明、文章、效果图和源码链接统一使用 AtomGit。 |
本项目的 ArkUI 页面不是 Compose Multiplatform UI 实现。上游滚轮的圆柱投影、行高模式与 Material3 样式留在
composeMain(见 8.2);ArkTSTextPicker承担滚轮交互,引擎输出滚轮的全部数据与语义。
二、实现路线图
第 1 阶段:项目初始化 ── 盘点模块与依赖边界、盘点 Compose / kotlinx-datetime 依赖面、确定交付物
第 2 阶段:目标与依赖打通 ── 根构建替换、eazytec Maven 置前、Kotlin 2.2.21-1.0.0 钉版、commonMain 零依赖化
第 3 阶段:门面与桥协议 ── WheelPickerL10n 门面、DatetimeWheelResolve JSON 契约、错误通道
第 4 阶段:原生桥接 ── @CName C ABI、C++ N-API、参数校验和内存释放
第 5 阶段:ArkUI 宿主 ── 语言/样式 chip 组、TextPicker 滚轮列、闰年联动和结果回显
第 6 阶段:示例与验证 ── HAP、签名、设备安装、真机运行和效果图
三、逐步实现过程
第 1 阶段:项目初始化
1.1 工程边界
datetime-wheel-picker/ 库模块(commonMain 内核 + composeMain 上游 UI 隔离,jvm + ohosArm64 + ohosX64)
datetime-wheel-picker/src/commonMain/ 格式引擎:33 语言 strings + StringsRegistry、DateOrder/DateField/
TimeFormat/MonthDisplayStyle、MonthStringsExt、LocalizedNumeralsExt、
CalendarMath、WheelPickerL10n 门面、TimeFormatter 纯接口
datetime-wheel-picker/src/composeMain/ 上游 Compose 滚轮 UI 原样移入(20 个文件,不编译)
example/nativeApp/ Kotlin/Native 动态库(Bridge + MiniJson)
example/harmonyApp/ DevEco Stage、CMake N-API 和 ArkTS 页面
README.OpenHarmony.md / _CN.md 鸿蒙适配说明(中英双份)
README.upstream.md / .zh-CN.md 上游原文保存
docs/ sample/ 上游文档站与多平台示例,留盘不进构建
构建拓扑走 cmp_qrose 模式(根构建即 ohos 构建):settings.gradle.kts 只 include :datetime-wheel-picker 与 :example:nativeApp,rootProject.name = "ohos_datetime_wheel_picker"。上游根构建里的 AGP classpath、compose / compose-compiler / KSP / vanniktech 发布插件、Maven Central 坐标与 sample 模块全部随根构建替换剥离——本 fork 不发 Maven Central。
1.2 工具链矩阵
| 项目 | 配置 |
|---|---|
| Kotlin Multiplatform | HarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven,cmp_qrose 已审核钉法) |
| JDK | 21(org.gradle.java.home 显式指向) |
| OpenHarmony 目标 | ohosArm64 + ohosX64(jvm 保留作交叉验证) |
| Gradle | 8.14.1(自上游 9.6.1 降级——2.2.21 旧线只支持到 Gradle 8.x,wrapper 与 cmp_qrose 同源) |
| third-party 依赖 | commonMain 为零(kotlinx-datetime / Lyricist / Compose 全部隔离) |
| DevEco product | 5.1.0(18) 兼容 |
| DevEco Studio | 5.1.0 Release |
| ABI | arm64-v8a + x86_64 |
| Bundle | org.terminator.ohos.datetimewheelpicker |
export JAVA_HOME="/path/to/jdk-21"
java -version
1.3 源码依赖度盘点
对上游 commonMain 全部 51 个源文件逐个核对 import,分四类处置:
| 类别 | 文件 | 依赖形态 | 处置 |
|---|---|---|---|
| 纯数据/纯函数 | 33 语言 strings/*、DateOrder、DateField、TimeFormat、MonthDisplayStyle、LocalizedNumeralsExt、LocaleExtensions、TimeFormatter、SnappedChangeNotifier、ChronoUnit | 纯 Kotlin(strings 仅多一个 Lyricist 注解 import) | 留在 commonMain(剥离注解/Compose 部分) |
| kotlinx-datetime 专属 | KLocalDateExt / KLocalDateTimeExt / KLocalTimeExt / SnappedDateTime | LocalDate / LocalTime / LocalDateTime / kotlin.time.Clock | 移入 composeMain,历法数学由 CalendarMath.kt 补齐 |
| Compose UI | WheelDatePicker / WheelTimePicker / WheelDateTimePicker、core/WheelPicker / WheelTextPicker / Standard* / Adaptive* / CJKWheelDatePicker / DateComponents / PickerSize / WheelRows / BarrelProperties / format/DateFormatter / format/CjkSuffixConfig | androidx.compose.*(runtime/ui/foundation/material3) | 移入 composeMain 原样留存 |
| 测试 | 7 个 commonTest + 2 个 jvmTest | 纯断言 / ui.unit.Dp / Compose 测试框架 | 纯测试留 commonTest;Dp / Compose 依赖移 composeTest 留存 |
expect/actual 全库为零——平台差异全部由 Compose 公共 API 抹平,这对"内核提取"是天然利好:留在 commonMain 的部分一个 expect 都不用补。
1.4 页面边界
标题区 DateTime Wheel Picker / 副标题(Compose Multiplatform 日期时间滚轮选择器 · OpenHarmony 适配演示)
状态行 解析语言 zh · CJK 是 · 12 小时制 · 库版本 1.5.0-ohos.1(版本握手行)
语言区 11 个语言 chip(zh-Hans-CN / en-US / en-GB / ja / ko / ar / fa / de-DE / ru / th / uz-Arab)
类型区 日期 / 时间 / 日期+时间
样式区 月份全称/缩写/数字、CJK 后缀 4 模式、制式 3 模式
滚轮区 日期列按 dateOrder 排列的 TextPicker + 时间列(时/分/上午下午)
结果区 当前选择(ISO 格式 + 12 小时折算)、仓库链接
页面启动时把设备本地时间作为初始 selection 传给桥(桥不做时钟调用),滚轮数据全量来自 Native 返回。
第 2 阶段:目标与依赖打通
2.1 制品线验证(本适配的成败手)
先查社区 Maven(https://maven.eazytec-cloud.com)上 kotlinx-datetime 的 ohos 制品。用 .module 元数据逐个验证:
org.jetbrains.kotlinx:kotlinx-datetime:0.8.0-0.1.0-02 / -05 / -07
variants: ohosArm64Api-published / ohosX64Api-published ✓(ohos 变体存在)
requires: kotlin-stdlib 2.4.20-0.1.0-02 / -06 / -07(全部是 2.4.20 元数据)
结论与 KStore 相反:ohos 变体存在,但全部基于 Kotlin 2.4.20 构建。已审核钉法 2.2.21-1.0.0 的编译器只能读到 2.3.0 元数据,直接依赖即报:
Class 'kotlin.Triple' was compiled with an incompatible version of Kotlin.
The binary version of its metadata is 2.4.0, expected version is 2.2.0.
两条路二选一:升到 Kotlin 2.4.20-0.1.0-07 沿用 datetime(KStore 路线),或保持 2.2.21-1.0.0、把 datetime 依赖整体隔离(qrose 路线)。本库选择后者——理由见决策 2。commonMain 从此零第三方依赖,这恰好是 cmp_qrose 审核配方的原生形态(qrose 的 commonMain.dependencies {} 本来就是空的)。
2.2 工具链钉版(为什么是 2.2.21-1.0.0 + Gradle 8.14.1)
本机 Kotlin/Native 预编译缓存(~/.konan/dependencies)同时存在 2.2.21-1.0.0 与 2.4.20-0.1.0-07/08 两代 HarmonyOS sysroot——cmp_qrose 正是在 2.2.21-1.0.0 上完成审核验收的。滚轮库没有 kotlinx 生态硬依赖(datetime 只是"便利依赖",历法数学 30 行就能补齐),钉已验证的旧线比追新线风险小;2.2.21 线要求 Gradle 8.x,wrapper 从上游 9.6.1 换到与 cmp_qrose 同源的 8.14.1,gradle.properties 补 kotlin.native.ignoreDisabledTargets=true 与 kotlin.native.distribution.downloadFromMaven=true。
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_datetime_wheel_picker"
include(":datetime-wheel-picker")
include(":example:nativeApp")
2.3 编译内核
datetime-wheel-picker/build.gradle.kts 收敛为纯 KMP 模块:
plugins {
alias(libs.plugins.multiplatform)
}
kotlin {
applyDefaultHierarchyTemplate()
jvmToolchain(21)
jvm() // 交叉验证用
ohosArm64()
ohosX64()
sourceSets {
all { languageSettings { optIn("kotlin.time.ExperimentalTime") } }
commonMain.dependencies { } // 零依赖是纪律,不是巧合
commonTest.dependencies { implementation(kotlin("test")) }
}
}
实测:
./gradlew :datetime-wheel-picker:compileKotlinJvm \
:datetime-wheel-picker:compileKotlinOhosArm64 \
:datetime-wheel-picker:compileKotlinOhosX64 # BUILD SUCCESSFUL
内核的三处外科手术:33 个语言文件剥离 @LyricistStrings 注解与 import;KSP 生成的聚合表由手写的 strings/StringsRegistry.kt 取代(33 个条目,键与上游 languageTag 逐字一致);DateOrder.match(Locale) / toLocalizedNumerals(Locale) 等 Compose 形态函数移入 composeMain,commonMain 侧改为 match(language, region) 纯参数版——行为逐行对应。
第 3 阶段:门面与桥协议建模
3.1 JSON 契约
dev.darkokoa.datetimewheelpicker/**(上游格式引擎,原样编译)
↓ WheelPickerL10n 门面(33 语言 / 顺序 / 制式 / 数字 / 闰年)
Bridge.kt(@CName × 1,扁平 JSON 进出)
↓ UTF-8 JSON
N-API datetimeWheelResolve()
↓
ArkUI Index.ets(单页滚动 + TextPicker 滚轮)
请求与响应:
请求 : {"locale":"zh-Hans-CN","kind":"datetime","monthStyle":"full",
"cjkSuffix":"show_all","hour12":"auto","yearStart":1970,"yearEnd":2077,
"selYear":2026,"selMonth":10,"selDay":16,"selHour":23,"selMinute":6}
响应 : {"ok":true,"kind":"datetime","languageTag":"zh","cjk":true,
"timeFormat":"HOUR_24","dateOrder":["YEAR","MONTH","DAY"],
"rows":{"year":[{"text":"2026年","value":2026},…],
"month":[{"text":"1月","value":1},…],
"day":[…],"hour":[…],"minute":[…]},
"selection":{"year":2026,"month":10,"day":16,"hour":23,"minute":6},
"libraryVersion":"1.5.0-ohos.1"}
{"ok":false,"error":"unknown kind: xxx"}
两个不对称设计:selection 的 sel* 字段允许传 -1 表示"未选择",桥端回退到范围起点(桥不做时钟调用——设备本地时间由 ArkTS 端 new Date() 注入,桥侧保持纯函数);hour12=auto 时制式由引擎按 locale 判定,on/off 可强制覆盖,方便验收两种排布。
3.2 门面为什么长这样
上游把格式化能力藏在 @Composable dateFormatter(locale, …) / timeFormatter(locale) 工厂后面,locale 来自 Compose 的 androidx.compose.ui.text.intl.Locale——这些签名在 ohos target 上无法编译。门面 WheelPickerL10n 把同一套规则改由纯 BCP-47 字符串驱动:
WheelPickerL10n.monthLabel(1, "zh", MonthDisplayStyle.NUMERIC, showSuffix = true) // "1月"
WheelPickerL10n.yearLabel(2026, "zh", showSuffix = true) // "2026年"
WheelPickerL10n.dateOrder("en-US") // MDY .timeFormat("en-GB") // AM_PM
WheelPickerL10n.clampDay(2024, 2, 31) // 29(闰年)
WheelPickerL10n.minuteLabel(9, "uz-Arab") // "۰۹"(本地数字系统)
internal 的上游函数(resolveStringsFromComponents、monthFullString、toLocalizedNumerals(strings)、isLeapYear)跨模块不可见,门面同时解决了可见性问题——这是 qrose"上游 internal 入口加公共静态门面"规则在本库的落地。桥与单测都只面对这一个 object。
3.3 错误通道
导出函数整体 try/catch,Kotlin 异常永远不跨 FFI:
@CName("DatetimeWheelResolve")
fun datetimeWheelResolve(request: String): String = try {
resolve(MiniJson.parse(request))
} catch (t: Throwable) {
"""{"ok":false,"error":${jsonString(t.message ?: t.toString())}}"""
}
非法 kind、越界年份、损坏 JSON,全部走同一个 ok:false 通道,ArkTS 侧统一渲染到状态行。
第 4 阶段:原生桥接(技术难点)
4.1 三层桥接
ArkTS
│ JSON string
▼
C++ N-API entry(libdatetime_napi.so)
│ const char*
▼
Kotlin/Native C ABI(libohosdatetime.so)
│ DatetimeWheelResolve
▼
UTF-8 JSON + DisposeString
4.2 Kotlin/Native 导出函数
@CName("DatetimeWheelResolve")
fun datetimeWheelResolve(request: String): String // 滚轮数据一趟进出一
| C ABI 符号 | ArkTS 方法 | 覆盖内核 |
|---|---|---|
DatetimeWheelResolve | datetimeWheelResolve(req) | 33 语言表 + 顺序/制式/后缀/数字/闰日全部规则 |
4.3 C++ N-API 方法分发
napi_property_descriptor desc[] = {
{"datetimeWheelResolve", nullptr, NapiDatetimeWheelResolve, nullptr, nullptr, nullptr,
static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr},
};
每个回调统一三步:napi_typeof 校验必须是 string、OH_LOG_INFO 打字节数、调 KN 函数并包装返回。KN 返回的字符串必须经 libohosdatetime_symbols()->DisposeString 归还 Kotlin 运行时,绝不 free()。libohosdatetime_api.h 按社区已审仓库同款手写(只声明 ExportedSymbols 结构与一个业务符号),不拖整份生成头。
4.4 CMake 和类型声明
add_library(ohosdatetime SHARED IMPORTED)
set_target_properties(ohosdatetime PROPERTIES
IMPORTED_LOCATION ${KN_SO}
IMPORTED_NO_SONAME TRUE)
add_library(datetime_napi SHARED napi_init.cpp)
target_link_libraries(datetime_napi PUBLIC
libace_napi.z.so
libhilog_ndk.z.so
ohosdatetime)
OHOS_ARCH 为空或 libohosdatetime.so 缺失直接 FATAL_ERROR(提示先跑 link 任务);$ORIGIN rpath 加 POST_BUILD 拷贝保证 NAPI so、KN so、libc++ 三件套同目录,dlopen 时互相可见。
export const datetimeWheelResolve: (request: string) => string;
第 5 阶段:ArkUI 宿主
5.1 注入选择与触发解析
aboutToAppear(): void {
const d: Date = new Date(); // 桥不做时钟调用,本地时间由页面注入
this.selYear = d.getFullYear();
this.selMonth = d.getMonth() + 1;
this.selDay = d.getDate();
this.selHour = d.getHours();
this.selMinute = d.getMinutes();
this.rebuild();
}
private rebuild(): void {
const req: BridgeRequest = { locale: this.localeTag, kind: this.kind, /* … */ };
const res: ResolveResult = JSON.parse(datetimeWheelResolve(JSON.stringify(req))) as ResolveResult;
/* 填充 dateColumns / timeColumns(@State 数组整体重赋值)→ TextPicker 刷新 */
}
五个 chip 组(语言/类型/月份样式/CJK 后缀/制式)切换都收敛到 onChip(group, value) 再 rebuild();年/月列变化时 rebuild() 重算天数列(闰年联动),日/分列只更新状态不触发重算。
5.2 页面交互
页面自上而下:标题与版本握手行、五组 chip、日期滚轮行(列序按 dateOrder)、时间滚轮行(AM/PM 列随制式出现/消失)、当前选择结果行、仓库链接。滚轮用 ArkTS 原生 TextPicker,canLoop(false) + 构造参数 selected 对齐选中下标。
5.3 对比度和错误边界
选中 chip 蓝底白字、未选中灰底深字;ok:false 时错误原文进状态行。Native 调用本身抛错(理论不该发生)同样被 try/catch 收进状态行。
第 6 阶段:示例与验证
6.1 工程结构
example/
├── nativeApp/
│ ├── build.gradle.kts 双 ABI sharedLib(baseName=ohosdatetime)+ cxxShared + copyKnSo*
│ └── src/ohosMain/kotlin/example/nativeapp/
│ └── DatetimeBridge.kt @CName × 1 + MiniJson(扁平 JSON,继承自 cmp_qrose 审核模式)
└── harmonyApp/
├── build-profile.json5 signingConfigs 留空(签名由 DevEco 自动生成回填)
├── AppScope/app.json5 bundleName org.terminator.ohos.datetimewheelpicker、versionName 1.5.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/libohosdatetime_api.h
├── src/main/cpp/types/libdatetime_napi/{index.d.ts, oh-package.json5}
└── src/main/ets/pages/Index.ets ArkUI 验收页面
6.2 构建、签名和安装
./gradlew :datetime-wheel-picker:jvmTest # 47 项交叉验证
./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.datetimewheelpicker -m entry
四、完整代码对照
4.1 整体架构
dev.darkokoa.datetimewheelpicker.strings/**(上游 33 语言资源,原样编译)
dev.darkokoa.datetimewheelpicker.core.format/**(上游顺序/制式/数字/月名纯函数)
dev.darkokoa.datetimewheelpicker.core/CalendarMath.kt(闰年/月天数,纯 Kotlin 补齐)
│
WheelPickerL10n(公共门面,桥与单测唯一入口)
│
Bridge.kt + MiniJson
│ DatetimeWheelResolve
libdatetime_napi.so
│ datetimeWheelResolve
Index.ets
│ JSON parse + @State 更新
▼
ArkUI 真机页面(chip 组 + TextPicker 滚轮)
4.2 文件清单
| 文件 | 职责 |
|---|---|
settings.gradle.kts | 极简 ohos 构建、eazytec Maven 置前、ohos_datetime_wheel_picker、sample 移出 |
gradle/libs.versions.toml | Kotlin 2.2.21-1.0.0 钉版(commonMain 零依赖,无第三方条目) |
build.gradle.kts(根) | 版本 1.5.0-ohos.1、全局禁用 Ohos*Test* 任务 |
datetime-wheel-picker/build.gradle.kts | 纯 KMP:jvm + ohosArm64 + ohosX64 |
src/commonMain/…/WheelPickerL10n.kt | 新增公共门面(locale 解析、标签、顺序、制式、闰年钳制) |
src/commonMain/…/strings/StringsRegistry.kt | 手写 33 语言聚合表,取代 KSP 生成物 |
src/commonMain/…/core/CalendarMath.kt | 闰年/月天数纯函数(镜像上游 KLocalDateExt 规则) |
src/composeMain/** | 上游 20 个 Compose/datetime 文件原样留存(不编译) |
example/nativeApp/build.gradle.kts | 双 ABI sharedLib、cxxShared 定位 libc++、copyKnSo* 回填 |
nativeApp/…/DatetimeBridge.kt | 1 个 C ABI、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 验收页面 |
README.OpenHarmony_CN.md | 鸿蒙适配主文档(19 节,599 行) |
4.3 关键 API 对照
| 层次 | API | 作用 |
|---|---|---|
| 引擎 | StringsMap | 33 语言资源聚合(取代上游 KSP 生成物) |
| 引擎 | resolveStringsFromComponents | zh-Hans-CN → zh 表、script 回退、en 兜底 |
| 引擎 | toLocalizedNumerals(strings) | ar ٠-٩ / fa·uz-Arab ۰-۹ 数字系统 |
| 引擎 | DateOrder.match(language, region) | MDY / DMY / YMD 字段顺序 |
| 门面 | WheelPickerL10n.monthLabel(…) | 月名(全称/缩写/数字)+ CJK 后缀 |
| 门面 | WheelPickerL10n.hourLabel / minuteLabel | 补零 + 本地数字;AM_PM 映射 1…12 |
| 门面 | WheelPickerL10n.daysInMonth / clampDay | 闰年感知天数与钳制 |
| Native | DatetimeWheelResolve | C ABI JSON 导出 |
| N-API | datetimeWheelResolve(req) | 向 ArkTS 暴露能力 |
| ArkTS | rebuild() | 组装请求、刷新滚轮列 |
4.4 ArkTS 与 Kotlin 的边界
const req: BridgeRequest = {
locale: 'uz-Arab', kind: 'date', monthStyle: 'full',
cjkSuffix: 'show_all', hour12: 'auto',
selYear: 2026, selMonth: 1, selDay: 9, selHour: -1, selMinute: -1
};
const res = JSON.parse(datetimeWheelResolve(JSON.stringify(req))) as ResolveResult;
// res.rows.month[0].text === 'جنوری',res.rows.year 含 '۲۰۲۶'(本地数字)
val strings = resolveStringsFromComponents("Arab", "uz", StringsMap)
val yearText = 2026.toLocalizedNumerals(strings) // "۲۰۲۶"
val monthText = monthFullString(1, strings) // "جنوری"
两者之间只传输扁平 JSON 字符串,不传输 Kotlin 对象或未校验的动态结构。
五、关键决策说明
决策 1:零依赖内核提取,而不是真编译整库
与 KStore 对偶的判断依据:KStore 的价值在纯 Kotlin 引擎,且 kotlinx 制品线齐全,真编译是正解;本库的价值几乎全在 Compose 渲染层(滚轮、barrel 投影、Material3),ohos 上编不了 Compose,"整库真编译"无从谈起。能跨平台复用的是 33 语言格式引擎——提取它,滚轮 UI 交给 ArkTS TextPicker,是 UI 库在当前生态下的正确切面。
决策 2:钉 2.2.21-1.0.0 旧线,不追 2.4.20 新线
kotlinx-datetime ohos 制品全部要求 Kotlin 2.4.20 元数据,理论上可以像 KStore 一样升到 2.4.20-0.1.0-07。但本库对 datetime 的真实用量只有闰年与月天数(30 行纯函数可覆盖),而 2.2.21-1.0.0 + Gradle 8.14.1 + JDK 21 是 cmp_qrose 完成审核验收的已验证组合、本机 Konan 缓存现成——为一个"便利依赖"换工具链线不成比例。代价是 Gradle 从 9.6.1 降到 8.14.1、Lyricist/KSP 剥离,收益是整条链路零悬念。
决策 3:根构建替换(cmp_qrose 模式),sample 留盘
上游根构建(AGP 9.3.1 classpath、compose/ksp/vanniktech 插件、Maven Central 发布、TYPESAFE_PROJECT_ACCESSORS)对鸿蒙没有保留价值,整个替换为极简 ohos 构建。sample/(androidApp/composeApp/iosApp)留盘不进 settings.gradle.kts——它们是上游多平台示例的存在方式,git mv 源码时也刻意不动 sample/ 一行。
决策 4:门面收口 + 桥不做时钟调用
internal 可见性与 @Composable 签名两个问题由 WheelPickerL10n 一个 object 同时解决;桥端不碰 kotlin.time.Clock——设备本地时间由 ArkTS new Date() 注入,sel*=-1 回退到范围起点。桥保持纯函数,同输入同输出,hour12=auto 的制式判定完全交给引擎。
决策 5:jvmTest 交叉验证 + compose 测试留存
引擎逻辑在 commonMain,JVM 与 ohos 编译同一份代码;47 项 jvmTest(上游纯测试文件 + 12 项新门面用例)是"内核没漂"的闸门。Dp / Compose 依赖的 5 个测试文件移入 composeTest 原样留存不编译;ohos 侧测试任务由根构建全局禁用(Ohos*Test*),测试统一 JVM 跑——测试文件尽量零改动,交叉验证的有效性才成立。
六、测试与验证
6.1 测试环境
本次验证使用 Windows(x64)、JDK 21、Gradle 8.14.1 wrapper、DevEco Studio 5.1.0 Release 与鸿蒙真机(arm64)。
6.2 JVM 交叉验证
./gradlew :datetime-wheel-picker:jvmTest
47 项用例全部通过:
| 测试类 | 用例数 | 钉住什么 |
|---|---|---|
LocaleResolutionTest | 26 | locale 解析:script 精确匹配/回退、en 兜底、真实 33 语言表、CJK 判定(去 Compose 化改写,断言不变) |
NumeralConversionTest | 9 | 数字系统:ASCII 透传、U+06F0/U+0660 双系、非数字字符不动(上游原文件零改动) |
WheelPickerL10nTest | 12 | 新增门面:标签/后缀/顺序/制式/AM-PM 映射/闰年钳制/支持语言表 |
LocaleResolutionTest 是唯一改写的测试文件——上游经 androidx.compose.ui.text.intl.Locale 驱动同样的断言,commonMain 去 Compose 后改为直接调用纯函数,断言值逐条保留。
6.3 HAP 验证
:datetime-wheel-picker:compileKotlinOhosArm64 / OhosX64: BUILD SUCCESSFUL
linkDebugSharedOhosArm64 / linkDebugSharedOhosX64: BUILD SUCCESSFUL
→ arm64-v8a/libohosdatetime.so(2,636,808 字节)+ libc++_shared.so
→ x86_64/libohosdatetime.so(2,266,000 字节)+ libc++_shared.so
符号检查: DatetimeWheelResolve / libohosdatetime_symbols 双 so 均导出
BuildNativeWithCmake: finished(双 ABI 编出 libdatetime_napi.so)
CompileArkTS: finished
PackageHap: finished
BUILD SUCCESSFUL
install bundle successfully
start ability successfully
6.4 功能用例
- 启动后状态行出现
解析语言 zh · CJK 是 · … · 库版本 1.5.0-ohos.1(版本来自真实 Native 调用,非静态文字); - 默认
zh-Hans-CN:年列带"年"后缀、月列为"1月…12月"、日列带"日"后缀,列序为年/月/日(YMD); - 切
en-US:月列变January…、列序变 月/日/年(MDY)、时间列出现 AM/PM; - 切
uz-Arab:年列变۲۰۲۶系 extended Arabic-Indic 数字、月名变جنوری——33 语言资源表真实生效; - 月份样式切"缩写":月列变
Jan/Feb/…;切"数字":变1/2/…; - CJK 后缀切"全隐":年/月/日列后缀全部消失;
- 年份选 2024 → 2 月天数列为 29 行;选 2023 → 28 行;先选 31 再切 2 月,选中值钳到 29/28(闰年联动);
- 制式切"强制12小时":小时列变 1…12,追加"上午/下午"列;切"强制24小时":恢复 0…23,AM/PM 列消失;
- 类型切"时间":日期滚轮整体隐藏、
dateOrder为 null;切"日期+时间":两组滚轮并排; - 每次切换后"当前选择"行按 ISO 格式回显(12 小时制折算为 24 小时)。
6.5 验证结论
本地化引擎、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 已完成链路验证。应用在鸿蒙真机上成功安装并进入前台,页面展示了真实的滚轮选择会话:33 语言格式引擎、CJK 后缀、本地数字系统、字段顺序、12/24 制式与闰年钳制闭环(见下节效果图)。
七、运行效果
7.1 真机截图

7.2 命令速查
git clone https://atomgit.com/oh-tpc/compose-datetime-wheel-picker.git
cd compose-datetime-wheel-picker
export JAVA_HOME="/path/to/jdk-21"
./gradlew :datetime-wheel-picker:compileKotlinOhosArm64 :datetime-wheel-picker:compileKotlinOhosX64 # klib
./gradlew :datetime-wheel-picker:jvmTest # 47 项交叉验证
./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.datetimewheelpicker -m entry
hdc shell hilog | grep -i "Dwp\|DatetimeNapi"
八、遗留问题与改进方向
8.1 踩坑复盘
- 只改 ArkTS 页面不算 KMP 适配,必须让
:datetime-wheel-picker真正编译成ohosArm64/ohosX64; settings.gradle.kts里 eazytec 双仓必须排在pluginManagement与dependencyResolutionManagement最前,否则配置期直接Unresolved reference: ohosArm64;- eazytec 的 kotlinx-datetime ohos 制品(
0.8.0-0.1.0-02/-05/-07)全部基于 Kotlin 2.4.20 元数据——ohos 变体存在≠可用,还得核对.module里的requires与编译器钉线配套;2.2.21-1.0.0引它会报metadata is 2.4.0, expected version is 2.2.0; - Lyricist 走 KSP 生成聚合表,鸿蒙工具链上没有 KSP——33 个语言文件剥注解后,聚合表手写(33 行 map),键必须与上游
languageTag逐字一致,否则uz-Arab这类 script 条目静默回落英文; - Gradle 9.6.1 降到 8.14.1 时,
Test.failOnNoDiscoveredTests这类新 API 会变成 script 编译错误——根/模块构建脚本重写时按 8.x 语法过一遍; - version catalog 的插件别名(
multiplatformvskotlin.multiplatform)在模块脚本与 toml 之间必须一致,抄审核仓库的 build 脚本时最容易踩; - 新 SDK 的
TextPicker已移除.selected()链式属性(API 10 废弃)——选中项必须走构造参数TextPicker({ range, selected }),报错是Property 'selected' does not exist on type 'TextPickerAttribute'; - 严格 ArkTS 的回调参数是逆变的:
onChange必须按(value: string | string[], index: number | number[]) => void全联合类型签名收参,内部再typeof index === 'number'收窄——写窄了两个 overload 全不匹配; @Builder尽量不收回调函数参数,改传原始类型 + 统一分发方法(onChip(group, value)),规避 ArkTS 编译器的参数形态限制;- 桥端不做时钟调用:
kotlin.time.Clock拿到的是 UTC 瞬时值,本地时区换算需要 datetime 库——干脆由 ArkTSnew Date()注入本地时间,桥保持纯函数; - HAP 缺当前 ABI 的 so 会报安装失败
9568347,abiFilters必须双 ABI,每个 ABI 三件套(NAPI so / KN so / libc++)缺一不可; - KN 返回字符串必须
DisposeString归还运行时,N-API 层不能free();签名材料由 DevEco 自动回填build-profile.json5,仓库内保持signingConfigs空数组,证书绝不进入 AtomGit。
8.2 已知问题
- 滚轮 UI 是 ArkTS
TextPicker重做,非 Compose 渲染:上游的圆柱投影(BarrelProperties)、行高模式(WheelRows)、selector 样式与 Material3 主题联动未在鸿蒙侧复刻(源文件在composeMain留存); @ComposableAPI(WheelDatePicker等)在鸿蒙侧不可用;minDate/maxDate范围钳制未暴露到桥(天数钳制已有),yearsRange以yearStart/yearEnd参数形式暴露;composeMain/composeTest不参与编译,仅作上游对接参考与未来接回底稿;- ohos 目标的测试任务全局禁用(
Ohos*Test*),测试统一 JVM 跑; - HAP 安装必须使用与 bundle name
org.terminator.ohos.datetimewheelpicker匹配的签名。
8.3 未来优化方向
- 用自绘 ArkTS 滚轮(List + 滚动吸附 + 透视变换)复刻上游 barrel 圆柱投影与渐隐;
- 桥补
minDate/maxDate、startDateTime全量入参; - 评估
.har形态发布,让 ArkTS 工程直接依赖而不是走 so 三件套; - 关注社区 CMP for ohos(1.9.x 线)进展,对齐后把
composeMain接回编译; - 在 CI 中加入 ohos 编译、ELF 符号检查和 HAP 构建。
九、总结
9.1 核心难点回顾
上游 datetime-wheel-picker(格式引擎 + Compose 滚轮)
→ 判定 Compose 不可编(社区 CMP 1.9.2 vs 上游 1.12.1)
→ 验证 kotlinx-datetime ohos 制品线(存在但元数据 2.4.0,与钉线冲突)
→ 定路线:零依赖内核提取(cmp_qrose 模式)
→ 工具链对齐 Kotlin 2.2.21-1.0.0 + Gradle 8.14.1 + JDK 21
→ 根构建替换 + 源码集拆分(commonMain 内核 / composeMain 留存)
→ Lyricist 剥离 + StringsRegistry + CalendarMath + WheelPickerL10n 门面
→ ohosArm64 / ohosX64 KLIB + 双 ABI 动态库
→ C ABI(@CName × 1)+ JSON 桥协议
→ C++ N-API(DisposeString 纪律)
→ ArkTS TextPicker 滚轮 + 闰年联动
→ ArkUI 真机单页验收(语言/样式/制式/钳制闭环)
9.2 封装层次
- 引擎层:上游 33 语言资源表、顺序/制式/数字/月名纯函数 +
CalendarMath历法补齐; - 门面层:
WheelPickerL10n把 internal 能力与 locale 字符串语义收成一个 object; - Native 层:双 ABI 动态库并通过 1 个 C ABI 输出 JSON;
- N-API 层:参数检查、字符串转换和 Native 内存释放;
- ArkTS 层:chip 组状态管理、请求组装、滚轮列构建、闰年联动与结果回显;
- DevEco 层:CMake、HAP、签名、安装和真机运行。
9.3 三条经验
- UI 库与引擎库的适配答案不同:KStore 这类纯引擎库在 kotlinx 制品线补齐后应当真编译整库;datetime-wheel-picker 这类价值在 Compose 渲染层的库,正确切面是"格式引擎提取 + ArkTS 重做交互壳"——路线选择取决于库的价值分布,而不是"哪种路线更先进";
- 制品可达性要查到元数据一层:ohos 变体存在、版本号也对,仍可能因 stdlib 元数据代差(2.4.0 vs 2.2.0)不可用;钉工具链线之前先
curl一下.module里的requires,能省掉整个下午; - 交叉验证是"内核没漂"的唯一可信证明:47 项
jvmTest与真机滚轮共用同一份commonMain——zh 的"年/月/日"后缀、uz-Arab 的 ۰-۹ 数字、2024-02-31→29 的钳制,在 JVM 断言里和真机滚轮里是同一段代码。
9.4 适配成果
当前适配已完成:
- 上游 1.5.0 格式引擎(33 语言、CJK 后缀、本地数字系统、字段顺序、12/24 制式、闰年钳制)的双 ABI Kotlin/Native 编译(commonMain 零第三方依赖);
- 公共门面
WheelPickerL10n与手写 33 语言聚合表(取代 KSP 生成物); - 1 个 C ABI 符号和 C++ N-API 包装层(符号导出双 so 验证);
- DevEco Stage ArkUI 单页验收页面(11 语言切换、3 种选择器类型、月份样式、CJK 后缀、制式、闰年联动、版本握手);
- 47 项 JVM 交叉验证全绿 + HAP 构建(双 ABI 六件套 so)、签名、设备安装和真机验证;
- 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)