在这里插入图片描述

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

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

一、为什么先挑 Decompose 下手

前面几篇已经把 Essenty 的地基铺好了——生命周期、状态保留、实例保留、返回键这四件事,在鸿蒙上都能跑。但地基不是房子。OpenHarmony 上真正缺的是"把页面组织起来"的那一层:多页面怎么切、返回栈谁来管、前进后退时上一个页面要不要销毁、进程被杀之后回到哪一页。

KMP 生态里干这件事的事实标准就是 Decompose。它把界面拆成一棵有生命周期的业务组件树,每个组件只认识自己的直接子组件,导航状态是一个纯函数的输入输出。Android、iOS、Desktop、Web 都能用它,唯独鸿蒙还没有。

挑它有三个理由。

第一,它是 Essenty 的天然续集。Decompose 的 ComponentContext 在源码里就是 Lifecycle + StateKeeper + InstanceKeeper + BackHandler 四个 Owner 的组合,而这四样恰好是上一篇已经适配过的东西。等于说地基已经打好了,这篇是在上面盖楼,技术栈 100% 复用,不用重新趟一遍平台差异。

第二,它的平台假设薄得惊人。这一点是我翻完源码之后最意外的收获:整个 decompose 模块里,需要平台提供实现的 expect 声明只有四个,而且其中三个在 Linux 上已经有现成实现,鸿蒙直接复用即可。真正要新写的只有一个主线程检查。也就是说,把 Decompose 搬到鸿蒙,难点不在"要写多少代码",而在"要先看清楚哪些代码根本不用写"。

第三,验收标准肉眼可见。前进、后退、返回键拦截、状态保留——每一条都能在真机上用手指头验证,不需要构造复杂场景。这比时区那种"数值对了但看不出来"的问题友好得多。

这篇文章记录完整过程:从 fork 源码、接入 HarmonyOS Kotlin 定制版、声明 ohosArm64 target,到补齐缺失实现、把返回键接到 ArkUI、导出符号给 ArkTS、打成 HAR,最后在真机上逐条验证。

二、先摸清源码结构:Decompose 到底把什么交给了平台

动手之前先做一件事:把 decompose 模块的源集摊开看一遍。不看清楚就改,很容易在错误的地方加代码。

decompose/src/ 下的源集是这样的:

decompose/src/
├── commonMain/    全部对外 API 与导航逻辑
│                  ChildController / NavState / NavStateSaver / Value / ChildStack ...
│                  以及 4 个 expect:Lock、checkMainThread、printError、KClass<*>.uniqueName
├── nonWebMain/    KClass<*>.uniqueName 的 actual(内容就一行:qualifiedName)
├── linuxMain/     Lock(pthread 递归锁)、printError、checkMainThread(注意:空实现)
├── androidMain/   mainthread/CheckMainThread.kt(Looper 版)+ DefaultComponentContextBuilder.kt
├── darwinMain/    mainthread/CheckMainThread.kt(Darwin 版)
├── jvmMain/       mainthread/CheckMainThread.kt(JVM 版)
└── jsMain/ wasmJsMain/    web 相关,与本次无关

这张表里有两个信息点直接决定了后面的工作量。

第一,没有 nativeMain 兜底。 很多 KMP 库会有一个 nativeMain 中间源集,把所有 native target 共有的实现放进去,新加一个 native target 时能"白拿"一批代码。Decompose 没有。它的 native 侧实现只放在 linuxMain 里,而 linuxMain 是只服务 linuxX64 / linuxArm64 这一组 target 的。ohosArm64 不属于这一组,所以新建 target 之后,编译器会老老实实把缺的声明全报出来——不会偷偷继承任何东西。

第二,nonWebMain 必须挂上。 KClass<*>.uniqueName 这个 expect 声明在 commonMain,actual 却放在 nonWebMain 里,它不属于任何一个具体平台源集。这一条是本次适配最容易踩空的地方:如果 ohosArm64Main 只挂在 commonMain 下面,编译 commonMain 阶段一切正常,一到 native 链接阶段就会因为缺 uniqueName 的 actual 而失败。

看清楚了,接下来就有章可循了。

