开源鸿蒙平台 CMP 三方库「kmp-compose-tab-container」适配全流程
本文记录
kmp-compose-tab-container接入 OpenHarmony 的完整过程,覆盖纯 Compose 库的依赖边界盘点、鸿蒙工具链钉版、状态机引擎移植、ohosArm64/ohosX64目标、Kotlin/Native 动态库、C ABI/N-API 桥接、ArkUI 真机页面、HAP 构建和真机验收。
kmp-compose-tab-container是一个 Compose Multiplatform Tab 容器库:TabContainer(selectedTab)挂容器、TabContainerScope.Tab(tab, eager, display)声明页面,内部用remember(tab)状态机实现四条核心语义——粘性懒加载(load一旦置 true 永不回退,切走再切回不重新初始化)、eager 预热(未选中也提前加载)、两级 display 覆盖(per-tab 覆盖容器级,默认实现把未选中内容以 scale-0 隐藏在组合树中)、隐藏 Tab 焦点隔离(focusProperties canFocus = false,方向键永远进不了看不见的页面)。全库仅 3 个源文件 119 行,100% 依赖compose.foundation。本次适配与 KStore 那批"真编译整库"的路线有本质区别:社区 Maven 上不存在 Compose 的 ohos 制品,这 119 行里没有任何一行可以在鸿蒙工具链下编译——因此走的是社区已过审的"引擎移植 + Compose 隔离"路线(qrose / Flippable / compose-collapsing-toolbar 家族):把上游TabContainerState的状态机语义逐条移植为纯 Kotlin 的TabContainerEngine,上游 Composable 原样隔离到composeMain(不注册不编译),引擎经 Kotlin/Native 动态库 + C ABI + N-API 暴露给 ArkTS,四条语义在真机 ArkUI 页面上逐条验收。
项目地址: AtomGit/oh-tpc/kmp-compose-tab-container.git
上游仓库: zj565061763/kmp-compose-tab-container
开发工具: 华为云码道
环境安装: 环境安装指南
一、背景
1.1 为什么做 OpenHarmony 平台 KMP/CMP 适配
kmp-compose-tab-container 为 Compose Multiplatform 应用提供"底部导航 Tab 容器"这一件事的完整答案:页面首次被选中(或 eager)才加载,加载后切走再切回不重新初始化——滚动位置、输入内容全部保住;隐藏的页面不参与焦点导航;需要提前渲染的页面用 eager = true 预热;显示/隐藏策略通过 display 参数两级覆盖。KMP 项目把这些能力放在 commonMain,由 Android、iOS 共享(上游 Demo 带 composeApp + iosApp 双端示例)。OpenHarmony 应用同样需要这套 Tab 容器语义,但上游构建(Kotlin 2.0.21 / Compose 1.7.3 / AGP 8.5.2)不会生成 OpenHarmony 变体。
如果只把示例页面改成 ArkTS 的 Tabs 组件,页面可以切换,却无法证明上游的懒加载状态机在 OpenHarmony ARM64 设备上完成编译、链接和执行——系统能力替掉的是 UI,替不掉的是语义。适配需要同时验证状态机引擎、Native 动态库、C ABI、N-API、ArkUI 页面对引擎状态的施加和 HAP 交付链路。
| 障碍 | 具体问题 |
|---|---|
| 目标缺失 | 必须增加 ohosArm64() / ohosX64() 才能生成 OpenHarmony KLIB。 |
| 无可编译部分 | 119 行源码 100% 依赖 androidx.compose.*,社区 CMP 1.9.2 + HarmonyOS Kotlin 2.2.21 工具链下一个文件都编不过——KStore 式"真编译整库"在这类纯 Compose 库上没有前提。 |
| 工具链不一致 | 上游 Kotlin 2.0.21 / Gradle 8.8 / AGP 8.5.2 与鸿蒙工具链无法共存于一个 Gradle 构建;必须整体替换根构建。 |
| 语言边界不同 | ArkTS 不能直接持有 Kotlin 对象,@Composable lambda 更不可能跨语言,需要 C ABI、C++ N-API 和 JSON。 |
| 组合语义跨不了桥 | TabDisplay 是 @Composable (content, selected) -> Unit,组合/隐藏/焦点是 Compose 运行时概念,C ABI 只能同步一来一回。 |
| 交付链路复杂 | .so、CMake、N-API、HAP、签名和 hdc 都要单独验收。 |
因此,本项目把边界放在:状态机引擎的 ohos 编译、根构建替换(cmp_qrose 模式)和 C ABI/N-API 桥接层。Tab 生命周期语义完全由移植后的引擎计算,ArkTS 只负责调用 Native、解析 JSON 和把 composed / visible / canFocus 施加到 ArkUI 组件树上。
1.2 库提供的能力
Demo 提供一个真实的 Tab 容器会话(4 个 Tab:Home / Live / Video / Me),对应桥接的全部入口:
- 选择(select):
engine.select(tab)对应上游TabContainer(selectedTab = tab)——切换当前选中页; - 观察(state):
engine.state(tab, eager, display)对应上游一次Tab(tab, eager, display)组合观察——注册 Tab、闩锁懒加载、返回七字段渲染投影; - 快照(snapshot):引擎视角的全部 Tab 状态表,对应上游整容器重组(所有
Tab()体重新执行); - 预热(eager):
VideoTab 以eager = true注册,进页面即加载,未选中也在树里隐藏待命; - 两种 display:
MeTab 以SELECTED_ONLY策略注册(对应上游 Demo 的自定义 displayif (selected) content()),未选中时内容离开组件树;DEFAULT策略(对应上游DefaultTabDisplay)未选中内容保持在树中、scale-0 隐藏、不可聚焦; - 版本握手(info):返回上游/ fork 版本号,来自真实 Native 调用而非静态文字。
响应包含:
| 字段 | 作用 |
|---|---|
ok | 操作是否成功,失败时 error 带原文。 |
selected / loaded | 是否选中 / 粘性懒加载标志(load 一旦 true 永不回退)。 |
composed / visible / canFocus | 内容是否应在树中 / 是否可见(scale 1)/ 是否可聚焦——ArkUI 施加渲染的三依据。 |
policy | 生效的 display 策略:per-tab 覆盖容器级,缺省 DEFAULT。 |
引擎侧完整能力(选择、粘性加载、eager、策略覆盖链、快照、重置)全部在 commonMain 的 TabContainerEngine 里随编译进入 .so,JVM 侧保留 10 项 jvmTest 交叉验证用例,证明 ohos .so 里执行的状态机逻辑没有漂移。
1.3 实现适配
| 维度 | 要求 |
|---|---|
| 语义复用 | 上游 TabContainerState 的语义逐条移植为纯 Kotlin 引擎,每条语义一个 JVM 测试用例钉死;上游 3 个 Compose 文件原样隔离,一字不改。 |
| 平台目标 | 根构建直接声明 ohosArm64 + ohosX64,保留 jvm 作交叉验证,库模块出 KLIB,example/nativeApp 出双 ABI 动态库。 |
| 构建隔离 | 根构建替换为极简 ohos 构建(cmp_qrose 模式);上游 composeApp(Android Demo)与 iosApp(iOS Demo)留盘不进构建。 |
| 桥接稳定 | 使用 1 个 C ABI 入口 + UTF-8 JSON(5 个 action),不传递 Kotlin 对象地址;异常永远不跨 FFI。 |
| 依赖纪律 | 引擎零第三方依赖(连 kotlinx.serialization 都不拉),.so 只含 Kotlin/Native 运行时与 :lib KLIB。 |
| UI 完整 | 单页覆盖全部桥接入口:4 Tab 内容区、底部导航、eager 开关、display 策略切换、引擎重置和快照面板。 |
| 仓库规范 | 项目说明、文章、效果图和源码链接统一使用 AtomGit,全仓不出现第三方镜像站名。 |
本项目的 ArkUI 页面不是 Compose Multiplatform UI 实现。
composeMain里隔离的上游 Composable 等 CMP 工具链对齐后接回;当前 ArkTS Demo 验收的是引擎语义,不是 Compose 渲染。
二、实现路线图
第 1 阶段:项目初始化 ── 盘点 119 行全 Compose 的依赖边界、确认"移植而非编译"路线、确定交付物
第 2 阶段:目标与依赖打通 ── 根构建替换、eazytec Maven 置前、Kotlin 2.2.21-1.0.0 钉版、Gradle wrapper 8.14.1
第 3 阶段:引擎移植与桥协议建模 ── TabContainerState → TabContainerEngine、TabDisplayPolicy 枚举化、5 动作 JSON 契约
第 4 阶段:原生桥接 ── @CName C ABI、C++ N-API、手写 api header、CMake 三件套
第 5 阶段:ArkUI 宿主 ── 引擎状态施加(scale-0 / focusable / 条件渲染)、参数控件、快照面板
第 6 阶段:示例与验证 ── HAP、签名、设备安装、真机运行和效果图
三、逐步实现过程
第 1 阶段:项目初始化
1.1 工程边界
lib/src/commonMain/ 移植引擎:TabContainerEngine / TabState / TabDisplayPolicy(纯 Kotlin,零依赖)
lib/src/commonTest/ 引擎 JVM 交叉验证(10 用例,逐条钉上游语义)
lib/src/composeMain/ 上游 3 个 Compose 文件原样隔离(TabContainer / TabContainerState / TabDisplay,不注册不编译)
example/nativeApp/ Kotlin/Native 动态库(Bridge + MiniJson)
example/harmonyApp/ DevEco Stage、CMake N-API 和 ArkTS 页面
README.OpenHarmony.md / _CN.md 鸿蒙适配说明(中英双份)
README.upstream.md 上游原文保存
composeApp/ iosApp/ 上游 Android / iOS Demo,留盘不进 settings
构建拓扑走 cmp_qrose 模式(根构建即 ohos 构建):settings.gradle.kts 只 include :lib 与 :example:nativeApp,rootProject.name = "ohos_compose_tab_container"。上游的 AGP / Compose Compiler / vanniktech 发布插件全部随根构建替换剥离——本 fork 不发 Maven Central。
1.2 工具链矩阵
| 项目 | 配置 |
|---|---|
| Kotlin Multiplatform | HarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven) |
| JDK | 21(Temurin,org.gradle.java.home 写进 gradle.properties) |
| OpenHarmony 目标 | ohosArm64 + ohosX64(jvm 保留作交叉验证) |
| Gradle | 8.14.1(wrapper 从上游 8.8 升级钉版;2.2.21 旧线不支持 9.x) |
| 上游构建 | Kotlin 2.0.21 / Compose 1.7.3 / AGP 8.5.2(随根构建替换整体剥离) |
| DevEco product | 5.1.0(18) 兼容 |
| DevEco Studio | 5.1.0 Release |
| ABI | arm64-v8a + x86_64 |
| Bundle | org.terminator.ohos.tabcontainer |
$env:JAVA_HOME = "C:\Program Files\Eclipse Adoptium\jdk-21.0.12.101-hotspot"
java -version
1.3 源码依赖度盘点
| 文件 | 行数 | 依赖形态 | 处置 |
|---|---|---|---|
TabContainer.kt | 41 | @Composable + compose.runtime / compose.foundation | 隔离 composeMain |
TabContainerState.kt | 47 | mutableStateOf / derivedStateOf / remember | 语义移植 → 引擎 |
TabDisplay.kt | 31 | graphicsLayer / focusProperties / typealias | 隔离 composeMain,语义枚举化 |
这个盘点结论决定了整条路线:与 KStore(7 + 4 个 commonMain 文件纯 Kotlin 可整体编译)相反,本库没有一个文件能进 ohos 编译;但状态机的语义内核(选择、粘性加载、display 覆盖链)与 Compose 运行时是弱耦合——remember(tab) 是按 key 存粘性布尔,derivedStateOf 是派生选中比较,graphicsLayer scale-0 + canFocus=false 是渲染投影。三者分别映射为引擎的注册表、selected 计算和 TabState 字段,移植面清晰。
1.4 页面边界
标题区 kmp-compose-tab-container / 引擎握手行(Kotlin 引擎已连接:上游 1.0.0 · fork 1.0.0-ohos.1)
Tab 内容区 Stack 四页:Home / Live(懒加载)、Video(eager)、Me(SELECTED_ONLY)
每页 3×3 可聚焦网格 + Tab 名 + loaded@HH:mm:ss 首帧标记
底部导航条 四个 Tab 按钮,点击走 select action
参数控件区 Video eager 开关(Toggle)/ Me display 策略切换(DEFAULT ↔ SELECTED_ONLY)/ Reset 引擎按钮
快照面板 引擎 snapshot 表:tab / sel / load / comp / vis / focus / policy(true 绿 false 灰)
说明行 状态全部由 Kotlin 引擎按上游语义计算,ArkTS 只按返回 JSON 渲染
页面启动时先调 info action 做握手,随后 select Home 初始化并逐 Tab state 注册。
第 2 阶段:目标与依赖打通
2.1 根构建替换(cmp_qrose 模式)
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_tab_container"
include(":lib")
include(":example:nativeApp")
gradle.properties 钉死 JDK 21 与 fork 版本,gradle/libs.versions.toml 收敛为单个 Kotlin 插件别名,Gradle wrapper 升到 8.14.1(上游 8.8 在 HarmonyOS Kotlin 2.2.21 线下无法完成配置期)。根 build.gradle.kts 统一版本号并全局禁用 ohos 测试任务:
allprojects {
group = "io.github.zj565061763.kmp"
version = providers.gradleProperty("version").orElse("1.0.0-ohos.1").get()
}
subprojects {
tasks.matching { it.name.contains("Ohos", ignoreCase = true) && it.name.contains("Test") }
.configureEach { enabled = false }
}
2.2 工具链钉版(为什么是 2.2.21-1.0.0)
上游是 Kotlin 2.0.21,鸿蒙线只有 2.2.21-1.0.0 及以上;本库引擎零第三方依赖,不出现 KStore 那种"coroutines 制品反推 stdlib 版本"的连锁——直接钉社区已过审的 2.2.21-1.0.0(qrose / Flippable / compose-collapsing-toolbar 同款),Gradle 对应降/升到 8.14.1。这条旧线的经验是:wrapper 版本必须与 Kotlin 线配套,2.2.21 线配 8.14.1,不能沿用上游 8.8,也不必上 9.x。
2.3 编译移植引擎
lib/build.gradle.kts 收敛为纯 KMP 模块:
plugins {
alias(libs.plugins.kotlin.multiplatform)
}
kotlin {
applyDefaultHierarchyTemplate()
jvmToolchain(21)
jvm() // 交叉验证用
ohosArm64()
ohosX64()
sourceSets {
// commonMain holds the portable tab engine: TabContainerEngine /
// TabState / TabDisplayPolicy. The upstream Compose composables stay
// in src/composeMain which is NOT compiled for any target here.
commonMain.dependencies {}
jvmTest.dependencies {
implementation(kotlin("test"))
}
}
}
实测:
.\gradlew.bat :lib:compileKotlinOhosArm64 :lib:compileKotlinOhosX64 # BUILD SUCCESSFUL
引擎三件套(TabContainerEngine / TabState / TabDisplayPolicy)是本 fork 仅有的新增库源码;上游 3 个 Compose 文件以 git mv 方式移入 composeMain,git diff 零行变更——隔离不是删除,CMP 工具链对齐后可以整体接回。
2.4 测试的处置
引擎测试写在 commonTest、只在 JVM 跑:ohos 侧只承担编译与链接(根构建已全局禁用 Ohos*Test*)。10 个用例逐条对应上游语义(见 6.2 节),任何一条失败都意味着移植漂移,而不是平台特性。
第 3 阶段:引擎移植与桥协议建模
3.1 移植的核心:把"一次组合"建模为"一次调用"
上游 TabContainerState.Tab() 的函数体是移植蓝本:
fun Tab(tab: Any, eager: Boolean, display: TabDisplay?, content: @Composable () -> Unit) {
val selected by remember(tab) { derivedStateOf { tab == _selectedTab } }
var load by remember(tab) { mutableStateOf(false) }
if (eager || selected) { load = true } // 粘性:只置 true,永不回退
if (load) {
val finalDisplay = display ?: _display // per-tab 覆盖容器级
if (finalDisplay != null) finalDisplay(content, selected)
else DefaultTabDisplay(selected, content)
}
}
引擎把每个语义原子一一映射:
| 上游(Compose) | 引擎(纯 Kotlin) |
|---|---|
remember(tab) { mutableStateOf(false) } | 注册表 LinkedHashMap<String, Registration>,首见注册 |
if (eager || selected) load = true | registration.loaded = true 闩锁 |
derivedStateOf { tab == _selectedTab } | selected = tab == selectedTab |
display ?: _display ?: DefaultTabDisplay | policy ?: containerPolicy ?: DEFAULT |
DefaultTabDisplay(保组合 + scale-0 + canFocus=false) | composed = loaded; visible = selected; canFocus = selected |
Demo 自定义 if (selected) content() | SELECTED_ONLY:composed = loaded && selected |
于是 state() 就是"一次组合观察",snapshot() 就是"整容器重组"——ArkTS 每次交互后重查快照,等价于 Compose 世界里重组一遍容器。
3.2 display 组合语义的枚举化
TabDisplay 是 @Composable (content, selected) -> Unit,理论上任意自定义。桥上不可能传输组合 lambda,分析上游与 Demo 实际用到的行为只有两类,建模为两态枚举 TabDisplayPolicy:
DEFAULT——上游DefaultTabDisplay:内容保持在树中(状态不丢),未选中 scale-0 隐藏且不可聚焦;SELECTED_ONLY——上游 Demo 的自定义 display:未选中即离开树(onDispose触发,仅rememberSaveable状态存活)。
自定义组合行为超出这两类的,属于"Compose 运行时能力",留在 Kotlin 侧等 composeMain 接回(见 8.2)。
3.3 JSON 契约
com.sd.lib.kmp.compose_tab_container/**(移植引擎,零依赖)
↓ select / state / snapshot / reset
Bridge.kt(@CName × 1,扁平 JSON 进出)
↓ UTF-8 JSON
N-API tabContainerStep(request)
↓
ArkUI Index.ets(单页滚动)
请求与响应:
请求 : {"action":"info"}
{"action":"select","tab":"Home"}
{"action":"state","tab":"Video","eager":true}
{"action":"state","tab":"Me","display":"SELECTED_ONLY"}
{"action":"snapshot"}
{"action":"reset"}
响应 : {"ok":true,"tab":"Me","selected":false,"loaded":true,"composed":false,
"visible":false,"canFocus":false,"policy":"SELECTED_ONLY"}
{"ok":true,"selected":"Home","tabs":[{...},{...},{...},{...}]}
{"ok":false,"error":"..."}
五个动作的分工:info 版本握手;select 对应 TabContainer(selectedTab = ...);state 对应一次 Tab(tab, eager, display) 组合观察(注册 + 闩锁 + 返回七字段投影);snapshot 返回引擎视角全表;reset 丢弃单例换新引擎——Demo 的"粘性加载重新可观测"全靠它。display 参数大小写不敏感,非法值与缺 tab 统一走 ok:false 通道。
3.4 错误通道
导出函数整体 try/catch,Kotlin 异常永远不跨 FFI:
@CName("TabContainerStep")
fun tabContainerStep(request: String): String = try {
dispatch(MiniJson.parse(request))
} catch (t: Throwable) {
errorJson(t.message ?: "exception in tab container bridge")
}
JSON 解析用与 qrose / Flippable 同款的 130 行手写 MiniJson(引号状态机,逗号在字符串内不分裂)——刻意不拉 kotlinx.serialization,让 .so 只含 Kotlin/Native 运行时和 :lib KLIB,产物 2.12 MB 里没有任何序列化框架。
第 4 阶段:原生桥接(技术难点)
4.1 三层桥接
ArkTS
│ JSON string
▼
C++ N-API entry(libtabcontainer_napi.so)
│ const char*
▼
Kotlin/Native C ABI(libohostabcontainer.so)
│ TabContainerStep
▼
UTF-8 JSON + DisposeString
直接导出 Kotlin 对象会暴露 ABI 和生命周期问题;ArkTS 自建 Tab 状态又会让语义漂移,所以选择 C ABI + JSON。
4.2 Kotlin/Native 导出函数
@CName("TabContainerStep")
fun tabContainerStep(request: String): String // info / select / state / snapshot / reset 一口进
| C ABI 符号 | ArkTS 方法 | 覆盖内核 |
|---|---|---|
TabContainerStep | tabContainerStep(req) | 引擎全部 5 个入口 + 单例生命周期(reset 换新实例) |
桥内持有单例引擎,对应 Demo 里唯一的 TabContainer composable。
4.3 C++ N-API 方法分发
napi_property_descriptor desc[] = {
{"tabContainerStep", nullptr, NapiTabContainerStep, nullptr, nullptr, nullptr,
static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr},
};
回调统一三步:napi_typeof 校验必须是 string、OH_LOG_INFO 打字节数、调 KN 函数并包装返回。KN 返回的字符串必须经 libohostabcontainer_symbols()->DisposeString 归还 Kotlin 运行时,绝不 free()。
libohostabcontainer_api.h 按社区已审仓库同款手写(只声明 ExportedSymbols 结构前缀与业务符号),不拖整份生成头。ABI 安全性论证:KN 生成的完整结构体前两个字段固定是 DisposeStablePointer / DisposeString,业务函数另有独立的 extern const char* TabContainerStep(const char*) 平铺符号——手写头只引用这三者,与生成头(115 行,含全部 kotlin kref 类型)逐字段核对过,链接期 -Wl,--no-undefined 兜底。
4.4 CMake 和类型声明
add_library(ohostabcontainer SHARED IMPORTED)
set_target_properties(ohostabcontainer PROPERTIES
IMPORTED_LOCATION ${KN_SO}
IMPORTED_NO_SONAME TRUE)
add_library(tabcontainer_napi SHARED napi_init.cpp)
target_link_libraries(tabcontainer_napi PUBLIC
libace_napi.z.so
libhilog_ndk.z.so
ohostabcontainer)
OHOS_ARCH 为空或 libohostabcontainer.so 缺失直接 FATAL_ERROR(提示先跑 link 任务);$ORIGIN rpath 加 POST_BUILD 拷贝保证 NAPI so、KN so、libc++ 三件套同目录,dlopen 时互相可见。
export const tabContainerStep: (request: string) => string;
KN so 的产出侧,example/nativeApp/build.gradle.kts 声明双 ABI binaries.sharedLib { baseName = "ohostabcontainer" },linkDebugShared* 完成后 finalizedBy 拷贝任务把 libohostabcontainer.so 与 konan 里匹配 triple 的 libc++_shared.so 回填到 harmonyApp/entry/libs/{arm64-v8a,x86_64}/。
第 5 阶段:ArkUI 宿主
5.1 引擎状态的施加
ArkTS 侧不做任何 Tab 状态决策,每次交互都是"发请求 → 按响应渲染":
private onSelectTab(tab: string): void {
const req: SelectRequest = { action: 'select', tab: tab };
const res = JSON.parse(tabContainerStep(JSON.stringify(req))) as SelectResult;
if (res.ok === true) {
this.selectedTab = res.selected ?? tab;
}
this.refresh(); // 逐 Tab state + snapshot,重建视图模型
}
渲染投影的施加规则与上游一一对应:
| 引擎字段 | ArkUI 施加 | 对应上游 |
|---|---|---|
rendered(= composed 投影) | if (row.rendered) 条件渲染 | if (load) { display(...) } / if (selected) content() |
visible | .scale({ x: v ? 1 : 0, y: v ? 1 : 0 }) | graphicsLayer { scaleX = 0f; scaleY = 0f } |
canFocus | 网格单元 .focusable(row.canFocus) | focusProperties { canFocus = false } |
loaded 首帧 | loaded@HH:mm:ss 标记(Map 记录,只发生一次) | remember 粘性 + Demo 的 DisposableEffect 日志 |
DEFAULT 隐藏页因此保留在组件树中只缩放归零(焦点被 focusable(false) 拦住),SELECTED_ONLY 隐藏页整个不渲染——两种策略在真机上肉眼可辨。
5.2 页面交互
自上而下:标题与握手行、Tab 内容区(Stack)、底部导航四按钮、参数控件区(Video 的 eager 开关、Me 的策略切换 chips、Reset 按钮)、快照面板(七列表格,true 绿 / false 灰)、说明行。每次交互后 refresh() 重查全部 Tab 的 state 与 snapshot,面板与内容区同步刷新。
5.3 ArkTS 严格模式与 ForEach key
全部请求 / 响应 / 视图模型定义为 interface(9 个),无 any。ForEach 的 key 生成函数把可变字段拼进 key(如 ${row.tab}|${row.rendered}|${row.visible}|${row.canFocus}|${row.loadedAt ?? ''})——ArkUI 的 ForEach 对同 key 项不重渲染,key 缺字段时快照表会"看起来不更新",这是 ArkTS 与 Compose 重组语义的一个易踩差异点。
第 6 阶段:示例与验证
6.1 工程结构
example/
├── nativeApp/
│ ├── build.gradle.kts 双 ABI sharedLib(baseName=ohostabcontainer)+ cxxShared + copyKnSo*
│ └── src/ohosMain/kotlin/Bridge.kt @CName("TabContainerStep") + MiniJson(继承自 cmp_qrose 审核模式)
└── harmonyApp/
├── build-profile.json5 signingConfig "default"(签名由 DevEco 自动生成回填)
├── AppScope/app.json5 bundleName org.terminator.ohos.tabcontainer、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/libohostabcontainer_api.h
├── src/main/cpp/types/libtabcontainer_napi/{index.d.ts, oh-package.json5}
└── src/main/ets/pages/Index.ets ArkUI 验收页面
6.2 构建、签名和安装
.\gradlew.bat :lib:jvmTest `
:example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64
# DevEco Studio 打开 example/harmonyApp(不要打开仓库根目录)
# File → Project Structure → Signing Configs 勾选 Automatically generate signature
# Build → Build Hap(s),或命令行:
cd example\harmonyApp
hvigorw assembleHap -p product=default -p buildMode=debug --no-daemon
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.tabcontainer -m entry
四、完整代码对照
4.1 整体架构
com.sd.lib.kmp.compose_tab_container/**(移植引擎)
│ TabContainerEngine / TabState / TabDisplayPolicy
│ select / state / snapshot / reset + 粘性 loaded 闩锁
lib/src/composeMain/**(上游 Composable 原样隔离,ohos 不编译)
Bridge.kt + MiniJson
│ TabContainerStep(@CName × 1)
libtabcontainer_napi.so
│ tabContainerStep
Index.ets
│ JSON parse + @State 更新 + .scale()/.focusable()/条件渲染
▼
ArkUI 真机页面(单页滚动,四 Tab 语义验收)
4.2 文件清单
| 文件 | 职责 |
|---|---|
settings.gradle.kts | 极简 ohos 构建、eazytec Maven 置前、ohos_compose_tab_container、composeApp / iosApp 移出 |
gradle/libs.versions.toml | Kotlin 2.2.21-1.0.0 单插件钉版 |
build.gradle.kts(根) | 版本 1.0.0-ohos.1、全局禁用 Ohos*Test* 任务 |
lib/build.gradle.kts | 纯 KMP:jvm + ohosArm64 + ohosX64,零依赖 |
lib/src/commonMain/.../TabContainerEngine.kt | 状态机移植(引擎 + TabState) |
lib/src/commonMain/.../TabDisplayPolicy.kt | display 组合语义枚举化 |
lib/src/commonTest/.../TabContainerEngineTest.kt | 10 用例 JVM 交叉验证 |
lib/src/composeMain/** | 上游 3 文件 git mv 隔离(零行变更) |
example/nativeApp/build.gradle.kts | 双 ABI sharedLib、cxxShared 定位 libc++、copyKnSo* 回填 |
example/nativeApp/.../Bridge.kt | 1 个 C ABI、5 动作分发、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 | 鸿蒙适配主文档(10 节) |
4.3 关键 API 对照
| 层次 | API | 作用 |
|---|---|---|
| 引擎 | select(tab) | 上游 TabContainerState.selectTab(纯赋值,无"取消选中") |
| 引擎 | state(tab, eager, policy) | 一次组合观察:注册、闩锁 loaded、返回七字段投影 |
| 引擎 | snapshot() | 整容器重组:按注册顺序重观察全部 Tab |
| 引擎 | setDisplay(policy) / reset() | 容器级 display / 清空一切 |
| 引擎 | TabState 七字段 | selected / loaded / composed / visible / canFocus / policy / tab |
| Native | TabContainerStep | C ABI JSON 导出(5 动作一口进) |
| N-API | tabContainerStep(req) | 向 ArkTS 暴露能力 |
| ArkTS | refresh() | 交互后重查 state + snapshot 重建视图模型 |
4.4 ArkTS 与 Kotlin 的边界
const res = JSON.parse(tabContainerStep(JSON.stringify({
action: 'state', tab: 'Me', display: 'SELECTED_ONLY'
}))) as TabStateResult;
// res.composed === false:Me 未选中,内容离开组件树——引擎算,ArkTS 施加
val engine = TabContainerEngine()
engine.select("Me")
engine.state("Me", policy = TabDisplayPolicy.SELECTED_ONLY)
// TabState(selected=true, loaded=true, composed=true, visible=true, canFocus=true, ...)
两者之间只传输扁平 JSON 字符串,不传输 Kotlin 对象或未校验的动态结构。
五、关键决策说明
决策 1:语义移植而不是等 CMP 对齐,也不是 ArkTS 重写
三条候选路线里,"等社区 CMP 出 ohos 制品再整体编译"没有时间表;"ArkTS 里用系统 Tabs 组件重写"等于把 119 行语义手抄一遍且无法自称复用。移植把上游状态机的每个语义原子(粘性闩锁、选中派生、覆盖链、渲染投影)映射到纯 Kotlin,10 个 JVM 用例逐条钉死——语义漂移在 CI 层面可检测。
决策 2:composeMain 隔离而不是删除
上游 3 个 Compose 文件以 git mv 进 lib/src/composeMain/(非注册源集,永不编译),git diff 零行变更。保留的意义:对照上游 API 的活文档 + CMP 工具链对齐后整体接回的开关。接回条件写进了 README:社区 compose.ui ohos 制品与 CMP 上游版本对齐。
决策 3:display 组合语义枚举化为两态
TabDisplay lambda 无法跨 ABI,但上游实现 + Demo 实际只呈现两类行为(保组合隐藏 / 选中才组合)。枚举化让桥协议稳定为一个字符串字段,ArkTS 侧施加规则固定;超出两类的自定义组合行为明确划为"留在 Kotlin 侧"(见 8.2),不假装能跨。
决策 4:state() 建模为"一次组合观察",snapshot() 有观察副作用
这是整条移植里最关键的对齐:上游每次重组都会重执行 Tab() 体(重新读 eager、重新闩锁 load)。因此引擎的 state() 不是纯读——它闩锁 loaded、更新最近的 eager/policy;snapshot() 等价于按注册顺序对每个 Tab 重放 state()。选中新 Tab 后直接 snapshot(),新选中页即为 loaded=true,与 Compose 行为一致。这个副作用是特性不是缺陷,已在 KDoc 里写明。
决策 5:jvmTest 交叉验证 + 引擎零依赖
引擎不依赖 coroutines / serialization / compose 任何一员,.so 里只有 Kotlin/Native 运行时 + 引擎 KLIB(arm64 2.12 MB);MiniJson 手写解析让桥也不引入序列化框架。JVM 与 ohos 跑同一份 commonMain,10 项 jvmTest 是"内核没漂"的闸门——本库没有上游现成测试可跑(上游无测试目录),交叉验证用例是按上游语义新写的,每条用例注释引用对应源码行为。
六、测试与验证
6.1 测试环境
本次验证使用 Windows(x86_64)、JDK 21(Temurin jdk-21.0.12.101-hotspot)、DevEco Studio 5.1.0 Release、OpenHarmony 命令行工具(hvigor / hdc)与鸿蒙真机(arm64)。
6.2 JVM 交叉验证
.\gradlew.bat :lib:jvmTest
10 项用例全部通过:
| 用例 | 钉住什么 |
|---|---|
nothing selected yet | 初始 _selectedTab == null:全 unselected,eager 仍可闩锁 |
initial select | 选中页加载,未选中懒加载页不组合(if (load) 为 false) |
sticky load | 切走后 loaded 保持、composed 保持、visible=false、canFocus=false |
eager | 未选中 eager 页 loaded + composed、scale-0 隐藏 |
selected only policy | SELECTED_ONLY 三段式:未选中离树 → 选中入树 → 再离树,loaded 始终粘性 |
per-tab policy overrides | display ?: _display 覆盖链 |
container display null | 未设置容器 display 时默认 DEFAULT |
snapshot returns registration order | 注册顺序 + 快照观察闩锁选中页 |
reset clears everything | 清空注册、选中与容器策略 |
selecting the same tab twice | selectTab 幂等(纯赋值) |
6.3 HAP 验证
:lib:compileKotlinOhosArm64 / OhosX64: BUILD SUCCESSFUL
linkDebugSharedOhosArm64 / linkDebugSharedOhosX64: BUILD SUCCESSFUL(首次全量 34s)
llvm-nm -D: TabContainerStep / libohostabcontainer_symbols 均为全局 T 符号
copyKnSoArm64 / copyKnSoX64: 三件套回填 entry/libs
BuildNativeWithCmake: finished(双 ABI 编出 libtabcontainer_napi.so)
CompileArkTS: finished
PackageHap: finished
BUILD SUCCESSFUL
install bundle successfully
start ability successfully
实测产物清单:
| 产物 | 位置 | 大小 |
|---|---|---|
libohostabcontainer.so (arm64) | entry/libs/arm64-v8a/ | 2,222,784 B ≈ 2.12 MB |
libohostabcontainer.so (x86_64) | entry/libs/x86_64/ | 1,858,392 B ≈ 1.77 MB |
libc++_shared.so ×2 | 各 ABI 目录 | 1,850,184 / 1,731,432 B |
链接期有一条已知无害警告:CAdapter 胶水 api.cpp:448:1: warning: non-void function does not return a value in all control paths——HarmonyOS Kotlin 生成的 napi 胶水代码,非业务源码,qrose 同款,.so 正常产出。
6.4 功能用例(真机实测)
- 启动后握手行显示
Kotlin 引擎已连接:compose-tab-container 上游 1.0.0 · fork 1.0.0-ohos.1(来自真实 Native 调用,非静态文字); - Home 初始选中,内容区出现
loaded@02:30:49首帧标记——懒加载只发生一次; - 切到 Live 再切回 Home:Home 不重新加载(无新时间戳),快照表中 Live 保持
load=true comp=true vis=false——粘性语义 + DEFAULT 保组合隐藏成立; - Video 从未被选中,但快照表
load=true comp=true——eager 预热在进页面那一刻完成; - Me 以 SELECTED_ONLY 运行:选中时内容入树,切走后快照
comp=false——离开组合树语义成立; - 隐藏 Tab 的 3×3 网格不可聚焦(
.focusable(false)生效),焦点永远进不了看不见的页面; - 快照面板七列(sel / load / comp / vis / focus / policy)与引擎返回逐字段一致,true 绿 / false 灰;
- 点 Reset 引擎重建,loaded 标记清空,粘性加载可再次观察。
6.5 验证结论
状态机引擎、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 已完成链路验证。应用在鸿蒙真机上成功安装并进入前台,四条上游语义(粘性懒加载、eager 预热、两级 display、焦点隔离)在真机页面上逐条得到确认(见下节效果图)。
七、运行效果
7.1 真机截图

7.2 命令速查
git clone https://atomgit.com/oh-tpc/kmp-compose-tab-container.git
cd kmp-compose-tab-container
$env:JAVA_HOME = "C:\Program Files\Eclipse Adoptium\jdk-21.0.12.101-hotspot"
.\gradlew.bat :lib:jvmTest # 10 项交叉验证
.\gradlew.bat :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64
# DevEco Studio 打开 example/harmonyApp,自动签名后:
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.tabcontainer -m entry
hdc shell hilog | grep TabContainer
八、遗留问题与改进方向
8.1 踩坑复盘
- 只改 ArkTS 页面不算 KMP 适配,必须让
:lib真正编译成ohosArm64/ohosX64目标并链出动态库; settings.gradle.kts里 eazytec 双仓必须排在pluginManagement与dependencyResolutionManagement最前,否则配置期直接Unresolved reference: ohosArm64;- 全局 Gradle init 脚本可能静默顶掉 eazytec 仓库:本机
~/.gradle/init.d/china-mirrors.gradle的镜像仓库与自定义maven(url)同名(默认名都是maven),RepositoryHandler.add()遇重名静默丢弃,eazytec 双仓在 settings 级被移除后插件解析只剩镜像——修法是镜像仓库改唯一名,排查特征是"eazytec 仓库消失、只搜到镜像"; - wrapper 版本与 Kotlin 线必须配套:2.2.21-1.0.0 线钉 Gradle 8.14.1,沿用上游 8.8 配置期就死;
- HarmonyOS Kotlin 的 CAdapter 不会把
@CName函数挂到 ArkTS 模块:libohostabcontainer.so里nm得到TabContainerStep,ArkTS 直接 import 却看不到导出——必须 CMake 再包一层libtabcontainer_napi.so; - 手写 api header 是 ABI 前缀结构体方案:只引用生成头里固定存在的前缀字段(
DisposeStablePointer/DisposeString)和平铺业务符号,与生成头逐字段核对 +-Wl,--no-undefined兜底; - KN 返回字符串必须
DisposeString归还运行时,N-API 层不能free(); - ArkUI ForEach 同 key 不重渲染:key 生成函数必须拼进可变字段(rendered / visible / loadedAt),否则快照表"看起来不更新";
- HAP 缺当前 ABI 的 so 会报安装失败
9568347,abiFilters必须双 ABI,每个 ABI 三件套(NAPI so / KN so / libc++)缺一不可; build-profile.json5的signingConfigs条目缺material字段会被 hvigor schema 拒绝——仓库内只写"signingConfig": "default"引用,勾选 DevEco 自动签名后由 IDE 回填完整配置,证书绝不进入仓库;- 链接期
api.cpp:448:1的 return-type 警告来自 CAdapter 胶水而非业务代码,不阻塞产物; - 全仓链接统一 AtomGit(本仓与社区
CPF-KMP-CMP),上游引用保留 github 原文链接,不出现任何第三方镜像站名。
8.2 已知问题
- 鸿蒙侧没有 Compose 组合树:
composed / visible / canFocus由引擎计算、由 ArkTS 施加,rememberSaveable级别的进程内状态保持语义由 ArkUI 组件树自身承担; - 引擎 Tab 键为
String,上游为Any(按equals):桥接层要求调用方传入稳定的 per-tab 标识,业务侧枚举名天然满足; TabDisplayPolicy只覆盖上游实现与 Demo 用到的两类 display 行为,任意自定义组合 lambda 不跨桥(留在 Kotlin 侧,等 composeMain 接回);- android / ios 目标剥离(源文件留在树里),发布插件一并剥离,本 fork 不发 Maven Central;
composeMain的接回条件是社区compose.uiohos 制品与 CMP 上游版本对齐,届时引擎降级为 ohos actual 的对照实现;- HAP 安装必须使用与 bundle name
org.terminator.ohos.tabcontainer匹配的签名。
8.3 未来优化方向
- 引擎加
deselect/ 多容器实例支持(上游当前也无,属于能力扩展而非适配缺口); - 把
loaded变化做成 N-API 线程安全回调推送,替代交互后主动重查快照; - 评估
.har形态发布,让 ArkTS 工程直接依赖而不是走 so 三件套; - 在 CI 中加入 ohos 编译、ELF 符号检查和 HAP 构建;
- 补充真机(x86_64)验收与自动化截图脚本。
九、总结
9.1 核心难点回顾
上游 lib(3 文件 119 行,100% Compose)
→ 依赖盘点:没有一行能进 ohos 编译 → 路线定为"引擎移植 + Compose 隔离"
→ 工具链对齐 Kotlin 2.2.21-1.0.0 + Gradle 8.14.1(cmp_qrose 模式根构建替换)
→ TabContainerState 语义逐原子移植 → TabContainerEngine(state()=一次组合观察)
→ TabDisplay 组合语义枚举化(DEFAULT / SELECTED_ONLY)
→ C ABI(@CName × 1)+ 5 动作扁平 JSON
→ C++ N-API(DisposeString 纪律)+ CMake 三件套
→ ArkTS 施加(scale-0 / focusable / 条件渲染)
→ ArkUI 真机单页验收(四 Tab 四语义 + 快照面板)
9.2 封装层次
- 引擎层:
TabContainerEngine选择、粘性闩锁、策略覆盖链、快照与重置; - Native 层:双 ABI 动态库并通过 1 个 C ABI 输出 JSON;
- N-API 层:参数检查、字符串转换和 Native 内存释放;
- ArkTS 层:请求构造、视图模型重建、渲染施加与快照面板;
- DevEco 层:CMake、HAP、签名、安装和真机运行。
9.3 三条经验
- 纯 Compose 库的适配价值在语义不在渲染:119 行里真正值得跨平台的是"粘性懒加载 + 焦点隔离"这组行为契约,把行为契约从渲染实现里剥出来,跨平台的部分自然浮现——
state()建模为"一次组合观察"是整个移植的支点; - 移植必须有闸门:本库没有上游测试可继承,10 项
jvmTest按上游语义新写、每条注释引用源码行为——"移植没漂"的唯一可信证明是同一份commonMain在 JVM 上跑出的期望值; - 两类库两条路线,判断依据是当天的制品可达性:KStore 那批因为 kotlinx ohos 制品线补齐而走"真编译整库",本库因为 Compose ohos 制品缺失而走"引擎移植"——路线选择必须基于当天事实,而不是把上一个库的结论套到下一个库上。
9.4 适配成果
当前适配已完成:
- 上游
1.0.0Tab 容器状态机的双 ABI Kotlin/Native 编译(语义移植引擎,10 项 JVM 交叉验证全绿); - 1 个 C ABI 符号和 C++ N-API 包装层(
llvm-nm验证全局 T 符号,arm64 ≈ 2.12 MB / x86_64 ≈ 1.77 MB); - DevEco Stage ArkUI 单页验收页面(四 Tab、eager 开关、display 策略切换、引擎重置、七列快照面板、loaded 首帧标记);
- 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)