本文记录 Odyssey 接入 OpenHarmony 的完整过程,覆盖导航库的 Compose 依赖度盘点、鸿蒙工具链钉版、状态机与渲染层分离手术、ohosArm64 / ohosX64 目标、Kotlin/Native 动态库、C ABI/N-API 桥接、ArkUI 四 Tab 真机页面、HAP 构建和真机验收。

Odyssey 是 AlexGladkov 维护的声明式 Compose Multiplatform 导航库:push / present / backstack、嵌套 Flow 子控制器、多栈 Tab(MultiStack,每个 Tab 独立栈)、ModalController(底部抽屉 / Alert)、LaunchFlag(singleNewTask / singleInstance / clearPrevious)、深链与 Breadcrumb 面包屑。它与社区已适配的存储类库(KStore,纯引擎零 UI)是相反的适配剖面:Odyssey 的全部价值长在 Compose UI 上,状态机(RootController / MultiStackRootController / ModalController,StateFlow 驱动)却被 @Composable 类型包裹。本次适配把这条边界切开——21 个 Compose 渲染文件隔离进不参与编译的 src/composeMain 留档,纯 Kotlin 导航状态机原样编译成 OpenHarmony KLIB,ArkUI 四 Tab Demo 通过单入口 JSON 桥驱动同一份状态机完成真机验收。

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

上游仓库: AlexGladkov/Odyssey

开发工具: 华为云码道

环境安装: 环境安装指南

一、背景

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

Odyssey 在 CMP 应用里提供的不是"又一个路由表",而是"把导航做成可观测的状态机":根栈 push / popBackStack / backToScreen,present 弹出带独立栈的 Flow,多栈 TabHost 每个子栈各自进化,模态栈由 ModalController 管理且根 pop 先收顶层模态,LaunchFlag 改变入栈策略,深链按 odyssey://screen/params 落到指定屏,每次转移发出 Breadcrumb 事件。这些能力全部写在 commonMain,由 Android、iOS、桌面、Web 共享。OpenHarmony 应用同样需要这套导航语义,但上游构建(Kotlin 1.9.22 / Compose 1.6.0 / Gradle 8.6 / AGP 8.2.2,九个 target 经 buildSrc 约定插件集中声明)不会生成 OpenHarmony 变体。

如果只把示例页面改成 ArkTS,页面能画出来,却无法证明导航状态机在 OpenHarmony ARM64 设备上完成编译、链接和执行。适配需要同时验证状态机、coroutines 运行时、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 交付链路。

障碍具体问题
目标缺失必须增加 ohosArm64() / ohosX64() 才能生成 OpenHarmony KLIB。
渲染层强绑定setNavigationContent / Navigator / ModalNavigator 全部 androidx.compose.*;上游 Compose 1.6 与社区鸿蒙 CMP 线版本对不齐,不能整体编译。
状态机被注解包裹RootController 的渲染槽位是 @Composable typealias,initServiceScreens() 在状态机里注册 Compose 内容——不剥干净就无法 headless 编译。
依赖矩阵状态机依赖 kotlinx-coroutines(StateFlow),需要 ohos 变体;com.benasher44:uuid、kotlinx-collections-immutable 等上游依赖没有 ohos 制品。
构建体系不同buildSrc 约定插件 + AGP + vanniktech 发布与鸿蒙工具链无法共存于一个 Gradle 构建。
交付链路复杂.so、CMake、N-API、HAP、签名和 hdc 都要单独验收。

因此,本项目把边界放在:导航状态机的 ohos 编译、根构建替换(cmp_qrose 模式)和 C ABI/N-API 桥接层。栈管理、LaunchFlag、Flow/多栈/模态语义完全由上游原版实现负责,ArkTS 只负责调用 Native、解析 JSON 状态快照和渲染页面。

1.2 库提供的能力