三、第一步:接入 HarmonyOS Kotlin 定制版

这是前置条件,也是最容易被忽略的一步。ohosArm64() 这个 target 在 Kotlin 官方主线发行版里并不存在,它是 OpenHarmony 适配生态中的定制能力。如果你用官方 Kotlin 插件直接写 ohosArm64(),Gradle 会报 Unresolved reference——插件根本不认识这个名字。

所以第一步是把工程使用的 Kotlin 切到 HarmonyOS Kotlin 定制版,并在 settings.gradle.kts 里把插件仓库指向 CPF-KMP-CMP 对应的发行仓库。

// settings.gradle.kts
pluginManagement {
    repositories {
        // HarmonyOS Kotlin 定制版插件仓库(地址见 CPF-KMP-CMP 发布说明)
        gradlePluginPortal()
        maven("https://jitpack.io")   // 上游 Decompose 的 gradle-setup-plugin 来自这里
    }
    resolutionStrategy {
        eachPlugin {
            if (requested.id.toString() == "com.arkivanov.gradle.setup") {
                useModule("com.github.arkivanov:gradle-setup-plugin:4a2bf5cb37")
            }
        }
    }
}

具体坐标和仓库地址以 CPF-KMP-CMP 组织的发布说明为准,那里会同步每一版的版本号与配套 Gradle、JDK 要求。

顺手建议:把上游 settings.gradle.kts 里的 sample:app-android、app-desktop、app-js 这些宿主模块先摘掉。鸿蒙适配阶段用不到它们,留着只会在配置阶段去解析 Android SDK,白白拖长首次同步时间。
在这里插入图片描述

【配图1:DevEco Studio 26.0.0 开发界面,工程已同步成功】

四、第二步:声明 target,然后让编译器告诉你缺什么

上游用的是 Arkivanov 自己的 gradle-setup-plugin,用 setupMultiplatform() 声明一套固定的 target,再用 setupSourceSets { val linux by bundle() } 这种 bundle 语法描述源集继承关系。这套 DSL 里没有 ohosArm64。

所以我没有走 bundle 语法,而是把这个 target 和它的源集显式补上。这样做的额外好处是:源集继承关系在文件里一眼可见,不依赖插件的内部命名约定。

// decompose/build.gradle.kts
kotlin {
    // 新增 1/2:target
    ohosArm64()

    // 新增 2/2:源集
    sourceSets {
        val commonMain by getting
        val commonTest by getting

        // nonWebMain 里放着 KClass.uniqueName 的 actual,必须挂上
        val nonWebMain by getting

        val ohosArm64Main by creating {
            dependsOn(nonWebMain)
        }
        val ohosArm64Test by creating {
            dependsOn(commonTest)
        }
    }
}

注意 dependsOn(nonWebMain) 这一行不是可选项。前面说过,uniqueName 的 expect 在 commonMain、actual 在 nonWebMain,而 nonWebMain 不属于任何平台源集——必须显式接上。

声明完先别急着写实现代码,先编译一次,把"缺什么"交给编译器报出来。这是 KMP 适配里最高效的做法,比对着源码猜要准得多:

./gradlew :decompose:compileKotlinOhosArm64

Kotlin/Native 的编译任务命名规则是 compileKotlin<首字母大写的 target 名>,所以这里就是 compileKotlinOhosArm64。

第一次编译会失败,报出三条 Expected declaration 'xxx' has no actual declaration in module <decompose>:

e: Lock.kt: Expected declaration 'Lock' has no actual declaration in module <decompose> for Native
e: mainthread/CheckMainThread.kt: Expected declaration 'checkMainThread' has no actual declaration ...
e: errorhandler/PrintError.kt: Expected declaration 'printError' has no actual declaration ...

每一条就是一个待补的 actual。三条,就是本次适配的全部工作面。

五、第三步:三个 actual 直接复用,第四个必须真写

5.1 Lock:逐字复用 Linux 的实现

先把 Lock 的定义看清楚。commonMain 里的声明是:

internal expect class Lock() {
    inline fun <T> synchronizedImpl(block: () -> T): T
}

