本文记录 ToDoApp-KMP 接入 OpenHarmony 的完整过程,覆盖上游依赖盘点、领域引擎提取、ohosArm64 / ohosX64 目标、Kotlin/Native 动态库、C ABI/N-API 桥接、ArkUI 真机页面、HAP 构建和真机验收。

ToDoApp-KMP 是基于 Compose Multiplatform 的 Android/iOS 双端待办事项应用:Voyager ScreenModel 承载 MVVM、Koin 做依赖注入、Realm Kotlin 落库(ObjectId 主键、Flow 响应式读取)、Ktor 预留网络层。本次适配与 KStore 的"真编译整库"路线有本质区别:上游绑定 Kotlin 1.9.24 / CMP 1.6.10 / Realm 1.13.0(带 C++ 原生核心),社区 HarmonyOS CMP 1.9.2 / Kotlin 2.2.21 线无法与之混编,Realm 也没有 ohos 制品——因此不迁移 UI、不硬凑依赖,而是把上游最有价值的领域层(模型 + 仓库语义 + 业务规则)提取成纯 Kotlin 引擎模块 todoengine,原样编译成 OpenHarmony 双 ABI 动态库,ArkUI 页面作为验收宿主。空标题校验的消息原文、删除后用原 _id 撤销重插、未知 id 静默 no-op 这些上游语义,全部由 12 项 JVM 测试钉住。

项目地址: AtomGit/oh-tpc/ToDoApp-KMP.git

上游仓库: devggaurav/ToDoApp-Compose-multiplatform

开发工具: 华为云码道

环境安装: 环境安装指南

一、背景

1.1 为什么做 OpenHarmony 平台 KMP/CMP 适配

ToDoApp-KMP 上游为 Android / iOS 提供了完整的待办闭环:Todo 模型(title / 可空 description / isDone / ObjectId 主键)、TodoRepository 七个方法(configureDB / insertTodo / getTodoById / getTodos(): Flow / deleteTodo / deleteAllTodos / updateTodo)、两个 ViewModel 里的业务规则(空标题守卫、保存保留完成态、_id 存在走 update 否则 insert)、删除后 SnackBar「Todo deleted」+ Undo 用原 id 重插。KMP 项目把这些能力放在 commonMain,由 Android、iOS 共享。OpenHarmony 应用同样需要这套语义,但上游构建不会生成 OpenHarmony 变体。

如果只把示例页面改成 ArkTS,页面可以显示按钮,却无法证明公共领域引擎在 OpenHarmony ARM64 设备上完成编译、链接和执行。适配需要同时验证引擎、身份保持、撤销恢复、持久化、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 交付链路。

障碍具体问题
工具链不一致上游 Kotlin 1.9.24 / CMP 1.6.10 / AGP 8.2.0 / Gradle 8.7,社区 HarmonyOS CMP 是 1.9.2 / Kotlin 2.2.21 线,两条线无法共存于一个 Gradle 构建,Compose UI 整体进不了鸿蒙。
依赖矩阵Realm Kotlin 1.13.0 带 C++ 原生核心,没有 ohos 制品,是硬阻塞;Voyager / Koin-Compose / Ktor 同样绑定上游工具链。
语言边界不同ArkTS 不能直接持有 Kotlin 对象,需要 C ABI、C++ N-API 和 JSON。
语义跨不了桥getTodos() 的 Flow 响应式、Realm 查询自然序、ObjectId 身份,都是 Kotlin 侧概念,C ABI 只能同步一来一回。
交付链路复杂.so、CMake、N-API、HAP、签名和 hdc 都要单独验收。

因此,本项目把边界放在:领域引擎的 ohos 编译、根构建替换(cmp_qrose 模式)和 C ABI/N-API 桥接层。业务语义完全由上游原版规则负责,ArkTS 只负责调用 Native、解析 JSON 和展示结果。

1.2 库提供的能力

Demo 提供一个真实的待办会话,对应桥接的全部入口:

  • 新建(add):title / description 进引擎,先过空标题守卫,再由插入序仓库分配 24 位十六进制 id(与 ObjectId.toHexString() 同形),追加到列表末尾;
  • 编辑(update):AddEdit 保存语义——只改标题与描述,isDone 默认保留(显式携带则覆盖);_id 不存在时静默 no-op,与上游 Realm findLatest 分支从不执行的行为一致;
  • 勾选(toggle):只改 isDone 一个字段,标题与描述原样保留;
  • 删除 + 撤销(delete → add + 原 id):删除响应回显被删对象,撤销时用原 id 重插并追加到末尾,复刻上游 Realm 重插行为;
  • 清空(deleteAll):AlertDialog 二次确认后整体清空;
  • 持久化闭环:每次成功响应携带 snapshot,宿主写入 @ohos.data.preferences;杀进程重启后 restore 恢复,值仍在——证明是落盘而不是内存缓存;
  • 版本握手:version action 返回 1.0.0-ohos.1,来自真实 Native 调用而非静态文字。

响应包含:

字段作用
ok操作是否成功,失败时 error 带原文。
action / todos回显本次操作与插入序完整列表({id, title, description, isDone})。
total / done列表计数与完成计数,驱动汇总条。
created / deletedadd 回显新建对象、delete 回显被删对象(撤销数据源)。
snapshot不透明快照字符串,宿主只管存取、不解析。

