本文记录 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 / cjklocale 解析结果(zh-Hans-CN → zh)与 CJK 判定。
timeFormatAM_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);ArkTS TextPicker 承担滚轮交互,引擎输出滚轮的全部数据与语义。

二、实现路线图

第 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 MultiplatformHarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven,cmp_qrose 已审核钉法)
JDK21(org.gradle.java.home 显式指向)
OpenHarmony 目标ohosArm64 + ohosX64(jvm 保留作交叉验证)
Gradle8.14.1(自上游 9.6.1 降级——2.2.21 旧线只支持到 Gradle 8.x,wrapper 与 cmp_qrose 同源)
third-party 依赖commonMain 为零(kotlinx-datetime / Lyricist / Compose 全部隔离)
DevEco product5.1.0(18) 兼容
DevEco Studio5.1.0 Release
ABIarm64-v8a + x86_64
Bundleorg.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 / SnappedDateTimeLocalDate / LocalTime / LocalDateTime / kotlin.time.Clock移入 composeMain,历法数学由 CalendarMath.kt 补齐
Compose UIWheelDatePicker / WheelTimePicker / WheelDateTimePicker、core/WheelPicker / WheelTextPicker / Standard* / Adaptive* / CJKWheelDatePicker / DateComponents / PickerSize / WheelRows / BarrelProperties / format/DateFormatter / format/CjkSuffixConfigandroidx.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 方法覆盖内核
DatetimeWheelResolvedatetimeWheelResolve(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.tomlKotlin 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.kt1 个 C ABI、MiniJson、错误通道
harmonyApp/…/cpp/napi_init.cppN-API 导出、参数检查、DisposeString
harmonyApp/…/cpp/CMakeLists.txtIMPORTED 引入 KN so、$ORIGIN rpath、POST_BUILD 三件套
harmonyApp/…/ets/pages/Index.etsArkUI 验收页面
README.OpenHarmony_CN.md鸿蒙适配主文档(19 节,599 行)

4.3 关键 API 对照

层次API作用
引擎StringsMap33 语言资源聚合(取代上游 KSP 生成物)
引擎resolveStringsFromComponentszh-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闰年感知天数与钳制
NativeDatetimeWheelResolveC ABI JSON 导出
N-APIdatetimeWheelResolve(req)向 ArkTS 暴露能力
ArkTSrebuild()组装请求、刷新滚轮列

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 项用例全部通过:

测试类用例数钉住什么
LocaleResolutionTest26locale 解析:script 精确匹配/回退、en 兜底、真实 33 语言表、CJK 判定(去 Compose 化改写,断言不变)
NumeralConversionTest9数字系统:ASCII 透传、U+06F0/U+0660 双系、非数字字符不动(上游原文件零改动)
WheelPickerL10nTest12新增门面:标签/后缀/顺序/制式/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 功能用例

  1. 启动后状态行出现 解析语言 zh · CJK 是 · … · 库版本 1.5.0-ohos.1(版本来自真实 Native 调用,非静态文字);
  2. 默认 zh-Hans-CN:年列带"年"后缀、月列为"1月…12月"、日列带"日"后缀,列序为年/月/日(YMD);
  3. 切 en-US:月列变 January…、列序变 月/日/年(MDY)、时间列出现 AM/PM;
  4. 切 uz-Arab:年列变 ۲۰۲۶ 系 extended Arabic-Indic 数字、月名变 جنوری——33 语言资源表真实生效;
  5. 月份样式切"缩写":月列变 Jan/Feb/…;切"数字":变 1/2/…;
  6. CJK 后缀切"全隐":年/月/日列后缀全部消失;
  7. 年份选 2024 → 2 月天数列为 29 行;选 2023 → 28 行;先选 31 再切 2 月,选中值钳到 29/28(闰年联动);
  8. 制式切"强制12小时":小时列变 1…12,追加"上午/下午"列;切"强制24小时":恢复 0…23,AM/PM 列消失;
  9. 类型切"时间":日期滚轮整体隐藏、dateOrder 为 null;切"日期+时间":两组滚轮并排;
  10. 每次切换后"当前选择"行按 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 踩坑复盘

  1. 只改 ArkTS 页面不算 KMP 适配,必须让 :datetime-wheel-picker 真正编译成 ohosArm64 / ohosX64;
  2. settings.gradle.kts 里 eazytec 双仓必须排在 pluginManagement 与 dependencyResolutionManagement 最前,否则配置期直接 Unresolved reference: ohosArm64;
  3. 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;
  4. Lyricist 走 KSP 生成聚合表,鸿蒙工具链上没有 KSP——33 个语言文件剥注解后,聚合表手写(33 行 map),键必须与上游 languageTag 逐字一致,否则 uz-Arab 这类 script 条目静默回落英文;
  5. Gradle 9.6.1 降到 8.14.1 时,Test.failOnNoDiscoveredTests 这类新 API 会变成 script 编译错误——根/模块构建脚本重写时按 8.x 语法过一遍;
  6. version catalog 的插件别名(multiplatform vs kotlin.multiplatform)在模块脚本与 toml 之间必须一致,抄审核仓库的 build 脚本时最容易踩;
  7. 新 SDK 的 TextPicker 已移除 .selected() 链式属性(API 10 废弃)——选中项必须走构造参数 TextPicker({ range, selected }),报错是 Property 'selected' does not exist on type 'TextPickerAttribute';
  8. 严格 ArkTS 的回调参数是逆变的:onChange 必须按 (value: string | string[], index: number | number[]) => void 全联合类型签名收参,内部再 typeof index === 'number' 收窄——写窄了两个 overload 全不匹配;
  9. @Builder 尽量不收回调函数参数,改传原始类型 + 统一分发方法(onChip(group, value)),规避 ArkTS 编译器的参数形态限制;
  10. 桥端不做时钟调用:kotlin.time.Clock 拿到的是 UTC 瞬时值,本地时区换算需要 datetime 库——干脆由 ArkTS new Date() 注入本地时间,桥保持纯函数;
  11. HAP 缺当前 ABI 的 so 会报安装失败 9568347,abiFilters 必须双 ABI,每个 ABI 三件套(NAPI so / KN so / libc++)缺一不可;
  12. KN 返回字符串必须 DisposeString 归还运行时,N-API 层不能 free();签名材料由 DevEco 自动回填 build-profile.json5,仓库内保持 signingConfigs 空数组,证书绝不进入 AtomGit。

8.2 已知问题

  • 滚轮 UI 是 ArkTS TextPicker 重做,非 Compose 渲染:上游的圆柱投影(BarrelProperties)、行高模式(WheelRows)、selector 样式与 Material3 主题联动未在鸿蒙侧复刻(源文件在 composeMain 留存);
  • @Composable API(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 三条经验

  1. UI 库与引擎库的适配答案不同:KStore 这类纯引擎库在 kotlinx 制品线补齐后应当真编译整库;datetime-wheel-picker 这类价值在 Compose 渲染层的库,正确切面是"格式引擎提取 + ArkTS 重做交互壳"——路线选择取决于库的价值分布,而不是"哪种路线更先进";
  2. 制品可达性要查到元数据一层:ohos 变体存在、版本号也对,仍可能因 stdlib 元数据代差(2.4.0 vs 2.2.0)不可用;钉工具链线之前先 curl 一下 .module 里的 requires,能省掉整个下午;
  3. 交叉验证是"内核没漂"的唯一可信证明: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。

参考文档

Logo

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

更多推荐