在这里插入图片描述

大家好,我是熊猫钓鱼!欢迎大家和我一起探讨技术。希望您能点赞关注,谢谢!

欢迎加入 KMP/CMP 鸿蒙化社区:https://atomgit.com/CPF-KMP-CMP

适配后仓库地址(AtomGit):https://atomgit.com/oh-tpc/ohos_Essenty

摘要

本文是 KMP 三方库鸿蒙化适配系列的第三篇。前两篇(kotlinx-datetime、kotlinx-io)翻车都源于库对平台的能力假设——假设某个能力存在,鸿蒙上没有,于是当场抛异常。Essenty 把问题换了性质:它假设的是"平台会在正确时机通知我、并在重建前替我保存状态",这是一条时序假设。时序错位不会崩溃,只会在旋转屏幕、切后台、页面回收之后表现为"状态莫名丢失",比能力缺失更隐蔽、更难查。全文按"能力是否碰平台"把 Essenty 分成三类——纯逻辑层(四个状态机 / 分发器,一行不改)、平台注入层(四个 *Owner,适配主战场)、落盘与序列化层(2.5.0 已移除 Parcelable,改由 kotlinx-serialization 输出 JSON 字符串),据此明确适配点不在库内部,而在"谁来驱动生命周期";随后给出把 OpenHarmony 生命周期映射到五档状态、StateKeeper 复用上一篇 kotlinx-io 落沙箱、InstanceKeeper 挂到 Ability、BackHandler 语义降级到 onBackPressed 的具体桥接代码,并逐条落实"句柄走 Long"“释放责任在调用方”"save/restore 时机对齐"等互操作契约;最后用"生命周期时序 / 自增序号状态重建 / 返回键消费"三组真机验证证明它真的成立。一句话方法论:先看清分层 → 用编译器暴露缺口 → 区分能力假设与时序假设 → 从应用层注入能力与时序 → 用可辨识用例验证。

目录

一、接着上一篇往下走

前两篇的结论是同一条:KMP 库适配的工作量,几乎与代码体量无关,而与库对平台做了多少隐含假设强相关。kotlinx-datetime 假设"系统会提供时区数据库",kotlinx-io 假设"路径可以直接 open()",两个假设都在鸿蒙上失效了。

但这两个都属于能力假设——库以为某个能力存在,结果不存在。Essenty 是第三个样本,假设的性质变了:它假设的是"平台会在正确的时机通知我,并且会在重建之前把我的状态保存下来"。这是时序假设,比能力假设更隐蔽:能力缺失会当场抛异常,时序错位不会——它只会在用户旋转屏幕、切后台再回来、或者页面被系统回收之后,表现为"状态莫名其妙丢了"。

Essenty 是 Arkadii Ivanov 为 Kotlin Multiplatform 写的一组基础库,包含 Lifecycle、StateKeeper、InstanceKeeper、BackHandler 四个核心模块,也是 Decompose 的底座。它的整个设计建立在一个前提上:宿主平台会驱动生命周期、会在重建前保存状态、会把返回事件转发过来。在 Android 上,这个宿主是 ComponentActivity 加上 androidx.lifecycle;在 OpenHarmony 上,没有这样的现成宿主。这套时序必须自己接。

二、先看清 Essenty 的分层

动手之前先把模块按"是否碰平台"分成三类,这直接决定了工作量分布。

第一类:纯逻辑层,一行都不用改。 essenty-lifecycle 里的 Lifecycle 状态机与 LifecycleRegistry,essenty-state-keeper 里的 StateKeeper 与 StateKeeperDispatcher,essenty-instance-keeper 里的 InstanceKeeper 与 InstanceKeeperDispatcher,essenty-back-handler 里的 BackDispatcher。这些全是纯 Kotlin,不含任何平台 API。它们只定义"状态怎么流转"“键值怎么存取”“返回事件怎么分发”,不关心事件从哪来、值存到哪里去。

第二类:平台注入层,适配的主战场。 Essenty 用一组 *Owner 接口把平台能力抽出来:LifecycleOwner、StateKeeperOwner、InstanceKeeperOwner、BackHandlerOwner。在 Android 上,实现这些接口的是 ComponentActivity;在 iOS、桌面、Web 上由应用自己实现。OpenHarmony 上没有现成的实现,这就是要补的部分。