一个同步块,没有别的。Linux 上的实现是用 pthread 的可重入互斥量:

internal actual class Lock actual constructor() {
    private val mutex = ...  // pthread_mutex_t, PTHREAD_MUTEX_RECURSIVE
    actual inline fun <T> synchronizedImpl(block: () -> T): T {
        pthread_mutex_lock(mutex.ptr)
        try { return block() } finally { pthread_mutex_unlock(mutex.ptr) }
    }
}

为什么鸿蒙能原样复用:OpenHarmony 的用户态运行时是 POSIX 兼容的,Kotlin/Native 为 ohosArm64 提供的 platform.posix cinterop 里 pthread_mutex_init / pthread_mutexattr_settype / PTHREAD_MUTEX_RECURSIVE 一应俱全。而 Decompose 需要的恰恰是可重入——DecomposeSettings.update() 内部会嵌套加锁,普通的互斥量会当场自锁。

所以 ohosArm64Main/kotlin/com/arkivanov/decompose/Lock.kt 就是把 linuxMain 那份拷过来。这不是偷懒,而是上游代码本身就没有平台特有的东西:它要的不是某个平台的专有 API,只是"平台得有个能重入的锁"。

⚠️ 有个反向注意点:不要图省事用 kotlin.native.concurrent.SynchronizedObject 替掉它。SynchronizedObject 不可重入,DecomposeSettings.update 嵌套加锁时会直接死锁,而且死锁现场在 native 侧,排查成本很高。

5.2 printError:也复用

commonMain 的声明是 internal expect fun printError(exception: Exception)。
Linux 侧实现是 exception.printStackTrace()。

鸿蒙上同样成立:Kotlin/Native 写 stderr 的内容会进到 DevEco Studio 的 hilog,日常排查直接看 Build 窗口就行。所以这个文件也是原样复用。

如果希望错误进 hilog 时带业务 tag,不要改 actual——把 DecomposeSettings.settings.onDecomposeError 指到自己的实现即可,那是 Decompose 留出来的正路。

5.3 checkMainThread:唯一要真写的地方

这个就值得展开了。先把三份上游实现摆在一起:

// androidMain
private val mainThreadId: Long? = Looper.getMainLooper().thread.id
internal actual fun checkMainThread() {
    if (settings.mainThreadCheckEnabled && mainThreadId != null && Thread.currentThread().id != mainThreadId) {
        onDecomposeError(NotOnMainThreadException(Thread.currentThread().name))
    }
}

// darwinMain / jvmMain  各有各的取主线程方式

// linuxMain —— 干脆是空的
internal actual fun checkMainThread() {
    // No-op
}

linuxMain 是空实现,因为 Linux 上没有"主线程"这个概念,库自己也知道这一层是空的。但鸿蒙不一样:ArkUI 有明确的主线程(UI 线程),而 Kotlin/Native 的 NAPI 同步调用默认就跑在调用方线程上——ArkTS 的 TaskPool、Worker 各有各的线程。也就是说,"从工作线程误改组件树"在鸿蒙上是完全可能发生的,这个检查不能空着。

那怎么判断"当前是不是主线程"?我没有去 native 侧反查线程归属,那种做法在鸿蒙上不可靠。我选择让 ArkTS 主动登记:

  • Ability 的生命周期回调(onWindowStageCreate / onForeground)一定在主线程上执行;
  • 在这个时机调一次 native 导出函数,把当前线程身份记下来;
  • 之后任何线程访问组件树,checkMainThread() 就能正确判定。
// ohosArm64Main
@Volatile
private var mainThreadId: ULong = 0uL

/** 由 ArkTS 在主线程(Ability 生命周期回调)里调用一次 */
fun markMainThread(): ULong {
    mainThreadId = pthread_self()
    return mainThreadId
}

internal actual fun checkMainThread() {
    if (!DecomposeSettings.settings.mainThreadCheckEnabled) return

    val expected = mainThreadId
    // 未登记则放行——与 Android 上取不到 Looper 时 mainThreadId == null 的行为一致,
    // 避免 Embedding 启动早期误报
    if (expected == 0uL) return

    val current = pthread_self()
    if (current != expected) {
        onDecomposeError(NotOnMainThreadException(currentThreadName = "ohos pthread_self=$current, expected=$expected"))
    }
}

