本文记录 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(viewModelScoped no-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 集合。
eventsnative 侧真实事件环形日志(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 宿主,无头运行时是让真实 @Composable API 可执行、可验收的最小载体;等社区 CMP 的 compose.ui ohos 制品与上游 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 MultiplatformHarmonyOS Kotlin 2.4.20-0.1.0-07(eazytec Maven)
JDK21(org.gradle.java.home 指向 Homebrew openjdk@21,另注册 Android Studio JBR)
OpenHarmony 目标ohosArm64 + ohosX64
Gradle8.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 product5.1.0(18) 兼容(命令行工具 SDK 实测打包)
DevEco Studio5.1.0 Release
ABIarm64-v8a + x86_64
Bundleorg.terminator.ohos.resaca
1.3 源码依赖度盘点
模块commonMain 文件依赖形态处置
resaca13Compose 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 方法覆盖内核
ResacaVersionresacaVersion()工具链握手(fork / 上游 / Kotlin / compose / lifecycle / coroutines / store)
ResacaDispatchresacaDispatch(req)add / remove / recompose / configChange / navigate / navigateBack / background / foreground / destroy / scopeOn / scopeOff / reset
ResacaSnapshotresacaSnapshot(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.tomlKotlin 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.kt3 个 C ABI、runBlocking 超时包裹
nativeApp/.../DemoMainDispatcherFactory.ktJVM 的 Dispatchers.Main 工厂(ServiceLoader)
harmonyApp/.../cpp/napi_init.cppN-API 导出、参数检查、DisposeString
harmonyApp/.../cpp/CMakeLists.txtIMPORTED 引入 KN so、$ORIGIN rpath
harmonyApp/.../ets/pages/Index.etsArkUI 验收页面
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 结算)
NativeResacaVersion 等 ×3C ABI JSON 导出
N-APIresacaDispatch(req) 等 ×3向 ArkTS 暴露数据
ArkTSop(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
recompositionReturnsSameInstance3 次重组 creations=1、accesses ≥4(同一实例被复用)
configChangeRetainsObjects2 次配置变更后 plain 与 ViewModel creations 均为 1
navigationRetainsObjectsAndBackRestoresThem返回栈期间保留、返回后 creations=1
permanentRemovalDisposesPlainObject移出组合后 close()、disposals=1
permanentRemovalClearsViewModelViewModel 移出组合后真实 onCleared()
clearDelayPostponesDisposal300ms 延迟内存活、超时后处置
scopeExtendsLifetimeBeyondComposition移出组合但在作用域内保留;key 离开 + ON_RESUME 结算后处置
backgroundDefersDisposalUntilForeground后台期间不处置、回前台结算
destroyClearsEverythingON_DESTROY + store.clear 后全部清空
resetStartsFreshSessionsession+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 功能用例(真机操作序列)

  1. 安装启动后页面标题 Resaca · OpenHarmony Demo,ArkUI 渲染、NAPI 模块加载、so 三件套 dlopen 正常(页面能调到 native 并拿到 JSON 响应即证链路);
  2. + 普通对象 → 对象卡片 #1、事件 created plain … #1;
  3. 重组 / 配置变更 / 导航离开+返回 → creations 恒为 1(同一实例被保留);
  4. 移出组合 → disposed 事件、alive:false(普通对象 close()、ViewModel onCleared());
  5. 作用域对象 移出组合(保留)→ key 离开作用域(自动补前后台结算)→ 处置;
  6. 销毁屏幕 → 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 踩坑复盘

  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 的初始化入口或改为懒初始化);
  2. desktop 版 LifecycleRegistry 的主线程探测会自锁:addObserver 内部用 runBlocking { Dispatchers.Main.immediate { … } } 探测主线程,limitedParallelism(1) 单线程上排队即死锁。处理:JVM 用真单线程 + 自定义 MainCoroutineDispatcher(lane 线程上 isDispatchNeeded=false 内联执行);
  3. coroutines 1.11 移除了 Dispatchers.setMain:JVM 改走 MainDispatcherFactory + META-INF/services(coroutines-swing 同款机制);
  4. 手动时钟不补 apply 通知就永不重组:tick() 先 Snapshot.sendApplyNotifications() 再发帧令牌(决策 4);
  5. frame clock 必须放进运行协程的上下文:只放 Recomposer(...) 构造参数会报 A MonotonicFrameClock is not available in this CoroutineContext;
  6. eazytec 是部分镜像:上游坐标有 pom 无 jar,Gradle 命中元数据后不回退仓库——dependencyResolutionManagement 里 mavenCentral 必须排在 eazytec 前;
  7. ohos 制品线无 desktop 变体:jvm 交叉验证只能链上游线,engineMain 按目标分别挂载源码目录(决策 2);
  8. Kotlin/Native 无 System.nanoTime():换 TimeSource.Monotonic;
  9. ArkTS 严格模式:对象字面量要显式 interface、const raw: string 显式标注、Flex 的 space 用 LengthMetrics.vp()(Column/Row 仍是数字);
  10. 作用域处置的结算时机:scopeOff 后不立即处置不是 bug——库把延迟处置结算挂在 ON_RESUME(scheduleToDisposeAfterReturningFromBackground),Demo 按钮自动补前后台周期,jvmTest 显式结算;
  11. KN 返回字符串必须 DisposeString 归还运行时,N-API 层不能 free();
  12. 签名材料不进仓库: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.ui ohos 制品对齐后回归;
  • 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.ui ohos 制品出现,把 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 三条经验

  1. 先验证生态制品线再定适配路线:compose runtime / lifecycle / coroutines 的 ohos 变体出现后,「提取零依赖内核」的正确答案变成了「真编译整库 + 无头运行时承载 @Composable API」——路线选择必须基于当天的制品可达性,而不是历史结论;
  2. 交叉验证链上游正式制品比自编更硬:ohos 线无 desktop 变体逼出来的「engineMain 按目标挂载 + jvm 链 Maven Central 上游制品」结构,反而让 13 项 jvmTest 成为库语义漂移的直接检测器;
  3. 错误边界是验收工具:真机首跑暴露的 tsfn 异常经桥接层完整透传到页面横幅——如果桥吞掉异常,这个 ohos 制品线级别的问题就会漏进「看起来能用」的假验收。

9.4 适配成果

当前适配已完成:

  • resaca 5.1.6 真实库源码的双 ohos 目标 KLIB 编译(commonMain 零重写,新增 actual 合计约 40 行);
  • 无头 Compose 运行时引擎(真实 @Composable API 在手动帧时钟驱动下执行重组);
  • 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。

参考文档

Logo

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

更多推荐