第三类:落盘与序列化,交给 kotlinx-serialization。 早期 Essenty 有 parcelable / parcelize 两个模块,把"可跨进程传递的对象"抽象成 Parcelable、Android 上落到 android.os.Parcel;但这两块在 Essenty 1.x→2.x 已被移除(本仓库 2.5.0 的模块目录里已经没有 parcelable/parcelize)。现在的 StateKeeper 直接用 kotlinx-serialization 把 @Serializable 状态编成一个 JSON 字符串,StateKeeperDispatcher.save() 返回的是 Map<String, String>(键→序列化后的值)。所以 OpenHarmony 侧要做的不是"搬 Parcel 字节容器",而是"把这个字符串持久化到沙箱"——正好接上一篇 kotlinx-io 的成果。

分层清楚之后,工作量就能预估了:状态机与分发器完全不动,全部注意力集中在"把 OpenHarmony 的四个平台能力,接到四个 Owner 接口上",以及"把 StateKeeper 的字符串存进沙箱"。

三、第一步:target 与 source set

前提和前两篇一致——接入 HarmonyOS Kotlin 定制版,ohosArm64() 这个 target 不在 Kotlin 官方主线里,用官方插件会直接报 Unresolved reference。版本切换与插件仓库配置见第一篇,这里只列 target 与 source set:

// lifecycle/build.gradle.kts(其余模块同理;本仓库模块目录是 lifecycle / state-keeper / instance-keeper / back-handler,不带 essenty- 前缀)
kotlin {
    jvm()
    js(IR) { nodejs() }
    linuxX64()
    macosArm64()
    iosArm64()

    // 本次新增:OpenHarmony
    ohosArm64()

    sourceSets {
        val commonMain by getting
        val commonTest by getting

        val ohosArm64Main by creating {
            dependsOn(commonMain)   // Essenty 的非 Android 实现是纯 Kotlin,就在 commonMain
        }
        val ohosArm64Test by creating {
            dependsOn(commonTest)
        }
    }
}

dependsOn(commonMain) 这里要特别说一句:很多人照搬"能力假设"那两篇的写法去 val nativeMain by getting,会直接报 Unresolved reference。因为 Essenty 根本没有独立的 nativeMain source set——它的非 Android 实现(Lifecycle 状态机、各 Dispatcher)本来就是一份纯 Kotlin,直接放在 commonMain,被所有平台共享。所以鸿蒙 target 挂到 commonMain 即可,真正需要 ohosArm64Main 单独写的,只有后面那几个桥接函数。
我的Dev Eco开发界面如下所示:
在这里插入图片描述
开发调试日志如下:
在这里插入图片描述

四、第二步:编译器暴露的缺口比想象中小

补完 target 先编译一次,让编译器报缺失的 actual:

./gradlew :essenty-lifecycle:compileKotlinOhosArm64
./gradlew :essenty-state-keeper:compileKotlinOhosArm64

这一步的缺口通常很小,原因第二节已经说明:四个核心模块的状态机与分发器都是纯 Kotlin,nativeMain 里已有实现,继承之后能直接编过。

这里有个容易走弯路的地方。 很多人一看到 Lifecycle 这个跨平台接口,就本能地认为"生命周期的适配就在这个接口里",然后花大量时间去翻它的实现。实际上 Essenty 的 Lifecycle 就是一份纯 Kotlin 接口,配一个 LifecycleRegistry 实现,直接放在 commonMain 被所有平台共享,跟平台一点关系都没有。真正的适配点不在 Lifecycle 内部,而在谁来调用 registry.onCreate() / onResume() / onDestroy()。换句话说,缺的不是状态机,是事件的来源。

这个判断一旦成立,后面的工作方向就完全变了:不是"改库",而是"写宿主"。

五、第三步:把 OpenHarmony 的生命周期接到 LifecycleOwner

OpenHarmony 的生命周期分两层:Ability 层是 onCreate / onForeground / onBackground / onDestroy,页面层是 onPageShow / onPageHide。Essenty 需要的 Lifecycle 状态是五档:INITIALIZED / CREATED / STARTED / RESUMED / DESTROYED。两层需要做一次映射:

OpenHarmony 事件Essenty 状态迁移
onCreateINITIALIZED → CREATED
onPageShowCREATED → STARTED → RESUMED
onPageHideRESUMED → STARTED
onBackgroundSTARTED → CREATED
onDestroyCREATED → DESTROYED