引擎侧完整能力(插入序、身份保持、字段级更新、防御性快照)全部在 commonMain 里随编译进入 .so,JVM 侧保留 12 项 jvmTest 交叉验证用例,证明 ohos .so 里执行的业务规则没有漂移。

1.3 实现适配

维度要求
代码复用模型、仓库接口与业务规则语义对等提取为 todoengine(4 个文件、143 行、零第三方依赖);上游 composeApp / iosApp 留盘不进构建。
平台目标根构建直接声明 ohosArm64 + ohosX64,保留 jvm() 作交叉验证,引擎出 KLIB,example/nativeApp 出双 ABI 动态库。
构建隔离根构建替换为极简 ohos 构建(cmp_qrose 模式);AGP / Compose 插件 / Realm 插件随根构建替换剥离。
桥接稳定使用 1 个 C ABI 入口和 UTF-8 JSON,不传递 Kotlin 对象地址;异常永远不跨 FFI。
依赖纪律引擎 commonMain 零依赖,工具链钉 HarmonyOS Kotlin 2.2.21-1.0.0,不引入任何序列化库(桥用手写 MiniJson)。
UI 完整单页两模式覆盖全部桥接入口:列表 / 编辑、勾选、删除撤销、清空确认、快照存档与版本握手。
仓库规范项目说明、文章、效果图和源码链接统一使用 AtomGit。

本项目的 ArkUI 页面不是 Compose Multiplatform UI 实现。社区 CMP 1.9.2 线与上游 1.6.10 无法混编,UI 层是 ArkUI 复刻(主题色取自上游 Material 3 蓝色方案),但列表背后的每一次增删改查都由真实的 Kotlin/Native 引擎执行。

二、实现路线图

第 1 阶段:项目初始化     ── 盘点模块与依赖边界、确定"引擎提取"路线与交付物
第 2 阶段:目标与依赖打通 ── 根构建替换、eazytec Maven 置前、Kotlin 2.2.21-1.0.0 钉版、Gradle 8.14.1
第 3 阶段:引擎提取       ── todoengine 语义对等移植(模型 + 规则 + 仓库)、零第三方依赖
第 4 阶段:桥协议建模     ── 9 个 action 的 JSON 契约、快照格式、错误通道
第 5 阶段:原生桥接       ── @CName C ABI、C++ N-API、参数校验和内存释放
第 6 阶段:ArkUI 宿主     ── 列表/编辑两模式、版本握手、preferences 存档注入
第 7 阶段:示例与验证     ── HAP、签名、设备安装、真机运行和效果图

三、逐步实现过程

第 1 阶段:项目初始化

1.1 工程边界
todoengine/                           提取的领域引擎(进 ohos 构建,jvm + ohosArm64 + ohosX64)
todoengine/src/commonMain/            Todo / TodoRules / TodoRepository / InMemoryTodoRepository(143 行,零依赖)
todoengine/src/jvmTest/               12 项语义测试(JVM 交叉验证)
composeApp/ iosApp/                   上游 CMP 应用(留盘不进 settings,绑定上游工具链)
example/nativeApp/                    Kotlin/Native 动态库(Bridge + MiniJson)
example/harmonyApp/                   DevEco Stage、CMake N-API 和 ArkTS 页面
README.OpenHarmony.md / _CN.md        鸿蒙适配说明(中英双份)
README.upstream.md                    上游原文保存
doc/img/                              真机效果图

构建拓扑走 cmp_qrose 模式(根构建即 ohos 构建):settings.gradle.kts 只 include :todoengine 与 :example:nativeApp,rootProject.name = "ohos_todoapp"。上游的 AGP / Compose / Realm / serialization 插件声明全部随根构建替换剥离——本 fork 不发 Maven Central,也不在鸿蒙构建里保留任何 Android 依赖。