这里有几个刻意的设计取舍,值得单独说明。

为什么用"登记"而不是"推断"? 因为 ArkTS 的主线程身份没有稳定的 native 侧查询入口:Ability 生命周期回调跑在主线程,但这是语义约定而不是可查询的 API。既然调用方(ArkTS)最清楚自己在哪个线程,就让它负责登记。

为什么没登记时要放行? 这与 Android 的行为对齐:Android 上如果 Looper.getMainLooper() 抛异常(极端早期启动场景),mainThreadId 为 null,检查直接跳过。宁可漏报也不要在启动早期误报一堆假错误。

为什么用 @Volatile? mainThreadId 会在主线程写、在其他线程读,不加 @Volatile 时 Kotlin/Native 不保证跨线程可见性,可能读到 0 从而静默跳过检查。

六、第四步:把返回键接到 ArkUI

补齐三个 actual 之后编译就能过了,如下所示:
在这里插入图片描述

通过后启动运行效果如下:
请添加图片描述

第二个真正的坑才开始:返回键。

先明确 Decompose 的返回键机制是纯逻辑的,不依赖任何平台 API:

val backDispatcher = BackDispatcher()
if (!backDispatcher.back()) {
    // 没有组件消费这个返回事件
}

BackDispatcher.back() 的派发规则是:把所有回调按 priority 升序排序,从末尾往前找,第一个 isEnabled 的回调被调用,返回 true;一个都没命中返回 false。

Decompose 在 childStack(handleBackButton = true) 时会自己注册一个出栈回调(priority 默认为 0)。而组件自己通过 childContext.backHandler.register(...) 注册的回调,会被 ChildBackHandler 包一层、priority 抬高后再注册到父 dispatcher 上。于是天然形成这个顺序:

组件自己注册的回调(priority + 1000)→ 先被检查
ChildStack 的自动出栈回调(priority 0)→ 后被检查
都没有 → back() 返回 false → 交还系统

鸿蒙侧要做的只是"把系统返回事件喂进 backDispatcher.back()",两条入口:

// 1. ArkUI 页面级:@Entry 组件上的 onBackPress
onBackPress(): boolean {
  return decompose.back();   // true = 组件消费了,页面不退出
}

// 2. UIAbility 级:页面没消费时才会走到这里
onBackPressed(): boolean {
  return decompose.back();
}

两端指向同一个 BackDispatcher,所以无论从哪条路径进来,组件注册的回调都会被按优先级正确询问。最终行为就是:

场景back() 结果表现
详情页按返回true出栈回上一页
确认页按返回(组件注册了拦截回调)true不出栈,只累加拦截次数
首页按返回(栈深为 1)false交还系统 → 退出应用

demo展示可操作页面如下:
请添加图片描述

确认页拦截返回时的界面与日志截图:
请添加图片描述
请添加图片描述
请添加图片描述
请添加图片描述

七、第五步:生命周期、状态、实例三件事对齐 UIAbility

返回键解决了,还剩三件事要接到鸿蒙的宿主上。

7.1 生命周期

Android 上,defaultComponentContext() 会把 Activity 的 Lifecycle 抠出来用。鸿蒙没有对应物,得自己拼:

val lifecycle = LifecycleRegistry()
val context = DefaultComponentContext(
    lifecycle = lifecycle,
    backHandler = backDispatcher,
)

// 时序必须与 Android 一致:ON_CREATE -> 建树 -> ON_START -> ON_RESUME
lifecycle.create()
rootComponent = RootComponent(context)
lifecycle.start()
lifecycle.resume()

对应的鸿蒙接线点:

// EntryAbility.onWindowStageCreate —— 主线程,且只执行一次
decompose.markMainThread();
decompose.init();

// EntryAbility.onForeground / onBackground / onDestroy
decompose.abilityEvent('resume');    // 也可能 'pause' / 'stop' / 'destroy'

