开源鸿蒙平台 KMP 三方库「BottomSheetNavigator3KMP」适配全流程
本文记录
BottomSheetNavigator3KMP(material3-navigation-kmp)接入 OpenHarmony 的完整过程,覆盖 Compose 导航库的选型分析、内核提取、ohosArm64/ohosX64目标、Kotlin/Native 动态库、C ABI/N-API 桥接、ArkUI 真机页面、HAP 构建和真机验收。
BottomSheetNavigator3KMP是基于 Compose Multiplatform 的 Material3 底部页导航库:它把ModalBottomSheet变成导航目的地——bottomSheet<T> { ... }声明路由、rememberBottomSheetNavigator()创建导航器、ModalBottomSheetLayout包住NavHost,sheet 的显示与隐藏完全由返回栈驱动,与 screen 共用同一套navigate/popBackStack。本次适配与 KStore 的"真编译整库"路线(上游有纯 Kotlin 存储引擎 + ohos kotlinx 制品线已补齐)有本质区别:本库上游 7 个源文件 100% 是 Compose 代码,社区 HarmonyOS CMP 线(1.9.2 / Kotlin 2.2.21)无法与上游 CMP 1.7.0-alpha01 / Kotlin 2.0.0 混线,因此走 CPF-KMP-CMP 社区对 Compose 库的既定路线(qrose / haze / PreCompose / voyager / pullrefresh / Odyssey 同路)——内核提取:上游BottomSheetNavigator的五条行为规则被完整提取为零依赖纯 Kotlin 状态机BottomSheetNavMachine编译进鸿蒙,Compose 渲染层隔离保留,ArkUI Demo 作为验收宿主。
项目地址: AtomGit/oh-tpc/BottomSheetNavigator3KMP.git
上游仓库: stefanoq21/BottomSheetNavigator3KMP
开发工具: 华为云码道
环境安装: 环境安装指南
一、背景
1.1 为什么做 OpenHarmony 平台 KMP/CMP 适配
material3-navigation-kmp 为 Compose Multiplatform 应用解决的是"sheet 即路由"这一件事:底部页不再是页面里的局部组件,而是 NavGraph 里的一等目的地。它卖的不是"又一个 sheet 组件",是 Material3 导航语义——sheet 的开合由返回栈驱动,返回键、遮罩点击、路由切换的行为与上游 androidx.navigation 的 BottomSheetNavigator 对齐。而这整套语义里真正有价值、且不依赖 Compose 渲染的部分,是它的导航状态机。
上游构建(Kotlin 2.0.0 / Compose 1.7.0-alpha01 / navigation-compose 2.8.0-alpha08)不会生成 OpenHarmony 变体,直接复编也没有可复编的纯 Kotlin 内核:
| 障碍 | 具体问题 |
|---|---|
| 源码形态 | 7 个源文件全部依赖 androidx.compose.* / androidx.navigation.*(Navigator / NavGraphBuilder / ModalBottomSheetLayout / 注解 expect-actual),没有一行可脱离 Compose 编译的代码。 |
| 工具链不一致 | 社区 HarmonyOS CMP 线是 1.9.2 / HarmonyOS Kotlin 2.2.21,无法与上游 CMP 1.7.0-alpha01 / Kotlin 2.0.0 混线。 |
| 依赖矩阵 | compose.material3 与 org.jetbrains.androidx.navigation:navigation-compose 都没有可用的鸿蒙侧同版本制品。 |
| 语言边界不同 | ArkTS 不能直接持有 Kotlin 对象,需要 C ABI、C++ N-API 和 JSON。 |
| 时序语义跨不了桥 | hide-first、retained entry、dismiss 来源区分这些"看起来简单其实有讲究"的时序规则,必须在 Kotlin 侧原样运行,ArkTS 重写一遍等于把坑再踩一遍。 |
| 交付链路复杂 | .so、CMake、N-API、HAP、签名和 hdc 都要单独验收。 |
因此,本项目把边界放在:上游导航状态机的提取与 ohos 编译、根构建替换(cmp_qrose 模式)和 C ABI/N-API 桥接层。导航语义完全由提取的内核负责,ArkTS 只负责调用 Native、渲染快照和回喂动画时序。
1.2 库提供的能力
Demo 复刻上游 composeApp 示例的全部入口,对应状态机的完整语义面:
- BottomSheetFullScreen:
skipPartiallyExpanded = true语义,sheet 直达Expanded档位; - BottomSheetWithCloseScreen:sheet 内 Close 按钮走
pop—— retained entry 流程的可视化证据; - BottomSheetWithParameters:带参路由,
params.id由 native 返回栈条目携带; - Zoom:普通 screen 目的地与 sheet 共用统一返回栈,screen 压栈时 sheet 隐藏但 entry 保留;
- BottomSheetOutsideNav:对照组——不走 native 返回栈的本地 sheet,与导航 sheet 形成行为对照;
- 返回栈可视化 + 事件日志:每次 dispatch 后,native 持有的返回栈以 chip 形式渲染(底 → 顶),事件序列带时间戳滚动留痕——这是验收五条规则的核心工具。
每个响应都是全量状态快照(扁平标量,见 3.1):
| 字段 | 作用 |
|---|---|
ok | 操作是否成功,失败时 error 带原文。 |
op / version | 回显本次操作;版本握手串 0.0.2-ohos.1(来自真实 Native 调用而非静态文字)。 |
backStackText | 统一返回栈编码:"1:home(screen);2:bottomSheetWithParameters(sheet)"。 |
topRoute / topIsSheet / topParamId | 栈顶条目的标量化描述。 |
sheetEnabled / sheetRoute / sheetTarget / sheetCurrent | 上游 sheetEnabled、retainedEntry.destination.route、SheetState.targetValue/currentValue 的对齐映射。 |
transitionsCount / pendingCount | 在途过渡数量、等待隐藏完成才出栈的 entry 数量(retained entry 证据)。 |
eventsText | 本次操作产生的事件序列:sheetShow(route,Expanded);transitionCompleted(2)。 |
1.3 实现适配
| 维度 | 要求 |
|---|---|
| 代码复用 | 上游五条行为规则逐条提取进零依赖纯 Kotlin 状态机(不重写语义、不复刻两个栈的框架细节),上游 Compose 文件物理隔离到 src/composeMain 不编译。 |
| 平台目标 | 根构建直接声明 ohosArm64 + ohosX64,保留 jvm 作交叉验证,库模块出 KLIB,example/nativeApp 出双 ABI 动态库。 |
| 构建隔离 | 根构建替换为极简 ohos 构建(cmp_qrose 模式);上游 composeApp / iosApp 留盘不进构建。 |
| 桥接稳定 | 使用 2 个 C ABI 和 UTF-8 JSON(扁平标量协议),不传递 Kotlin 对象地址;异常永远不跨 FFI。 |
| 依赖纪律 | 内核零依赖(比 Odyssey 的 coroutines 线更简),libs.versions.toml 只剩 HarmonyOS Kotlin 插件一项。 |
| UI 完整 | 两 Tab 覆盖全部入口:返回栈可视化、状态卡、5 入口、对照组、事件日志、版本握手。 |
| 仓库规范 | 项目说明、文章、效果图和源码链接统一使用 AtomGit。 |
本项目的 ArkUI 页面不是 Compose Multiplatform UI 实现。鸿蒙侧没有可与上游混线的 Compose 渲染线,ArkTS Demo 是把状态机暴露的导航语义画出来。
二、实现路线图
第 1 阶段:项目初始化 ── 盘点 7 个源文件的依赖形态、确认"无纯 Kotlin 内核"、确定提取边界
第 2 阶段:目标与依赖打通 ── 根构建替换、eazytec Maven 置前、HarmonyOS Kotlin 2.2.21-1.0.0 钉版、Gradle 8.14.1
第 3 阶段:内核提取 ── BottomSheetNavMachine:五条规则逐条落码 + 15 项 JVM 测试钉死不变量
第 4 阶段:桥协议建模 ── 扁平标量 JSON 契约、事件 token、错误通道
第 5 阶段:原生桥接 ── @CName C ABI、C++ N-API、参数校验和内存释放
第 6 阶段:ArkUI 宿主 ── hide-first 编排、detents 生命周期回喂、返回栈可视化、事件日志
第 7 阶段:示例与验证 ── HAP、签名、设备安装、真机运行和效果图
三、逐步实现过程
第 1 阶段:项目初始化
1.1 工程边界
material3-navigation-kmp/src/commonMain/ 新增:BottomSheetNavMachine(零依赖导航状态机,进 ohos 构建)
material3-navigation-kmp/src/commonTest/ 新增:15 项 JVM 交叉验证测试
material3-navigation-kmp/src/composeMain/ 上游 7 个 Compose 源文件整体隔离(不注册源集不编译)
material3-navigation-kmp/src/androidMain/ 上游遗留 manifest(不进构建)
example/nativeApp/ Kotlin/Native 动态库(Bridge + MiniJson)
example/harmonyApp/ DevEco Stage、CMake N-API 和 ArkTS 页面
composeApp/ 、iosApp/ 上游 Android/iOS/Desktop 示例,留盘不进 settings.gradle.kts
README.OpenHarmony.md / _CN.md 鸿蒙适配说明(中英双份)
构建拓扑走 cmp_qrose 模式(根构建即 ohos 构建):settings.gradle.kts 只 include :material3-navigation-kmp 与 :example:nativeApp,rootProject.name = "ohos_bottomsheetnavigator3kmp"。上游的 AGP / Compose 编译器 / vanniktech 发布插件全部随根构建替换剥离——本 fork 不发 Maven Central。
1.2 工具链矩阵
| 项目 | 配置 |
|---|---|
| Kotlin Multiplatform | HarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven) |
| JDK | 21(org.gradle.java.home 指向 Eclipse Temurin jdk-21.0.12.101-hotspot) |
| OpenHarmony 目标 | ohosArm64 + ohosX64(jvm 保留作交叉验证) |
| Gradle | 8.14.1(上游 wrapper 为 8.7,2.2.21 社区线钉 8.14.1——qrose / pullrefresh 同款) |
| 依赖 | 零(内核不依赖 coroutines / serialization,ohosArm64() 目标由 HarmonyOS Kotlin 发行版自带) |
| DevEco product | 5.1.0(18) 兼容 |
| DevEco Studio | 5.1.0 Release |
| ABI | arm64-v8a + x86_64 |
| Bundle | org.terminator.ohos.bottomsheetnavigator3kmp |
$env:JAVA_HOME = "C:\Program Files\Eclipse Adoptium\jdk-21.0.12.101-hotspot"
java -version
1.3 源码依赖度盘点
| 上游文件 | 行数 | 依赖形态 | 处置 |
|---|---|---|---|
BottomSheetNavigator.kt | 283 | Navigator + Compose runtime(produceState/snapshotFlow/LaunchedEffect)+ material3 SheetState | 提取状态机:时序规则落进 BottomSheetNavMachine,渲染部分隔离 |
ModalBottomSheetLayout.kt | 67 | material3 ModalBottomSheet 布局 | 隔离到 composeMain,ArkUI bindSheet 重建 |
NavGraphBuilder.kt | 120 | bottomSheet DSL + NavDestinationBuilder | 隔离;DSL 语义由桥的 navigate(route, isSheet) op 承载 |
Annotations.kt + 3 个 actual | 10 | @NavigatorName expect/actual(android typealias / 自定义注解) | 隔离;统一栈建模下无需注解机制 |
值得说明的是提取物的选择:上游 Navigator 里真正"值钱"的不是 283 行代码本身,而是它编码的五条时序规则(见 3.2)。这五条规则在 Android 上由 Compose runtime 驱动,在鸿蒙上必须由一个显式状态机承载——因为宿主换成了 ArkUI,动画时序改为宿主回喂。
1.4 页面边界
Tab1 导航 Demo
标题区 "Bottom sheet navigation examples"(上游原文)
状态卡 sheetTarget / sheetCurrent / sheetEnabled / transitionsInProgress 数量 / pendingRemovals 数量
返回栈区 chip 一排(底 → 顶),sheet 条目黄色标记——证明栈在 native 侧
页面区 由栈顶决定:home → 5 按钮;zoom → "Zoom param:testId-Zoom" + back
sheet 区 bindSheet 渲染三个 bottomSheet 目的地(复刻上游文案),Close → pop
日志区 每条带 [HH:mm:ss.SSS] 时间戳 + op + events 概要(最新在前,上限 100 条)
Tab2 关于
版本握手 bottomSheetNavVersion() 实测值与预期比对
库简介 / 架构分层图 / 上游与本仓 AtomGit 链接 / 免责说明
第 2 阶段:目标与依赖打通
2.1 制品线验证
本库的依赖盘点结果比 KStore 简单一个数量级:内核零依赖——不需要验证 kotlinx 制品线,唯一要确认的是 HarmonyOS Kotlin 发行版(org.jetbrains.kotlin.multiplatform 2.2.21-1.0.0,eazytec Maven)自带 ohosArm64() / ohosX64() target DSL。这一步在 qrose / pullrefresh / Odyssey 适配中已反复验证,本仓直接沿用钉法。
2.2 工具链钉版(2.2.21-1.0.0 + Gradle 8.14.1)
settings.gradle.kts 把 eazytec 双仓写在最前(顺序错误会在配置期直接 Unresolved reference: ohosArm64):
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_bottomsheetnavigator3kmp"
include(":material3-navigation-kmp")
include(":example:nativeApp")
gradle/libs.versions.toml 收敛到一项:
[versions]
kotlin = "2.2.21-1.0.0"
[plugins]
kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
2.3 编译内核
material3-navigation-kmp/build.gradle.kts 收敛为纯 KMP 模块:
plugins {
alias(libs.plugins.kotlin.multiplatform)
}
kotlin {
applyDefaultHierarchyTemplate()
jvmToolchain(21)
jvm() // 交叉验证用
ohosArm64() // 真机
ohosX64() // DevEco 模拟器(x86_64)
sourceSets {
commonMain.dependencies {}
commonTest.dependencies {
implementation(kotlin("test"))
}
}
}
根构建沿用社区纪律:版本统一走 gradle.properties 的 version=0.0.2-ohos.1,全局禁用 ohos 测试任务:
subprojects {
tasks.matching { it.name.contains("Ohos", ignoreCase = true) && it.name.contains("Test") }
.configureEach { enabled = false }
}
实测:
./gradlew :material3-navigation-kmp:jvmTest # BUILD SUCCESSFUL,15/15
./gradlew :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64
# BUILD SUCCESSFUL → libohosbottomsheetnavigator.so(arm64 约 2.2MB / x64 约 1.9MB,debug)
第 3 阶段:内核提取(技术难点)
3.1 五条规则逐条落码
上游 BottomSheetNavigator 的行为可以归结为五条规则,BottomSheetNavMachine.kt 逐条对应:
| # | 上游规则(源码出处) | 内核实现 |
|---|---|---|
| ① | hide-first:任何返回栈变化都先隐藏 sheet,再决定是否重显——sheetInitializer 的 produceState transform 里 sheetEnabled = false 无条件先行 | onBackStackChanged():先 hideSheet(),再判定栈顶是否 sheet 目的地 |
| ② | 栈顶即 sheet 才显示:retainedEntry = backStack.lastOrNull(),仅当它是 bottomSheet 目的地时组合 ModalBottomSheet | onBackStackChanged() 后半段:top.isSheet → sheetEnabled = true、sheetTarget = skipPartiallyExpanded ? Expanded : PartiallyExpanded |
| ③ | retained entry:pop 后 entry 保留到 sheet 完全隐藏——“返回栈已更新但 sheet 可能还在退场” | pop() 把栈顶 id 放进 pendingRemovals 只发 SheetHideRequested;真正删除在 onSheetHidden() 回喂后 |
| ④ | dismiss 来源区分:onDismissRequest 里"过渡在途 → markTransitionComplete;无过渡 → 立即 pop" | userDismissed() 三分支:popPending 命中 → 完成过渡并出栈;show 过渡在途 → 仅完成过渡(entry 保留,上游同款);无过渡 → 立即出栈 |
| ⑤ | 完全显示清空在途过渡:currentOnSheetShown 把 transitionsInProgress 全部 markTransitionComplete | onSheetShown():sheetCurrent = sheetTarget,一次性清空集合并逐 id 发完成事件 |
skipPartiallyExpanded 语义原样保留:上游 rememberBottomSheetNavigator(skipPartiallyExpanded) 透传给 rememberModalBottomSheetState,内核在构造参数里同款透传。
3.2 统一返回栈建模(诚实披露)
上游结构是一个 NavController 挂两个 navigator(screen navigator 与 bottomSheetNavigator 各自持栈)。内核简化为 screens 和 sheets 共享一个栈、NavEntry.isSheet 标记类型:上游两栈协同的全部可观测行为都收敛在"栈顶是不是 sheet 目的地"这一个判定上;统一栈让 ArkTS 用一个 backStackText 字符串就能做完整返回栈可视化。衍生语义(screen 压到 sheet 之上时 sheet 隐藏但 entry 保留、pop 回来时经 hide-first 路径重显)在 15 项测试里各有钉子。
3.3 桥协议建模(扁平标量)
请求与响应:
请求 : {"op":"reset","skipPartiallyExpanded":true}
{"op":"navigate","route":"bottomSheetWithParameters","isSheet":true,"params":{"id":"testId-123"}}
{"op":"navigate","route":"zoom","isSheet":false,"params":{"id":"testId-Zoom"}}
{"op":"pop"} / {"op":"userDismissed"} / {"op":"onSheetShown"} / {"op":"onSheetHidden"} / {"op":"state"}
响应 : {"ok":true,"op":"...","version":"0.0.2-ohos.1","backStackText":"1:home(screen);2:...(sheet)",
"topRoute":"...","topIsSheet":true,"topParamId":"testId-123",
"sheetEnabled":true,"sheetRoute":"...","sheetTarget":"Expanded","sheetCurrent":"Expanded",
"transitionsCount":0,"pendingCount":1,
"eventsText":"sheetShow(...,Expanded);transitionCompleted(2)"}
{"ok":false,"error":"..."}
为什么响应是扁平标量而不是嵌套 JSON 对象——这是本次真机验收抓出来的坑:ArkTS 运行时会拒绝把 JSON.parse 得到的无原型对象用于类型化上下文(赋给 @State 类型化字段时抛 Unexpected Object in JSON,华为官方 FAQ 有载)。初版协议的 backStack:[{id,route,isSheet,params}] / top:{...} / events:[{type,...}] 嵌套结构在 Demo 首次 dispatch 时全军覆没。修正为:返回栈与事件在 native 侧编码为字符串、集合只报数量,ArkTS 侧只读取标量并用普通字符串操作(split / indexOf)渲染;请求侧同样由 ArkTS 手工拼接 JSON 字符串,不走 JSON.stringify——与已过审的 pullrefresh / qrose Demo 同一消费模式。
错误通道:每个导出整体 try/catch,Kotlin 异常永远不跨 FFI:
@CName("BottomSheetNavDispatch")
fun bottomSheetNavDispatch(request: String): String = try {
/* op 分发 */
} catch (t: Throwable) {
"""{"ok":false,"error":${jsonString(t.message ?: t.toString())}}"""
}
第 4 阶段:原生桥接
4.1 三层桥接
ArkTS
│ JSON string
▼
C++ N-API entry(libbottomsheetnavigator_napi.so)
│ const char*
▼
Kotlin/Native C ABI(libohosbottomsheetnavigator.so)
│ BottomSheetNavDispatch / BottomSheetNavVersion
▼
UTF-8 JSON + DisposeString
4.2 Kotlin/Native 导出函数
| C ABI 符号 | ArkTS 方法 | 覆盖内核 |
|---|---|---|
BottomSheetNavDispatch | bottomSheetNavDispatch(req) | 8 个 op 一口进,返回全量状态快照 + 事件序列 |
BottomSheetNavVersion | bottomSheetNavVersion() | fork 版本握手(真机版本行的数据来源) |
example/nativeApp/build.gradle.kts 双 ABI sharedLib(baseName ohosbottomsheetnavigator),linkDebugSharedOhos* 任务 finalizedBy Copy 任务,把 so 与 ~/.konan/dependencies 对应 triple(aarch64-linux-ohos / x86_64-linux-ohos)的 libc++_shared.so 一起拷进 harmonyApp/entry/libs/<abi>/。
4.3 C++ N-API 方法分发
napi_property_descriptor desc[] = {
{"bottomSheetNavDispatch", nullptr, NapiBottomSheetNavDispatch, nullptr, nullptr, nullptr,
static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr},
{"bottomSheetNavVersion", nullptr, NapiBottomSheetNavVersion, nullptr, nullptr, nullptr,
static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr},
};
每个回调统一三步:napi_typeof 校验必须是 string、OH_LOG_INFO 打字节数、调 KN 函数并包装返回。KN 返回的字符串必须经 libohosbottomsheetnavigator_symbols()->DisposeString 归还 Kotlin 运行时,绝不 free()。libohosbottomsheetnavigator_api.h 按社区已审仓库同款手写(只声明 ExportedSymbols 结构与两个业务符号),不拖整份生成头。
4.4 CMake 和类型声明
add_library(ohosbottomsheetnavigator SHARED IMPORTED)
set_target_properties(ohosbottomsheetnavigator PROPERTIES
IMPORTED_LOCATION ${KN_SO}
IMPORTED_NO_SONAME TRUE)
add_library(bottomsheetnavigator_napi SHARED napi_init.cpp)
target_link_libraries(bottomsheetnavigator_napi PUBLIC
libace_napi.z.so
libhilog_ndk.z.so
ohosbottomsheetnavigator)
OHOS_ARCH 为空或 KN so 缺失直接 FATAL_ERROR(提示先跑 link 任务);$ORIGIN rpath 加 POST_BUILD 拷贝保证 NAPI so、KN so、libc++ 三件套同目录,dlopen 时互相可见。
export const bottomSheetNavDispatch: (request: string) => string;
export const bottomSheetNavVersion: () => string;
nm_modname、类型包名、ArkTS import 名三处统一为 bottomsheetnavigator_napi。
第 5 阶段:ArkUI 宿主
5.1 启动与 hide-first 编排
aboutToAppear(): void {
this.versionLine = bottomSheetNavVersion(); // 版本握手
this.dispatch('reset', '{"op":"reset","skipPartiallyExpanded":true}');
this.dispatch('navigate', this.navReq('home', false, '{}', false)); // 建栈 home
}
sheet 开关编排是 Demo 的灵魂(applySheetDirective):
- 响应事件含
sheetHide且当前 sheet 打开 → 先关;onSheetHidden的响应会再次下发sheetShow(若新栈顶仍是 sheet 目的地),届时再开——sheet → sheet 切换先关再开,事件顺序即 hide-first 的证据; sheetEnabled=true且sheetRoute非空 → 打开,内容切到新 route、参数切到栈顶topParamId;- 无 hide 事件但
sheetEnabled=false且 UI 仍开(userDismissed/reset路径)→ 关。
5.2 生命周期回喂与 API 18 的 detents 细节
sheet 的显示/隐藏动画时序由宿主回喂内核:onAppear / onDetentsDidChange 到 LARGE 档 → onSheetShown(状态镜像去重,避免双报);onDisappear → onSheetHidden(retained entry 在此之后才出栈,chip 变化即证据);遮罩 shouldDismiss → userDismissed。
两个 API 18 的编译期细节:SheetOptions 的回调名是复数 onDetentsDidChange(且回调参数是当前档位下标 number,不是档位数组);detents 是定长元组 [档位, 档位?, 档位?] 而非开放数组——helper 的返回类型必须按元组声明。
5.3 页面交互与错误边界
Tab1 自上而下:标题、状态卡(5 行实时镜像)、返回栈 chip、页面区、事件日志;返回键在栈顶是 sheet 或栈长大于 1 时转 pop。Tab2 展示版本握手行、库简介(五条规则)、ArkTS → NAPI → C ABI → 状态机 → KN so 分层说明与上游 / AtomGit 链接。ok:false 时错误原文进日志;Native 调用本身抛错同样被 try/catch 收进日志区,绝不让 HAP 崩。
第 6 阶段:示例与验证
6.1 工程结构
example/
├── nativeApp/
│ ├── build.gradle.kts 双 ABI sharedLib(baseName=ohosbottomsheetnavigator)+ copyKnSo*
│ └── src/ohosMain/kotlin/example/nativeapp/
│ └── Bridge.kt @CName × 2 + MiniJson(支持一层嵌套 params)
└── harmonyApp/
├── build-profile.json5 signingConfigs 留空(签名由 DevEco 自动生成回填)
├── AppScope/app.json5 bundleName org.terminator.ohos.bottomsheetnavigator3kmp、versionName 0.0.2-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/libohosbottomsheetnavigator_api.h
├── src/main/cpp/types/libbottomsheetnavigator_napi/{index.d.ts, oh-package.json5}
└── src/main/ets/pages/Index.ets ArkUI 两 Tab 验收页面
6.2 构建、签名和安装
./gradlew :material3-navigation-kmp:jvmTest
./gradlew :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64
cd example/harmonyApp
hvigorw assembleHap -p product=default -p buildMode=debug --no-daemon
签名在 DevEco Studio 的 File → Project Structure → Signing Configs 中勾选自动生成,证书写入本机,不进入仓库(仓库内 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.bottomsheetnavigator3kmp -m entry
四、完整代码对照
4.1 整体架构
stefanoq21 上游 7 文件(100% Compose,隔离在 src/composeMain 不编译)
│ 提取
BottomSheetNavMachine(零依赖纯 Kotlin,五条规则)
│ jvm / ohosArm64 / ohosX64 同一份源码
Bridge.kt + MiniJson
│ BottomSheetNavDispatch / BottomSheetNavVersion
libbottomsheetnavigator_napi.so
│ bottomSheetNavDispatch / bottomSheetNavVersion
Index.ets
│ 标量读取 + @State 更新 + 动画时序回喂
▼
ArkUI 真机页面(两 Tab:导航 Demo / 关于)
4.2 文件清单
| 文件 | 职责 |
|---|---|
settings.gradle.kts | 极简 ohos 构建、eazytec Maven 置前、ohos_bottomsheetnavigator3kmp、composeApp/iosApp 移出 |
gradle/libs.versions.toml | 只剩 Kotlin 2.2.21-1.0.0 一项(内核零依赖) |
build.gradle.kts(根) | 版本 0.0.2-ohos.1、全局禁用 Ohos*Test* 任务 |
material3-navigation-kmp/build.gradle.kts | 纯 KMP:jvm + ohosArm64 + ohosX64 |
material3-navigation-kmp/src/commonMain/.../BottomSheetNavMachine.kt | 内核:五条规则 + 事件模型 + 统一栈 |
material3-navigation-kmp/src/commonTest/.../BottomSheetNavMachineTest.kt | 15 项 JVM 交叉验证 |
example/nativeApp/build.gradle.kts | 双 ABI sharedLib、cxxShared 定位 libc++、copyKnSo* 回填 |
nativeApp/.../Bridge.kt | 2 个 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 验收页面(hide-first 编排 + 回栈可视化) |
README.OpenHarmony_CN.md | 鸿蒙适配主文档(21 节) |
4.3 关键 API 对照
| 层次 | API | 作用 |
|---|---|---|
| 内核 | navigate(route, isSheet, params, singleTop) | 推入目的地;sheet 与 screen 共用统一栈,hide-first 先行 |
| 内核 | pop() | sheet 栈顶走 retained-entry 流程(pendingRemovals,等隐藏回喂) |
| 内核 | userDismissed() | 遮罩点击三分支(上游 onDismissRequest 语义) |
| 内核 | onSheetShown() / onSheetHidden() | 宿主回喂动画时序;后者落实出栈 |
| 内核 | sheetTarget / sheetCurrent | 对齐 material3 SheetState.targetValue / currentValue |
| Native | BottomSheetNavDispatch / BottomSheetNavVersion | C ABI JSON 导出 |
| N-API | bottomSheetNavDispatch(req) / bottomSheetNavVersion() | 向 ArkTS 暴露能力 |
| ArkTS | dispatch(op, request) | 触发操作、刷新标量镜像、编排 sheet 开关 |
4.4 ArkTS 与 Kotlin 的边界
// 请求手工拼接(不走 JSON.stringify),响应只读标量
const res = JSON.parse(bottomSheetNavDispatch(
'{"op":"navigate","route":"bottomSheetWithParameters","isSheet":true,"params":{"id":"testId-123"},"singleTop":false}'
)) as BridgeState;
// res.backStackText === '1:home(screen);2:bottomSheetWithParameters(sheet)'
// res.sheetTarget === 'Expanded'(skipPartiallyExpanded=true)
// sheet 展开后回喂:
bottomSheetNavDispatch('{"op":"onSheetShown"}');
"navigate" -> {
val route = json.str("route") ?: return err(op, "missing route")
machine.navigate(
route = route,
isSheet = json.bool("isSheet") ?: return err(op, "missing isSheet"),
params = json.obj("params")?.entries ?: emptyMap(),
singleTop = json.bool("singleTop") ?: false,
)
}
两者之间只传输扁平 JSON 字符串,不传输 Kotlin 对象或未校验的动态结构。
五、关键决策说明
决策 1:提取状态机内核而不是整库复编
KStore 能"真编译整库"是因为上游有纯 Kotlin 引擎且 ohos kotlinx 制品线已补齐;本库 7 个文件 100% Compose、依赖的 material3 / navigation-compose 没有可混线的鸿蒙制品——整库复编此路不通。而库的价值恰恰高度集中:五条时序规则就是"sheet 即路由"的全部语义。提取状态机(而非在 ArkTS 里重写)让这些规则以 Kotlin 原样运行,JVM 测试直接钉死,ArkTS 重写的漂移风险归零。
决策 2:工具链钉 2.2.21-1.0.0 + Gradle 8.14.1
本仓不需要 kotlinx 制品线,直接沿用社区 2.2.21 钉法(qrose / pullrefresh / Odyssey 同款);Gradle 从上游 8.7 升到社区线 8.14.1。零依赖内核意味着 libs.versions.toml 只剩一项,制品可达性风险最小。
决策 3:统一返回栈建模,并诚实披露
上游两栈(screen navigator / bottomSheetNavigator 各自持栈)简化为单栈 + isSheet 标记:全部可观测行为收敛于"栈顶是不是 sheet 目的地"。衍生语义(screen 压栈时 sheet 隐藏但 entry 保留、pop 回来经 hide-first 重显)在测试里钉死,文档明示这是建模简化。
决策 4:桥协议扁平标量化
初版嵌套协议(backStack[] / top{} / events[])在真机上被 ArkTS 运行时以 Unexpected Object in JSON 拒绝(JSON.parse 产物不能进类型化上下文)。修正为字符串编码 + 数量标量 + 手工拼请求,与 pullrefresh / qrose 已验证的消费模式对齐。协议设计期就把"宿主运行时的类型纪律"当一等约束,是这个坑的教训。
决策 5:JVM 交叉验证是"内核没漂"的唯一可信证明
15 项 commonTest 跑的正是内核编进 so 的同一份源码:hide-first 顺序、retained entry 时序、dismiss 三分支、singleTop 不闪动、screen-over-sheet 重显、skipPartiallyExpanded 目标档位逐项有钉子。ohos 侧只承担编译与链接,测试统一 JVM。
六、测试与验证
6.1 测试环境
本次验证使用 Windows(x86_64,Git Bash)、JDK 21(Eclipse Temurin)、DevEco Studio 5.1.0 Release、OpenHarmony 命令行工具(hvigor / hdc)与鸿蒙真机(arm64)。
6.2 JVM 交叉验证
./gradlew :material3-navigation-kmp:jvmTest
15 项用例全部通过:
| 用例组 | 数量 | 钉住什么 |
|---|---|---|
| 显示目标 | 2 | skipPartiallyExpanded 两态:true → Expanded,false → PartiallyExpanded |
| hide-first | 1 | sheet → sheet 切换先 sheetHide 后 sheetShow,顺序断言 |
| retained entry | 2 | pop 后 entry 留栈、pendingRemovals 非空、sheetCurrent 不动;onSheetHidden 后出栈 |
| 完整闭环 | 1 | Home → 带参 sheet → pop → 隐藏回喂 → 栈回到 home |
| dismiss 三分支 | 3 | 无过渡立即出栈 / pop 过渡在途完成并出栈 / show 过渡在途仅完成不出栈 |
| 过渡清空 | 1 | onSheetShown 一次性清空 transitionsInProgress |
| singleTop | 1 | 复用栈顶,无 hide/show 事件(不闪动) |
| screen 交互 | 2 | screen 压栈 sheet 隐藏但 entry 保留;pop 回栈顶 sheet 经 hide-first 重显 |
| 边界 | 2 | 空机 pop / userDismissed 无操作;reset 清空一切 |
6.3 链路验证
:material3-navigation-kmp:jvmTest: 15/15 通过
linkDebugSharedOhosArm64 / linkDebugSharedOhosX64: BUILD SUCCESSFUL
llvm-nm: BottomSheetNavDispatch / BottomSheetNavVersion 均为全局 T 符号
copyKnSoArm64 / copyKnSoX64: 三件套就位(libohosbottomsheetnavigator.so + libc++_shared.so,双 ABI)
BuildNativeWithCmake / CompileArkTS / PackageHap: finished
install bundle successfully / start ability successfully
6.4 功能用例(真机验收清单)
- 「关于」Tab 版本握手显示
0.0.2-ohos.1(真实 Native 调用); - 启动后返回栈 chip 显示
1:home(screen),状态卡全 Hidden; - 点 BottomSheetWithParameters:sheet 弹出显示
BSWithParametersLayout - id: testId-123(参数来自 native 栈顶条目),日志出现sheetShow(bottomSheetWithParameters,Expanded),sheet 展开后transitionsInProgress清零; - 点 Close:日志先
sheetHide,此时 chip 仍在栈内(retained entry,pendingCount=1),sheet 完全收起后onSheetHidden回喂、entryRemoved出现、chip 回到 home——规则 ③ 的可视化; - 连续打开两个不同 sheet:日志顺序必须是
sheetHide → sheetShow(规则 ① hide-first); - 遮罩点击关闭与 Close 按钮产生不同事件序列(规则 ④);
- Zoom:screen 压栈 sheet 隐藏、chip 显示 screen 在顶;back 返回后 sheet 重显;
- BottomSheetOutsideNav 对照组不产生任何 native 日志。
6.5 验证结论
状态机内核、Kotlin/Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 已完成链路验证;应用在鸿蒙真机上安装并进入前台,返回栈可视化与事件日志完整呈现五条规则的运行证据(见下节效果图)。
七、运行效果
7.1 真机截图

7.2 命令速查
git clone https://atomgit.com/oh-tpc/BottomSheetNavigator3KMP.git
cd BottomSheetNavigator3KMP
export JAVA_HOME="/path/to/jdk-21"
./gradlew :material3-navigation-kmp:jvmTest # 15 项交叉验证
./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.bottomsheetnavigator3kmp -m entry
hdc shell hilog | grep BottomSheetNav
八、遗留问题与改进方向
8.1 踩坑复盘
- 只改 ArkTS 页面不算适配,必须让库模块真正编译成
ohosArm64/ohosX64;对 100% Compose 的库,正确动作是提取状态机而不是硬凑 Compose 运行时; settings.gradle.kts里 eazytec 双仓必须排在pluginManagement与dependencyResolutionManagement最前,否则配置期直接Unresolved reference: ohosArm64;- ArkTS 的 JSON.parse 产物不能进类型化上下文(
@State类型化字段/接口校验抛Unexpected Object in JSON,华为官方 FAQ 有载):桥协议必须扁平标量化,返回栈/事件用字符串编码,请求手工拼接不走JSON.stringify——初版嵌套协议在真机上全军覆没就是这一条; - API 18 的
SheetOptions回调名是复数onDetentsDidChange(单数直接编译错),且回调参数是当前档位下标number而非档位数组; detents是定长元组[档位, 档位?, 档位?]而非开放数组:三元表达式推断出的SheetSize[]过不了类型检查,helper 返回类型必须按元组声明;bindSheet的detents档位与内核sheetTarget要建立映射(LARGE↔Expanded),skipPartiallyExpanded=true的路由单档LARGE;- KN 返回字符串必须
DisposeString归还运行时,N-API 层不能free(); - HAP 缺当前 ABI 的 so 会报安装失败
9568347,abiFilters必须双 ABI,每个 ABI 三件套缺一不可;DevEco 模拟器是 x86_64,真机是 arm64; - 根构建的
/build/与.idea/*是根锚定模式,盖不住嵌套目录——example/harmonyApp/.idea/、entry/libs/、.hvigor/、DevEco 生成的.clang-tidy/.clangd要单独追加 gitignore; build-profile.json5的signingConfigs仓库内留空数组、product 引用default,勾选自动签名后由 IDE 回填完整配置,签名材料绝不进仓;- 上游
composeApp/iosApp依赖旧工具链(AGP / Compose 1.7-alpha),必须从settings.gradle.kts移出,且文档警告不要用 IDE 打开仓库根目录去 sync。
8.2 已知问题
- 鸿蒙侧没有与上游可混线的 Compose 渲染线,本 fork 交付的是导航状态机内核 + ArkUI 验收宿主,不是 Compose UI;
- 上游 Compose 层隔离在
src/composeMain(物理保留不编译),待社区 CMP 线与上游版本对齐后接回; - retained entry 的时序由宿主回调驱动:内核不知道动画时长,
onSheetShown/onSheetHidden喂早喂晚会造成sheetCurrent与真实动画错位; - 统一返回栈是对上游"两个 Navigator 各自持栈"的建模简化,衍生语义已测试钉死并在文档披露;
- android / ios / desktop 目标剥离(源文件留在树里),发布配置一并剥离,本 fork 不发 Maven Central;
- HAP 安装必须使用与 bundle name
org.terminator.ohos.bottomsheetnavigator3kmp匹配的签名。
8.3 未来优化方向
- 把 hide-first 的"先关再开"改为可配置的交叉过渡(上游 Compose 语义同为串行,先保持一致);
- 评估
.har形态发布 ArkTS 侧封装,让业务工程直接依赖而不是走 so 三件套; - 在 CI 中加入 ohos 编译、ELF 符号检查和 HAP 构建;
- 补充模拟器(x86_64)验收矩阵与
hdc uitest自动化截图脚本; - 社区 CMP 线升级后,把
composeMain隔离层接回真实 Compose 渲染。
九、总结
9.1 核心难点回顾
上游 7 文件(100% Compose,无纯 Kotlin 内核可复编)
→ 提取五条时序规则 → BottomSheetNavMachine(零依赖)
→ 根构建替换(cmp_qrose 模式,HarmonyOS Kotlin 2.2.21-1.0.0 / Gradle 8.14.1)
→ ohosArm64 / ohosX64 KLIB + 15 项 JVM 交叉验证
→ C ABI(@CName × 2)+ 扁平标量 JSON 协议(ArkTS 运行时类型纪律)
→ C++ N-API(DisposeString 纪律)
→ ArkUI 真机两 Tab 验收(hide-first 编排 + 返回栈可视化 + 事件日志)
9.2 封装层次
- 内核层:五条规则的零依赖状态机 + 统一返回栈 + 事件模型;
- Native 层:双 ABI 动态库,2 个 C ABI 输出扁平 JSON;
- N-API 层:参数检查、字符串转换和 Native 内存释放;
- ArkTS 层:标量状态镜像、sheet 开关编排(hide-first)、动画时序回喂、日志与错误文本;
- DevEco 层:CMake、HAP、签名、安装和真机运行。
9.3 三条经验
- 适配路线由源码形态决定:KStore 有纯 Kotlin 引擎和补齐的 ohos 制品线,答案是"真编译整库";本库 100% Compose,答案是"提取状态机"。两者共用同一套桥接与交付骨架——路线选择必须基于库本身的依赖形态,而不是"上次怎么做的";
- 桥协议要把宿主运行时的类型纪律当一等约束:嵌套 JSON 在 Kotlin 侧毫无成本,在 ArkTS 侧却会被运行时拒绝。扁平标量协议(字符串编码 + 数量标量 + 手工拼请求)是跨 ArkTS 边界的稳妥默认,pullrefresh / qrose 的先例加上本次的教训一致指向这一点;
- 时序类语义的验收要靠"可视化证据":导航状态机的正确性看不见摸不着,返回栈 chip + 事件时间戳日志让 hide-first、retained entry、dismiss 三分支每一条都有肉眼可核对的证据——Demo 不只是演示,是验收工具。
9.4 适配成果
当前适配已完成:
- 上游
material3-navigation-kmp0.0.2五条导航规则的双 ABI Kotlin/Native 编译(0.0.2-ohos.1,内核零依赖); - 2 个 C ABI 符号和 C++ N-API 包装层(
llvm-nm验证全局 T 符号); - DevEco Stage ArkUI 两 Tab 验收页面(5 入口复刻 + 返回栈可视化 + 事件日志 + 版本握手 + 对照组);
- 15 项 JVM 交叉验证全绿 + 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)