开源鸿蒙平台 KMP 三方库 kotlinx-datetime 适配全流程:从 ohosArm64 target 到真机时区验证

开源鸿蒙平台 KMP 三方库 kotlinx-datetime 适配全流程:从 ohosArm64 target 到真机时区验证
欢迎加入 KMP/CMP 鸿蒙化社区:https://atomgit.com/CPF-KMP-CMP
适配后仓库地址(AtomGit):https://atomgit.com/oh-tpc/ohos_kotlinx-datetime
一、为什么先挑 kotlinx-datetime 下手
Kotlin Multiplatform 生态里,时间处理几乎是一个绕不开的基础设施。凡是需要记录事件时间、做倒计时、按天聚合数据、或者只是想在日志里打一个带时区的本地时间,最后都会落到 kotlinx-datetime 上。它是 JetBrains 官方维护的库,Instant、LocalDate、LocalDateTime、TimeZone 这套 API 已经成为 KMP 世界处理时间的事实标准,绝大多数 KMP 应用在 commonMain 里都直接引用了它。
但把任何一个 KMP 库搬到 OpenHarmony 上,第一道坎永远是同一个:官方发布物里没有 ohosArm64 的产物。你在 commonMain 里写下 implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.8.0"),Gradle 在解析依赖时会直接告诉你找不到匹配的 variant——不是网络问题,也不是版本问题,就是这个库从来没有为 OpenHarmony 这个 target 编译过。
kotlinx-datetime 特别适合作为一次"从 0 到 1"的适配样本,原因有三个。第一,它是纯逻辑库,API 边界清晰,没有庞大到看不完的源码树。第二,它的难点非常集中且有代表性——不在业务逻辑,而在平台侧的时区数据与系统时钟,这恰好是 OpenHarmony 沙箱环境里最容易踩空的地方。第三,适配完成后可以在真机上一眼验证:界面上显示的本地时间对不对,时区名字对不对,不需要复杂的交互就能确认结果。
这篇文章记录的是完整过程:从 fork 源码、接入 HarmonyOS Kotlin 定制版、声明 ohosArm64 target,到处理时区、导出符号给 ArkTS、打成 HAR 放进鸿蒙工程,最后在真机上跑通并验证。
二、先摸清库的源码结构,再动手
动手改之前必须先看清楚这个库是怎么组织的,否则很容易在错误的地方加代码。kotlinx-datetime 0.8.0 的源码大致分成三块:
commonMain 放的是全部对外 API 和纯计算逻辑。Instant 的加减、LocalDate 的格式化解析、DateTimePeriod 的运算,这些都不依赖任何平台能力,因此在所有 target 上共用同一份实现。这也是为什么适配工作量看起来不大——绝大部分代码不需要碰。
平台 source set(jvmMain / nativeMain 等) 放的是两类真正需要平台配合的东西:
- 系统时钟:
Clock.System.now()要拿到当前时间戳,各平台取值方式不同。 - 系统时区:
TimeZone.currentSystemDefault()要拿到设备当前时区,这个更麻烦,它需要一份可用的时区数据库(tzdb)。
时区这一块是重点。在 Native 平台上,kotlinx-datetime 读取时区的默认策略是去找操作系统提供的 tzdb 文件:Darwin 平台读 /var/db/timezone/zoneinfo,Linux 平台读 /usr/share/zoneinfo。如果系统里找不到有效的时区数据库,它会回退到 kotlinx-datetime-zoneinfo 这个 artifact 里内置的 TZDB。
kotlinx-datetime-zoneinfo 是单独的发布坐标,里面打包了完整的 IANA 时区数据。这个 artifact 的存在,直接决定了我们后面处理时区问题的思路。
三、第一步:接入 HarmonyOS Kotlin 定制版
这是整个适配的前置条件,也是最容易被忽略的一步。ohosArm64() 这个 target 在 Kotlin 官方主线发行版里并不存在,它是 OpenHarmony 适配生态中的定制能力。如果你用官方 Kotlin 插件直接写 ohosArm64(),Gradle 会报 Unresolved reference,因为插件根本不认识这个 target 名字。
所以第一步是把工程使用的 Kotlin 版本切到 HarmonyOS Kotlin 定制版(当前对应 Kotlin 2.2.21-1.0.0 这一发行线),并在 settings.gradle.kts 里把插件仓库指向 KMP/CMP 鸿蒙化发行版对应的仓库。
具体坐标和仓库地址以 CPF-KMP-CMP 组织的发布说明为准,那里会同步每一版的版本号与配套 Gradle、JDK 要求。
// settings.gradle.kts
pluginManagement {
repositories {
// HarmonyOS Kotlin 定制版插件仓库(地址见 CPF-KMP-CMP 发布说明)
maven("https://atomgit.com/CPF-KMP-CMP")
gradlePluginPortal()
mavenCentral()
}
}
dependencyResolutionManagement {
repositories {
mavenCentral()
}
}
环境上我用的是 DevEco Studio 26.0.0 Release + JDK 21 + Gradle 8.14.1,真机 ROM 为 HarmonyOS 6.1 以上。版本这块建议以当前平台最新版为准,定制版 Kotlin 与 DevEco Studio 之间是有配套关系的,不要随意混搭。开发及构建界面如下:

四、第二步:声明 target,然后让编译器告诉你缺什么
插件就位之后,在共享模块的 build.gradle.kts 里补上 target 声明。这里我刻意没有一次性写完所有适配代码,而是先只加 target 和 source set,把"缺什么"交给编译器报出来——这是 KMP 适配里最高效的做法,比对着源码猜要准得多。
// kotlinx-datetime/build.gradle.kts
kotlin {
jvm()
js(IR) { nodejs() }
linuxX64()
macosArm64()
// 本次新增:OpenHarmony
ohosArm64()
sourceSets {
val commonMain by getting
val nativeMain by getting
// 新建 ohosArm64 专属 source set
val ohosArm64Main by creating {
dependsOn(nativeMain)
}
val ohosArm64Test by creating {
dependsOn(commonTest.get())
}
}
}
注意 dependsOn(nativeMain) 这一行是有意为之。OpenHarmony 的运行时是 POSIX 兼容的,kotlinx-datetime 在 nativeMain 里已有的那套基于 POSIX 的时钟与文件读取实现,大部分可以直接复用。让 ohosArm64Main 继承 nativeMain,就能把重复实现压到最低,只在真正有差异的地方做覆盖。
声明完成后跑一次编译,把缺失的实现暴露出来:
./gradlew :kotlinx-datetime:compileKotlinOhosArm64
Kotlin/Native 的编译任务命名规则是 compileKotlin<首字母大写的 target 名>,所以这里就是 compileKotlinOhosArm64。第一次编译大概率会失败,报出若干条 Expected declaration 'xxx' has no actual declaration in module —— 每一条都是一个待补的 actual。把它们逐条补齐,编译通过,target 就算接上了。
五、第三步:时区——本次适配真正的坑
编译通过之后,真正的麻烦才开始。写一个最小验证跑在真机上:
val now = Clock.System.now()
val zone = TimeZone.currentSystemDefault()
println("zone=$zone local=${now.toLocalDateTime(zone)}")
结果是 zone=UTC,而设备实际在 Asia/Shanghai(东八区)。时间戳是对的,但时区错了整整八个小时。
根因在第二节里已经埋下伏笔:TimeZone.currentSystemDefault() 在 Native 上要去找系统时区数据库,而 OpenHarmony 应用的沙箱环境里并不存在 /usr/share/zoneinfo。系统找不到 tzdb,就只能回退,最终落到 UTC 上。这不是 kotlinx-datetime 的 bug,而是"平台没有提供它期望的数据源"。
解决思路有两条。第一条是让库自带 tzdb,也就是引入 kotlinx-datetime-zoneinfo,把时区数据打进包里,彻底摆脱对系统文件的依赖。这条路的代价是包体积会明显增加,因为完整 IANA 时区库并不小。
第二条路更适合 OpenHarmony:时区 ID 从应用层拿,再传给 KMP 层。鸿蒙的国际化模块 @ohos.i18n 提供了时区读取能力——i18n.getTimeZone() 返回当前系统时区对象,getID() 拿到的就是标准的 IANA 时区 ID,形如 Asia/Shanghai(系统能力 SystemCapability.Global.I18n)。把它交给 TimeZone.of(id),就能精确构造出正确的时区对象,既不用打包 tzdb,也不依赖沙箱里不存在的文件。
这里特意没有用 @ohos.systemDateTime。该模块虽然在早期文档里出现过,但已被标记为停止维护,新代码应当统一走 @ohos.i18n,否则后续平台版本升级时会平白多出一笔迁移成本。
我最终采用的是两条路结合:优先用应用层传入的时区 ID,取不到时再回退到内置 TZDB。
// commonMain
expect object PlatformTimeZone {
/** 平台可提供的系统时区 ID,取不到返回 null */
fun systemTimeZoneIdOrNull(): String?
}
// ohosArm64Main
actual object PlatformTimeZone {
// 由 ArkTS 侧通过 i18n.getTimeZone().getID() 注入,
// OpenHarmony 沙箱内无 /usr/share/zoneinfo,不能依赖文件读取
private var injectedZoneId: String? = null
fun inject(zoneId: String) {
injectedZoneId = zoneId
}
actual fun systemTimeZoneIdOrNull(): String? = injectedZoneId
}
上层拿到结果后统一收敛:
// commonMain
fun currentZoneOrFallback(): TimeZone {
val id = PlatformTimeZone.systemTimeZoneIdOrNull()
return if (id != null) {
runCatching { TimeZone.of(id) }.getOrElse { TimeZone.UTC }
} else {
// 回退到内置 TZDB(需引入 kotlinx-datetime-zoneinfo)
TimeZone.currentSystemDefault()
}
}
这样处理之后,真机上的 zone 就是 Asia/Shanghai,本地时间与系统状态栏完全一致。
六、第四步:把能力导出给 ArkTS
KMP 层的逻辑要能被鸿蒙页面调用,需要走 Kotlin/Native 导出 C 符号、再由 NAPI 桥接注册的链路。Kotlin 侧用 @CName 指定符号名:
// ohosArm64Main
@CName("kmp_datetime_now_in_zone")
fun nowInZone(zoneId: String): String {
val zone = runCatching { TimeZone.of(zoneId) }.getOrElse { TimeZone.UTC }
val now = Clock.System.now().toLocalDateTime(zone)
return "${now.date} ${now.hour.toString().padStart(2, '0')}:" +
"${now.minute.toString().padStart(2, '0')}:${now.second.toString().padStart(2, '0')} @$zone"
}
同时确认 binaries.sharedLib 里做了 export,否则符号不会出现在动态库里:
ohosArm64().binaries.sharedLib {
baseName = "kmpdatetime"
export(project(":kotlinx-datetime"))
}
然后是 C++ 侧的 NAPI 注册:
// src/main/cpp/napi_init.cpp
#include "napi/native_api.h"
extern "C" const char* kmp_datetime_now_in_zone(const char* zoneId);
static napi_value NowInZone(napi_env env, napi_callback_info info) {
size_t argc = 1;
napi_value args[1] = { nullptr };
napi_get_cb_info(env, info, &argc, args, nullptr, nullptr);
size_t len = 0;
napi_get_value_string_utf8(env, args[0], nullptr, 0, &len);
std::string zoneId(len, '\0');
napi_get_value_string_utf8(env, args[0], zoneId.data(), len + 1, &len);
napi_value result;
napi_create_string_utf8(env, kmp_datetime_now_in_zone(zoneId.c_str()), NAPI_AUTO_LENGTH, &result);
return result;
}
EXTERN_C_START
static napi_value Init(napi_env env, napi_value exports) {
napi_property_descriptor desc[] = {
{ "nowInZone", nullptr, NowInZone, nullptr, nullptr, nullptr, napi_default, nullptr }
};
napi_define_properties(env, exports, sizeof(desc) / sizeof(desc[0]), desc);
return exports;
}
EXTERN_C_END
static napi_module demoModule = {
.nm_version = 1,
.nm_flags = 0,
.nm_filename = nullptr,
.nm_register_func = Init,
.nm_modname = "kmpdatetime",
.nm_priv = nullptr,
.reserved = { 0 },
};
extern "C" __attribute__((constructor)) void RegisterModule(void) {
napi_module_register(&demoModule);
}
HAR 模块的入口声明:
// Index.d.ts
export const nowInZone: (zoneId: string) => string;
// Index.ets
import nativeLib from 'libkmpdatetime.so';
export const nowInZone: (zoneId: string) => string = nativeLib.nowInZone;
七、第五步:打包 HAR 并接入鸿蒙工程
编译出来的动态库需要按鸿蒙的约定放好目录,才能被正确打进 HAR:
src/main/
├── cpp/
│ ├── napi_init.cpp
│ └── types/libkmpdatetime/Index.d.ts
├── ets/Index.ets
└── libs/arm64-v8a/libkmpdatetime.so
HAR 模块的 oh-package.json5:
{
"name": "ohos_kmpdatetime",
"version": "1.0.0",
"description": "kotlinx-datetime OpenHarmony 适配",
"main": "Index.ets",
"types": "Index.d.ts"
}
在鸿蒙工程中引入 HAR 后,页面里就可以直接调用了:
import { nowInZone } from 'ohos_kmpdatetime';
import i18n from '@ohos.i18n';
@Entry
@Component
struct Index {
@State timeText: string = '--';
aboutToAppear() {
// 时区 ID 由应用层提供,绕开沙箱内缺失的 tzdb 文件
const zoneId: string = i18n.getTimeZone().getID();
this.timeText = nowInZone(zoneId);
}
build() {
Column({ space: 12 }) {
Text('kotlinx-datetime on OpenHarmony')
.fontSize(18).fontWeight(FontWeight.Bold)
Text(this.timeText)
.fontSize(22)
.fontColor('#0A59F7')
}
.width('100%').height('100%')
.justifyContent(FlexAlign.Center)
}
}
接入前建议先确认符号确实导出了,这一步能省掉大量排查时间:
llvm-nm -D libkmpdatetime.so | findstr kmp_datetime
能看到 T kmp_datetime_now_in_zone,说明 Kotlin/Native 侧的导出是成功的。
启动模拟器运行,完成编译无问题:

模拟器启动后运行效果:

八、第六步:操作验证
部署后,页面正确显示出本地时间与 Asia/Shanghai 时区标识,与系统状态栏时间一致。


为了验证不是"碰巧对上",我做了两组对照:把设备时区手动切到 America/New_York,重启应用后页面时间同步变化为当地本地时间;再把应用层传入的时区改成 UTC,输出也随之变为 UTC 时间。三组结果都正确,说明时区链路是真正走通的,而不是被硬编码兜住了。
九、踩坑清单
坑一:ohosArm64() 报未定义。 十有八九是还在用 Kotlin 官方主线插件。这个 target 只在 HarmonyOS Kotlin 定制版里存在,必须先把插件版本切过去。
坑二:时区恒为 UTC。 前面已经展开过,根因是 OpenHarmony 应用沙箱里没有 /usr/share/zoneinfo,库找不到 tzdb 就回退到 UTC。不要在 native 侧硬编码时区 ID 绕过去,那样切时区就废了;正确做法是从应用层把 i18n.getTimeZone().getID() 的结果传进来。
坑三:llvm-nm -D 能看到符号,但 ArkTS 侧 import 不到。 这是最迷惑人的一类问题。原因在于 Kotlin/Native 导出的 C 符号和 ArkTS 能 import 的模块接口不是一回事——中间还隔着 NAPI 注册这一层。如果 napi_init.cpp 里的 nm_modname 和 ArkTS 侧 import 的库名不一致,或者 Index.d.ts 没声明、oh-package.json5 的 main 没指向入口文件,都会出现"符号明明在,就是调不到"的现象。按符号名 → 模块名 → 声明文件 → 包入口这个顺序逐一核对即可。
坑四:改完 Kotlin 代码,产物没更新。 Kotlin/Native 的编译缓存比较激进,遇到产物与代码不一致时先 ./gradlew clean 再重新构建,比反复找代码问题高效。
十、小结
kotlinx-datetime 的适配过程其实很典型:真正的难点从来不在 Kotlin 代码本身,而在平台侧的隐含假设。这个库默认"系统会提供时区数据库",而 OpenHarmony 的沙箱环境不提供;只要识别出这个假设,把数据来源换成应用层注入,问题就解决了。整个适配改动量很小,但如果没有想清楚这一点,就很容易在 native 侧反复折腾却始终得到 UTC。
下一步我打算沿着同样的思路继续推进 kotlinx-io 与 okio,这两个库的难点会落在文件系统抽象上,和时区问题属于同一类——都是平台能力与库预期之间的错位。
欢迎加入 KMP/CMP 鸿蒙化社区,一起共建 OpenHarmony 跨平台生态:
https://atomgit.com/CPF-KMP-CMP
适配后仓库地址(AtomGit):
https://atomgit.com/oh-tpc/ohos_kotlinx-datetime
环境信息:DevEco Studio 26.0.0 Release / HarmonyOS Kotlin 2.2.21-1.0.0 / Gradle 8.14.1 / JDK 21 / 真机 ROM 6.1+ / kotlinx-datetime 0.8.0
更多推荐




所有评论(0)