这里有个必须讲清楚的工程约束:根 ComponentContext 一定要在 UIAbility 里创建,不能放在页面的 aboutToAppear 或 build 里。 这一点和 Decompose 官方文档对 Compose 的警告是同一个道理——组件树的根只能创建一次,放进页面就会出现"页面重建一次就多出一棵组件树",而且是静默的:功能看着正常,只是泄漏和日志翻倍。

7.2 状态保留:鸿蒙与 Android 最大的差异

这是整篇里最需要注意的一条。

Android 上 StateKeeper 背后是 SavedStateRegistry,Bundle 直接落盘,组件的 save/restore 是自动的——很多 Android 开发者甚至没意识到自己在用 Decompose 的状态保留。

而在非 Android 平台上,Decompose 要求业务代码显式给出序列化器:

// commonMain 里写的组件代码
private var draft: Int = stateKeeper.consume(key = "draft", strategy = Int.serializer()) ?: 0

init {
    stateKeeper.register(key = "draft", strategy = Int.serializer()) { draft }
}

不写这两行,鸿蒙上就永远不会恢复状态——而且不会有任何报错,只是"进程被杀之后再回来,草稿没了"。这类问题如果没意识到平台差异,能查很久。

落盘载体在鸿蒙上选 @ohos.data.preferences:进程被杀之前把 stateKeeper.save() 的结果写进 preferences,冷启动时先 restore 再建树。

顺序是关键:必须先恢复 StateKeeper,再创建组件树,否则组件 consume 的时候历史状态还没进去,等于白存。这和 Android 上 onCreate(savedInstanceState) 里先有 savedInstanceState 再建组件树是同一个道理。

7.3 实例保留:注意触发场景不同

InstanceKeeper 的语义(类似 AndroidX ViewModel)是一致的,但最有说服力的场景不一样。

Android 上主要靠"配置变更不重建进程"来体现;鸿蒙这边更直观的是返回栈里的组件不销毁:

  • push 到新页面后,前一个组件降到 CREATED,但它没有被销毁,仍然在跑;
  • 所以它的 InstanceKeeper 实例还在,计数、缓存、连接都保得住;
  • 只有组件被从栈里移出(pop / replaceAll / destroy)时,才会走 InstanceKeeper.Instance.onDestroy()。

这一条在 demo 里的表现非常清楚:首页计数 +1 两次 → 进详情 → 返回 → 首页计数还是 2。如果哪天组件被误销毁了,计数会归零,一眼就能看出来。

八、第六步:把能力导出给 ArkTS

Kotlin 层的逻辑要被鸿蒙页面调用,需要走 Kotlin/Native 导出 C 符号、再由 NAPI 桥接注册的链路。

8.1 Kotlin 侧:@CName 指定符号名

@CName("decompose_mark_main_thread")
fun decomposeMarkMainThread(): Long = markMainThread().toLong()

@CName("decompose_init")
fun decomposeInit() { OhosDecomposeHost.init() }

@CName("decompose_back")
fun decomposeBack(): Boolean = OhosDecomposeHost.onBackPressed()

@CName("decompose_state_json")
fun decomposeStateJson(): String = OhosDecomposeHost.currentStateJson()

三条硬约束:

  1. @CName 的函数不能有默认参数、不能是 internal,参数只能用 NAPI 认得的类型(Int / Long / Boolean / Double / String / 指针);
  2. 符号名一旦发布就别再改。改了名字之后 .so 里符号还在(llvm-nm 能看到),但 ArkTS 调用直接失效,属于最难排查的一类问题;
  3. 返回值不要是 Kotlin 对象。只返回 String / Boolean / Int 最省事。

关于数据边界,我只传 String(JSON)和 Boolean,不传对象、不传回调。理由和序列化那篇一样:传对象要多一层类型映射,而且会把 Kotlin 对象的生命周期漏到 ArkTS 侧;传回调则要在 NAPI 上处理线程与引用计数,收益极低。用 JSON 虽然"土",但边界干净、可打日志、可断言。

8.2 动态库的 export

ohosArm64 {
    binaries.sharedLib {
        baseName = "decompose"
        // 把 decompose 以及它 api 依赖的 essenty 四个模块一起导出
        export(project(":decompose"))
    }
}