1.2 工具链矩阵
项目配置
Kotlin MultiplatformHarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven)
JDK21(org.gradle.java.home 指向 Eclipse Adoptium jdk-21.0.12.101-hotspot)
OpenHarmony 目标ohosArm64 + ohosX64(jvm() 保留作交叉验证)
Gradle8.14.1(上游 wrapper 8.7 升级;2.2.21 线验证过的 wrapper 版本)
引擎依赖无——commonMain 零第三方依赖,桥用手写 MiniJson
DevEco product5.1.0(18) 兼容
DevEco Studio5.1.0 Release
ABIarm64-v8a + x86_64
Bundleorg.terminator.ohos.todoapp
$env:JAVA_HOME = "C:\Program Files\Eclipse Adoptium\jdk-21.0.12.101-hotspot"
java -version
1.3 源码依赖度盘点
上游源码依赖形态处置
data/Todo.ktRealmObject + kbson ObjectId语义提取:字段与可空性对齐,id 换 24 位 hex 字符串(同形)
data/TodoRepository.kt接口,getTodos(): Flow语义提取:方法名逐一保留,Flow → List(桥迫偏差,见 8.2)
data/TodoRepositoryImpl.ktRealm open/query/write重写为插入序内存仓库,自然序与字段级行为对齐上游
presentation/*ViewModel.ktVoyager ScreenModel + Compose 状态规则提取:空标题守卫、保存分流、撤销重插进引擎与桥
presentation/*Screen.kt ui/theme/*Compose UI / Material 3留盘不进构建,ArkUI 复刻(主题色同源搬运)
di/KoinModule.ktKoin留盘不进构建,鸿蒙侧引擎为模块级单例

上游 expect/actual 只有 Platform / getPlatform() 一处展示性声明,领域层没有任何 expect——这意味着提取后的引擎不需要补任何平台 actual,commonMain 直接三目标编译。

1.4 页面边界
标题区          Todos 大标题 / 版本握手行(KMP 待办引擎 1.0.0-ohos.1 · Kotlin/Native)/ 清空按钮(红字胶囊)
汇总条          共 N 项 · 完成 K 项 + 最近一次操作状态(如"已保存")
列表区          插入序卡片:复选框、标题(完成划线)、描述单行、✕ 删除、行点击进编辑
撤销条          Todo deleted / Undo(5 秒超时,对应上游 SnackBar)
编辑模式        ‹ 返回 / 标题输入 / 描述多行 / 保存按钮 / 空标题红字错误
悬浮按钮        右下角 +(primary 蓝色),对应上游 FloatingActionButton

页面启动时先调 version 做握手,再从 @ohos.data.preferences 读快照走 restore(无存档则 state)。

第 2 阶段:目标与依赖打通

2.1 路线判定(本适配的成败手)

KStore 适配的第一步是验证社区 Maven 上有没有 ohos 制品线——有,就真编译整库。ToDoApp 的盘点结论相反:

上游依赖结论
Compose Multiplatform 1.6.10社区鸿蒙线是 1.9.2 / Kotlin 2.2.21,与 1.6.10 / 1.9.24 无法混编,UI 整体出局
Realm Kotlin 1.13.0C++ 原生核心,无 ohos 制品,硬阻塞
Voyager / Koin-Compose / Ktor绑定上游工具链,且属 UI / DI / 网络外壳
kotlinx-datetime / multiplatform-settings可移植,但提取后的领域层用不到

三个关键结论:其一,值得带走的是领域语义而不是某个依赖;其二,领域层本身是纯 Kotlin(Realm 只出现在 TodoRepositoryImpl 与模型注解上),提取后 commonMain 零依赖,Kotlin 2.2.21 编译零成本;其三,持久化语义换由"引擎快照 + 宿主 preferences"承担,与上游"Realm 落库"在可观测行为(重启数据仍在)上等价。路线就此定为 cmp_qrose 引擎提取——2048 / Vico / Reorderable 已沿同一路径过审。

2.2 工具链钉版(为什么是 2.2.21-1.0.0 与 Gradle 8.14.1)

引擎零依赖,工具链不需要迁就任何制品线,直接对齐社区已过审的钉法:HarmonyOS Kotlin 2.2.21-1.0.0(qrose / 2048 同款),Gradle wrapper 从上游 8.7 升到 8.14.1(2.2.21 线验证过的版本),JDK 21 用 org.gradle.java.home 写死绝对路径。settings.gradle.kts 把 eazytec 仓库写在最前:

pluginManagement {
    repositories {
        maven("https://maven.eazytec-cloud.com/nexus/repository/maven-public/")
        maven("https://maven.eazytec-cloud.com/nexus/content/groups/harmonyos")
        gradlePluginPortal()
        google()
        mavenCentral()
    }
}

rootProject.name = "ohos_todoapp"

include(":todoengine")
include(":example:nativeApp")

仓库顺序不是风格问题而是正确性问题:mavenCentral() 里同样存在 org.jetbrains.kotlin 坐标,若排在前面会解析到官方 Kotlin,配置期直接 Unresolved reference: ohosArm64。

2.3 编译引擎

todoengine/build.gradle.kts 收敛为纯 KMP 模块:

plugins {
    alias(libs.plugins.kotlin.multiplatform)
}

kotlin {
    applyDefaultHierarchyTemplate()
    jvmToolchain(21)

    jvm()                            // 交叉验证用
    ohosArm64()
    ohosX64()

    sourceSets {
        commonMain.dependencies {}
        commonTest.dependencies {
            implementation(kotlin("test"))
        }
    }
}

实测:

.\gradlew.bat :todoengine:compileKotlinOhosArm64 :todoengine:compileKotlinOhosX64   # BUILD SUCCESSFUL
2.4 测试的处置:上游没有测试,语义用例全部新建

上游仓库没有任何测试。本 fork 在 jvmTest 新建 12 项语义测试(见 6.2),每一项对应一条上游可观测行为;同时根构建全局禁用 ohos 测试任务,ohos 侧只承担编译与链接:

subprojects {
    tasks.matching { it.name.contains("Ohos", ignoreCase = true) && it.name.contains("Test") }
        .configureEach { enabled = false }
}

第 3 阶段:桥协议建模

3.1 JSON 契约
com.gc.todo.engine/**(提取的领域引擎,三目标编译)
        ↓ 插入序仓库 / 空标题守卫 / 身份保持
Bridge.kt(@CName × 1,扁平 JSON 进出)
        ↓ UTF-8 JSON
N-API todoAppAction(request)
        ↓
ArkUI Index.ets(单页两模式)

请求与响应:

请求 : {"action":"add","title":"买牛奶","description":""}
       {"action":"update","id":"…24hex…","title":"…","description":"…"}
       {"action":"toggle","id":"…","isDone":true}
       {"action":"delete","id":"…"}
       {"action":"deleteAll"}
       {"action":"restore","snapshot":"<不透明快照>"}
       {"action":"state"} / {"action":"version"}
响应 : {"ok":true,"action":"…","todos":[{id,title,description,isDone}…],
        "total":N,"done":K,"snapshot":"…","created":{…}?,"deleted":{…}?}
       {"ok":false,"error":"The title can't be empty"}

两个不对称设计:delete 的响应回显被删对象(deleted 字段),撤销数据由引擎权威给出而不是宿主缓存,避免宿主与真实状态错位;add 携带 id 即撤销重插路径——引擎保留原身份并把对象追加到末尾,与上游 Realm 重插自然序一致。null 描述在快照里编码为 ""(上游 TextField 从不产生 null)。

3.2 为什么是单入口 + 全量状态

ArkTS 侧通过 NAPI 拿到的是一组字符串导出,单入口 TodoAppAction 把版本演进收敛在一个函数签名上——将来加 action 只是加分支,不改 ABI,也不需要动 index.d.ts。上游 Compose 靠 Flow 自动重组,桥上没有订阅机制,最便宜的等价物就是每次操作后回传完整列表与计数,宿主整体刷新;待办列表的数据量决定了这个朴素方案没有性能问题。引擎为模块级单例,进程内即会话;跨进程存活交给宿主的快照持久化。

3.3 错误通道

整个分发包在 try/catch(Throwable) 里,Kotlin 异常永远不跨 FFI:

@CName("TodoAppAction")
fun todoAppAction(request: String): String = try {
    /* action 分发 */
} catch (t: Throwable) {
    """{"ok":false,"error":${jsonString(t.message ?: t.toString())}}"""
}