Demo(四 Tab ArkUI 页面)提供一个真实的导航会话,对应桥接的全部入口:

  • 推入/弹出:root.push / popBackStack / backToScreen,根栈可视化展示每一层的 realKey、动画与方向;
  • present 流:弹出 Present 流(自带独立栈),流内继续 push,收流整栈退回;
  • 多栈 Tab:启动 Main 多栈宿主,Feed / Search / Cart 三个 Tab 各持独立栈,switchTab 切换不丢栈;
  • 模态:ModalController 叠放底部抽屉与 Alert,根 pop 先收最顶层模态,modalDismiss 立即移除;
  • LaunchFlag:singleNewTask 清栈重启 / singleInstance 复用 / clearPrevious 移除前一个,肉眼可见栈形变化;
  • 深链:setDeepLink + pushDeepLink / presentDeepLink,odyssey://Actions/hello 直接路由;
  • 观测:每一步的 Breadcrumb 面包屑(Start → Actions、Actions -> Push、tab #0 <-> #1…)实时滚动,level 徽章显示根栈深度。

响应内嵌完整状态快照:

字段作用
ok / op操作是否成功(失败时 error 带原文)与本次操作回显。
current当前屏:realKey / 清洗后 key / params / animation(none|push|present|fade) / time / forward。
removeNavConfiguration.screenToRemove 的清洗键——上游 pop 动画的"退场屏"语义。
level / stack[]根栈深度与根 backstack 逐层快照。
flow / tabs顶部为 Flow 时的子控制器栈 / 多栈宿主的 Tab 名、位置与当前 Tab 栈。
modals[] / topModal模态栈(key、name、dialogState)与顶层模态。
breadcrumbs[]最近 10 条导航事件。

状态机的全部能力(LaunchFlag、深链路由、子控制器树、模态优先级)都在上游 commonMain 里原样随编译进入 .so,JVM 侧保留 7 项 jvmTest 交叉验证用例,证明 ohos .so 里执行的导航逻辑没有漂移。

1.3 实现适配

维度要求
代码复用导航状态机(RootController 607 行、双控制器、扩展集)原样编译,不重写;Compose 渲染层 21 + 4 个文件移入 composeMain 留档待工具链对齐;新增源文件仅 3 个(ohos RandomKey actual、桥、状态仓)。
平台目标根构建直接声明 ohosArm64 + ohosX64,保留 jvm 作交叉验证,库模块出 KLIB,example/nativeApp 出双 ABI 动态库。
构建隔离根构建替换为极简 ohos 构建(cmp_qrose 模式);buildSrc 整体移除;odyssey-android(Hilt)与 sample/* 留盘不进构建。
桥接稳定1 个 C ABI 单入口 + UTF-8 JSON,不传递 Kotlin 对象地址;异常永远不跨 FFI。
依赖纪律唯一外部依赖 coroutines 换到社区 ohos 制品线(1.10.2-1.0.0);uuid 依赖以同语义实现替换(jvm java.util.UUID,ohos 零依赖计数器)。
UI 完整四 Tab 覆盖全部桥接入口:推入/弹出、present/模态、多栈、Launch/深链 + 面包屑日志。
仓库规范项目说明、文章、效果图和源码链接统一使用 AtomGit。

本项目的 ArkUI 页面不是 Compose Multiplatform UI 实现。Odyssey 的渲染层(AnimatedContent 过场、ModalNavigator 抽屉)留在 composeMain;ArkTS Demo 画的是同一台状态机的输出——栈形、动画类型、方向、模态栈、面包屑,证明的是导航语义跨平台成立。

二、实现路线图

第 1 阶段:项目初始化     ── 模块与 Compose 依赖度盘点、coroutines ohos 制品线验证、路线定型(状态机编译 + 渲染层隔离)
第 2 阶段:目标与依赖打通 ── 根构建替换、eazytec Maven 置前、Kotlin 2.2.21-1.0.0 钉版、Gradle 8.14.1、buildSrc 移除
第 3 阶段:源码手术       ── composeMain 隔离(21+4 文件)、RootController 剥注解、TabItem/TabsNavModel 类型下沉、RandomKey 双平台 actual
第 4 阶段:桥协议建模     ── 14 个 op 的 JSON 契约、全量状态快照 schema、错误通道、冷启动补画
第 5 阶段:原生桥接       ── @CName C ABI、C++ N-API、参数校验和内存释放
第 6 阶段:ArkUI 宿主     ── 四 Tab 页面、HAP、签名、设备安装、真机运行和效果图

三、逐步实现过程

第 1 阶段:项目初始化

1.1 工程边界
odyssey-core/                         上游导航模型(16 个纯 Kotlin 文件,进 ohos 构建)
odyssey-compose/src/commonMain/       上游状态机(14 个纯 Kotlin 文件,进 ohos 构建)
odyssey-compose/src/composeMain/      上游 Compose 渲染层(21 个文件 + jvmMain 4 个 UI actual,留档不编译)
odyssey-compose/src/ohosMain/         新增:createUniqueKey 的 ohos actual
odyssey-android/                      Hilt 集成,Android 专属,留盘不进 settings
sample/                               上游各平台示例,留盘不进 settings
example/nativeApp/                    Kotlin/Native 动态库(Bridge + NavigationStore + MiniJson)
example/harmonyApp/                   DevEco Stage、CMake N-API 和 ArkTS 四 Tab 页面
README.OpenHarmony.md / _CN.md        鸿蒙适配说明(中英双份)
README.upstream.md                    上游原文保存
doc/                                  本文章与真机效果图

构建拓扑走 cmp_qrose 模式(根构建即 ohos 构建):settings.gradle.kts 只 include :odyssey-core、:odyssey-compose 与 :example:nativeApp,rootProject.name = "ohos_odyssey"。上游 buildSrc(multiplatform-setup 约定插件 + maven 发布脚本)、AGP、Hilt、vanniktech 发布全部随根构建替换剥离——本 fork 不发 Maven Central。

1.2 工具链矩阵
项目配置
Kotlin MultiplatformHarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven)
JDK21(Temurin,org.gradle.java.home 钉 jdk-21.0.12.101-hotspot)
OpenHarmony 目标ohosArm64 + ohosX64(jvm 保留作交叉验证)
Gradle8.14.1(上游 8.6;2.2.21 旧线必须降 8.x,qrose 同款)
coroutines社区 ohos 制品线 1.10.2-1.0.0(与 KMPalette 同配对)
DevEco product5.1.0(18) 兼容
DevEco Studio5.1.0 Release
ABIarm64-v8a + x86_64
Bundleorg.terminator.ohos.odyssey
export JAVA_HOME="/path/to/jdk-21"
java -version
1.3 源码依赖度盘点(本适配的成败手)

逐文件统计 androidx.compose import,得到整个适配的地基:

模块commonMain 文件Compose 依赖形态处置
odyssey-core16零 Compose import(构建脚本里的 compose 依赖是遗留声明,实际 0 使用);唯一外部依赖 coroutines(CFlow)整体编译,一行不动
odyssey-compose3514 个文件零 Compose import(控制器/扩展/Bundle/建图);21 个文件 1~15 个 import(渲染层)14 个编译,21 个隔离
odyssey-android2Hilt/Android 专属留盘不进构建

关键发现有两个。其一,RootController.kt(607 行,状态机心脏)只有 3 个 Compose import:@Composable typealias × 2、Color × 1——栈管理、LaunchFlag、Flow/多栈构造、深链路由、面包屑全部是 MutableStateFlow + 纯 Kotlin。其二,TabItem / TabsNavModel 这类"机器只消费 name、UI 字段全是 Painter/Color/TextStyle"的类型,正是 qrose 适配里 Code128Type 下沉的同款情况。这两点决定了"状态机编译 + 渲染层隔离"可行。

1.4 页面边界
标题区          Odyssey · OpenHarmony / level 徽章 / 顶层模态徽章
Tab 切换        Push/Pop · Present/弹窗 · 多栈 Tab · Launch/深链
Push/Pop        当前屏卡片、根栈列表、push×2 / pop / backToScreen
Present/弹窗    流内当前屏、present 流、流内 push、ModalSheet / AlertDialog / 关闭、模态栈
多栈 Tab        启动 Main、Feed/Search/Cart 切换 chips、Tab 内推入、当前 Tab 栈
Launch/深链     LaunchFlag 选择、launch 三入口、odyssey:// 输入、setDeepLink / pushDeepLink / presentDeepLink
底部            桥状态行 + Breadcrumb 面包屑(最近 10 条,最新在前)

页面启动时先发 {"op":"create"} 建图冷启动(等效上游 setNavigationContent 的首屏绘制),之后每个按钮都是一次 odysseyDispatch 往返。

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

2.1 制品线验证

先查社区 Maven(https://maven.eazytec-cloud.com)上 coroutines 的 ohos 制品:

org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2-1.0.0
  variants: ohosArm64Api-published / ohosX64Api-published / jvmApi-published / jvmRuntime-published

1.10.2-1.0.0 线与 HarmonyOS Kotlin 2.2.21-1.0.0 配对(KMPalette 同款组合,已过审)。与 KStore 适配对照:它需要的 coroutines 1.11.0-0.1.0-07 基于 stdlib 2.4.20,必须升 Kotlin 到 2.4.20-0.1.0-07;而 Odyssey 状态机只用到 StateFlow/Flow 基础面,1.10.2 线完全够用——版本跟着依赖走,而不是跟风升线。其余上游依赖(com.benasher44:uuid、kotlinx-collections-immutable、Compose 四件套)经盘点要么机器代码零使用,要么可以用更少的依赖同语义替换(见 3.4)。

2.2 工具链钉版与根构建替换

上游 Kotlin 1.9.22 / Compose 1.6.0 与鸿蒙工具链互斥,钉 HarmonyOS Kotlin 2.2.21-1.0.0(qrose / haze 同款旧线),Gradle 从 8.6 升 8.14.1。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()
    }
}
// dependencyResolutionManagement 同构,PREFER_PROJECT

rootProject.name = "ohos_odyssey"

include(":odyssey-core")
include(":odyssey-compose")
include(":example:nativeApp")

gradle.properties 钉死关键项:version=1.3.61-ohos.1、kotlin.native.ignoreDisabledTargets=true、kotlin.native.distribution.downloadFromMaven=true、org.gradle.java.home 指向 Temurin 21。版本目录瘦身到三项:HarmonyOS Kotlin 插件、社区 coroutines、kotlin-test。buildSrc 必须整体移除——它的构建脚本引用版本目录里已删除的 AGP/compose 条目,留着会拖垮整个配置期。

根构建只保留两件事:设 group / version,全局禁用 ohos 测试任务:

subprojects {
    tasks.matching { it.name.contains("Ohos", ignoreCase = true) && it.name.contains("Test") }
        .configureEach { enabled = false }
}
2.3 编译状态机

两个库模块收敛为纯 KMP 形态:

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

kotlin {
    applyDefaultHierarchyTemplate()
    jvmToolchain(21)
    jvm()            // 交叉验证用
    ohosArm64()
    ohosX64()

    sourceSets {
        commonMain.dependencies {
            api(project(":odyssey-core"))
            implementation(libs.kotlinx.coroutines.core)   // 1.10.2-1.0.0
        }
        commonTest.dependencies {
            implementation(libs.kotlin.test)
        }
    }
}

实测:

./gradlew :odyssey-core:compileKotlinJvm :odyssey-compose:compileKotlinJvm   # BUILD SUCCESSFUL
./gradlew :odyssey-compose:jvmTest                                          # 7/7 PASSED

注意 odyssey-core 在鸿蒙线的正确形态:commonMain 零改动、构建脚本里遗留的 compose 依赖声明直接删除(源码本就没有一行 Compose import)。

2.4 测试依赖的处置

上游没有 commonTest。本仓新写 7 个状态机用例(上游语义的回归网),统一在 JVM 跑,ohos 侧只承担编译与链接——根构建的 Ohos*Test* 禁用规则让测试依赖永远不需要 ohos 变体。

第 3 阶段:源码手术(技术难点)

3.1 composeMain 隔离清单

21 个 commonMain Compose 文件 + 4 个 jvmMain UI actual 移入 src/composeMain(不注册源集,不参与编译,git 以 100% rename 保留历史):

setup/(Odyssey + Setup.kt、OdysseyConfiguration.kt、SetNavigationContent.kt)
local/LocalRootController.kt
base/(Navigator.kt、BottomBarNavigator.kt、AnimatedHost.kt)
animations/(AnimatedContentWithCallback.kt、Navigation + Animation.kt、Transitions.kt)
helpers/(NoRippleClickable.kt、WindowSize.kt)
navigation/modal_navigation/(ModalNavigator.kt、configuration/、views/×2)
utils/(ModalNavigator + FullScreen.kt)
extensions/(RootComposeBuilder + DSL.kt、Flow + State.kt)
navigation/bottom_bar_navigation/(原 TabItem.kt、原 BottomNavConfiguration.kt)
composeMain/jvmMain/(WindowSize、Odyssey + Setup、OdysseyConfiguration、ModalNavigator + FullScreen)

androidMain / iosMain / jsMain / macosMain / wasmJsMain 整目录无对应 target,自然不编译,原样留档。

3.2 RootController 四刀一增

607 行状态机,只动 Compose 接缝:

// ① typealias 保留签名、剥 @Composable——状态机把渲染槽位当不透明 lambda
typealias RenderWithParams<T> = (T) -> Unit
typealias Render = (key: String) -> Unit

// ② backgroundColor 从 androidx.compose.ui.graphics.Color 改为 Any?(机器只透传给子控制器)
var backgroundColor: Any? = null

// ③ 删除 @Composable RenderScreen / renderScreen 两个渲染入口(留档 composeMain)

// ④ initServiceScreens() 置空——上游在这里为 flow/multiStack 保留键注册
//    CompositionLocalProvider + Navigator 的 Compose 内容;headless 构建由宿主渲染
private fun initServiceScreens() {}

// ⑤ 新增只读 backstack(qrose 的非 Compose 公共入口同思路,给 headless 宿主读栈)
val backstack: List<Screen> get() = _backstack.toList()

launch / popBackStack / backToScreen / removeTopScreen / handleLaunchFlag / proceedDeepLink / launchFlowScreen / launchMultiStackScreen / handleScreenBreadcrumbs——状态转移逻辑一行不动。

3.3 类型下沉
类型机器消费UI 字段处置
TabItemname@Composable configuration: TabConfiguration(Painter/Color/TextStyle)commonMain 留无 Compose 版;原文件进 composeMain
TabsNavModel / TabsNavConfiguration / TabsNavType多栈类型签名@Composable get、launchedEffect剥注解留 commonMain;Bottom/Top/Custom 三个 UI 配置进 composeMain
RootComposeBuilder程序化建图(桥在用)无(一个未使用的 Color import)删废 import 留 commonMain
3.4 RandomKey 双平台 actual

上游 createUniqueKey 的 actual 依赖 com.benasher44:uuid(无 ohos 制品)。同语义替换:

// jvmMain:该库在 JVM 上的实现本就是包 java.util.UUID,依赖整个去掉
actual fun createUniqueKey(key: String): String = "$key${java.util.UUID.randomUUID()}"

// ohosMain:零依赖计数器 + splitmix64,键只需进程内唯一
private val counter = AtomicLong(0L)
actual fun createUniqueKey(key: String): String {
    val n = counter.incrementAndGet()
    var x = (n.toULong() * 0x9E3779B97F4A7C15uL) xor (n.toULong() shl 17)
    x = (x xor (x shr 30)) * 0xBF58476D1CE4E5B9uL
    x = (x xor (x shr 27)) * 0x94D049BB133111EBuL
    return "$key-$n-${x.toString(16)}"
}

kotlinx-collections-immutable 经盘点机器代码零使用,直接不进依赖表——最终整个 fork 的外部依赖只剩 coroutines 一项。

第 4 阶段:桥协议建模

4.1 JSON 契约
ru.alexgladkov.odyssey.*(上游状态机,原样编译)
        ↓ RootController / MultiStackRootController / ModalController + StateFlow
Bridge.kt + NavigationStore.kt(@CName × 1,扁平 JSON 进出)
        ↓ UTF-8 JSON
N-API odysseyDispatch()
        ↓
ArkUI Index.ets(四 Tab)

14 个 op 一口进:

create / snapshot                      建图冷启动(重建状态机)/ 读全量快照
push {screen,params}                   推入"活动子作用域"(流子控制器或当前 Tab,否则根)
present {screen,startScreen,params}    弹出流
launch {screen,startScreen,startTabPosition,params,flag}  通用启动 + LaunchFlag
pop / backTo {screen}                  弹出 / 退回指定屏
switchTab {position}                   多栈切 Tab
modalSheet {name} / modalAlert {name} / modalDismiss {key?}  模态三件套
setDeepLink {uri}                      设置深链
pushDeepLink / presentDeepLink {screen,params}             深链路由(push/present 两种动画语义)

响应统一 {"ok":true,"op":"...","state":{...全量快照}} 或 {"ok":false,"op":"...","error":"..."}。全量 state 内嵌是刻意设计:C ABI 同步一来一回没有推送通道,ArkTS 每次操作后拿到的就是渲染所需的全部事实,无需订阅。

4.2 状态仓与冷启动补画

NavigationStore 持有 RootController 单例,建图镜像上游 sample(Actions / Push 简单屏,Present、TabPresent 流,Main 多栈 = Feed / Search / Cart 三 Tab 各含 Tab 屏),ensure() 时挂 ModalController 与 Breadcrumb 回调(保留最近 10 条)。

一个必须补的语义:上游里 Flow / TabHost 的首屏由 Navigator / BottomBarNavigator 调 drawCurrentScreen 画出,headless 桥要扮演这个角色:

private fun drawActiveScopeIfEmpty() {
    when (val params = NavigationStore.ensure().currentScreen.value?.screen?.params) {
        is FlowBundle -> if (params.rootController.backstack.isEmpty())
            params.rootController.drawCurrentScreen(startScreen = params.startScreen)
        is MultiStackBundle -> {
            val active = params.rootController.stackChangeObserver.value ?: return
            if (active.rootController.backstack.isEmpty()) active.rootController.drawCurrentScreen()
        }
        else -> {}
    }
}

present / launch / switchTab 收尾时调用,保证 Demo 永不渲染空栈。realKey 里的保留键(odyssey_flow_reserved_type$Present)由桥侧 cleanKey 复刻上游 cleanRealKeyFromType 清洗。

4.3 错误通道与协议纪律

导出整体 try/catch,Kotlin 异常永远不跨 FFI;非法 JSON、缺 screen、未知 op、非 TabHost 上 switchTab,全部走同一个 ok:false 通道,ArkTS 统一渲染到桥状态行。JSON 解析用手写 MiniJson 扁平解析器(qrose 审核模式,KN 依赖最小化,不上 kotlinx.serialization)。Dispatchers.Main 只存在于 CFlow.watch(Swift 侧旧接口),桥全程直读 StateFlow.value / Flow.first(),不碰 Main——避开鸿蒙线主线程派发的已知问题。

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

5.1 三层桥接
ArkTS
  │ JSON string
  ▼
C++ N-API entry(libodyssey_napi.so)
  │ const char*
  ▼
Kotlin/Native C ABI(libohosodyssey.so)
  │ OdysseyDispatch
  ▼
UTF-8 JSON + DisposeString
5.2 Kotlin/Native 导出函数
@CName("OdysseyDispatch")
fun odysseyDispatch(request: String): String   // 14 个 op 一口进
C ABI 符号ArkTS 方法覆盖内核
OdysseyDispatchodysseyDispatch(req)建图 + push / present / launch / pop / backTo / switchTab / 模态三件套 / 深链三件套 / snapshot
5.3 C++ N-API 方法分发
napi_property_descriptor desc[] = {
    {"odysseyDispatch", nullptr, NapiOdysseyDispatch, nullptr, nullptr, nullptr,
     static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr},
};

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

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

add_library(odyssey_napi SHARED napi_init.cpp)
target_link_libraries(odyssey_napi PUBLIC
  libace_napi.z.so
  libhilog_ndk.z.so
  ohosodyssey)

OHOS_ARCH 为空或 libohosodyssey.so 缺失直接 FATAL_ERROR(提示先跑 link 任务);$ORIGIN rpath 加 POST_BUILD 拷贝保证 NAPI so、KN so、libc++ 三件套同目录,dlopen 时互相可见。

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

第 6 阶段:ArkUI 宿主

6.1 页面交互

四 Tab 全中文 UI。Push/Pop Tab:当前屏卡片(realKey、参数、动画类型/时长、方向)+ 根栈列表(带"当前"标记)+ push×2 / pop / backToScreen。Present/弹窗 Tab:流内当前屏、present 流、流内 push、ModalSheet(“分享面板”)/ AlertDialog(“确认删除”)/ 关闭、模态栈可视化(含 dialogState)。多栈 Tab:启动 Main、Feed/Search/Cart 色块 chips、Tab 内推入、当前 Tab 栈。Launch/深链 Tab:LaunchFlag 四选 chips、launch 三入口、odyssey://Actions/hello 输入框、setDeepLink / pushDeepLink / presentDeepLink。底部桥状态行(如 modalDismiss ✓ level=3)+ Breadcrumb 反向滚动列表。

6.2 构建、签名和安装
./gradlew :odyssey-compose:jvmTest                                          # 7 项交叉验证
./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,不进入仓库(仓库内 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.odyssey -m entry

四、完整代码对照

4.1 整体架构

ru.alexgladkov.odyssey.core/**(上游模型原样编译)
    │ Screen / NavConfiguration / Breadcrumb / LaunchFlag
ru.alexgladkov.odyssey.compose/**(上游状态机原样编译,Compose 剥离)
    │ RootController / MultiStackRootController / ModalController + StateFlow
example/nativeApp(Bridge.kt + NavigationStore.kt + MiniJson)
    │ OdysseyDispatch(@CName × 1)
libodyssey_napi.so
    │ odysseyDispatch
Index.ets
    │ JSON parse + @State 更新
    ▼
ArkUI 真机四 Tab 页面(栈形 / 动画 / 模态 / 面包屑可视化)

4.2 文件清单

文件职责
settings.gradle.kts极简 ohos 构建、eazytec Maven 置前、ohos_odyssey、odyssey-android/sample 移出
gradle/libs.versions.tomlHarmonyOS Kotlin 2.2.21-1.0.0 + coroutines 1.10.2-1.0.0 + kotlin-test
build.gradle.kts(根)版本 1.3.61-ohos.1、全局禁用 Ohos*Test* 任务
odyssey-core/build.gradle.kts纯 KMP:jvm + ohosArm64 + ohosX64,唯一依赖 coroutines
odyssey-compose/build.gradle.kts同构 + api(project(":odyssey-core"))
odyssey-compose/src/composeMain/**上游 Compose 渲染层留档(21 + 4 文件,不编译)
odyssey-compose/src/ohosMain/.../RandomKey.ktAtomicLong + splitmix64 零依赖 actual
odyssey-compose/src/commonTest/.../RootControllerTest.kt7 项状态机交叉验证
example/nativeApp/build.gradle.kts双 ABI sharedLib(baseName=ohosodyssey)、cxxShared 定位 libc++、copyKnSo* 回填
nativeApp/.../Bridge.kt1 个 C ABI、14 op 分发、MiniJson、错误通道、冷启动补画
nativeApp/.../NavigationStore.kt状态机宿主、建图、全量状态快照序列化
harmonyApp/.../cpp/napi_init.cppN-API 导出、参数检查、DisposeString
harmonyApp/.../cpp/CMakeLists.txtIMPORTED 引入 KN so、$ORIGIN rpath、POST_BUILD 三件套
harmonyApp/.../ets/pages/Index.etsArkUI 四 Tab 验收页面
README.OpenHarmony_CN.md鸿蒙适配主文档(25 节)

4.3 关键 API 对照

层次API作用
状态机RootController.launch统一入口:Simple / Flow / MultiStack 三分发 + LaunchFlag + 深链
状态机popBackStack / backToScreen弹出(模态优先)/ 递归退回指定屏
状态机currentScreen: StateFlow<NavConfiguration?>当前屏 + screenToRemove(退场屏语义)
状态机backstack(本仓新增,只读)headless 宿主读栈的唯一入口
状态机MultiStackRootController.switchTab多栈切换,发 Breadcrumb.TabSwitch
状态机ModalController.present / popBackStack(key)模态叠放 / 按 key 移除
NativeOdysseyDispatchC ABI JSON 导出(14 op)
N-APIodysseyDispatch(req)向 ArkTS 暴露能力
ArkTSsend(request)触发操作并以全量 state 刷新 UI

4.4 ArkTS 与 Kotlin 的边界

const res = JSON.parse(odysseyDispatch('{"op":"push","screen":"Push","params":"#3"}')) as DispatchOk;
// res.ok === true;res.state.stack = [Actions, Push(当前)],level=2,
// res.state.current.animation='push', time=300, forward=true
"push" -> {
    val target = NavigationStore.activeChildController() ?: NavigationStore.ensure()
    target.push(screen = json.screenArg(), params = json.paramsArg())
    NavigationStore.stateJson()
}

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

五、关键决策说明

决策 1:状态机编译 + Compose 隔离,而不是硬接社区 CMP 线

四条备选路线:ArkTS 重写导航(白做,栈管理/Flow/多栈/深链语义已存在且可测);把 setNavigationContent 原样编进 ohos(上游 Compose 1.6 与社区鸿蒙 CMP 线对不齐,强行对齐等于再打一套 Compose 发行版);只用 odyssey-core 丢掉状态机(RootController 等全在 odyssey-compose 里,等于重写);等社区制品齐了再做(阻塞交付)。选定路线:剥掉注解让状态机 headless 编译,渲染层留档 composeMain 待工具链对齐后接回,两条路不冲突。

决策 2:工具链钉 2.2.21-1.0.0 + Gradle 8.14.1,版本跟着依赖走

状态机只用 StateFlow/Flow 基础面,coroutines 1.10.2-1.0.0(配对 Kotlin 2.2.21-1.0.0)足够;不像 KStore 需要 1.11.0 线而必须升 Kotlin 2.4.20。2.2.21 旧线要求 Gradle 降 8.x(8.14.1,qrose 同款),上游 8.6 直接升级。两条工具链线的经验不能互相套用,钉版依据是"依赖矩阵的最小可行组合"。

决策 3:根构建替换(cmp_qrose 模式),odyssey-android / sample 留盘

上游 buildSrc 约定插件 + AGP + Hilt + vanniktech 发布对鸿蒙没有保留价值,整体替换为极简 ohos 构建。odyssey-android(Hilt 注入)是 Android 专属语义,sample/* 依赖上游 Compose 1.6 工具链——留盘不进 settings.gradle.kts,git 历史完整保留。

决策 4:单入口 JSON + 全量 state 内嵌

只导出 1 个 C ABI(OdysseyDispatch),对齐 qrose 已过审的桥面;每个变更操作的响应内嵌渲染所需的全部事实(栈形、动画、方向、模态、面包屑),同步一来一回,不需要 N-API 线程安全回表——推送通道留作未来优化。MiniJson 手写解析,KN 侧不引入 kotlinx.serialization。

决策 5:jvmTest 7 用例交叉验证(新写回归网)

上游没有测试。本仓在 commonTest 新写 7 个用例钉住状态机语义(push/pop 往返、单层 pop 触发 onApplicationFinish、SingleNewTask 清栈、ClearPrevious 栈形、Flow 子控制器构造、多栈 switchTab、模态优先级),JVM 与 ohos 跑同一份 commonMain——测试绿 + ohos 链接成功,就是"状态机没漂"的闸门。

六、测试与验证

6.1 测试环境

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

6.2 JVM 交叉验证

./gradlew :odyssey-compose:jvmTest

7 项用例全部通过:

用例钉住什么
push_and_pop_returns_to_startpush 入栈、pop 回落,currentScreen 驱动
pop_on_single_root_screen_fires_application_finish根栈只剩一层时 pop 触发 onApplicationFinish
launch_flag_single_new_task_clears_backstackSingleNewTask 清栈重启
launch_flag_clear_previous_replaces_topClearPrevious 移除前一个(栈变 [start, start])
flow_launch_creates_child_controllerFlowBundle 参数、子控制器类型与 startScreen 路由
multistack_launch_and_tab_switch多栈构造、Tab 数量、switchTab 后 stackChangeObserver 指向 SearchTab
modal_present_and_root_pop_dismisses_modal_first模态叠放、top() 语义、根 pop 先收模态、按 key 移除

6.3 构建链验证

:odyssey-core:compileKotlinJvm / :odyssey-compose:compileKotlinJvm: BUILD SUCCESSFUL
:odyssey-compose:jvmTest: 7/7 PASSED
linkDebugSharedOhosArm64 / linkDebugSharedOhosX64: BUILD SUCCESSFUL
copyKnSoArm64 / copyKnSoX64: 三件套回填 entry/libs/{arm64-v8a,x86_64}

KN so(未 strip debug)arm64 约 5.7 MB、x86_64 约 5.3 MB,连同对应 triple 的 libc++_shared.so(约 1.85 / 1.7 MB)自动拷入 HAP 工程;HAP 由 DevEco 构建签名后经 hdc install 落到真机,aa start 拉起 EntryAbility。

6.4 真机功能用例

  1. 启动即 create:根栈出现 Actions,面包屑 Start -> Actions,level=1;
  2. Push/Pop Tab 连续 push Push(#1)(#2):栈形增长,"当前"标记上移,动画卡片显示 push(300ms) 前进;
  3. pop 逐层回落;根栈只剩 Actions 时再 pop,onApplicationFinish 触发(应用退出语义);
  4. Present/弹窗 Tab:present Present 后流内当前屏为 PresentScreen,流内 push Push 只进流栈不污染根栈,根栈第三层显示 Present;
  5. ModalSheet(分享面板) + AlertDialog(确认删除) 叠放:顶部出现"模态"徽章,根 pop 先收最顶层 Alert,modalDismiss 清空后徽章消失;
  6. 多栈 Tab:启动 Main 后 switchTab 到 SearchTab,面包屑出现 tab #0 <-> #1,Tab 内推入只影响当前 Tab 栈,切回 Feed 栈原样保留;
  7. Launch/深链:singleNewTask 启动 Actions 后栈深回到 1;setDeepLink(odyssey://Actions/hello) + pushDeepLink 按 URI 路由,params=hello 出现在当前屏卡片;
  8. 每步操作的桥状态行(如 modalDismiss ✓ level=3)与 Breadcrumb 列表实时留痕。

6.5 验证结论

导航状态机、coroutines 运行时、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 已完成链路验证。应用在鸿蒙真机上成功安装并进入前台,四 Tab 会话展示了真实的导航状态:栈形演化、LaunchFlag 策略、Flow/多栈子控制器、模态优先级、深链路由与面包屑事件流(见下节效果图)。

七、运行效果

7.1 真机截图

在这里插入图片描述

7.2 命令速查

git clone https://atomgit.com/oh-tpc/Odyssey.git
cd Odyssey
export JAVA_HOME="/path/to/jdk-21"
./gradlew :odyssey-core:compileKotlinJvm :odyssey-compose:compileKotlinJvm   # 交叉验证编译
./gradlew :odyssey-compose:jvmTest                                          # 7 项状态机回归
./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.odyssey -m entry
hdc shell hilog | grep Odyssey

八、遗留问题与改进方向

8.1 踩坑复盘

  1. 只改 ArkTS 页面不算 KMP 适配,必须让状态机真正编译成 ohosArm64 / ohosX64 动态库——而导航库的前提是先把 Compose 从状态机里剥干净,@Composable typealias、Color 属性、initServiceScreens 里的 Compose 注册一处不剥都在编译期炸;
  2. settings.gradle.kts 里 eazytec 双仓必须排在 pluginManagement 与 dependencyResolutionManagement 最前,否则配置期直接 Unresolved reference: ohosArm64;
  3. buildSrc 不能"留着不用":它引用版本目录里已删除的 AGP/compose 条目,版本目录一瘦身它的自身构建就崩,必须整体移除;
  4. com.benasher44:uuid 没有 ohos 制品——jvm actual 改 java.util.UUID(该库 JVM 实现本就是包它),ohos 手写 AtomicLong + splitmix64,依赖不进 KN 就没有解析风险;
  5. K2 编译器字符串模板 ${...} 里不能写 ::class 类字面量:jsonString(it.dialogState::class.simpleName) 会让解析器在模板处断裂,且报错行号指向几十行之外(级联出十几个 Unresolved + Syntax error,真实断点在模板内)——提到局部变量再插值;
  6. 接口属性不能 smart cast:ScreenInteractor.params 判型后直接用报 “smart cast is impossible”(open/custom getter),同样先接局部变量;
  7. Dispatchers.Main 只存在于 CFlow.watch(iOS 侧旧观察接口),headless 桥全程直读 StateFlow.value / Flow.first(),不碰 Main——避开鸿蒙线主线程派发的已知问题;
  8. 上游 Flow/TabHost 首屏是 Navigator / BottomBarNavigator 调 drawCurrentScreen 画的,headless 桥必须在 present / launch / switchTab 收尾时补 drawActiveScopeIfEmpty(),否则流/Tab 栈一直为空;
  9. 批量 sed 替换库名有大小写陷阱:s/qrose/Odyssey/g 会把 libqrose_napi 变成 libOdyssey_napi、bundleName 变大写——so 名、包名、模块名是大小写敏感标识符,提交前必须逐个终检;
  10. 源码文件名带空格和加号(Odyssey + Setup.kt)在 shell 批处理时必须 -print0 | read -d '',否则文件清单直接错乱;
  11. HAP 缺当前 ABI 的 so 会报安装失败 9568347,abiFilters 必须双 ABI,每个 ABI 三件套(libodyssey_napi.so / libohosodyssey.so / libc++_shared.so)缺一不可;
  12. KN 返回字符串必须 DisposeString 归还运行时,N-API 层不能 free();签名材料由 DevEco 自动写入 build-profile.json5,提交前用 git update-index --skip-worktree 保护,证书绝不进入 AtomGit。

8.2 已知问题

  • Compose 渲染层(AnimatedContent 过场、ModalNavigator 抽屉/Alert 动画)未编译,composeMain 留档待社区 CMP 鸿蒙线与上游版本对齐后接回;当前 ArkTS Demo 以文字化卡片重述动画元数据(类型/时长/方向);
  • currentScreen StateFlow 的订阅式推送不跨 C ABI:桥是同步一次一调用,ArkTS 以操作响应内嵌的全量快照观察状态;
  • 桥的 params 走字符串通道(扁平 MiniJson 不解嵌套),复杂参数由调用方自行编解码;
  • odyssey-android(Hilt)与 sample/* 不进构建(源文件留在树里);发布、签名发布链一并剥离,本 fork 不发 Maven Central;
  • ohos 目标无测试运行器,状态机回归统一在 JVM;
  • HAP 安装必须使用与 bundle name org.terminator.ohos.odyssey 匹配的签名。

8.3 未来优化方向

  • 社区 CMP Compose 鸿蒙线推进后,把 composeMain 的 21 个渲染文件接回编译,切换到真 Compose 渲染;
  • 增加状态变化推送通道(N-API 线程安全回表),替代操作后全量快照;
  • 把 Breadcrumb 做成可订阅事件流导出(分析/埋点场景);
  • 在 CI 中加入 ohos 编译、ELF 符号检查和 HAP 构建;
  • 补充模拟器(x86_64)与其他设备 ABI 的验收矩阵和自动化截图脚本。

九、总结

9.1 核心难点回顾

上游 odyssey-core + odyssey-compose(commonMain)
    → 依赖度盘点:core 零 Compose,compose 35 文件 = 14 纯机器 + 21 渲染层
    → 工具链对齐 Kotlin 2.2.21-1.0.0(coroutines 1.10.2-1.0.0 配对)+ Gradle 8.14.1
    → 根构建替换(cmp_qrose 模式)+ buildSrc 移除 + 模块裁剪
    → 源码手术:composeMain 隔离(21+4)、RootController 四刀一增、类型下沉、RandomKey 双平台
    → ohosArm64 / ohosX64 KLIB + 7 项 jvmTest 回归
    → C ABI(@CName × 1,14 op JSON)+ 全量 state 内嵌
    → C++ N-API(DisposeString 纪律)
    → ArkTS JSON 解析
    → ArkUI 真机四 Tab 验收(栈形 / LaunchFlag / Flow / 多栈 / 模态 / 深链 / 面包屑)

9.2 封装层次

  • 模型层:上游原版 Screen / NavConfiguration / Breadcrumb / LaunchFlag;
  • 状态机层:上游原版 RootController / MultiStackRootController / ModalController(Compose 接缝剥离)+ 新增只读 backstack;
  • Native 层:生成双 ABI 动态库并通过 1 个 C ABI 输出 JSON;
  • N-API 层:完成参数检查、字符串转换和 Native 内存释放;
  • ArkTS 层:管理页面状态、操作分发、全量快照渲染与面包屑日志;
  • DevEco 层:完成 CMake、HAP、签名、安装和真机运行。

9.3 三条经验

  1. 导航类 CMP 库的适配钥匙是依赖度盘点而不是"能不能编 Compose":逐文件数 androidx.compose import,把"机器消费的字段"和"UI 载荷"分开(TabItem 只用 name、TabsNavModel 只做类型签名),状态机就自己从 Compose 里站出来了——渲染层隔离到留档源集,比硬接工具链或重写都便宜;
  2. 版本跟着依赖矩阵走:状态机只需要 StateFlow 基础面,coroutines 1.10.2-1.0.0 + Kotlin 2.2.21 旧线就是最小可行组合;需要 1.11.0 线的库(KStore)才必须升 2.4.20——钉版依据是依赖可达性,不是"新即正确";
  3. headless 桥要接住 UI 宿主的隐性职责:上游 Navigator / BottomBarNavigator 不只是画页面,还负责 drawCurrentScreen 冷启动——漏掉这层,流和多栈的栈永远是空的;把宿主职责逐条列出来镜像,才是完整的桥。

9.4 适配成果

当前适配已完成:

  • Odyssey 1.3.61 导航状态机的双 ABI Kotlin/Native 编译(上游 commonMain 状态机零重写,手术仅限 Compose 接缝;git 以 100% rename 保留全部历史);
  • 1 个 C ABI 符号 + C++ N-API 包装层,14 个 op 的 JSON 协议与全量状态快照;
  • DevEco Stage ArkUI 四 Tab 验收页面(推入/弹出、present/模态、多栈、LaunchFlag/深链 + 面包屑);
  • 7 项 JVM 交叉验证全绿 + 双 ABI 动态库(arm64 约 5.7 MB / x86_64 约 5.3 MB debug)+ HAP 构建、签名、真机安装与运行验证;
  • AtomGit 项目文档、OpenHarmony README(中英双份 + 25 节中文主文档)和本地真机效果图(适配提交 鸿蒙适配,82 个文件,+2688 / −655)。

适配过程 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开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