开源鸿蒙平台 CMP 三方库「resaca」适配全流程
本文记录
resaca接入 OpenHarmony 的完整过程,覆盖对象作用域库盘点、ohos 制品线选型、真实库源码ohosArm64/ohosX64双目标编译、无头 Compose 运行时搭建、C ABI / N-API 桥接、ArkUI 验收页面、HAP 构建和真机安装运行。
resaca是 Sebastian Lobato Genco 维护的 Compose Multiplatform 对象作用域/保留库:rememberScoped/viewModelScoped把任意对象和 ViewModel scope 到请求它的 Composable 的生命周期——跨越重组、跨越配置变更、跨越导航返回栈,在永久离开组合后按规则处置。本次适配与社区早期路线(qrose / haze / PreCompose / voyager 的「零依赖内核提取」)有本质区别:社区 Maven 上 compose runtime / lifecycle / coroutines 的 ohos 制品线已经齐全,因此不需要提取内核——真实的resaca/src/commonMain(全部 13 个文件,含ScopedViewModelContainer生命周期状态机)被原样编译成 OpenHarmony KLIB,一个真实的无头 Compose 运行时(no-op Applier + 手动 MonotonicFrameClock + 主线程内联派发)在设备上驱动真实的rememberScoped/viewModelScoped/rememberKeysInScope,ArkUI 页面作为验收宿主。项目地址: AtomGit/oh-tpc/resaca.git
上游仓库: sebaslogen/resaca
开发工具: 华为云码道
环境安装: 环境安装指南
一、背景
1.1 为什么做 OpenHarmony 平台 KMP/CMP 适配
resaca 为 Compose 应用解决的是「记忆的范围」问题:remember 撑不过 Activity 重建,ViewModel 只能 scope 到 Activity / Fragment / Navigation 目的地,而 resaca 把对象的生命周期精确对齐到请求它的那块 UI。KMP 项目把这些逻辑放在 commonMain,由 Android、iOS、桌面、Web 共享。OpenHarmony 应用同样需要这套能力,但上游构建(Kotlin 2.4.20 / Gradle 9.8.0 / AGP 9.4.1)不会生成 OpenHarmony 变体。
如果只把示例页面改成 ArkTS,页面可以显示按钮,却无法证明公共的作用域状态机在 OpenHarmony ARM64 设备上完成编译、链接和执行。适配需要同时验证:库本体 KLIB 编译、真实 @Composable API 在 Composition 中的执行、保留/处置语义逐位一致、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 交付链路。
| 障碍 | 具体问题 |
|---|---|
| 目标缺失 | 必须增加 ohosArm64() / ohosX64() 才能生成 OpenHarmony KLIB。 |
| 工具链不一致 | 上游 Gradle 9.8.0 / AGP 9.4.1 / dokka / kover / BCV 与鸿蒙工具链无法共存于一个 Gradle 构建。 |
| Compose 依赖 | 全部核心代码依赖 org.jetbrains.compose.runtime(含 saveable)与多平台 lifecycle——早期适配因此被迫抽内核;现在需要验证 ohos 制品线是否真的可用。 |
| 无 Compose UI 宿主 | 鸿蒙侧没有 Compose UI,rememberScoped 是 @Composable,必须有真 Composition 承载。 |
| 语言边界 | ArkTS 不能直接持有 Kotlin 对象,需要 C ABI、C++ N-API 和 JSON。 |
| 交付链路复杂 | .so、CMake、N-API、HAP、签名和 hdc 都要单独验收。 |
因此,本项目把边界放在:真实 :resaca 的 ohos 编译、根构建替换(cmp_qrose 模式)、无头 Compose 运行时和 C ABI / N-API 桥接层。作用域语义完全由上游原版实现负责,ArkTS 只负责调用 Native、解析 JSON 和展示结果。
1.2 库提供的能力
Demo 提供一个真实的无头 Composition 会话,对应桥接的全部入口:
- 三类作用域对象:普通对象(
rememberScoped(key))、ViewModel(viewModelScopedno-builder 重载 + owner 默认 factory)、作用域对象(rememberScoped(key, keyInScopeResolver),resolver 来自父级rememberKeysInScope,即上游 README 的 LazyColumn 视口模式); - 屏幕生命周期模拟:
recompose(强制重组)、configChange(组合销毁重建、host 存活)、navigate/navigateBack(返回栈语义)、background/foreground、destroy(ON_DESTROY +ViewModelStore.clear()); - 作用域集合操作:
scopeOn/scopeOff增删 key; - clearDelay:处置延迟窗口。
快照响应包含:
| 字段 | 作用 |
|---|---|
objects[].creations / seq | 对象被真实 builder 调用创建的次数——保留语义的验收锚点:重组 / 配置变更 / 导航往返后必须恒为 1。 |
objects[].accesses | 每次组合中 rememberScoped 返回同一实例的次数。 |
objects[].disposals / alive | 真实 AutoCloseable.close() / ViewModel.onCleared() 触发次数与存活态。 |
generation / compositions | 组合重建代数与成功应用的组合次数(来自 SideEffect)。 |
lifecycle / compositionAlive | 模拟 Activity / NavBackStackEntry 的 registry 状态与组合存活。 |
scopeKeys | 作用域 key 集合。 |
events | native 侧真实事件环形日志(created / disposed / lifecycle / 组合代数)。 |
JVM 侧还保留 13 项 jvmTest 交叉验证用例(链上游发布的 resaca-desktop:5.1.6 正式制品),证明 ohos .so 里执行的作用域语义没有漂移。
1.3 实现适配
| 维度 | 要求 |
|---|---|
| 代码复用 | :resaca 的 commonMain 全部文件原样编译,不提取、不重写;仅新增 3 个 ohosMain actual(no-op 生命周期处理器 ×2 + 无反射 ViewModel factory),WeakReference / ClassName 经默认层级模板直接继承上游 nativeMain actual。 |
| 平台目标 | 根构建直接声明 ohosArm64 + ohosX64,:resaca 出 KLIB,example/nativeApp 出双 ABI 动态库。 |
| 构建隔离 | 根构建替换为极简 ohos 构建(cmp_qrose 模式);上游构建与文档存 *.upstream / README.upstream.md 留档;上游模块(resacahilt / resacakoin / resacametro / sample / samplecmp)留盘不进构建。 |
| 真 Composition | 无头 Compose 运行时:no-op Applier、手动 MonotonicFrameClock(每 tick 先 Snapshot.sendApplyNotifications())、真实 Recomposer.runRecomposeAndApplyChanges()。 |
| 桥接稳定 | 3 个 C ABI 和 UTF-8 JSON,不传递 Kotlin 对象地址;每次操作返回全量快照。 |
| UI 完整 | 单页覆盖全部桥接入口:状态卡片、四类对象添加、八个体生命周期操作、对象卡片(creations / accesses / disposals)、事件日志和错误横幅。 |
| 仓库规范 | 项目说明、文章、效果图和源码链接统一使用 AtomGit。 |
本项目的 ArkUI 页面不是 Compose Multiplatform UI 实现。鸿蒙侧没有 Compose UI 宿主,无头运行时是让真实
@ComposableAPI 可执行、可验收的最小载体;等社区 CMP 的compose.uiohos 制品与上游 1.12 线对齐后,业务可直接依赖本 KLIB 回归 Compose 写法。
二、实现路线图
第 1 阶段:项目初始化 ── 盘点模块依赖边界、验证 ohos 制品线、确定交付物
第 2 阶段:目标与依赖打通 ── 根构建替换、仓库顺序、Kotlin 2.4.20-0.1.0-07 钉版、KLIB 编译
第 3 阶段:无头运行时 ── DemoScene / DemoHost / ResacaEngine、手动帧时钟、主线程内联派发
第 4 阶段:原生桥接 ── @CName C ABI ×3、C++ N-API、参数校验和内存释放
第 5 阶段:ArkUI 宿主 ── JSON 快照、状态卡片、操作按钮和错误边界
第 6 阶段:示例与验证 ── jvmTest、双 ABI 链接、HAP、设备安装和真机运行
三、逐步实现过程
第 1 阶段:项目初始化
1.1 工程边界
resaca/ 上游库本体(进 ohos 构建,ohosArm64 + ohosX64)
resaca/src/ohosMain/ 新增:3 个 actual(约 40 行)
example/nativeApp/src/engineMain/ 无头运行时引擎(ResacaEngine / DemoScene / MiniJson)
example/nativeApp/src/jvmMain/ JVM Dispatchers.Main 工厂 + ServiceLoader 资源
example/nativeApp/src/ohosMain/ Bridge.kt(@CName × 3)
example/nativeApp/src/jvmTest/ 13 项交叉验证
example/harmonyApp/ DevEco Stage、CMake N-API 和 ArkTS 页面
README.OpenHarmony.md / _CN.md 鸿蒙适配说明(中英双份)
README.upstream.md 上游原文保存
resacahilt/ resacakoin/ resacametro/ sample/ samplecmp/
上游平台集成件,留盘不进构建
构建拓扑走 cmp_qrose 模式(根构建即 ohos 构建):settings.gradle.kts 只 include :resaca 与 :example:nativeApp,rootProject.name = "ohos_resaca"。
1.2 工具链矩阵
| 项目 | 配置 |
|---|---|
| Kotlin Multiplatform | HarmonyOS Kotlin 2.4.20-0.1.0-07(eazytec Maven) |
| JDK | 21(org.gradle.java.home 指向 Homebrew openjdk@21,另注册 Android Studio JBR) |
| OpenHarmony 目标 | ohosArm64 + ohosX64 |
| Gradle | 8.14.1 |
| ohos 制品线 | compose runtime(-saveable) 1.12.0-0.1.0-09、lifecycle 三件套 2.11.0-0.1.0-09、coroutines 1.11.0-0.1.0-07 |
| DevEco product | 5.1.0(18) 兼容(命令行工具 SDK 实测打包) |
| DevEco Studio | 5.1.0 Release |
| ABI | arm64-v8a + x86_64 |
| Bundle | org.terminator.ohos.resaca |
1.3 源码依赖度盘点
| 模块 | commonMain 文件 | 依赖形态 | 处置 |
|---|---|---|---|
resaca | 13 | Compose Runtime + 多平台 lifecycle + coroutines,零 Android 专属 | 整体编译(双 ohos 目标) |
resacahilt | — | 纯 Android(Hilt / KSP) | 留盘不进构建 |
resacakoin / resacametro | — | koin / metro 尚无 ohos 制品线 | 留盘不进构建 |
sample / samplecmp | — | AGP / iOS 壳工程 | 留盘不进构建 |
expect 声明共 5 处:PlatformLifecycleHandler、ObserveComposableContainerLifecycle、DefaultViewModelProviderFactory、KClass.getClassName()、WeakReference。默认层级模板让 ohos 目标直接继承 nativeMain,后两处复用上游既有 actual;ohosMain 只补前三个——这就是整个 fork 全部的新增库代码:
// resaca/src/ohosMain/.../PlatformLifecycleHandler.ohos.kt —— 与 iOS/desktop 同形的 no-op
public actual typealias PlatformLifecycleHandler = DefaultPlatformLifecycleHandler
// resaca/src/ohosMain/.../ComposeLifecycleObservers.ohos.kt —— 空函数体
// resaca/src/ohosMain/.../DefaultViewModelProviderFactory.ohos.kt —— 无反射、抛 UnsupportedOperationException(与 iOS actual 同形)
结论:上游的平台注入面本来就极窄——desktop / iOS 的 actual 几乎全是 no-op。过去挡住这类库适配的是「ohos 上没有 compose/lifecycle 制品」,本次适配首先验证这个障碍已经消失。
1.4 页面边界
标题区 RESACA · OPENHARMONY DEMO / 版本握手行(来自 resacaVersion() 真调用)
状态卡片 session/generation、组合应用次数、生命周期状态、组合存活、作用域 keys
对象请求区 +普通对象 / +ViewModel / +带延迟对象 / +作用域对象(列表项)
生命周期操作区 重组 / 配置变更 / 导航离开 / 导航返回 / 退到后台 / 回到前台 / 销毁屏幕 / 重置会话
对象卡片 id、类型、存活徽标、#seq、创建/访问/处置计数、移出组合、key 离开作用域
日志区 native 侧真实事件环形日志(最新在顶部)
公共区 错误横幅、底部快照状态行
页面启动时先调 resacaVersion() 做握手,再发一次 snapshot 同步初始状态。
第 2 阶段:目标与依赖打通
2.1 制品线验证(本适配的成败手)
先查社区 Maven 上有没有 compose / lifecycle 的 ohos 变体。用 Nexus REST API 与 .module 元数据验证:
org.jetbrains.compose.runtime:runtime:1.12.0-0.1.0-09
variants: ohosArm64ApiElements-published / ohosX64ApiElements-published
org.jetbrains.androidx.lifecycle:lifecycle-viewmodel(-compose):2.11.0-0.1.0-09
variants: 同上(另有完整 ios/desktop/android 变体元数据)
org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0-0.1.0-07
variants: 同上
上游 resaca 5.1.6 用的是 compose 1.12.1 / lifecycle 2.11.0 / coroutines 1.11.0——ohos 线与上游线同代。也就是说这不是「手放一个 jar 碰运气」,是正经的 Gradle 变体解析,commonMain 里一行依赖坐标都不用改(只改版本号到 ohos 线)。
同时发现两个镜像层面的硬约束:
- ohos 线只发布 ohos 变体(其
.module中 desktop 变体的 available-at 指针指向不存在的-desktop:1.12.0-0.1.0-09坐标); - eazytec 仓库对上游坐标是部分镜像(有 pom 无 jar)。
因此 dependencyResolutionManagement 的仓库顺序是 mavenCentral 在前、eazytec 在后:上游线从 Central 完整获取;ohos 线版本号在 Central 不存在,自然落到 eazytec。pluginManagement 保持 eazytec 置前(Kotlin 插件只在 eazytec)。
2.2 工具链钉版
社区此前批准的钉法是 HarmonyOS Kotlin 2.2.21-1.0.0(qrose / haze 用的)。但 compose/lifecycle ohos 线带着 kotlin-stdlib 2.4 元数据,旧钉法读不了。与 FlowMVI 适配一致,Kotlin 钉 2.4.20-0.1.0-07——与上游源码的 Kotlin 2.4.20 同代,上游编译器纪律(explicitApi()、-Xexpect-actual-classes)原样可用。
2.3 编译真实库
// resaca/build.gradle.kts
kotlin {
applyDefaultHierarchyTemplate()
explicitApi() // 上游纪律保留
ohosArm64()
ohosX64()
compilerOptions { freeCompilerArgs.add("-Xexpect-actual-classes") }
sourceSets {
commonMain.dependencies {
api(libs.compose.runtime) // 1.12.0-0.1.0-09
api(libs.compose.runtime.saveable)
api(libs.lifecycle.viewmodel) // 2.11.0-0.1.0-09
api(libs.lifecycle.viewmodel.compose)
implementation(libs.lifecycle.runtime.compose)
api(libs.kotlinx.coroutines.core) // 1.11.0-0.1.0-07
}
}
}
实测:
./gradlew :resaca:compileKotlinOhosArm64 # BUILD SUCCESSFUL
./gradlew :resaca:compileKotlinOhosX64 # BUILD SUCCESSFUL
13 个 commonMain 文件一行未动,包括 ScopedViewModelContainer(markedForDisposal / disposingJobs / isInForeground 三态协同的处置状态机)、双键管理(InternalKey 位置键 + ExternalKey 版本键)和 ScopedViewModelOwner(ViewModelStore.clear() → onCleared() 的处置链路)。
2.4 构建 Native 产物
./gradlew :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64
copyKnSoArm64 / copyKnSoX64 由 finalizedBy 挂在链接任务后,把 libohosresaca.so、匹配 triple 的 libc++_shared.so 和 CAdapter 生成的 libohosresaca_api.h 一并拷进 example/harmonyApp/entry/libs/<abi>/。产物:arm64 约 20MB、x86_64 约 18MB(debug 未裁剪,HAP 内 strip 后约 7MB / 5.6MB)。
第 3 阶段:无头 Compose 运行时(本适配的核心新东西)
鸿蒙上没有 Compose UI,但 rememberScoped 是 @Composable——要证明「真库在跑」,就必须有一个真的 Composition。example/nativeApp/src/engineMain 的 ResacaEngine 搭了这套运行时:
no-op Applier<Unit> 场景没有 UI 树,只有运行时状态
ManualFrameClock 手动帧时钟:tick() 先 Snapshot.sendApplyNotifications()
再发帧令牌(真实平台由帧驱动发通知唤醒 Recomposer)
Recomposer runRecomposeAndApplyChanges() 在主线程上驱动重组
DemoHost 「Activity / NavBackStackEntry 等价物」:ViewModelStoreOwner
+ LifecycleOwner + HasDefaultViewModelProviderFactory 三合一,
配置变更/导航时组合销毁重建而 host 存活——保留语义的载体
InMemorySaveableStateRegistry 让 rememberSaveable 在任意平台工作
DemoScene 里是货真价实的库调用:
@Composable
internal fun DemoScene(host: DemoHost) {
CompositionLocalProvider(
LocalViewModelStoreOwner provides host.owner,
LocalLifecycleOwner provides host.owner,
LocalSaveableStateRegistry provides host.saveableRegistry,
) {
val resolver = rememberKeysInScope(host.scopeKeys) // 父级作用域解析器(LazyColumn 模式)
for (spec in host.visibleSpecs) {
key(spec.id) {
when (spec.kind) {
PLAIN -> rememberScoped(key = spec.id, clearDelay = spec.clearDelay) { TrackedObject(...) }
VIEWMODEL -> viewModelScoped<TrackedViewModel>(key = spec.id, clearDelay = spec.clearDelay)
SCOPED -> rememberScoped(key = spec.id, keyInScopeResolver = resolver, ...) { TrackedObject(...) }
}
}
}
SideEffect { host.onCompositionApplied() } // 组合应用计数
}
}
处置的可观测性直接挂在库的清理路径上:普通对象实现 AutoCloseable(ScopedViewModelUtils.clearLastDisposedObject 对普通对象调 close()),ViewModel 覆写 onCleared()(ScopedViewModelOwner.clear() → ViewModelStore.clear())。事件全部产生于 native 侧库代码,ArkTS 只渲染。
为什么引擎源码放 engineMain 而不是 commonMain:ohos 线只有 ohos 变体、上游线只有 desktop 变体,同一坐标两套版本无法在 common 源集共存。把引擎物理放 src/engineMain、分别 srcDir 进 jvmMain / ohosMain,每个目标各链各的依赖线——jvm 侧链上游发布的 io.github.sebaslogen:resaca-desktop:5.1.6 正式制品(注意上游把 jvm 目标命名为 desktop),这比「本仓自编 jvm」更有对照价值:ohos .so 与 Maven Central 上的上游制品跑的是同一套语义,jvmTest 就是漂移检测器。
Dispatchers.Main 的平台胶水:库内部的 viewModelScope / threadSafeRunnerOnMain 走 Dispatchers.Main.immediate。JVM 上 coroutines 1.11 已从 core 移除 Dispatchers.setMain,因此 jvmMain 通过标准的 MainDispatcherFactory + META-INF/services 注册一个专属单线程 Main(coroutines-swing 同款机制);ohos 上使用 coroutines ohos 构建提供的内置 Main。这一步在踩坑表里还有后续(见 8.1)。
第 4 阶段:原生桥接(技术难点)
4.1 三层桥接
ArkTS
│ JSON string
▼
C++ N-API entry(libresaca_napi.so)
│ const char*
▼
Kotlin/Native C ABI(libohosresaca.so)
│ ResacaVersion / ResacaDispatch / ResacaSnapshot
▼
UTF-8 JSON + DisposeString
4.2 Kotlin/Native 导出函数
@CName("ResacaVersion")
fun resacaVersion(): String = try { engine.versionJson() } catch (t: Throwable) { errJson(t) }
@CName("ResacaDispatch")
fun resacaDispatch(request: String): String = try {
runBlocking { withTimeout(DispatchTimeoutMs) { engine.dispatch(request) } }
} catch (t: Throwable) { errJson(t) }
// ResacaSnapshot 同构,2s 超时
三个导出一览:
| C ABI 符号 | ArkTS 方法 | 覆盖内核 |
|---|---|---|
ResacaVersion | resacaVersion() | 工具链握手(fork / 上游 / Kotlin / compose / lifecycle / coroutines / store) |
ResacaDispatch | resacaDispatch(req) | add / remove / recompose / configChange / navigate / navigateBack / background / foreground / destroy / scopeOn / scopeOff / reset |
ResacaSnapshot | resacaSnapshot(req) | 只读快照(页面状态行与轮询用) |
每个导出整体 try/catch,异常转 {"ok":false,"error":"..."},Kotlin 异常永远不跨 FFI。
4.3 C++ N-API 方法分发
napi_property_descriptor desc[] = {
{"resacaVersion", nullptr, NapiResacaVersion, ...},
{"resacaDispatch", nullptr, NapiResacaDispatch, ...},
{"resacaSnapshot", nullptr, NapiResacaSnapshot, ...},
};
每个回调统一三步:napi_typeof 校验必须是 string、OH_LOG_INFO 打字节数、调 KN 函数并包装返回。KN 返回的字符串必须经 libohosresaca_symbols()->DisposeString 归还 Kotlin 运行时,绝不 free()。CAdapter 生成的 libohosresaca_api.h 随构建同步进 cpp/include/,不手写。
4.4 CMake 和类型声明
add_library(ohosresaca SHARED IMPORTED)
set_target_properties(ohosresaca PROPERTIES
IMPORTED_LOCATION ${KN_SO}
IMPORTED_NO_SONAME TRUE)
add_library(resaca_napi SHARED napi_init.cpp)
target_link_libraries(resaca_napi PUBLIC
libace_napi.z.so
libhilog_ndk.z.so
ohosresaca)
OHOS_ARCH 为空或 libohosresaca.so 缺失直接 FATAL_ERROR(提示先跑 link 任务);$ORIGIN rpath 加 POST_BUILD 拷贝保证 NAPI so、KN so、libc++ 三件套同目录,dlopen 时互相可见。
export const resacaVersion: () => string;
export const resacaDispatch: (request: string) => string;
export const resacaSnapshot: (request: string) => string;
第 5 阶段:ArkUI 宿主
5.1 操作分发与快照应用
private op(op: string, id?: string): void {
const req: OpRequest = { op: op };
if (id !== undefined) { req.id = id; }
const raw: string = resacaDispatch(JSON.stringify(req));
this.apply(JSON.parse(raw) as Snapshot);
}
每个响应都带全量快照,ArkTS 用 @State 直接刷新状态卡片、对象卡片、门控和事件日志,不重新实现任何作用域语义。「key 离开作用域」按钮在 scopeOff 后自动补 background→foreground 周期——这是库的真实语义:延迟处置在 ON_RESUME 时由 scheduleToDisposeAfterReturningFromBackground 结算。
5.2 验收锚点的可视化
对象卡片的核心是 creations 计数:同一 id 在任意多次重组 / 配置变更 / 导航往返后 creations 必须是 1(同一实例被保留),处置后 alive:false 且事件日志出现 disposed。卡片上同时显示 #seq(第几个实例)、accesses(被复用次数)、createdInGeneration(生成于第几代组合)。
5.3 对比度和错误边界
主色深青按钮白字,次级按钮浅灰;统计数值加粗、标签浅灰。ok:false 时错误横幅红底白字显示 error 原文(真机上这条横幅第一时间暴露了 ohos Main 调度器的 tsfn 问题,见 8.1——错误边界本身就是验收工具)。
第 6 阶段:示例与验证
6.1 工程结构
example/
├── nativeApp/src/
│ ├── engineMain/kotlin/example/nativeapp/
│ │ ├── ResacaEngine.kt(会话:组合生命周期 + 操作协议 + settle)
│ │ ├── DemoScene.kt(无头场景 + DemoHost + ManualFrameClock)
│ │ └── MiniJson.kt(扁平 JSON,继承自 cmp_qrose / FlowMVI 审核模式)
│ ├── jvmMain/kotlin/(DemoMainDispatcherFactory + 平台常量)
│ │ └── resources/META-INF/services/kotlinx.coroutines.internal.MainDispatcherFactory
│ ├── ohosMain/kotlin/example/nativeapp/Bridge.kt(@CName × 3)
│ └── jvmTest/kotlin/example/nativeapp/ResacaEngineTest.kt(13 项交叉验证)
└── harmonyApp/
├── entry/src/main/cpp/CMakeLists.txt
├── entry/src/main/cpp/napi_init.cpp
├── entry/src/main/cpp/include/libohosresaca_api.h(构建同步的生成头)
└── entry/src/main/ets/pages/Index.ets
6.2 构建、签名和安装
./gradlew :resaca:compileKotlinOhosArm64 :resaca:compileKotlinOhosX64 # KLIB
./gradlew :example:nativeApp:jvmTest # 交叉验证
./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 中勾选自动生成,证书写入本机 ~/.ohos/config,不进入仓库。
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.resaca -m entry
四、完整代码对照
4.1 整体架构
com.sebaslogen.resaca/**(上游 :resaca 原样编译)
│ rememberScoped / viewModelScoped / rememberKeysInScope / ScopedViewModelContainer
▼
ResacaEngine + DemoScene(无头 Compose 运行时)
│ ResacaVersion / ResacaDispatch / ResacaSnapshot
▼
libresaca_napi.so
│ resacaVersion / resacaDispatch / resacaSnapshot
▼
Index.ets
│ JSON parse + @State 更新
▼
ArkUI 真机页面(单页滚动)
4.2 文件清单
| 文件 | 职责 |
|---|---|
settings.gradle.kts | 极简 ohos 构建、eazytec 插件置前、Central 依赖置前、ohos_resaca |
gradle/libs.versions.toml | Kotlin 2.4.20-0.1.0-07 + ohos/upstream 双制品线钉版 |
resaca/build.gradle.kts | 纯 KMP:双 ohos 目标、上游纪律全保留 |
resaca/src/ohosMain/...(3 个文件) | 全部新增库代码(no-op ×2 + 无反射 factory) |
example/nativeApp/build.gradle.kts | 双 ABI sharedLib、copyKnSo* 回填、头文件同步、engineMain 双目标挂载 |
nativeApp/.../ResacaEngine.kt | 会话:操作协议、组合生命周期、settle |
nativeApp/.../DemoScene.kt | 无头场景、DemoHost、手动帧时钟、注册表 |
nativeApp/.../MiniJson.kt | 扁平 JSON 解析与转义 |
nativeApp/.../Bridge.kt | 3 个 C ABI、runBlocking 超时包裹 |
nativeApp/.../DemoMainDispatcherFactory.kt | JVM 的 Dispatchers.Main 工厂(ServiceLoader) |
harmonyApp/.../cpp/napi_init.cpp | N-API 导出、参数检查、DisposeString |
harmonyApp/.../cpp/CMakeLists.txt | IMPORTED 引入 KN so、$ORIGIN rpath |
harmonyApp/.../ets/pages/Index.ets | ArkUI 验收页面 |
README.OpenHarmony_CN.md | 鸿蒙适配主文档 |
4.3 关键 API 对照
| 层次 | API | 作用 |
|---|---|---|
| 库 | rememberScoped(key) { … } | 对象 scope 到 Composable,跨重组/配置变更/导航保留 |
| 库 | viewModelScoped<T>(key) | ViewModel 版本,处置走真实 onCleared() |
| 库 | rememberKeysInScope(keys) | LazyColumn 视口语义:key 在作用域内则对象保留 |
| 库 | ScopedViewModelContainer | 保留/处置状态机(clearDelay、后台挂起、ON_RESUME 结算) |
| Native | ResacaVersion 等 ×3 | C ABI JSON 导出 |
| N-API | resacaDispatch(req) 等 ×3 | 向 ArkTS 暴露数据 |
| ArkTS | op(op) / add(kind) | 触发操作并刷新 UI |
4.4 ArkTS 与 Kotlin 的边界
const res = JSON.parse(resacaDispatch(JSON.stringify({ op: 'configChange' }))) as Snapshot;
// res.generation 递增、res.objects[].creations 不变 —— 同一实例跨配置变更被保留
两者之间只传输扁平 JSON 字符串,不传输 Kotlin 对象或未校验的动态结构。
五、关键决策说明
决策 1:真编译整库而不是提取零依赖内核
qrose / haze / PreCompose / voyager 当时抽内核,是因为 ohos 上没有它们依赖的制品。本次适配先验证制品线(compose runtime / lifecycle / coroutines 均带 ohos 变体),障碍已消失——再抽内核等于把 ScopedViewModelContainer 状态机手抄一遍,反而引入漂移风险。
决策 2:jvm 交叉验证链上游正式制品而不是自编 jvm
ohos 制品线只发 ohos 变体,jvm 目标只能链上游线。这反而是更强的对照::example:nativeApp:jvmTest 链 Maven Central 上的 io.github.sebaslogen:resaca-desktop:5.1.6,ohos .so 与上游正式制品跑同一套语义,13 项测试就是漂移检测器。
决策 3:无头 Compose 运行时承载真实 @Composable API
rememberScoped 必须在真 Composition 里执行才能谈验收。no-op Applier + 手动 MonotonicFrameClock + 真实 Recomposer 是不引入 Compose UI 的最小可行宿主;compositions 计数来自 SideEffect,重组是真实的(Snapshot 全局写观察者驱动)。
决策 4:手动时钟必须补 sendApplyNotifications
真实平台每帧由平台帧驱动(Android 的 Choreographer)调用 Snapshot.sendApplyNotifications() 唤醒 Recomposer;手动时钟不补这一步,初始组合之后永远不会重组。tick() 的第一件事就是发 apply 通知。
决策 5:模块取舍跟着制品线走
resacahilt 纯 Android、koin / metro 无 ohos 制品,全部留盘不进构建;等制品线出现即可按同样模式接入。
六、测试与验证
6.1 测试环境
本次验证使用 macOS(arm64)、JDK 21(Homebrew openjdk@21)、DevEco Studio 5.1.0 Release、OpenHarmony 命令行工具(hvigor / ohpm / hdc)与鸿蒙真机(arm64)。
6.2 JVM 交叉验证
./gradlew :example:nativeApp:jvmTest
13 项用例全部通过:
| 测试 | 钉住什么 |
|---|---|
versionHandshake | 握手 JSON:ok:true、fork=5.1.6-ohos.1、store=ScopedViewModelContainer |
addObjectCreatesOneInstance | 首次请求创建一次、事件 created |
recompositionReturnsSameInstance | 3 次重组 creations=1、accesses ≥4(同一实例被复用) |
configChangeRetainsObjects | 2 次配置变更后 plain 与 ViewModel creations 均为 1 |
navigationRetainsObjectsAndBackRestoresThem | 返回栈期间保留、返回后 creations=1 |
permanentRemovalDisposesPlainObject | 移出组合后 close()、disposals=1 |
permanentRemovalClearsViewModel | ViewModel 移出组合后真实 onCleared() |
clearDelayPostponesDisposal | 300ms 延迟内存活、超时后处置 |
scopeExtendsLifetimeBeyondComposition | 移出组合但在作用域内保留;key 离开 + ON_RESUME 结算后处置 |
backgroundDefersDisposalUntilForeground | 后台期间不处置、回前台结算 |
destroyClearsEverything | ON_DESTROY + store.clear 后全部清空 |
resetStartsFreshSession | session+1、旧 bookkeeping 清空 |
invalidOpsReturnErrors | 未知 op / 重复 id 返回 ok:false 不崩 |
6.3 HAP 与符号验证
:resaca:compileKotlinOhosArm64 / compileKotlinOhosX64: BUILD SUCCESSFUL
:example:nativeApp:jvmTest: 13/13 通过
linkDebugSharedOhosArm64 / linkDebugSharedOhosX64: BUILD SUCCESSFUL
llvm-nm -D: ResacaDispatch / ResacaSnapshot / ResacaVersion / libohosresaca_symbols 均为全局 T 符号
hvigorw assembleHap: BUILD SUCCESSFUL
entry-default-unsigned.hap 约 15MB,内含双 ABI 各三件套(libresaca_napi.so / libohosresaca.so / libc++_shared.so);HAP 内 strip 后 KN so arm64 约 7MB、x86_64 约 5.6MB。
6.4 功能用例(真机操作序列)
- 安装启动后页面标题
Resaca · OpenHarmony Demo,ArkUI 渲染、NAPI 模块加载、so 三件套dlopen正常(页面能调到 native 并拿到 JSON 响应即证链路); + 普通对象→ 对象卡片#1、事件created plain … #1;重组/配置变更/导航离开+返回→creations恒为 1(同一实例被保留);移出组合→disposed事件、alive:false(普通对象close()、ViewModelonCleared());- 作用域对象
移出组合(保留)→key 离开作用域(自动补前后台结算)→ 处置; 销毁屏幕→ScopedViewModelContainer.onCleared()连锁清空全部对象。
6.5 验证结论
库本体 KLIB 编译、真实 @Composable API 的无头 Composition 执行、13 项 JVM 交叉验证、双 ABI 链接、ELF 符号、HAP 构建与真机安装运行链路已验证。真机页面上 ArkUI 渲染与 N-API 链路工作正常;ohos coroutines Main 调度器的 tsfn 初始化问题在真机握手处暴露(详见 8.1 第 1 条),属于 ohos 制品线的基础设施问题,已在遗留问题中跟踪。
七、运行效果
7.1 真机截图

7.2 命令速查
git clone https://atomgit.com/oh-tpc/resaca.git
cd resaca
export JAVA_HOME="/path/to/jdk-21"
./gradlew :resaca:compileKotlinOhosArm64 :resaca:compileKotlinOhosX64 # KLIB
./gradlew :example:nativeApp:jvmTest # 交叉验证
./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.resaca -m entry
hdc shell hilog | grep Resaca
八、遗留问题与改进方向
8.1 踩坑复盘
- ohos coroutines 内置 Main 调度器的 tsfn 未初始化:反编译 klib 确认,ohos 版
Dispatchers.Main的跨线程派发走runOnMainThread→callTSFWithData(tsfn, …),而tsfn是一个必须由initMainHandler(env)(需要 napi_env)初始化的 lateinit 全局——该入口未在任何公开路径被调用。真机上引擎初始化中delay()从定时器线程恢复、向 Main 跨线程派发时触发lateinit property tsfn has not been initialized。对策:引擎侧全部改用Main.immediate主线程内联派发、以阻塞小睡替代delay规避跨线程恢复,并就该问题向 ohos 制品线反馈(需要暴露带 env 的初始化入口或改为懒初始化); - desktop 版 LifecycleRegistry 的主线程探测会自锁:
addObserver内部用runBlocking { Dispatchers.Main.immediate { … } }探测主线程,limitedParallelism(1)单线程上排队即死锁。处理:JVM 用真单线程 + 自定义MainCoroutineDispatcher(lane 线程上isDispatchNeeded=false内联执行); - coroutines 1.11 移除了
Dispatchers.setMain:JVM 改走MainDispatcherFactory+META-INF/services(coroutines-swing 同款机制); - 手动时钟不补 apply 通知就永不重组:
tick()先Snapshot.sendApplyNotifications()再发帧令牌(决策 4); - frame clock 必须放进运行协程的上下文:只放
Recomposer(...)构造参数会报A MonotonicFrameClock is not available in this CoroutineContext; - eazytec 是部分镜像:上游坐标有 pom 无 jar,Gradle 命中元数据后不回退仓库——
dependencyResolutionManagement里 mavenCentral 必须排在 eazytec 前; - ohos 制品线无 desktop 变体:jvm 交叉验证只能链上游线,
engineMain按目标分别挂载源码目录(决策 2); - Kotlin/Native 无
System.nanoTime():换TimeSource.Monotonic; - ArkTS 严格模式:对象字面量要显式 interface、
const raw: string显式标注、Flex 的space用LengthMetrics.vp()(Column/Row 仍是数字); - 作用域处置的结算时机:
scopeOff后不立即处置不是 bug——库把延迟处置结算挂在 ON_RESUME(scheduleToDisposeAfterReturningFromBackground),Demo 按钮自动补前后台周期,jvmTest 显式结算; - KN 返回字符串必须
DisposeString归还运行时,N-API 层不能free(); - 签名材料不进仓库:DevEco 自动写入
build-profile.json5,必要时git update-index --skip-worktree保护。
8.2 已知问题
- ohos coroutines Main 调度器 tsfn 问题(8.1 第 1 条)修复前,真机引擎会话在首次跨线程 Main 派发处返回错误——JVM 侧不受影响(自有 Main 工厂),13 项交叉验证全部通过;
- 鸿蒙侧没有 Compose UI:
rememberScoped等 API 编译进 KLIB 但只能由宿主 Composition 调用,等社区 CMP 的compose.uiohos 制品对齐后回归; resacahilt/resacakoin/resacametro三个 DI 集成未适配(依赖无 ohos 制品);- Builder 版
viewModelScoped(需要 SavedStateRegistryOwner 注入)未在 Demo 覆盖,用的是 no-builder 重载 + owner 默认 factory(与上游 desktopTest 的做法一致); viewModel()默认 factory 在 ohos 上抛UnsupportedOperationException(无反射构造,与 iOS actual 同形);- ArkTS 侧是请求-响应快照,没有推送式状态事件;
- 模拟器是 x86_64,HAP 必须双 ABI,缺当前 ABI 的 so 报
9568347。
8.3 未来优化方向
- 就 tsfn 初始化问题向 ohos coroutines 制品线提 issue / PR(建议暴露
initMainHandler(env)公开入口或改为首次派发时懒初始化),合入后移除引擎侧规避代码; - 眉标(badge)级 CI:KLIB 编译、jvmTest、双 ABI 链接、ELF 符号检查和 HAP 构建流水线化;
- 等社区 CMP 的
compose.uiohos 制品出现,把samplecmp接回,无头运行时降级为测试基建; - 增加推送式状态事件(N-API 线程安全回表),替代请求-响应快照;
- 补模拟器(x86_64)与其他设备 ABI 的验收矩阵和自动化截图脚本。
九、总结
9.1 核心难点回顾
上游 :resaca(13 个 commonMain 文件)
→ 验证 compose runtime / lifecycle / coroutines 的 ohos 制品线
→ 工具链对齐 Kotlin 2.4.20-0.1.0-07(cmp_qrose 根构建替换模式)
→ ohosArm64 / ohosX64 KLIB(仅 3 个 ohosMain actual,约 40 行)
→ 无头 Compose 运行时(真实 rememberScoped 在真 Composition 中执行)
→ C ABI(@CName × 3)+ 扁平 JSON
→ C++ N-API(DisposeString 纪律)
→ ArkTS JSON 解析
→ ArkUI 真机单页验收
9.2 封装层次
- 内核层:上游原版
rememberScoped/viewModelScoped/rememberKeysInScope与ScopedViewModelContainer状态机; - 运行时层:无头 Compose 运行时(no-op Applier、手动帧时钟、真实 Recomposer);
- Native 层:双 ABI 动态库 + 3 个 C ABI JSON 导出;
- N-API 层:参数检查、字符串转换和 Native 内存释放;
- ArkTS 层:页面状态、操作分发与错误文本;
- DevEco 层:CMake、HAP、签名、安装和真机运行。
9.3 三条经验
- 先验证生态制品线再定适配路线:compose runtime / lifecycle / coroutines 的 ohos 变体出现后,「提取零依赖内核」的正确答案变成了「真编译整库 + 无头运行时承载 @Composable API」——路线选择必须基于当天的制品可达性,而不是历史结论;
- 交叉验证链上游正式制品比自编更硬:ohos 线无 desktop 变体逼出来的「engineMain 按目标挂载 + jvm 链 Maven Central 上游制品」结构,反而让 13 项 jvmTest 成为库语义漂移的直接检测器;
- 错误边界是验收工具:真机首跑暴露的 tsfn 异常经桥接层完整透传到页面横幅——如果桥吞掉异常,这个 ohos 制品线级别的问题就会漏进「看起来能用」的假验收。
9.4 适配成果
当前适配已完成:
- resaca
5.1.6真实库源码的双 ohos 目标 KLIB 编译(commonMain 零重写,新增 actual 合计约 40 行); - 无头 Compose 运行时引擎(真实
@ComposableAPI 在手动帧时钟驱动下执行重组); - 3 个 C ABI 符号和 C++ N-API 包装层(
llvm-nm验证全局 T 符号); - 13 项 JVM 交叉验证(链上游
resaca-desktop:5.1.6正式制品)全部通过; - DevEco Stage ArkUI 验收页面与 HAP 构建(双 ABI 六件套 so,约 15MB)、签名、真机安装运行;
- 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。
参考文档
更多推荐


所有评论(0)