空标题(消息原文 "The title can't be empty")、未知 action、损坏的快照,全部走同一个 ok:false 通道,ArkTS 侧统一渲染;编辑屏把这条原文显示成红字,和上游逐字一致。

第 4 阶段:原生桥接(技术难点)

4.1 三层桥接
ArkTS
  │ JSON string
  ▼
C++ N-API entry(libtodoapp_napi.so)
  │ const char*
  ▼
Kotlin/Native C ABI(libohostodoapp.so)
  │ TodoAppAction
  ▼
UTF-8 JSON + DisposeString

直接导出 Kotlin 对象会暴露 ABI 和生命周期问题;只导出整数又会让页面复制业务规则,所以选择 C ABI + JSON。

4.2 Kotlin/Native 导出函数
@CName("TodoAppAction")
fun todoAppAction(request: String): String   // 9 个 action 一口进
C ABI 符号ArkTS 方法覆盖内核
TodoAppActiontodoAppAction(req)引擎全部入口:version / state / add / update / toggle / delete / deleteAll / restore
libohostodoapp_symbols(NAPI 内部)DisposeString 归还 KN 字符串
4.3 C++ N-API 方法分发
napi_property_descriptor desc[] = {
    {"todoAppAction", nullptr, NapiTodoAppAction, nullptr, nullptr, nullptr,
     static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr},
};

每个回调统一三步:napi_typeof 校验必须是 string、OH_LOG_INFO 打字节数、调 KN 函数并包装返回。KN 返回的字符串必须经 libohostodoapp_symbols()->DisposeString 归还 Kotlin 运行时,绝不 free()。libohostodoapp_api.h 按社区已审仓库同款手写(只声明 ExportedSymbols 结构与业务符号),不拖整份生成头。

4.4 CMake 和类型声明
add_library(ohostodoapp SHARED IMPORTED)
set_target_properties(ohostodoapp PROPERTIES
  IMPORTED_LOCATION ${KN_SO}
  IMPORTED_NO_SONAME TRUE)

add_library(todoapp_napi SHARED napi_init.cpp)
target_link_libraries(todoapp_napi PUBLIC
  libace_napi.z.so
  libhilog_ndk.z.so
  ohostodoapp)

OHOS_ARCH 为空或 libohostodoapp.so 缺失直接 FATAL_ERROR(提示先跑 link 任务);$ORIGIN rpath 加 POST_BUILD 拷贝保证 NAPI so、KN so、libc++_shared.so 同目录,dlopen 时互相可见。-Wl,--no-undefined 让符号缺失在链接期暴露而不是运行期。

export const todoAppAction: (request: string) => string;

第 5 阶段:ArkUI 宿主

5.1 握手、注入和触发操作
aboutToAppear(): void {
  this.darkQuery = this.getUIContext().getMediaQuery()
    .matchMediaSync('(prefers-color-scheme: dark)');
  this.openPrefs();
  this.handshake();   // version:首页副标题来自真实 Native 调用
  this.boot();        // 有存档则 restore,无存档 state
}

