在这里插入图片描述
开源鸿蒙平台 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 官方维护的库,InstantLocalDateLocalDateTimeTimeZone 这套 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.json5main 没指向入口文件,都会出现"符号明明在,就是调不到"的现象。按符号名 → 模块名 → 声明文件 → 包入口这个顺序逐一核对即可。

坑四:改完 Kotlin 代码,产物没更新。 Kotlin/Native 的编译缓存比较激进,遇到产物与代码不一致时先 ./gradlew clean 再重新构建,比反复找代码问题高效。

十、小结

kotlinx-datetime 的适配过程其实很典型:真正的难点从来不在 Kotlin 代码本身,而在平台侧的隐含假设。这个库默认"系统会提供时区数据库",而 OpenHarmony 的沙箱环境不提供;只要识别出这个假设,把数据来源换成应用层注入,问题就解决了。整个适配改动量很小,但如果没有想清楚这一点,就很容易在 native 侧反复折腾却始终得到 UTC。

下一步我打算沿着同样的思路继续推进 kotlinx-iookio,这两个库的难点会落在文件系统抽象上,和时区问题属于同一类——都是平台能力与库预期之间的错位。

欢迎加入 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

Logo

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

更多推荐