开源鸿蒙平台 CMP 三方库「ToDoApp-KMP」适配全流程
本文记录
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,与上游 RealmfindLatest分支从不执行的行为一致; - 勾选(toggle):只改
isDone一个字段,标题与描述原样保留; - 删除 + 撤销(delete → add + 原 id):删除响应回显被删对象,撤销时用原 id 重插并追加到末尾,复刻上游 Realm 重插行为;
- 清空(deleteAll):AlertDialog 二次确认后整体清空;
- 持久化闭环:每次成功响应携带
snapshot,宿主写入@ohos.data.preferences;杀进程重启后restore恢复,值仍在——证明是落盘而不是内存缓存; - 版本握手:
versionaction 返回1.0.0-ohos.1,来自真实 Native 调用而非静态文字。
响应包含:
| 字段 | 作用 |
|---|---|
ok | 操作是否成功,失败时 error 带原文。 |
action / todos | 回显本次操作与插入序完整列表({id, title, description, isDone})。 |
total / done | 列表计数与完成计数,驱动汇总条。 |
created / deleted | add 回显新建对象、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 Multiplatform | HarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven) |
| JDK | 21(org.gradle.java.home 指向 Eclipse Adoptium jdk-21.0.12.101-hotspot) |
| OpenHarmony 目标 | ohosArm64 + ohosX64(jvm() 保留作交叉验证) |
| Gradle | 8.14.1(上游 wrapper 8.7 升级;2.2.21 线验证过的 wrapper 版本) |
| 引擎依赖 | 无——commonMain 零第三方依赖,桥用手写 MiniJson |
| DevEco product | 5.1.0(18) 兼容 |
| DevEco Studio | 5.1.0 Release |
| ABI | arm64-v8a + x86_64 |
| Bundle | org.terminator.ohos.todoapp |
$env:JAVA_HOME = "C:\Program Files\Eclipse Adoptium\jdk-21.0.12.101-hotspot"
java -version
1.3 源码依赖度盘点
| 上游源码 | 依赖形态 | 处置 |
|---|---|---|
data/Todo.kt | RealmObject + kbson ObjectId | 语义提取:字段与可空性对齐,id 换 24 位 hex 字符串(同形) |
data/TodoRepository.kt | 接口,getTodos(): Flow | 语义提取:方法名逐一保留,Flow → List(桥迫偏差,见 8.2) |
data/TodoRepositoryImpl.kt | Realm open/query/write | 重写为插入序内存仓库,自然序与字段级行为对齐上游 |
presentation/*ViewModel.kt | Voyager ScreenModel + Compose 状态 | 规则提取:空标题守卫、保存分流、撤销重插进引擎与桥 |
presentation/*Screen.kt ui/theme/* | Compose UI / Material 3 | 留盘不进构建,ArkUI 复刻(主题色同源搬运) |
di/KoinModule.kt | Koin | 留盘不进构建,鸿蒙侧引擎为模块级单例 |
上游 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.0 | C++ 原生核心,无 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 方法 | 覆盖内核 |
|---|---|---|
TodoAppAction | todoAppAction(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.toml | Kotlin 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.kt | 12 项语义交叉验证 |
example/nativeApp/build.gradle.kts | 双 ABI sharedLib、copyKnSo* 回填 HAP libs |
nativeApp/…/Bridge.kt | 1 个 C ABI、MiniJson、快照切分器、错误通道 |
harmonyApp/…/cpp/napi_init.cpp | N-API 导出、参数检查、DisposeString |
harmonyApp/…/cpp/CMakeLists.txt | IMPORTED 引入 KN so、$ORIGIN rpath、POST_BUILD 三件套 |
harmonyApp/…/ets/pages/Index.ets | ArkUI 验收页面 |
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" |
| Native | TodoAppAction | C ABI JSON 导出(9 action 一口进) |
| N-API | todoAppAction(req) | 向 ArkTS 暴露能力 |
| ArkTS | dispatch(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 生成 | 2 | restore 按给定顺序整体替换;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 功能用例
- 启动后首页副标题出现
KMP 待办引擎 1.0.0-ohos.1 · Kotlin/Native(来自真实 Native 调用,非静态文字); - 点 FAB
+进入编辑模式,输入标题与描述保存,列表按插入序出现新卡片,汇总条变为共 1 项 · 完成 0 项; - 状态位显示
已保存——快照已写入 preferences; - 勾选复选框只改完成态,标题划线、描述保留;
- 点 ✕ 删除,底部出现「Todo deleted / Undo」条;点 Undo 以原 id 重插追加到末尾;
- 「清空」弹出 AlertDialog 二次确认后整体清空;
- 空标题保存被引擎拒绝,红字显示原文
"The title can't be empty",不产生列表变更; - 杀掉应用重启,存档经
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 踩坑复盘
- 只把 ArkTS 页面写好看不算 KMP 适配,必须让
:todoengine真正编译成ohosArm64/ohosX64动态库并跑通llvm-nm符号验证; settings.gradle.kts里 eazytec 双仓必须排在pluginManagement与dependencyResolutionManagement最前,否则配置期直接Unresolved reference: ohosArm64;- commonMain 里没有
MutableList.removeIf(JVM 专属),跨平台写法是removeAll { … }——这类 API 差异在 ohos 编译期才会暴露; - Gradle wrapper 不是越新越好:2.2.21 线社区验证的是 8.14.1,直接沿用上游 8.7 反而会在插件解析上报错;
- 命令行构建前必须设
JAVA_HOME指向 JDK 21,org.gradle.java.home只管守护进程、救不了 gradlew 启动; - 新版 DevEco SDK 移除了
getPreferencesSync(context, name: string)字符串重载,第二个参数必须传Options对象({ name: … }),传字符串直接 ArkTS 编译错误; getContext(this)/mediaquery.matchMediaSync/AlertDialog.show在新 SDK 全部弃用,官方替代分别是this.getUIContext().getHostContext()/getUIContext().getMediaQuery().matchMediaSync(...)/getUIContext().showAlertDialog(...);getMainWindowSync().setWindowBackgroundColor对 phone 设备直接警告 unavailable,窗口背景交给start_window_background资源即可;- preferences 的
getSync/putSync/flush会抛异常,ArkTS 严格检查要求逐个 try/catch 包裹,否则编译期连告警带失败难以定位; - ArkTS 严格模式禁止无类型对象字面量:
{ action: 'deleteAll' }必须写成… as DeleteAllRequest或先声明具名变量; - HAP 缺当前 ABI 的 so 会报安装失败
9568347,abiFilters必须双 ABI,每个 ABI 三件套缺一不可(libtodoapp_napi.so/libohostodoapp.so/libc++_shared.so); - ArkTS 只能
import { todoAppAction } from 'libtodoapp_napi.so'——HarmonyOS Kotlin 的 CAdapter 不会把@CName业务符号挂给 ArkTS,直接 importlibohostodoapp.so会得到 export 错误; - KN 返回字符串必须经
libohostodoapp_symbols()->DisposeString归还运行时,N-API 层不能free(); - Checkbox 的
onChange对程序化select变化也会触发,必须加value !== todo.isDone守卫,否则状态回刷造成循环触发; - 签名材料由 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 是上游固有三方行为(RealmfindLatest分支不执行),保留而非"修复"。
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 三条经验
- 路线选择必须基于当天的依赖盘点:有 ohos 制品线的库真编译整库(KStore 路径),UI/存储绑死旧工具链的应用提取领域引擎(本项目 / 2048 路径)——两条路径共用同一套根构建替换与桥接纪律,区别只在"带走多少";
- CRUD 应用跨桥的生命线是身份与顺序:id 跨语言往返必须稳定(24 位 hex 同形
ObjectId.toHexString()),重插位置必须复刻上游自然序(追加到末尾),两者都要有专项测试钉死,防止被后来者当 bug 改掉; - 全量状态响应是 Flow 最便宜的等价物:没有订阅机制就每次回传完整列表 + 计数 + 快照,顺带把撤销数据与持久化一并解决——协议的朴素换来的是宿主永远不可能与引擎状态错位。
9.4 适配成果
当前适配已完成:
- 上游领域层(模型 + 仓库语义 + 业务规则)的双 ABI Kotlin/Native 编译(
todoengine143 行零依赖,语义对等提取); - 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。
参考文档
更多推荐
所有评论(0)