private dispatch(request: DispatchRequest): BridgeResponse {
  return JSON.parse(todoAppAction(JSON.stringify(request))) as BridgeResponse;
}

boot() 顺序:读 todoapp_prefs 的 snapshot 键 → 非空则 {"action":"restore","snapshot":…} → 否则 {"action":"state"}。每次成功响应后 persist(res.snapshot) 写回 preferences 并 flush()。

5.2 页面交互

列表模式自上而下:Todos 标题 + 版本握手行、共 N 项 · 完成 K 项 汇总条(右侧滚动显示最近状态)、插入序卡片(Checkbox 只改完成态、标题完成划线、描述单行省略、✕ 删除、行点击进编辑)、右下角 + FAB。删除后底部弹出「Todo deleted / Undo」条 5 秒,撤销调 add + 原 id;「清空」走 AlertDialog 二次确认。编辑模式对应上游 AddEdit 屏:标题 TextInput、描述 TextArea、保存按钮。暗色模式经 mediaquery 跟随系统;主题色逐项取自上游 ui/theme/Color.kt 的 Material 3 蓝色方案(亮 primary #415F91 / 背景 #F9F9FF,暗 primary #AAC7FF / 背景 #111318)。

5.3 对比度和错误边界

主操作蓝色 FAB 白字,清空为 errorContainer 红字胶囊,语义与破坏性对齐;ok:false 时错误原文进汇总条状态位,空标题错误同时以红字钉在标题输入框下。Native 调用本身抛错(理论不该发生)被 dispatch 的 try/catch 收进状态条。

第 6 阶段:示例与验证

6.1 工程结构
example/
├── nativeApp/
│   ├── build.gradle.kts                      双 ABI sharedLib(baseName=ohostodoapp)+ copyKnSo*
│   └── src/ohosMain/kotlin/Bridge.kt         @CName × 1 + MiniJson + 快照切分器
└── harmonyApp/
    ├── build-profile.json5                   signingConfigs 留空(签名由 DevEco 自动生成回填)
    ├── AppScope/app.json5                    bundleName org.terminator.ohos.todoapp、versionName 1.0.0-ohos.1
    └── entry/
        ├── build-profile.json5               externalNativeOptions + abiFilters [arm64-v8a, x86_64]
        ├── libs/{arm64-v8a,x86_64}/          linkDebugShared* 经 copyKnSo* 回填(gitignore)
        ├── src/main/cpp/CMakeLists.txt       IMPORTED 引入 KN so、$ORIGIN rpath
        ├── src/main/cpp/napi_init.cpp        N-API 导出、参数检查、DisposeString
        ├── src/main/cpp/include/libohostodoapp_api.h
        ├── src/main/cpp/types/libtodoapp_napi/{index.d.ts, oh-package.json5}
        └── src/main/ets/pages/Index.ets      ArkUI 验收页面
6.2 构建、签名和安装
.\gradlew.bat :todoengine:jvmTest
.\gradlew.bat :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64

# 用 DevEco Studio 打开 example/harmonyApp(不是仓库根目录),然后:
hvigorw assembleHap -p product=default -p buildMode=debug

签名在 DevEco Studio 的 File → Project Structure → Signing Configs 中勾选自动生成,证书写入本机,不进入仓库(仓库内 build-profile.json5 的 signingConfigs 保持空数组)。

hdc install -r entry\build\default\outputs\default\entry-default-signed.hap
hdc shell aa start -a EntryAbility -b org.terminator.ohos.todoapp -m entry

四、完整代码对照

4.1 整体架构

com.gc.todo.engine/**(提取的领域引擎,三目标编译)
    │ Todo 模型 / TodoRules 空标题守卫 / TodoRepository 接口 / 插入序仓库
example/nativeApp Bridge.kt + MiniJson
    │ TodoAppAction(9 action 分发 + 快照编解码)
libtodoapp_napi.so
    │ todoAppAction
Index.ets
    │ JSON parse + @State 更新 + preferences 存档
    ▼
ArkUI 真机页面(单页两模式)

4.2 文件清单

文件职责
settings.gradle.kts极简 ohos 构建、eazytec Maven 置前、ohos_todoapp、上游模块移出
gradle/libs.versions.tomlKotlin 2.2.21-1.0.0 单版本源
build.gradle.kts(根)版本 1.0.0-ohos.1、全局禁用 Ohos*Test* 任务
todoengine/build.gradle.kts纯 KMP:jvm + ohosArm64 + ohosX64,commonMain 零依赖
todoengine/src/commonMain/…/Todo.kt模型 + newTodoId()(24 位 hex,同形 ObjectId)
todoengine/src/commonMain/…/TodoRules.kt空标题守卫,消息原文与上游逐字一致
todoengine/src/commonMain/…/TodoRepository.kt上游接口镜像(偏差集中声明在 KDoc)
todoengine/src/commonMain/…/InMemoryTodoRepository.kt插入序仓库:自然序、身份保持、静默 no-op、restore
todoengine/src/jvmTest/…/TodoEngineTest.kt12 项语义交叉验证
example/nativeApp/build.gradle.kts双 ABI sharedLib、copyKnSo* 回填 HAP libs
nativeApp/…/Bridge.kt1 个 C ABI、MiniJson、快照切分器、错误通道
harmonyApp/…/cpp/napi_init.cppN-API 导出、参数检查、DisposeString
harmonyApp/…/cpp/CMakeLists.txtIMPORTED 引入 KN so、$ORIGIN rpath、POST_BUILD 三件套
harmonyApp/…/ets/pages/Index.etsArkUI 验收页面
README.OpenHarmony_CN.md鸿蒙适配主文档(十节)

4.3 关键 API 对照

层次API作用
引擎insertTodo(todo): Todo插入并返回存储对象(id 缺省时生成 24 位 hex)
引擎getTodoById(id) / getTodos()按 id 取回 / 插入序快照列表
引擎updateTodo(todo)按 id 复制字段,未知 id 静默 no-op(同上游)
引擎deleteTodo(todo): Boolean按 id 删除并报告是否命中
引擎restore(items)整体替换,等价于带内容重开库
引擎TodoRules.validateTitle空标题守卫,"The title can't be empty"
NativeTodoAppActionC ABI JSON 导出(9 action 一口进)
N-APItodoAppAction(req)向 ArkTS 暴露能力
ArkTSdispatch(op)触发操作、刷新 @State、持久化快照

4.4 ArkTS 与 Kotlin 的边界

const res = JSON.parse(todoAppAction(JSON.stringify({
  action: 'add', title: '买牛奶', description: '两盒'
})));
// res.ok === true;res.todos 为插入序完整列表;res.snapshot 交给 preferences 持久化
"add" -> {
    TodoRules.validateTitle(json.str("title"))
    val stored = repository.insertTodo(
        Todo(
            id = json.str("id"),                  // 撤销重插时携带原 id
            title = json.str("title"),
            description = json.str("description"),
            isDone = json.str("isDone").equals("true", ignoreCase = true),
        ),
    )
    stateJson("add", stored)
}

两者之间只传输扁平 JSON 字符串,不传输 Kotlin 对象或未校验的动态结构。

五、关键决策说明

决策 1:引擎提取而不是真编译整库

KStore 适配时 kotlinx ohos 制品线已补齐,真编译整库是正确答案。ToDoApp 的盘点结论相反:Compose UI 绑死 1.6.10 工具链、Realm 带 C++ 核心无 ohos 制品——再想真编译等于把整个 UI 栈和数据库一起迁版本,风险不可控。领域层本身是纯 Kotlin,提取成 143 行零依赖模块后,语义完整、编译面最小。路线选择必须基于当天的依赖盘点,而不是把上一个项目的结论直接套用。

决策 2:根构建替换(cmp_qrose 模式),上游模块留盘

上游根构建(AGP / Compose / Realm / serialization 插件、Android 配置块)对鸿蒙没有保留价值,整个替换为极简 ohos 构建。composeApp / iosApp 留盘不进 settings.gradle.kts——留在盘上保留与上游 diff 的可读性,也方便上游更新时做三方对照。

决策 3:单入口 9-action JSON + 全量状态响应

Flow 无法跨 C ABI,桥上也没有订阅机制;与其造一套增量协议,不如每次操作后回传完整列表 + 计数 + 快照。这同时解决了三件事:宿主状态永远与引擎一致(无缓存错位)、撤销数据由引擎权威回显、快照顺手完成持久化。待办量级下没有任何性能代价。

决策 4:身份与顺序是 CRUD 跨桥的两条生命线

撤销重插要求 id 跨 JSON 桥往返后仍然唯一标识同一条待办——所以 ObjectId 变成 24 位 hex 字符串(同形 toHexString())。重插后的位置必须追加到末尾而不是回到原位——所以仓库用插入序列表复刻 Realm 查询自然序。这两条都有专项测试(insertKeepsExplicitIdForUndoReinsert 等),防止后来者当成 bug "修复"掉。

决策 5:上游无测试,语义用例全部新建 + JVM 交叉验证

上游仓库零测试。本 fork 在 jvmTest 新建 12 项用例,每一项对应一条上游可观测行为(空标题消息原文、未知 id 静默 no-op、字段级更新保位、防御性快照……)。Kotlin/Native 与 JVM 共享同一份 commonMain,JVM 上验证过的语义就是 ohos .so 里执行的语义,中间不存在任何手工翻译——这是"内核没漂"的唯一可信证明。

六、测试与验证

6.1 测试环境

本次验证使用 Windows(x64)、JDK 21(Eclipse Adoptium)、DevEco Studio 5.1.0 Release、OpenHarmony 命令行工具(hvigor / hdc)与鸿蒙真机(arm64)。

6.2 JVM 交叉验证

.\gradlew.bat :todoengine:jvmTest

12 项用例全部通过:

测试类分组用例数钉住什么
插入与身份2新建分配唯一 24 位 hex id 且按插入序追加;撤销重插保留原 id、追加到末尾(Realm 自然序)
业务规则1空白标题被拒,消息原文 "The title can't be empty"
读取2按 id 往返取回、未知 id 返回 null;getTodos 返回脱离后续变更的快照
更新语义3字段复制且 id 与位置不变;未知 id 静默 no-op(与上游一致);勾选只改 isDone 保留标题描述
删除2只移除目标行、重复删除返回 false;清空后列表为空
快照与 id 生成2restore 按给定顺序整体替换;1000 次抽样 id 无重复

6.3 HAP 验证

:todoengine:compileKotlinOhosArm64 / OhosX64: BUILD SUCCESSFUL
:example:nativeApp:linkDebugSharedOhosArm64 / linkDebugSharedOhosX64: BUILD SUCCESSFUL
llvm-nm -D: TodoAppAction / libohostodoapp_symbols 均为全局 T 符号(双 ABI)
copyKnSoArm64 / copyKnSoX64: 三件套回填 entry/libs(gitignore,不入库)
CompileArkTS: finished(新 SDK 弃用 API 已全部替换,见 8.1)
PackageHap: finished
BUILD SUCCESSFUL
install bundle successfully
start ability successfully

entry-default-unsigned.hap 约 5.1 MB(5,396,472 字节),内含双 ABI 各三件套(libtodoapp_napi.so / libohostodoapp.so / libc++_shared.so);未 strip 的 KN so arm64 约 2.3 MB(2,411,704 字节)、x86_64 约 2.0 MB(2,045,552 字节)。

6.4 功能用例

  1. 启动后首页副标题出现 KMP 待办引擎 1.0.0-ohos.1 · Kotlin/Native(来自真实 Native 调用,非静态文字);
  2. 点 FAB + 进入编辑模式,输入标题与描述保存,列表按插入序出现新卡片,汇总条变为 共 1 项 · 完成 0 项;
  3. 状态位显示 已保存——快照已写入 preferences;
  4. 勾选复选框只改完成态,标题划线、描述保留;
  5. 点 ✕ 删除,底部出现「Todo deleted / Undo」条;点 Undo 以原 id 重插追加到末尾;
  6. 「清空」弹出 AlertDialog 二次确认后整体清空;
  7. 空标题保存被引擎拒绝,红字显示原文 "The title can't be empty",不产生列表变更;
  8. 杀掉应用重启,存档经 restore 恢复,待办仍在——落盘持久化成立,不是内存缓存。

6.5 验证结论

领域引擎、身份保持、快照持久化、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 已完成链路验证。应用在鸿蒙真机上成功安装并进入前台,页面展示了真实的待办会话:版本握手、新建、汇总计数与已保存状态(见下节效果图)。

七、运行效果

7.1 真机截图

在这里插入图片描述

7.2 命令速查

git clone https://atomgit.com/oh-tpc/ToDoApp-KMP.git
cd ToDoApp-KMP
$env:JAVA_HOME = "C:\Program Files\Eclipse Adoptium\jdk-21.0.12.101-hotspot"
.\gradlew.bat :todoengine:jvmTest                                    # 12 项语义交叉验证
.\gradlew.bat :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64
cd example\harmonyApp
hvigorw assembleHap -p product=default -p buildMode=debug
hdc install -r entry\build\default\outputs\default\entry-default-signed.hap
hdc shell aa start -a EntryAbility -b org.terminator.ohos.todoapp -m entry
hdc shell hilog | grep TodoApp

八、遗留问题与改进方向

8.1 踩坑复盘

  1. 只把 ArkTS 页面写好看不算 KMP 适配,必须让 :todoengine 真正编译成 ohosArm64 / ohosX64 动态库并跑通 llvm-nm 符号验证;
  2. settings.gradle.kts 里 eazytec 双仓必须排在 pluginManagement 与 dependencyResolutionManagement 最前,否则配置期直接 Unresolved reference: ohosArm64;
  3. commonMain 里没有 MutableList.removeIf(JVM 专属),跨平台写法是 removeAll { … }——这类 API 差异在 ohos 编译期才会暴露;
  4. Gradle wrapper 不是越新越好:2.2.21 线社区验证的是 8.14.1,直接沿用上游 8.7 反而会在插件解析上报错;
  5. 命令行构建前必须设 JAVA_HOME 指向 JDK 21,org.gradle.java.home 只管守护进程、救不了 gradlew 启动;
  6. 新版 DevEco SDK 移除了 getPreferencesSync(context, name: string) 字符串重载,第二个参数必须传 Options 对象({ name: … }),传字符串直接 ArkTS 编译错误;
  7. getContext(this) / mediaquery.matchMediaSync / AlertDialog.show 在新 SDK 全部弃用,官方替代分别是 this.getUIContext().getHostContext() / getUIContext().getMediaQuery().matchMediaSync(...) / getUIContext().showAlertDialog(...);getMainWindowSync().setWindowBackgroundColor 对 phone 设备直接警告 unavailable,窗口背景交给 start_window_background 资源即可;
  8. preferences 的 getSync / putSync / flush 会抛异常,ArkTS 严格检查要求逐个 try/catch 包裹,否则编译期连告警带失败难以定位;
  9. ArkTS 严格模式禁止无类型对象字面量:{ action: 'deleteAll' } 必须写成 … as DeleteAllRequest 或先声明具名变量;
  10. HAP 缺当前 ABI 的 so 会报安装失败 9568347,abiFilters 必须双 ABI,每个 ABI 三件套缺一不可(libtodoapp_napi.so / libohostodoapp.so / libc++_shared.so);
  11. ArkTS 只能 import { todoAppAction } from 'libtodoapp_napi.so'——HarmonyOS Kotlin 的 CAdapter 不会把 @CName 业务符号挂给 ArkTS,直接 import libohostodoapp.so 会得到 export 错误;
  12. KN 返回字符串必须经 libohostodoapp_symbols()->DisposeString 归还运行时,N-API 层不能 free();
  13. Checkbox 的 onChange 对程序化 select 变化也会触发,必须加 value !== todo.isDone 守卫,否则状态回刷造成循环触发;
  14. 签名材料由 DevEco 自动写入 build-profile.json5,提交前保持仓库内 signingConfigs 空数组,证书绝不进入 AtomGit。

8.2 已知问题

  • 鸿蒙侧无 Compose UI,界面是 ArkUI 复刻:主题色取自上游 Material 3 蓝色方案,交互对齐,但不做像素级一致;上游 Compose 的隐式动画、涟漪未逐帧复刻;
  • Realm → 内存引擎 + 宿主 preferences 快照:插入序复刻自然序、restore 等价重开库,但失去 Flow 响应式订阅(桥上无等价物),getTodos() 由 Flow<List<Todo>> 改为一次性 List;
  • insertTodo 返回存储后的 Todo、deleteTodo 返回 Boolean——上游无返回值,偏差为桥回显所迫,已在接口 KDoc 集中声明;
  • 快照中 null 描述编码为 ""(上游 TextField 从不产生 null,可空性仅在模型层保留);
  • android / ios 目标剥离(composeApp / iosApp 源文件留在树里),上游的发布链路一并剥离,本 fork 不发 Maven Central;
  • updateTodo 未知 id 静默 no-op 是上游固有三方行为(Realm findLatest 分支不执行),保留而非"修复"。

8.3 未来优化方向

  • ArkUI 侧补充列表项动画与 FAB 转场,进一步逼近上游 Compose 观感;
  • 评估 .har 形态发布桥层,让 ArkTS 工程直接依赖而不是走 so 三件套;
  • 引擎层评估 kotlinx ohos 制品线(coroutines / serialization)可用后再引入,替换手写 MiniJson;
  • 在 CI 中加入 ohos 编译、ELF 符号检查和 HAP 构建;
  • 补充模拟器(x86_64)验收矩阵与自动化截图脚本,覆盖暗色主题效果图。

九、总结

9.1 核心难点回顾

上游 composeApp(CMP 1.6.10 / Kotlin 1.9.24 / Voyager / Koin / Realm)
    → 依赖盘点:Compose 混编不可行、Realm 无 ohos 制品 → 定路线 cmp_qrose 引擎提取
    → 工具链对齐 Kotlin 2.2.21-1.0.0(Gradle 8.14.1 / JDK 21)
    → 根构建替换(上游模块留盘)
    → todoengine 三目标编译 + 12 项 JVM 语义测试
    → C ABI(@CName × 1)+ 9-action JSON + 全量状态响应
    → C++ N-API(DisposeString 纪律)
    → ArkTS JSON 解析 + preferences 存档
    → ArkUI 真机单页两模式验收(握手 / 增删改查 / 撤销 / 持久化)

9.2 封装层次

  • 引擎层:Todo 模型、TodoRules 空标题守卫、插入序仓库、身份保持与防御性快照;
  • Native 层:生成双 ABI 动态库并通过 1 个 C ABI 输出 JSON;
  • N-API 层:完成参数检查、字符串转换和 Native 内存释放;
  • ArkTS 层:管理页面状态、JSON 校验、操作分发、快照存档与错误文本;
  • DevEco 层:完成 CMake、HAP、签名、安装和真机运行。

9.3 三条经验

  1. 路线选择必须基于当天的依赖盘点:有 ohos 制品线的库真编译整库(KStore 路径),UI/存储绑死旧工具链的应用提取领域引擎(本项目 / 2048 路径)——两条路径共用同一套根构建替换与桥接纪律,区别只在"带走多少";
  2. CRUD 应用跨桥的生命线是身份与顺序:id 跨语言往返必须稳定(24 位 hex 同形 ObjectId.toHexString()),重插位置必须复刻上游自然序(追加到末尾),两者都要有专项测试钉死,防止被后来者当 bug 改掉;
  3. 全量状态响应是 Flow 最便宜的等价物:没有订阅机制就每次回传完整列表 + 计数 + 快照,顺带把撤销数据与持久化一并解决——协议的朴素换来的是宿主永远不可能与引擎状态错位。

9.4 适配成果

当前适配已完成:

  • 上游领域层(模型 + 仓库语义 + 业务规则)的双 ABI Kotlin/Native 编译(todoengine 143 行零依赖,语义对等提取);
  • 1 个 C ABI 符号和 C++ N-API 包装层(llvm-nm 验证全局 T 符号);
  • DevEco Stage ArkUI 单页两模式验收页面(握手、新建、编辑、勾选、删除撤销、清空、快照存档);
  • 12 项 JVM 交叉验证全绿 + HAP 构建(双 ABI 六件套 so,未签名约 5.1 MB)、签名、设备安装和真机验证;
  • 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开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