映射表本身没有难度,真正的坑在于句柄。Kotlin 侧的 LifecycleRegistry 是对象图,ArkTS 侧不能直接持有它,只能拿一个句柄。这里踩到了上一篇互操作文章里讲过的"句柄契约":ArkTS 的 number 只有 53 位整数精度,如果 Kotlin 侧用 Double 承载自增 ID,一旦超过 2^53 就会静默丢精度,句柄指向错误的实例。所以句柄必须走 Long,并在 C ABI 上以 int64 传递。

// lifecycle/src/ohosArm64Main/kotlin/ohos/LifecycleBridge.kt
private val registries = mutableMapOf<Long, LifecycleRegistry>()
private var nextId = 1L

@CName("essenty_lifecycle_create")
fun essentyLifecycleCreate(): Long {
    val id = nextId++
    registries[id] = LifecycleRegistry()
    return id
}

@CName("essenty_lifecycle_move_to")
fun essentyLifecycleMoveTo(handle: Long, state: Int) {
    val registry = registries[handle] ?: return
    when (state) {
        1 -> registry.onCreate()
        2 -> registry.onStart()
        3 -> registry.onResume()
        4 -> registry.onPause()
        5 -> registry.onStop()
        6 -> registry.onDestroy()
    }
}

@CName("essenty_lifecycle_release")
fun essentyLifecycleRelease(handle: Long) {
    registries.remove(handle)?.onDestroy()
}

ArkTS 侧只负责在页面回调里把事件转成状态码:

import { essentyLifecycleCreate, essentyLifecycleMoveTo, essentyLifecycleRelease } from 'libessenty';

@Entry
@Component
struct Index {
  private handle: number = 0;

  aboutToAppear(): void {
    this.handle = essentyLifecycleCreate();
    essentyLifecycleMoveTo(this.handle, 1); // CREATED
  }

  onPageShow(): void {
    essentyLifecycleMoveTo(this.handle, 3); // RESUMED
  }

  onPageHide(): void {
    essentyLifecycleMoveTo(this.handle, 5); // STOPPED
  }

  aboutToDisappear(): void {
    essentyLifecycleRelease(this.handle);
  }
}

注意 essentyLifecycleRelease 必须在 aboutToDisappear 里调用。这对应上一篇互操作文章里的"生命周期契约":句柄的释放责任在调用方。漏掉这一步,registries 这张表会随着页面反复进出单调增长,表现为内存缓慢上涨——不会报错,只会越来越卡。

六、第四步:StateKeeper 的后端,正好交给 kotlinx-io

StateKeeper 是 Essenty 里最贴近"重建"语义的模块。它的用法是:

class CounterState(val value: Int)

class CounterComponent(stateKeeper: StateKeeper) {
    private val state = stateKeeper.consume("counter", CounterState.serializer()) ?: CounterState(0)
    ...
}

consume 负责"取出并清除",register 负责"登记待保存"。至于这些值保存到哪里,Essenty 不管——StateKeeperDispatcher.save() 返回的是一个 Map<String, String>(每个键对应一个 @Serializable 值序列化后的 JSON 串),落盘动作由宿主完成。Android 上宿主把它塞进 onSaveInstanceState 的 Bundle,而 OpenHarmony 上没有 Bundle 这条路,需要自己选一个持久化后端。

这里我直接复用了上一篇的成果——用 kotlinx-io 写到应用沙箱里。理由是:沙箱路径由 context.filesDir 注入,天然满足"绝不硬编码绝对路径"的要求;而且 kotlinx-io 已经验证过可用,不必再引入新的依赖。

// state-keeper/src/ohosArm64Main/kotlin/ohos/StateKeeperBridge.kt
// StateKeeperDispatcher.save() 返回 Map<String, String>(键 -> @Serializable 值的 JSON 串)
@CName("essenty_state_save")
fun essentyStateSave(handle: Long, sandbox: String): String {
    val dispatcher = dispatchers[handle] ?: return ""
    // 把整张 map 再序列化成一段 JSON 文本,交给平台落盘
    val payload = Json.encodeToString(dispatcher.save())

    // 复用 kotlinx-io,落盘到沙箱
    val dir = Path(sandbox, "essenty")
    SystemFileSystem.createDirectories(dir)
    val file = Path(dir, "state.json")
    val sink = SystemFileSystem.sink(file).buffered()
    sink.buffer.writeString(payload)
    sink.close()

    return file.toString()
}

