开源鸿蒙平台 CMP 三方库「compose-router」适配全流程
本文记录
compose-router接入 OpenHarmony 的完整过程,覆盖路由库盘点、工具链钉版、上游 Compose 源码隔离、语义孪生引擎、ohosArm64/ohosX64目标、Kotlin/Native 动态库、C ABI/N-API 桥接、ArkUI 真机页面、HAP 构建和真机验收。
compose-router是 zsoltk 开发的 Jetpack Compose 路由库:Routercomposable 为任意一段 UI 提供独立 back stack(push/pop/newRoot/replace/pushAndDropNested五个操作),返回键沿 Router 树从最内层冒泡,BundleScope+persistentInt提供按栈元素隔离的持久化,LocalRouting支持深链一次设定多层状态。本次适配与 KStore 的"真编译整库"路线有本质区别:compose-router 全库六个源文件每一行都站在androidx.compose.runtime(mutableStateOf/CompositionLocal/@Composable)上,连一个commonMain都没有,社区 CMP 1.9.2 与上游 Compose 1.0 时代的 API 也无法对齐——因此走"语义孪生"路线:把 Router / BackStack / BackPressHandler / saved-state 的语义逐行翻译成不依赖 Compose 的纯 Kotlin 引擎,用 37 项 JVM 测试钉死语义不漂移,上游源码原样存档于composeMain等工具链对齐后接回,上游两个示例 app(lifelike / nested-containers)被完整复刻成可驱动的场景,一个真实的路由引擎运行在真机上,ArkUI 四 Tab 页面作为验收宿主。
项目地址: AtomGit/oh-tpc/compose-router.git
上游仓库: zsoltk/compose-router
开发工具: 华为云码道
环境安装: 环境安装指南
一、背景
1.1 为什么做 OpenHarmony 平台 KMP/CMP 适配
compose-router 为 Compose 应用提供"导航栈"这一件事的完整答案:Router(defaultRouting) { backStack -> ... } 一行声明一个带独立历史的路由层级,任意嵌套;BackStack 的五个操作覆盖了根切换(newRoot)、历史替换(replace)与丢弃子树状态的前进(pushAndDropNested);返回键不写一行 OnBackPressedCallback 就能从最内层栈逐层向外冒泡;persistentInt 把局部状态挂在"当前栈顶元素"的 Bundle scope 上,push 换 scope、pop 整树丢弃、pop 回旧元素恢复旧值;LocalRouting 接收一个路由元素列表,逐层消费,深链一发直达任意嵌套状态。上游 README 的定位是单 Activity、无 Fragment、无 Navigation 组件的 Compose 应用结构。
但上游已于 2022 年声明 DEPRECATED(后继项目为 bumble-tech/Appyx),停更于 Compose 1.0.2 / Kotlin 1.5.21,构建链是 AGP 7.1.0-alpha02 + 旧版 Compose Compiler + useIR 时代参数。OpenHarmony 应用同样需要"独立 back stack + 返回键冒泡 + 按栈元素隔离的持久化"这套导航语义,而上游构建不会生成 OpenHarmony 变体。
| 障碍 | 具体问题 |
|---|---|
| 目标缺失 | 必须增加 ohosArm64() / ohosX64() 才能生成 OpenHarmony KLIB。 |
| 工具链不一致 | 上游 Kotlin 1.5.21 / Gradle 7.0 / AGP 7.1.0-alpha02 与鸿蒙工具链无法共存于一个 Gradle 构建。 |
| Compose 依赖 | 全库 6 个源文件全部依赖 androidx.compose.runtime,mutableStateOf / CompositionLocal / @Composable / SideEffect / DisposableEffect 无处不在——不存在可以"原样编译"的纯 Kotlin 子集。 |
| Android API 依赖 | savedinstancestate 三个文件依赖 android.os.Bundle;示例 app 依赖 AppCompatActivity / Intent / Uri。 |
| 语言边界不同 | ArkTS 不能直接持有 Kotlin 对象,需要 C ABI、C++ N-API 和 JSON。 |
| 导航语义跨不了桥 | 返回键冒泡、CompositionLocal 提供的 LocalRouting 下发、remember 缓存语义都是 Compose 运行时概念,C ABI 只能同步一来一回。 |
| 交付链路复杂 | .so、CMake、N-API、HAP、签名和 hdc 都要单独验收。 |
因此,本项目把边界放在:路由语义的纯 Kotlin 孪生引擎(router 模块 commonMain)、根构建替换(cmp_qrose 模式)和 C ABI/N-API 桥接层。导航语义完全由孪生引擎按上游逐条实现,ArkTS 只负责调用 Native、解析 JSON 快照和渲染页面。
1.2 库提供的能力
Demo 把上游两个示例 app 的全部交互搬上鸿蒙,对应桥接的全部入口:
- 嵌套容器(nested 场景,twin of
:app-nested-containers):递归 5 层容器各带独立 back stack,Root.NEXT让子树颜色 Red→Green→Blue 循环(push),Back stack: N实时显示栈深,Local data: counter是按栈元素隔离的persistentInt——push 后计数从新元素的 0 开始,pop 回旧元素恢复旧值; - 注册与相册(lifelike 场景,twin of
:app-lifelike):Splash→RegUserName→RegUserPhone→RegConfirmSmsCode→RegFinal 注册流程逐步 push,LOGIN 是newRoot,Gallery/News/Profile 三菜单是 LoggedIn 层的 push,相册列表→照片列表→全屏照片是 Gallery 层的第三级栈; - 返回键冒泡:系统返回键 →
{"op":"back"}→BackPressHandler.handle()从最内层可出栈的 Router 逐层向外,全部拒绝时返回backHandled:false(应用应退出); - 深链路由列表:
app-nested://default/BGR一发设定前三层子树颜色,app-lifelike://go-to-profile?name=fake&phone=123123一发直达 Profile; - 栈注册表与持久化检查器:State 页实时 dump 全局
backStackMap(含被弃用的兄弟栈——上游 map 永不清理的语义 1:1 保留)与BundleEngine的嵌套 scope 树。
响应是全量 UI 快照:
| 字段 | 作用 |
|---|---|
ok / error | 操作是否成功,失败时 error 带原文。 |
scenario / event | 当前场景与本次事件的人类可读描述。 |
backHandled | back 操作是否被路由树消化(null 表示本次非 back 操作)。 |
nested.containers | nested 场景的递归容器树(level/name/color/stackSize/stack/counter/children)。 |
lifelike.* | lifelike 场景的屏幕链(Root/LoggedOut/LoggedIn/Gallery 层级)、菜单选中项、相册列表、gallery 栈。 |
stacks / persistence | 全局栈注册表与 scope 持久化树。 |
引擎侧完整能力(五个栈操作、handler id 链、K$lastIndex scope 生命周期、深链 head 匹配 override / tail 下发)全部在孪生引擎里实现并进入 .so,JVM 侧保留 37 项交叉验证用例(21 项引擎语义 + 16 项场景端到端),证明 ohos .so 里执行的路由逻辑没有漂移。
1.3 实现适配
| 维度 | 要求 |
|---|---|
| 代码复用 | 上游 6 个源文件原样存档于 router/src/composeMain(纯 rename,不编译);引擎是语义孪生而非重写导航框架——BackStack / BackPressHandler 逐行对应,Router composable 翻译为 RouterEngine,android.os.Bundle 子集化为 BundleEngine。 |
| 平台目标 | 根构建直接声明 ohosArm64 + ohosX64,保留 jvm 作交叉验证,库模块出 KLIB,example/nativeApp 出双 ABI 动态库。 |
| 构建隔离 | 根构建替换为极简 ohos 构建(cmp_qrose 模式);上游两个 Android 示例 app(app-lifelike / app-nested-containers)留盘不进构建,其路由树被复刻进 nativeApp 的场景层。 |
| 桥接稳定 | 使用 1 个 C ABI(RouterDispatch)和 UTF-8 JSON 全量快照,不传递 Kotlin 对象地址;异常永远不跨 FFI。 |
| 语义钉死 | 37 项 JVM 测试逐条对照上游源码注释写断言;上游怪癖(backStackMap 永不清理、深链重组重放)显式决策并在文档记录。 |
| UI 完整 | 四 Tab 覆盖两个示例 app 全部交互 + 深链试验台 + 状态检查器;系统返回键接入路由树。 |
| 仓库规范 | 项目说明、文章、效果图和源码链接统一使用 AtomGit。 |
本项目的 ArkUI 页面不是 Compose Multiplatform UI 实现。compose-router 上游的 UI 就是宿主 app 自己的 Composable,库只管导航语义;ArkTS Demo 只是把桥暴露的路由状态画出来,同时把系统返回键喂回路由树。
二、实现路线图
第 1 阶段:项目初始化 ── 盘点模块与依赖边界(6 文件逐一分类)、确定"语义孪生"路线、确定交付物
第 2 阶段:目标与依赖打通 ── 根构建替换、eazytec Maven 置前、Kotlin 2.2.21-1.0.0 钉版、Gradle 8.14.1
第 3 阶段:语义孪生建模 ── BackStack/BackPressHandler 孪生、RouterEngine、BundleEngine、深链一次消费
第 4 阶段:原生桥接 ── @CName RouterDispatch、C++ N-API、参数校验和内存释放
第 5 阶段:ArkUI 宿主 ── 四 Tab 页面、dispatch 帮助函数、onBackPress 接入、深链试验台
第 6 阶段:示例与验证 ── HAP、签名、设备安装、真机运行和效果图
三、逐步实现过程
第 1 阶段:项目初始化
1.1 工程边界
router/src/commonMain/ 语义孪生引擎(BackStack / BackPressHandler / RouterEngine / BundleEngine)
router/src/composeMain/ 上游 6 个源文件原样存档(git 纯 rename,不参与编译)
router/src/jvmTest/ 引擎语义交叉验证(21 项)
app-lifelike/ app-nested-containers/ 上游 Android 示例,留盘不进 settings
example/nativeApp/src/commonMain/ 两个示例 app 的场景孪生(LifelikeScenario / NestedScenario)
example/nativeApp/src/ohosMain/ Bridge.kt(@CName RouterDispatch + MiniJson)
example/nativeApp/src/jvmTest/ 场景端到端验证(16 项)
example/harmonyApp/ DevEco Stage、CMake N-API 和 ArkTS 四 Tab 页面
README.OpenHarmony.md / _CN.md 鸿蒙适配说明(中英双份)
README.upstream.md 上游原文保存
构建拓扑走 cmp_qrose 模式(根构建即 ohos 构建):settings.gradle.kts 只 include :router 与 :example:nativeApp,rootProject.name = "ohos_compose-router"。上游根构建的 AGP classpath、compose 版本 ext、JitPack 发布配置全部随根构建替换剥离。
1.2 工具链矩阵
| 项目 | 配置 |
|---|---|
| Kotlin Multiplatform | HarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven) |
| JDK | 21(Temurin,org.gradle.java.home 钉死绝对路径) |
| OpenHarmony 目标 | ohosArm64 + ohosX64(jvm 保留作交叉验证) |
| Gradle | 8.14.1(wrapper 重生成;2.2.21 旧线要求 Gradle 8.x) |
| 第三方依赖 | 零——引擎不依赖 coroutines / serialization,commonMain.dependencies {} 为空 |
| DevEco product | 5.1.0(18) 兼容 |
| DevEco Studio | 5.1.0 Release |
| ABI | arm64-v8a + x86_64 |
| Bundle | org.terminator.ohos.router |
$env:JAVA_HOME = "C:\Program Files\Eclipse Adoptium\jdk-21.0.12.101-hotspot"
java -version
1.3 源码依赖度盘点
上游核心库只有 6 个文件、约 400 行,逐一分类决定处置:
| 文件 | 依赖形态 | 处置 |
|---|---|---|
router/BackStack.kt | mutableStateOf 状态 + 纯逻辑 | 孪生:去掉状态层,五个操作与 onElementRemoved 通知逐行保留 |
backpress/BackPressHandler.kt | 树逻辑 + CompositionLocal 定义 | 孪生:树语义原样,CompositionLocal 管线不进引擎 |
router/Router.kt | @Composable + 全部 CompositionLocal 编排 | 孪生:RouterEngine —— fetchBackStack 三分支、handler id 链、downStreamRoute、back-press lambda、K$lastIndex scope 逐条翻译 |
savedinstancestate/BundleScope.kt | android.os.Bundle + SideEffect | 子集化:BundleEngine(Int + 子 scope 的树) |
savedinstancestate/SavedInstanceState.kt | android.os.Bundle + 根 Bundle 单例 | 子集化:RouterWorld.rootBundle |
savedinstancestate/Persistent.kt | remember + Bundle 读写 | 孪生:PersistentInt(按 bundle 语义恢复) |
两个 Android 示例 app 的价值不在 UI 而在路由结构:lifelike 的 Root→LoggedOut/LoggedIn→Gallery/News/Profile→相册三级栈、nested 的递归五层容器与 nbChildrenPerLevel/colorSets 常量、两个 DeepLink.kt 的解析规则——全部被复刻进 example/nativeApp 的场景层,成为可被 16 项端到端测试驱动的"活规格"。
1.4 页面边界
标题区 compose-router · OpenHarmony / scenario·backHandled·版本行 / 仓库链接
Tab1 Nested 递归容器树渲染:彩色卡片 + name.NEXT 按钮 + Back stack 计数 + Local data 计数器
Tab2 Lifelike 屏幕层级链(Root/LoggedOut/LoggedIn/Gallery 胶囊)+ 注册流程 / 菜单 / 相册列表 / 照片 / 登出
Tab3 DeepLink 两个预置深链按钮 + 自定义 uri 输入 + 应用(按前缀自动 init 对应场景)
Tab4 State 全局栈注册表(含被弃用兄弟栈)+ persistence scope 树 JSON
返回键 onBackPress → {"op":"back"};backHandled=false 时提示"应用应退出"且不退出
页面启动时 aboutToAppear 自动 init nested;Tab 切换时自动 ensureScenario 保证当前页场景已加载。
第 2 阶段:目标与依赖打通
2.1 依赖盘点(本路线的先决判断)
与 KStore 不同,compose-router 的引擎不依赖任何 kotlinx 库——上游连 commonMain 都没有,自然不存在"制品线是否齐全"的问题。这意味着两点:其一,Kotlin 2.2.21-1.0.0(qrose / haze 批准的旧线)即可满足,没有"stdlib 元数据反推钉版"的压力;其二,适配的难点完全不在依赖解析,而在第 3 阶段的语义孪生。工具链选择 Gradle 8.14.1(2.2.21 线的已验证组合),JDK 21 写进 gradle.properties。
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_compose-router"
include(":router")
include(":example:nativeApp")
2.2 上游源码隔离
6 个上游文件 git mv 到 router/src/composeMain/kotlin/...(保留 rename 历史),不在任何 build.gradle.kts 里声明——KGP 直接无视该目录。它存在的意义是对照上游 API:等社区 CMP 工具链与 Compose 1.x API 对齐后,Router.kt 一行不改就能移回编译源集。这是 cmp_qrose 已审模式(Painter 文件隔离)在"全库皆 Compose"场景下的推广。
2.3 编译孪生引擎
router/build.gradle.kts 收敛为纯 KMP 模块:
plugins {
alias(libs.plugins.kotlin.multiplatform)
}
kotlin {
applyDefaultHierarchyTemplate()
jvmToolchain(21)
jvm()
ohosArm64()
ohosX64()
sourceSets {
commonMain.dependencies {}
jvmTest.dependencies {
implementation(kotlin("test"))
}
}
}
实测:
.\gradlew.bat :router:compileKotlinOhosArm64 :router:compileKotlinOhosX64 # BUILD SUCCESSFUL
第 3 阶段:语义孪生建模(本适配的核心)
3.1 孪生的总原则
孪生不是重写:每一条语义都要能在上游源码里指出对应行,测试断言的消息里直接引用上游注释。三个翻译模式:
- 去状态:
var elements by mutableStateOf(listOf(...))→var elements: List<T> = listOf(...),操作序列一字不改; - 去组合:
@Composable的编排行为翻译为显式生命周期——SideEffect { add }→init { attach() },DisposableEffect { onDispose { remove } }→dispose(),"重组"翻译为场景层的 dispose 全部 + 重建; - 去平台:
android.os.Bundle的getBundle/putBundle/remove子集化为BundleEngine树节点。
3.2 RouterEngine:Router composable 的非组合式孪生
class RouterEngine(
private val world: RouterWorld,
val contextId: String, // 上游 reified T::class.java.name 或显式 "$level.$id"
private val routingType: String, // 上游 as? T 的类型判据
defaultRouting: Any,
private val parent: RouterEngine?,
routing: List<Any> = emptyList() // LocalRouting 当前值
) {
val handler = BackPressHandler("${parentHandlerId()}.$contextId")
val downstreamRouting: List<Any> = // 上游 downStreamRoute:head 无条件下发丢弃
if (routing.size > 1) routing.takeLast(routing.size - 1) else emptyList()
private val upstreamBundle = parent?.currentScope ?: world.rootBundle
val backStack = fetchBackStack(handler.id, defaultRouting, routingFromAmbient)
val handleBackPressHere: () -> Boolean = { handler.handle() || backStack.pop() }
/* attach() / dispose() 对应 SideEffect / DisposableEffect;
currentScope = upstreamBundle.scope("K${backStack.lastIndex}") 对应 BundleScope(autoDispose=false) */
}
四条关键语义的翻译对照:
| 上游(Router.kt) | RouterEngine |
|---|---|
BackPressHandler("${upstreamHandler.id}.$contextId") | handler id 链 Root.<ctx>.<ctx>,栈按此键入 backStackMap |
route.firstOrNull() as? T → override 分支建新栈 | routing.firstOrNull()?.takeIf { routingType 匹配 } → BackStack(override, ...) 替换 map 条目 |
route.takeLast(route.size - 1) 无条件下发 | downstreamRouting 同款——head 类型不匹配也照样被消费 |
SideEffect/DisposableEffect 注册注销 back-press lambda | attach()/dispose(),根级 Router 挂在 world.rootHandler(对应 Activity 的 handler) |
深链"一次消费"决策:上游 LocalRouting 由 Activity 的 intent 一次性提供,但 fetchBackStack 在每次重组都会重跑 override 分支——深链到达后任何导航都会被重新打回深链状态(上游 README 也只称其为 POC)。引擎按上游文档声明的意图实现"一次消费":场景层把 routing 列表注入首次重组后即清空,后续事件都在既有栈上操作。这条差异连同下面两条怪癖一起写进 README.OpenHarmony_CN.md 第 6 节的显式清单。
3.3 两条上游怪癖的 1:1 保留
- backStackMap 永不清理:登出
newRoot(LoggedOut)后 LoggedIn 栈对象仍在 map 里——再次登录时fetchBackStack命中existing分支,菜单深度原样恢复;登出后看到的也是注册流程末尾(RegFinal)而非 Splash。引擎保留此行为,并有专门测试(logout_lands_on_the_retained_logged_out_stack.../relogin_restores_the_previous_logged_in_depth)与 Demo 展示点; - persistentInt 的 scope 语义:上游
remember会在 pop 回旧元素时显示 remember 缓存值;引擎按 bundle 语义恢复该元素 scope 里最后写入的值——这是"scoped saved state"文档意图的实现,差异同样显式记录。
3.4 桥协议建模
router/src/commonMain(孪生引擎)
↓ BackStack / BackPressHandler / RouterEngine / BundleEngine
example/nativeApp commonMain(场景层:两个示例 app 的完整复刻)
↓ init / tap / back / deeplink / state 事件 → 全量 UI 快照
Bridge.kt(@CName × 1,扁平 JSON 进、嵌套 JSON 出)
↓ UTF-8 JSON
N-API routerDispatch()
↓
ArkUI Index.ets(四 Tab)
单入口全量快照而非细粒度多符号:路由操作天然是"事件 → 整棵可见树变化",快照式协议让 ArkTS 无需维护任何镜像状态,每次 JSON.parse 后直接渲染。
第 4 阶段:原生桥接(技术难点)
4.1 三层桥接
ArkTS
│ JSON string
▼
C++ N-API entry(librouter_napi.so)
│ const char*
▼
Kotlin/Native C ABI(libohosrouter.so)
│ RouterDispatch
▼
UTF-8 JSON + DisposeString
4.2 Kotlin/Native 导出函数
@CName("RouterDispatch")
fun routerDispatch(request: String): String // init / tap / back / deeplink / state 一口进
| C ABI 符号 | ArkTS 方法 | 覆盖内核 |
|---|---|---|
RouterDispatch | routerDispatch(req) | 两个示例场景全部事件 + 返回键 + 深链 + 状态查询 |
llvm-nm -D 双 ABI 均验证全局 T RouterDispatch 符号。
4.3 C++ N-API 方法分发
napi_property_descriptor desc[] = {
{"routerDispatch", nullptr, NapiRouterDispatch, nullptr, nullptr, nullptr,
static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr},
};
回调统一三步:napi_typeof 校验必须是 string(先探长再 std::vector<char> 拷贝,规避 API 26 头文件把缓冲区标成非 const 的坑)、OH_LOG_INFO 打字节数、调 KN 函数并包装返回。KN 返回的字符串必须经 libohosrouter_symbols()->DisposeString 归还 Kotlin 运行时,绝不 free()。libohosrouter_api.h 按社区已审仓库同款手写。
4.4 CMake 和类型声明
add_library(ohosrouter SHARED IMPORTED)
set_target_properties(ohosrouter PROPERTIES
IMPORTED_LOCATION ${KN_SO}
IMPORTED_NO_SONAME TRUE)
add_library(router_napi SHARED napi_init.cpp)
target_link_libraries(router_napi PUBLIC
libace_napi.z.so
libhilog_ndk.z.so
ohosrouter)
OHOS_ARCH 为空或 libohosrouter.so 缺失直接 FATAL_ERROR(提示先跑 link 任务);$ORIGIN rpath 加 POST_BUILD 拷贝保证三件套同目录。
export const routerDispatch: (request: string) => string;
第 5 阶段:ArkUI 宿主
5.1 dispatch 帮助函数与返回键接入
private dispatch(req: Record<string, string>): RouterResponse | null {
try {
const parsed: RouterResponse =
JSON.parse(routerDispatch(JSON.stringify(req))) as RouterResponse;
if (!parsed.ok) { this.toast(`请求失败: ${parsed.error ?? ''}`); return null; }
this.applySnapshot(parsed); // @State 全量刷新:event/stacks/persistence/containers/life
return parsed;
} catch (e) { /* 收进日志,不跨 UI 抛 */ }
}
onBackPress(): boolean {
const r = this.dispatch({ 'op': 'back' });
if (r !== null && r.backHandled === false) {
this.backNote = '返回事件未被路由树处理(应用应退出)'; // Demo 不退出,把语义演示出来
}
return true;
}
5.2 四 Tab 交互
- Nested:递归
@Builder ContainerCard渲染快照树——彩色圆角卡片(颜色表来自上游material_colors.xml逐色提取)、name.NEXT按钮、Back stack: N、可点Local data: N,子容器横向滚动排列,第五层显示Leaf; - Lifelike:顶部屏幕层级胶囊链(Root → LoggedOut/LoggedIn → Gallery)实时反映三台 Router 的栈顶;未登录是注册流程屏(NEXT 逐步 push,RegFinal 变 LOGIN 按钮);已登录按菜单渲染——Gallery 列出七个相册(上游
albums数据原样),点击进入照片列表再点开全屏照片;Profile 显示用户名电话 + LOGOUT; - DeepLink:预置
app-nested://default/BGR与app-lifelike://go-to-profile?...两个按钮 + 自定义输入,按 uri 前缀自动 init 对应场景再发 deeplink; - State:栈注册表逐条显示(id 是完整 handler 链如
Root.0.Root.1.R1——上游backStackMap的真实键形态),persistence 树以缩进 JSON 呈现。
5.3 错误边界
ok:false 时错误原文进 toast 与 hilog;Native 调用异常同样被 try/catch 收进日志区,页面永不白屏。
第 6 阶段:示例与验证
6.1 工程结构
example/
├── nativeApp/
│ ├── build.gradle.kts 双 ABI sharedLib(baseName=ohosrouter)+ cxxShared + copyKnSo*
│ └── src/
│ ├── commonMain/kotlin/ohos/router/demo/Scenarios.kt 两个示例 app 的场景孪生 + 深链解析
│ ├── ohosMain/kotlin/ohos/router/demo/Bridge.kt @CName RouterDispatch + MiniJson
│ └── jvmTest/kotlin/ohos/router/demo/ 16 项场景端到端
└── harmonyApp/
├── build-profile.json5 signingConfigs 留空(签名由 DevEco 自动生成回填)
├── AppScope/app.json5 bundleName org.terminator.ohos.router、versionName 0.27.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/libohosrouter_api.h
├── src/main/cpp/types/librouter_napi/{index.d.ts, oh-package.json5}
└── src/main/ets/pages/Index.ets ArkUI 四 Tab 验收页面
6.2 构建、签名和安装
.\gradlew.bat :router:jvmTest :example:nativeApp:jvmTest # 37 项交叉验证
.\gradlew.bat :example:nativeApp:linkDebugSharedOhosArm64 `
:example:nativeApp:linkDebugSharedOhosX64
# DevEco Studio 打开 example/harmonyApp(不要打开仓库根目录)
hvigorw assembleHap -p product=default -p buildMode=debug --no-daemon
签名在 DevEco Studio 的 File → Project Structure → Signing Configs 中勾选自动生成,证书不进入仓库(仓库内 build-profile.json5 的 signingConfigs 保持空数组)。
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.router -m entry
四、完整代码对照
4.1 整体架构
router/src/commonMain(语义孪生引擎)
│ BackStack<T>(五操作) / BackPressHandler(树) / RouterEngine(编排) / BundleEngine(scope)
example/nativeApp commonMain(场景层)
│ LifelikeScenario / NestedScenario(上游两个示例 app 的路由树复刻)
│ + DeepLink 解析(host/query 逐行对应上游 DeepLink.kt)
Bridge.kt + MiniJson
│ RouterDispatch(单 C ABI,JSON 全量快照)
librouter_napi.so
│ routerDispatch
Index.ets
│ JSON parse + @State 全量刷新
▼
ArkUI 真机页面(四 Tab:Nested / Lifelike / DeepLink / State)
4.2 文件清单
| 文件 | 职责 |
|---|---|
settings.gradle.kts | 极简 ohos 构建、eazytec Maven 置前、ohos_compose-router、两个示例 app 移出 |
gradle/libs.versions.toml | Kotlin 2.2.21-1.0.0 钉版 + multiplatform 插件别名 |
build.gradle.kts(根) | 版本 0.27.0-ohos.1、全局禁用 Ohos*Test* 任务 |
router/build.gradle.kts | 纯 KMP:jvm + ohosArm64 + ohosX64,零依赖 |
router/src/composeMain/** | 上游 6 文件原样存档(git rename,不编译) |
router/src/commonMain/** | 孪生引擎四文件 |
router/src/jvmTest/** | 21 项引擎语义验证 |
example/nativeApp/build.gradle.kts | 双 ABI sharedLib、cxxShared 定位 libc++、copyKnSo* 回填 |
nativeApp/.../Scenarios.kt | 两个示例 app 的场景孪生 + 深链解析 |
nativeApp/.../Bridge.kt | 1 个 C ABI、MiniJson、错误通道 |
nativeApp/src/jvmTest/** | 16 项场景端到端 |
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 四 Tab 验收页面 |
README.OpenHarmony_CN.md | 鸿蒙适配主文档(27 节) |
4.3 关键 API 对照
| 层次 | API | 作用 |
|---|---|---|
| 引擎 | BackStack.push / pop / replace / newRoot / pushAndDropNested | 五操作逐行孪生,pop() 不弹最后元素返回 false |
| 引擎 | BackStack.onElementRemoved(index) | 上游回调原样——触发 K$index scope 整树丢弃 |
| 引擎 | BackPressHandler.handle() | children.reversed().any { it() } 原样 |
| 引擎 | RouterEngine(world, contextId, routingType, defaultRouting, parent, routing) | Router composable 孪生;attach()/dispose() 对应 SideEffect/DisposableEffect |
| 引擎 | RouterEngine.persistentInt(key, default) | persistentInt 孪生,跟随当前栈顶 scope |
| 引擎 | RouterWorld | 上游三全局:backStackMap + rootBundle + rootHandler |
| Native | RouterDispatch | C ABI JSON 导出(单入口全量快照) |
| N-API | routerDispatch(req) | 向 ArkTS 暴露能力 |
| ArkTS | dispatch(op) / onBackPress() | 触发事件并刷新 UI / 返回键喂入路由树 |
4.4 ArkTS 与 Kotlin 的边界
// 打开相册:Gallery 层 push PhotosOfAlbum(Cats)
const res = JSON.parse(routerDispatch(JSON.stringify({
op: 'tap', target: 'album.open', album: 'Cats'
}))) as RouterResponse;
// res.ok === true; res.lifelike.galleryStack === ['AlbumList', 'PhotosOfAlbum(Cats)']
// 场景层:galleryRouter.backStack.push(GalleryRoute.PhotosOfAlbum("Cats"))
"album.open" -> {
galleryRouter?.backStack?.push(GalleryRoute.PhotosOfAlbum(name))
lastEvent = "push PhotosOfAlbum($name)"
}
两者之间只传输扁平 JSON 字符串,不传输 Kotlin 对象或未校验的动态结构。
五、关键决策说明
决策 1:语义孪生而不是硬编 Compose 或重写导航框架
三条路里硬编 Compose 编不过(社区 CMP 1.9.2 对不上 Compose 1.0 时代 API,且 Router 深度依赖 CompositionLocal 编排);用 ArkTS 重写导航框架则丢掉上游语义(五个栈操作的边界行为、返回键冒泡顺序、scope 生命周期都是踩过坑的细节)。孪生是中间道路:每条语义有上游源码行号可查、有测试钉死,上游已停更意味着孪生一旦钉死就长期有效。
决策 2:工具链用 2.2.21-1.0.0 + Gradle 8.14.1,而不是 KStore 的 2.4.20 线
compose-router 引擎零第三方依赖,不存在"依赖制品线反推 stdlib 版本"的压力,直接用社区批准的 qrose 同款旧线(2.2.21-1.0.0 / Gradle 8.14.1 / JDK 21),与已审仓库的复用面最大。KStore 需要升 2.4.20-0.1.0-07 是因为 coroutines 制品线的元数据——两条工具链线的选择依据不同,不能互相套用。
决策 3:根构建替换(cmp_qrose 模式),示例 app 转场景层留盘
上游两个示例 app 是 Android 工程,不可能进 ohos 构建;但它们是上游语义的唯一"活文档"——路由树结构、注册流程转移表、nbChildrenPerLevel/colorSets 常量、深链解析规则全部复刻进 nativeApp 的 Scenarios.kt,既成为 16 项端到端测试的被测对象,也成为 ArkTS Demo 的数据源。原目录留盘不进 settings,与上游对照始终有据可查。
决策 4:单 C ABI + 全量快照 JSON
路由操作是"事件 → 整棵可见树变化",快照式协议让 ArkTS 侧零镜像状态、零状态同步 bug;细粒度符号(pushSymbol/popSymbol…)只会把栈状态复制到 ArkTS 侧,反而引入两边不一致的空间。JSON 体积在两个示例 app 的规模下完全无压力。
决策 5:37 项 JVM 交叉验证 + 上游怪癖显式保留
21 项引擎测试逐条对照上游源码注释写断言(pop() 的"最后元素不可弹"、newRoot 的逆序通知、any 的短路顺序、override 建新栈、K$index 整树丢弃、兄弟栈独立性);16 项场景测试把两个示例 app 的用户旅程端到端跑通。上游怪癖(backStackMap 永不清理导致登出落点与再登录恢复、深链重组重放)不修——1:1 保留、写进文档、做成 Demo 展示点,这是"孪生没有漂"的最强证据。
六、测试与验证
6.1 测试环境
本次验证使用 Windows(x86_64)、JDK 21(Temurin 21.0.12)、DevEco Studio 5.1.0 Release、OpenHarmony 命令行工具(hvigor / hdc)与鸿蒙真机(arm64)。
6.2 JVM 交叉验证
.\gradlew.bat :router:jvmTest :example:nativeApp:jvmTest
37 项用例全部通过:
| 测试类 | 用例数 | 钉住什么 |
|---|---|---|
BackStackTest | 7 | 五操作 + onElementRemoved 索引通知 + 最后元素不可弹 |
BackPressHandlerTest | 4 | 逆序询问、短路冒泡、子节点注销 |
RouterEngineTest | 10 | handler id 链、重组复用栈、深链 override 建新栈、head 无条件消费、返回键最内层优先、K0/K1 scope 生命周期、newRoot 全清、兄弟栈独立 |
LifelikeScenarioTest | 9 | 注册流程 push、back 逐层退、newRoot 登录、相册下钻与冒泡、登出落 RegFinal、再登录恢复深度、深链直达 Profile |
NestedScenarioTest | 7 | 递归树结构、Red→Green→Blue 循环、计数器 scope 隔离与往返、最深可出栈者优先、深链 BGR 三层 override、被弃用兄弟栈留册 |
6.3 HAP 验证
:router:compileKotlinOhosArm64 / OhosX64: BUILD SUCCESSFUL
linkDebugSharedOhosArm64 / linkDebugSharedOhosX64: BUILD SUCCESSFUL
llvm-nm -D: RouterDispatch 均为全局 T 符号(双 ABI)
copyKnSoArm64 / copyKnSoX64: 三件套回填 entry/libs(arm64 so 2,824,080 字节;x86_64 2,421,680 字节)
BuildNativeWithCmake: finished(双 ABI 编出 librouter_napi.so)
CompileArkTS: finished
PackageHap: finished
BUILD SUCCESSFUL
install bundle successfully
start ability successfully
6.4 功能用例
- 启动进入 Nested Tab:五层递归容器渲染,Root 卡片蓝灰底、子树红色系,
Back stack: 1; - 点
Root.NEXT:根栈变[SubtreeRed, SubtreeGreen],子容器整体切到绿色系(L1.G1); - 点
Local data两下到 2,再 NEXT 后计数归 0(新元素的 scope),系统返回键一次——栈退回 Red,计数恢复 2; - 连点 NEXT 让某中层栈加深后按返回键:最深层可出栈的容器先退,全部栈只剩一个元素时返回
backHandled:false并提示"应用应退出"; - 切 Lifelike Tab:注册流程四步 NEXT 到 RegFinal,LOGIN 进入——屏幕链变 Root → LoggedIn(Demo user) → Gallery → AlbumList,事件行显示
newRoot LoggedIn(Demo user); - 点相册 Cats:Gallery 栈变
[AlbumList, PhotosOfAlbum(Cats)];点 Photo 5 变三级;三次返回键依次退照片、退相册、冒泡弹掉菜单项; - Profile 页 LOGOUT:回到注册流程末尾(RegFinal,非 Splash——上游 backStackMap 永不清理);再 LOGIN 直接恢复 Gallery/Profile 菜单深度;
- DeepLink Tab 发
app-nested://default/BGR:Nested 场景前三层分别变 Blue/Green/Red;发app-lifelike://go-to-profile?name=fake&phone=123123:lifelike 一发直达 Profile,用户信息 fake/123123; - State Tab:栈注册表列出全部栈(含切子树后被弃用的
1.R1),persistence 树显示K0/K1嵌套 scope 与计数器值。
6.5 验证结论
孪生引擎、场景层、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 已完成链路验证。应用在鸿蒙真机上成功安装并进入前台,页面展示了真实的路由会话:嵌套容器栈操作、scoped 计数器、返回键逐层冒泡、注册/登录/登出的栈保留语义与深链多层直达(见下节效果图)。
七、运行效果
7.1 真机截图

7.2 命令速查
git clone https://atomgit.com/oh-tpc/compose-router.git
cd compose-router
export JAVA_HOME="/path/to/jdk-21" # Windows PowerShell 用 $env:JAVA_HOME = "..."
./gradlew :router:compileKotlinOhosArm64 :router:compileKotlinOhosX64 # klib
./gradlew :router:jvmTest :example:nativeApp:jvmTest # 37 项交叉验证
./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.router -m entry
hdc shell hilog | grep RouterPage
八、遗留问题与改进方向
8.1 踩坑复盘
- 只改 ArkTS 页面不算适配,必须让
:router真正编译成ohosArm64/ohosX64——孪生引擎没有 Compose 依赖后,这一步才成为可能; settings.gradle.kts里 eazytec 双仓必须排在pluginManagement与dependencyResolutionManagement最前,否则配置期直接Unresolved reference: ohosArm64;- 根构建替换时
git rm旧gradle.properties后忘写新文件,构建靠orElse("0.27.0-ohos.1")兜底才没炸——版本兜底值和 JDK 钉死路径必须落回文件,文档同步修正; - Kotlin 属性初始化按声明顺序执行:
init { launch() }放在配置属性(maxNestingLevel/colorSets)之前,首组合读到 0 和空 map,递归树直接变叶子——init 块必须排在全部属性之后; RouterWorld的backStackMap/rootBundle用internal修饰,跨模块(nativeApp 场景层)不可见直接编译失败——场景层本就对应上游 app 对路由状态的一等访问权,应为 public 并注释缘由;String.format是 JVM-only API,ohos 编译报Unresolved reference 'format'——KN 通用代码里\uXXXX转义要手写十六进制拼接;- ArkTS
@State变量命名不能撞组件基类内置属性:tabIndex与通用属性冲突报 10505001,改名activeTab即可; - ArkTS 禁止结构化类型:
parsed.nested ?? null把NestedState赋给ContainerNode状态报 10605999/10605030,必须显式判空后取.containers字段; promptAction.showToast已弃用且声明可抛异常——换this.getUIContext().getPromptAction().showToast(...)并包 try/catch,警告全清;- 上游
.gitignore的/build是根锚定模式,盖不住嵌套目录——example/nativeApp/build/、router/build/、DevEco 生成的.clangd/.clang-tidy要单独追加,否则混进提交; - HAP 缺当前 ABI 的 so 会报安装失败
9568347,abiFilters必须双 ABI,每个 ABI 三件套(NAPI so / KN so / libc++)缺一不可; - KN 返回字符串必须
DisposeString归还运行时,N-API 层不能free()。
8.2 已知问题
- Compose API 本身(
Routercomposable / CompositionLocal)未编入 ohos——引擎是语义孪生而非同一段代码;composeMain存档等社区 CMP 与 Compose 1.x API 对齐后接回; - 深链"一次消费"是按上游文档意图的实现,与上游"每次重组重放 override"的字面行为有差异(后者会把应用钉死在深链状态,上游仅称 POC)——差异已显式记录于 README.OpenHarmony_CN.md;
backStackMap永不清理、登出落点在注册流程末尾、再登录恢复菜单深度——上游怪癖 1:1 保留,Demo 当作展示点而非缺陷;persistentInt的 remember 缓存语义未复刻,引擎按 bundle 语义恢复(pop 回旧元素显示该 scope 最后写入值);android.os.Bundle由BundleEngine子集替代(Int + 子 scope),深链Intent/Uri解析换成字符串解析(host/query 逻辑逐行对应);- 上游 android 示例工程(
app-lifelike/app-nested-containers)留盘不进构建,发布、JitPack 配置一并剥离; - HAP 安装必须使用与 bundle name
org.terminator.ohos.router匹配的签名。
8.3 未来优化方向
- 评估
RouterEngine上层补一层 ArkTS 声明式封装(routerDispatch 的事件 → 快照协议固定后,可在 ArkTS 侧做类型化包装); - 把场景层从 Demo 语义提升为可复用的"路由声明 DSL",让业务 App 用 Kotlin 侧声明路由树;
- 接入上游后继 Appyx 的语义对照,评估孪生引擎的演进基线;
- 在 CI 中加入 ohos 编译、ELF 符号检查和 HAP 构建;
- 补充模拟器(x86_64)与其他设备 ABI 的验收矩阵和自动化截图脚本。
九、总结
9.1 核心难点回顾
上游 compose-router(6 文件全 Compose)
→ 路线决策:语义孪生(硬编不过 / 重写丢语义 / 孪生可钉死)
→ 工具链 Kotlin 2.2.21-1.0.0 + Gradle 8.14.1(零依赖库用已审旧线)
→ 根构建替换(cmp_qrose 模式)+ 上游源码 composeMain 存档
→ 孪生引擎四文件(BackStack / BackPressHandler / RouterEngine / BundleEngine)
→ 两个示例 app 复刻为场景层(路由树/常量/深链解析逐行对应)
→ ohosArm64 / ohosX64 KLIB + 双 ABI 动态库
→ C ABI(@CName × 1)+ 全量快照 JSON
→ C++ N-API(DisposeString 纪律)
→ ArkTS 四 Tab + onBackPress 接入路由树
→ 37 项 JVM 交叉验证 + HAP + 真机验收(含上游怪癖展示点)
9.2 封装层次
- 引擎层:
BackStack五操作、BackPressHandler树、RouterEngine编排(handler id 链 / 深链消费 / scope 生命周期)、RouterWorld三全局; - 场景层:lifelike / nested 两个示例 app 的路由树、事件与深链解析复刻;
- Native 层:双 ABI 动态库并通过 1 个 C ABI 输出全量快照 JSON;
- N-API 层:参数检查、字符串转换和 Native 内存释放;
- ArkTS 层:四 Tab 页面状态、事件分发、返回键接入与错误文本;
- DevEco 层:CMake、HAP、签名、安装和真机运行。
9.3 三条经验
- 纯 Compose 库的正确答案是语义孪生,前提是语义可钉死:compose-router 全库 400 行、每条规则都能逐行翻译并用测试钉住,孪生的漂移风险收敛到一张显式差异清单;库越大、Compose 渗透越深,孪生成本越高——路线选择要看库的"可翻译面积";
- 上游怪癖是资产不是负债:backStackMap 永不清理、深链重组重放这类行为,1:1 保留后反而成为 Demo 最有说服力的展示点(登出落点、再登录恢复深度),也把"孪生没有漂"变成肉眼可见的事实;
- 把上游示例 app 当活规格:16 项端到端测试驱动的不是随手写的 Demo,而是上游两个示例工程的完整复刻——路由树、常量表、深链规则全部有出处,测试即验收、Demo 即文档。
9.4 适配成果
当前适配已完成:
- compose-router
0.27.0(已弃用存量语义)的纯 Kotlin 孪生引擎双 ABI Kotlin/Native 编译(上游源码零改动存档于 composeMain); - 1 个 C ABI 符号和 C++ N-API 包装层(
llvm-nm验证双 ABI 全局 T 符号,arm64 so 约 2.8 MB / x86_64 约 2.4 MB); - 上游两个示例 app 的完整场景复刻(lifelike 注册/相册/深链 + nested 递归容器/计数器/深链);
- DevEco Stage ArkUI 四 Tab 验收页面(嵌套容器、注册流程、菜单、相册下钻、返回键冒泡、深链试验台、栈与持久化检查器);
- 37 项 JVM 交叉验证全绿(21 引擎语义 + 16 场景端到端)+ HAP 构建(双 ABI 六件套 so)、签名、设备安装和真机验证;
- 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)