export 这一行不能省。虽然我们只导出自己的 @CName 函数,但桥接层里一旦有泛型/内联代码被展开到 essenty 里,链接期就会报 undefined symbol。

8.3 NAPI 与 HAR

C++ 侧的注册只做参数拆包,没有业务逻辑。最容易出错的不是代码,而是"三处名字必须一致":

Kotlin 的 @CName("decompose_xxx")  ⇄  nm_modname = "decompose"  ⇄  ArkTS import 'libdecompose.so'

三处只要有一处对不上,就会出现"llvm-nm 能看到符号、ArkTS 就是调不到"的经典现象。

HAR 的目录结构:

har-ohos-decompose/
├── oh-package.json5
├── Index.ets
├── Index.d.ts
├── libs/arm64-v8a/libdecompose.so
└── src/main/cpp/
    ├── napi_init.cpp
    └── types/libdecompose/Index.d.ts

接入前先确认符号真的导出了,这一步能省掉后面一半的排查时间:

llvm-nm -D har-ohos-decompose/libs/arm64-v8a/libdecompose.so | grep decompose_

期望看到:

T decompose_back
T decompose_init
T decompose_mark_main_thread
T decompose_state_json
...

【配图5:llvm-nm 符号导出结果截图】

九、第七步:操作验证

部署后,页面显示当前导航栈、宿主生命周期、各组件状态与操作日志。下面按顺序走一遍验收。

① 启动。 栈深 1,Home 为 RESUMED,宿主 RESUMED。

【配图6:应用启动后的首页,导航栈只有 Home】

② 前进。 点「push 详情 #1」。栈深变 2,Detail 为 RESUMED,Home 降到 CREATED 但日志里没有它的 onDestroy——这是"返回栈里的组件不销毁"的直接证据。

【配图7:push 详情后的界面,日志显示 Home 降到 CREATED】

③ 后退。 按系统返回键。Detail 被消费并出栈,日志出现 VisitCounter.onDestroy();Home 回到 RESUMED。如果事先在首页点过两次计数,此时首页的 InstanceKeeper 计数仍然是 2——说明组件从头到尾没被销毁过。

【配图8:返回后首页,计数保持 2,日志显示 Detail 已销毁】

④ 返回键拦截。 点「push 确认页(拦截返回)」,进入确认页后按系统返回键:不会退出,只在页面上累加"拦截返回次数"。再点「直接 pop」也不行——因为组件自己注册的 BackCallback 优先级更高,会先把事件吃掉。点「确认离开」之后才真正出栈。

【配图9:确认页拦截返回,拦截次数累加】

⑤ 栈空交还系统。 在首页(栈深 1)按返回键,事件不被消费,应用正常退出。日志会记录一次"返回键未被消费,交还系统"。

⑥ 状态保留。 首页把草稿加到 3,然后分别点两个按钮:

  • 「模拟配置变更」:组件对象全部重建、状态走内存里的 StateKeeper 恢复 → 草稿仍是 3;
  • 「模拟进程重建」:先把状态 hex 落进 preferences,再清掉进程内一切,从磁盘恢复 → 草稿仍是 3,但 InstanceKeeper 计数归零(进程都没了,实例自然不存在,这是符合预期的行为)。

【配图10:进程重建后草稿值恢复为 3】

⑦ 重复配置。 连点两次「push 详情 #7」。两次都能入栈,栈深变 3,两份的 Child#key 分别是 detail_7#0 和 detail_7#1,组件实例不同、状态槽位互不影响。这是 Decompose 3.4 起转正的 Duplicate Configurations 能力,也是 Child#key 从 Any 改成 String 的原因——否则在 Compose 侧会直接抛 Key XYZ was used multiple times。

⑧ 后台降级。 把应用切到后台再切回来,日志会显示整棵子树降到 CREATED、回前台后又恢复 RESUMED,过程中没有任何组件被销毁。

十、踩坑清单

坑一:ohosArm64() 报未定义。 十有八九是还在用 Kotlin 官方主线插件。这个 target 只在 HarmonyOS Kotlin 定制版里存在,必须先把插件版本切过去。

