本文记录 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):Video Tab 以 eager = true 注册,进页面即加载,未选中也在树里隐藏待命;
  • 两种 display:Me Tab 以 SELECTED_ONLY 策略注册(对应上游 Demo 的自定义 display if (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 MultiplatformHarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven)
JDK21(Temurin,org.gradle.java.home 写进 gradle.properties)
OpenHarmony 目标ohosArm64 + ohosX64(jvm 保留作交叉验证)
Gradle8.14.1(wrapper 从上游 8.8 升级钉版;2.2.21 旧线不支持 9.x)
上游构建Kotlin 2.0.21 / Compose 1.7.3 / AGP 8.5.2(随根构建替换整体剥离)
DevEco product5.1.0(18) 兼容
DevEco Studio5.1.0 Release
ABIarm64-v8a + x86_64
Bundleorg.terminator.ohos.tabcontainer
$env:JAVA_HOME = "C:\Program Files\Eclipse Adoptium\jdk-21.0.12.101-hotspot"
java -version
1.3 源码依赖度盘点
文件行数依赖形态处置
TabContainer.kt41@Composable + compose.runtime / compose.foundation隔离 composeMain
TabContainerState.kt47mutableStateOf / derivedStateOf / remember语义移植 → 引擎
TabDisplay.kt31graphicsLayer / 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 = trueregistration.loaded = true 闩锁
derivedStateOf { tab == _selectedTab }selected = tab == selectedTab
display ?: _display ?: DefaultTabDisplaypolicy ?: 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 方法覆盖内核
TabContainerSteptabContainerStep(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.tomlKotlin 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.ktdisplay 组合语义枚举化
lib/src/commonTest/.../TabContainerEngineTest.kt10 用例 JVM 交叉验证
lib/src/composeMain/**上游 3 文件 git mv 隔离(零行变更)
example/nativeApp/build.gradle.kts双 ABI sharedLib、cxxShared 定位 libc++、copyKnSo* 回填
example/nativeApp/.../Bridge.kt1 个 C ABI、5 动作分发、MiniJson、错误通道
harmonyApp/.../cpp/napi_init.cppN-API 导出、参数检查、DisposeString
harmonyApp/.../cpp/CMakeLists.txtIMPORTED 引入 KN so、$ORIGIN rpath、POST_BUILD 三件套
harmonyApp/.../ets/pages/Index.etsArkUI 验收页面
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
NativeTabContainerStepC ABI JSON 导出(5 动作一口进)
N-APItabContainerStep(req)向 ArkTS 暴露能力
ArkTSrefresh()交互后重查 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 policySELECTED_ONLY 三段式:未选中离树 → 选中入树 → 再离树,loaded 始终粘性
per-tab policy overridesdisplay ?: _display 覆盖链
container display null未设置容器 display 时默认 DEFAULT
snapshot returns registration order注册顺序 + 快照观察闩锁选中页
reset clears everything清空注册、选中与容器策略
selecting the same tab twiceselectTab 幂等(纯赋值)

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 功能用例(真机实测)

  1. 启动后握手行显示 Kotlin 引擎已连接:compose-tab-container 上游 1.0.0 · fork 1.0.0-ohos.1(来自真实 Native 调用,非静态文字);
  2. Home 初始选中,内容区出现 loaded@02:30:49 首帧标记——懒加载只发生一次;
  3. 切到 Live 再切回 Home:Home 不重新加载(无新时间戳),快照表中 Live 保持 load=true comp=true vis=false——粘性语义 + DEFAULT 保组合隐藏成立;
  4. Video 从未被选中,但快照表 load=true comp=true——eager 预热在进页面那一刻完成;
  5. Me 以 SELECTED_ONLY 运行:选中时内容入树,切走后快照 comp=false——离开组合树语义成立;
  6. 隐藏 Tab 的 3×3 网格不可聚焦(.focusable(false) 生效),焦点永远进不了看不见的页面;
  7. 快照面板七列(sel / load / comp / vis / focus / policy)与引擎返回逐字段一致,true 绿 / false 灰;
  8. 点 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 踩坑复盘

  1. 只改 ArkTS 页面不算 KMP 适配,必须让 :lib 真正编译成 ohosArm64 / ohosX64 目标并链出动态库;
  2. settings.gradle.kts 里 eazytec 双仓必须排在 pluginManagement 与 dependencyResolutionManagement 最前,否则配置期直接 Unresolved reference: ohosArm64;
  3. 全局 Gradle init 脚本可能静默顶掉 eazytec 仓库:本机 ~/.gradle/init.d/china-mirrors.gradle 的镜像仓库与自定义 maven(url) 同名(默认名都是 maven),RepositoryHandler.add() 遇重名静默丢弃,eazytec 双仓在 settings 级被移除后插件解析只剩镜像——修法是镜像仓库改唯一名,排查特征是"eazytec 仓库消失、只搜到镜像";
  4. wrapper 版本与 Kotlin 线必须配套:2.2.21-1.0.0 线钉 Gradle 8.14.1,沿用上游 8.8 配置期就死;
  5. HarmonyOS Kotlin 的 CAdapter 不会把 @CName 函数挂到 ArkTS 模块:libohostabcontainer.so 里 nm 得到 TabContainerStep,ArkTS 直接 import 却看不到导出——必须 CMake 再包一层 libtabcontainer_napi.so;
  6. 手写 api header 是 ABI 前缀结构体方案:只引用生成头里固定存在的前缀字段(DisposeStablePointer / DisposeString)和平铺业务符号,与生成头逐字段核对 + -Wl,--no-undefined 兜底;
  7. KN 返回字符串必须 DisposeString 归还运行时,N-API 层不能 free();
  8. ArkUI ForEach 同 key 不重渲染:key 生成函数必须拼进可变字段(rendered / visible / loadedAt),否则快照表"看起来不更新";
  9. HAP 缺当前 ABI 的 so 会报安装失败 9568347,abiFilters 必须双 ABI,每个 ABI 三件套(NAPI so / KN so / libc++)缺一不可;
  10. build-profile.json5 的 signingConfigs 条目缺 material 字段会被 hvigor schema 拒绝——仓库内只写 "signingConfig": "default" 引用,勾选 DevEco 自动签名后由 IDE 回填完整配置,证书绝不进入仓库;
  11. 链接期 api.cpp:448:1 的 return-type 警告来自 CAdapter 胶水而非业务代码,不阻塞产物;
  12. 全仓链接统一 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.ui ohos 制品与 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 三条经验

  1. 纯 Compose 库的适配价值在语义不在渲染:119 行里真正值得跨平台的是"粘性懒加载 + 焦点隔离"这组行为契约,把行为契约从渲染实现里剥出来,跨平台的部分自然浮现——state() 建模为"一次组合观察"是整个移植的支点;
  2. 移植必须有闸门:本库没有上游测试可继承,10 项 jvmTest 按上游语义新写、每条注释引用源码行为——"移植没漂"的唯一可信证明是同一份 commonMain 在 JVM 上跑出的期望值;
  3. 两类库两条路线,判断依据是当天的制品可达性:KStore 那批因为 kotlinx ohos 制品线补齐而走"真编译整库",本库因为 Compose ohos 制品缺失而走"引擎移植"——路线选择必须基于当天事实,而不是把上一个库的结论套到下一个库上。

9.4 适配成果

当前适配已完成:

  • 上游 1.0.0 Tab 容器状态机的双 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。

参考文档

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