@CName("essenty_state_restore")
fun essentyStateRestore(handle: Long, sandbox: String): Boolean {
    val file = Path(sandbox, "essenty", "state.json")
    if (!SystemFileSystem.exists(file)) return false

    val text = SystemFileSystem.source(file).buffered().use { it.buffer.readString() }
    val map: Map<String, String> = Json.decodeFromString(text)
    dispatchers[handle]?.restore(map)
    return true
}

这里有两个设计取舍值得说清楚。

第一,为什么不用 PersistentStorage。 ArkUI 的 PersistentStorage 确实能持久化键值,但它是 ArkTS 侧的能力,Kotlin 侧够不着。而 StateKeeper 的数据结构是 Kotlin 对象图序列化出来的字节,把它拆成 ArkTS 的键值对再拼回去,等于多做一次无意义的编解码。把字节直接落盘,让 Kotlin 侧自己管自己的格式,是更干净的分工。

第二,时机比接口更重要。 save 必须在页面真正销毁之前调用,restore 必须在组件构造之前调用。OpenHarmony 里这两个时机分别是 onPageHide 之前和 aboutToAppear 之初。顺序错位的后果不是崩溃,而是"恢复出来的是上一次的数据"——这类 bug 极难排查,所以真机验证时一定要用可辨识的序号(比如每次递增的计数器)而不是固定值。

七、第五步:InstanceKeeper 与 BackHandler

InstanceKeeper 解决的是另一类问题:对象需要在页面重建后保持同一个实例,而不是重新构造。典型的例子是播放器、长连接的会话。Essenty 的 InstanceKeeperDispatcher 同样是纯 Kotlin,问题只在于"谁来持有它"。

答案很直接:挂在 UIAbility 上。Ability 实例在页面反复进出时是存活的,把 dispatcher 存在 Ability 的作用域里,就天然满足了"跨页面重建存活"的语义。

// InstanceKeeperDispatcher 的持有者是 Ability,而不是页面
private val keepers = mutableMapOf<Long, InstanceKeeperDispatcher>()

@CName("essenty_keeper_attach")
fun essentyKeeperAttach(handle: Long): Unit {
    keepers[handle] = InstanceKeeperDispatcher()
}

@CName("essenty_keeper_detach")
fun essentyKeeperDetach(handle: Long): Unit {
    keepers.remove(handle)?.destroy()
}

BackHandler 则是四个模块里和平台耦合最浅、但最容易写错的一个。Essenty 的 BackDispatcher 支持预测性返回(predictive back),有 onBackStarted / onBackProgressed / onBackCancelled / onBackPressed 四个回调。OpenHarmony 的 onBackPress 只给了一个"按下"时机,没有手势进度。所以适配时要做一次语义降级:把 onBackPress 映射到 onBackPressed,onBackStarted / onBackProgressed / onBackCancelled 在这条链路上不触发。

onBackPress(): boolean {
  const consumed: number = essentyBackDispatch(this.handle);
  // 返回 true 表示已被业务消费,阻止默认返回行为
  return consumed === 1;
}

关键在返回值语义:Essenty 的 BackDispatcher 会按注册顺序询问每个 BackCallback,只要有一个返回 true 就算已消费。ArkTS 的 onBackPress 返回 true 表示"我处理了,不要走默认返回",返回 false 表示"继续走默认行为"。两者语义正好对齐,所以桥接函数只要把 Kotlin 侧的布尔结果原样传回来即可,不需要额外转换。

八、第六步:导出、打包与真机验证

导出与打包链路和前两篇一致:Kotlin 侧用 @CName 指定导出符号,C++ 侧用 NAPI 注册模块,产物 .so 放进 HAR 的 libs/arm64-v8a/。细节不再重复,这里只补一个验证顺序——先确认符号,再排查调用:

llvm-nm -D libessenty.so | findstr essenty_

我们来看下运行效果:
在这里插入图片描述
可以非常清楚地看到生命周期的变化过程,并且响应点击事件进行状态切换:
在这里插入图片描述
修改状态到Destroy:
在这里插入图片描述
OK!我们再看看跨重建存活情况,以及返回键消费情况:
在这里插入图片描述
使用keep和detach给你演示功能。
在这里插入图片描述
真机记录操作日志如上所示。
在这里插入图片描述
可以看到都运行成功了!

四个模块的符号应该都能看到:

T essenty_lifecycle_create
T essenty_lifecycle_move_to
T essenty_state_save
T essenty_state_restore
T essenty_back_dispatch

符号在、但 ArkTS 侧 import 不到,问题一定在 NAPI 注册层,按"符号名 → 模块名(nm_modname)→ Index.d.ts 声明 → oh-package.json5 的 main 入口"顺序核对即可。

运行log如下所示:
在这里插入图片描述

真机验证要分三组做,缺一组都说明不了问题:

  1. 生命周期时序:在 Lifecycle 的每个回调里打日志,页面进出一次,确认状态迁移序列是 CREATED → STARTED → RESUMED → STARTED → STOPPED → DESTROYED,没有跳档、没有重复。
  2. 状态重建:用自增计数器做验证。写入后把页面完全退出(不是切后台),再重新进入,确认读回来的是上次的值而不是初值。
  3. 返回键消费:注册一个返回拦截器,返回 true 时页面不退出,返回 false 时正常退出。

图 1:真机运行截图 —— 页面显示生命周期状态迁移序列
图 2:状态重建验证 —— 退出前与重进后的计数器对照
图 3:hdc shell 查看沙箱内 essenty/state.json 的实际文件
图 4:llvm-nm -D libessenty.so 符号导出结果截图
(投稿前请补入上述四张真实截图)

九、踩坑清单

坑一:ohosArm64() 报未定义。 和前两篇同一个原因,还在用 Kotlin 官方主线插件。这个 target 只在 HarmonyOS Kotlin 定制版里存在。

坑二:在 Lifecycle 类里找适配点。 最容易浪费时间的一类。Essenty 的 Lifecycle 在非 Android 平台是自带实现,纯 Kotlin 状态机,不需要改。缺的是事件来源,不是状态机。

坑三:句柄用 Double 承载。 超过 2^53 后静默丢精度,句柄指向错误实例,且不报错。必须用 Long / int64。

坑四:忘记释放句柄。 LifecycleRegistry 与 InstanceKeeperDispatcher 都保存在 Kotlin 侧的 Map 里,页面销毁时若不调用 release / detach,表会单调增长,表现为内存缓慢上涨。

坑五:save 与 restore 时机错位。 后果不是崩溃,而是恢复出上一次的数据。验证时务必用可辨识的自增序号,不要用固定值。

坑六:误以为返回键支持预测性返回。 OpenHarmony 的 onBackPress 只有"按下"一个时机,onBackStarted / onBackProgressed / onBackCancelled 在这条链路上不会触发,需要在文档里明确降级语义,而不是假装支持。

坑七:改完代码产物没更新。 与前两篇相同,Kotlin/Native 编译缓存比较激进,遇到产物与代码不一致时先 ./gradlew clean 再构建。

十、小结

Essenty 把前两篇的方法论往前推了一步。之前我开发测试的kotlinx-datetime 和 kotlinx-io 考验的是"能不能把平台能力注入进去",Essenty 考验的是"能不能把平台的时序对齐上去"——它不缺任何能力,缺的是"什么时候该通知我"。

这也解释了为什么这类库的适配容易翻车:能力缺失会当场抛异常,时序错位只会静默地丢状态。 前者编译器帮你兜底,后者只能靠验证用例兜底。所以这次真机验证我特意拆成三组,其中"状态重建"那组必须用自增序号而不是固定值——固定值会让错误的实现看起来是对的。

四个模块走下来,这套方法论已经可以固化成一句更完整的表述:先看清分层 → 用编译器暴露缺口 → 找出库对平台的隐含假设(能力假设与时序假设要分开看)→ 把平台能力与时序从应用层注入 KMP 层 → 用可辨识的验证用例证明它真的成立。

欢迎加入 KMP/CMP 鸿蒙化社区,一起共建 OpenHarmony 跨平台生态:
https://atomgit.com/CPF-KMP-CMP

适配后仓库地址(AtomGit):
https://atomgit.com/oh-tpc/ohos_Essenty

推荐使用码道进行 KMP/CMP 工程的代码补全与适配辅助,专属邀请入口:
https://developer.huawei.com/codeartsco.html?source=dmzntgwatomgit1&sourcead=dmzntgwatomgiths


环境信息:DevEco Studio 26.0.0 Release / HarmonyOS Kotlin 2.2.21-1.0.0 / Gradle 8.14.1 / JDK 21 / 真机 ROM 6.1+ / Essenty 2.5.0

Logo

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

更多推荐