坑二:编译 commonMain 通过,native 链接阶段报缺 uniqueName。 根因是 ohosArm64Main 没有 dependsOn(nonWebMain)。uniqueName 的 expect 在 commonMain、actual 在 nonWebMain,而 nonWebMain 不属于任何平台源集。症状很有欺骗性:报错点看起来在 commonMain 的 Utils.kt 上,容易让人以为是自己的代码有问题。

坑三:以为是 Dispatchers.Main 的问题,其实 Decompose 根本不依赖 coroutines。 这是我在方案阶段自己走的弯路。翻 decompose/build.gradle.kts 就能确认,commonMain 的依赖只有 essenty 四个模块 + kotlinx-serialization-core,没有 kotlinx-coroutines。Decompose 表达线程约束的方式只有同步回调 + checkMainThread() 这一处。所以不需要给 ohosArm64 补 Dispatchers.Main 的 actual。反过来也提醒一句:用 Decompose 时别指望它帮你切线程,耗时逻辑要自己 launch。

坑四:主线程检查一直不生效。 检查一下 ArkTS 有没有在 onWindowStageCreate 里调 decompose_mark_main_thread()。没登记时 mainThreadId 是 0,检查会直接放行——这是刻意的容错设计,但也意味着忘了登记就等于没有检查。

坑五:llvm-nm -D 能看到符号,ArkTS 侧 import 不到。 原因在于 Kotlin/Native 导出的 C 符号和 ArkTS 能 import 的模块接口不是一回事,中间还隔着 NAPI 注册这一层。按 符号名 → nm_modname → Index.d.ts → oh-package.json5 的 main 这个顺序逐一核对即可。

坑六:状态在鸿蒙上从来不恢复。 先确认组件里有没有写 stateKeeper.register / consume 并给了序列化器。Android 上是自动的,鸿蒙上必须显式写,而且不会报错。另外检查恢复时序:必须先把 SerializableContainer 灌进 StateKeeper,再建组件树。

坑七:把根 ComponentContext 建在页面里。 页面重建一次就多一棵组件树,功能看着正常但日志翻倍、内存持续增长。根只能在 UIAbility 里建,且只建一次。

十一、小结

Decompose 的适配过程其实很反直觉:看起来是"把一个大框架搬上鸿蒙",实际做下来只有一处逻辑要新写。

根本原因是 Decompose 把自己拆得很干净——平台相关的东西全部推给了 Essenty,而 Essenty 在上一篇已经落地了。剩下那四个 expect 里,Lock 和 printError 是"平台有 POSIX 就能过",uniqueName 是"挂对源集就白拿",唯一有增量的是 checkMainThread():它把上游在 Linux 上被迫留白的检查,在鸿蒙上用"ArkTS 主线程登记"补成了真实现。

所以这次适配真正的收获不是代码量,而是三条判断:

  1. 先看源集,再决定写什么。 nativeMain 有没有、nonWebMain 放的是什么,直接决定了工作量是"3 个文件"还是"30 个文件"。
  2. 平台差异要往"机制"上看,不要往"API"上看。 Dispatchers.Main 那个弯路就是典型的"想当然"——如果一开始就去读 build.gradle.kts 的依赖,能省掉半天。
  3. 状态保留是 Android 与非 Android 之间最大的行为差异。 Android 自动、其他平台手动,且不写不报错。这一条不只对 Decompose 成立,对整个 Essenty 系(以及所有依赖 SavedStateRegistry 的库)都成立。

下一步我打算沿着同一条链路继续推进:先把 extensions-compose 的鸿蒙化排上(依赖 Compose Multiplatform 那条线,把组件树渲染到 OHRender),再回头看看网络侧——Ktor 的鸿蒙引擎目前还是空白,那块含金量更高,但要处理的坑也更多。

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

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


环境信息:DevEco Studio 26.0.0 Release / HarmonyOS Kotlin 2.2.21-1.0.0 / Gradle 8.14.1 / JDK 21 / 真机 ROM 6.1+ / Decompose 3.5.0(Kotlin 2.1.0,Essenty 2.5.0,kotlinx-serialization 1.6.3)

Logo

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

更多推荐