本文记录 Composables UI 接入 OpenHarmony 的完整过程,覆盖组件库盘点、设计系统数值层提取、真实内核编译、ohosArm64 / ohosX64 目标、Kotlin/Native 动态库、C ABI/N-API 桥接、ArkUI 五 Tab 页面、HAP 构建和设备验收。

Composables UI 是 Composable Horizons 的 Compose Multiplatform 组件库:Button、Checkbox、TriStateCheckbox、Switch、RadioGroup、Slider、Progress、Tabs、TextField、AlertDialog、BottomSheet、DropdownMenu、Tooltip、Disclosure、NavigationBar、Toolbar、Scrollbars、Separations、Text、Icon,外加 Theme 令牌系统与窗口断点,以「现代、无障碍、每个组件单文件可复制」为卖点,是 Material Compose 之外的非 Material 选择。本次适配与 KStore 的"真编译整库"路线恰好相反:组件库的 25 个源文件全部是 @Composable 代码,依赖 Compose 1.11 / Kotlin 2.4 / composeunstyled 2.8.0 二进制制品,鸿蒙工具链上无法整体编译——因此走回 cmp_qrose 的内核提取路线,但提取的不是算法,而是设计系统的数值层:26 个明暗色彩令牌、9 个形状半径、窗口断点阈值、触控-指针尺寸数学、TriState 状态机、进度条扫描公式与全部动效参数,编成 ohosArm64 / ohosX64 KLIB;上游 Compose 源码零损耗保存在 src/composeMain 不参与编译,ArkUI 五 Tab Demo 按同一套数值把组件重绘出来。

项目地址: AtomGit/oh-tpc/composables-ui

上游仓库: composablehorizons/ui

开发工具: 华为云码道

环境安装: 环境安装指南

一、背景

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

Composables UI 为 Compose 应用提供一整套组件的「设计决策全集」:每个组件长什么样(令牌、形状、阴影)、多大(48/36dp 触控-指针二分法)、怎么动(强调曲线、时长、缩放系数)、怎么流转(TriState 循环、进度扫描)。KMP 项目把这些决策放在 commonMain,由 Android、iOS、桌面、Web 共享。OpenHarmony 应用同样需要这套组件语义,但上游构建(Kotlin 2.4.0 / Compose 1.11.1 / AGP 9.0.0)不会生成 OpenHarmony 变体,而社区 CMP 停在 1.9.2 / HarmonyOS Kotlin 2.2.21。

如果只把示例页面改成 ArkTS,页面可以显示按钮,却无法证明这套设计系统在 OpenHarmony ARM64 设备上完成编译、链接和执行——两端的按钮圆角、禁用透明度、对话框缓动曲线就会各自为政。适配需要同时验证设计令牌内核、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 交付链路。

障碍具体问题
渲染边界25 个源文件全部 import androidx.compose.*,@Composable 无法编进 ohos;社区 CMP 1.9.2 上也没有上游用的新 TextFieldState / Navigation 3 API。
二进制依赖组件交互机制在 com.composables:composeunstyled:2.8.0 二进制制品里,没有 ohos 变体,手势物理抽不出来。
工具链不一致上游 Kotlin 2.4.0 / Gradle 9.5.1 / AGP 9.0.0 与鸿蒙工具链无法共存于一个 Gradle 构建。
语言边界不同ArkTS 不能直接持有 Kotlin 对象,需要 C ABI、C++ N-API 和 JSON。
「UI 库」的定义争议渲染过不了桥,必须回答「一个纯 UI 库到底适配什么」——答案是把组件拆成「数值 + 渲染」,数值过桥,渲染重放。
交付链路复杂.so、CMake、N-API、HAP、签名和 hdc 都要单独验收。

因此,本项目把边界放在:设计系统数值层的提取与 ohos 编译、根构建替换(cmp_qrose 模式)和 C ABI/N-API 桥接层。组件语义完全由上游源码值负责,JVM 侧 19 项断言把每个数值钉死在上游源码行上,ArkTS 只负责调用 Native、解析 JSON 和按数值渲染。

1.2 库提供的能力

Demo 提供五个 Tab,对应桥接的全部入口:

  • 主题(theme):明/暗双色系 26 个色彩令牌(含 ARGB 半透明预合成)、9 个形状半径、2 套阴影、涟漪/禁用透明度、排版样式,Light/Dark × Touch/Pointer 四档联动;
  • 规格(spec):17 组组件尺寸——按钮/Tab/输入框/菜单项的 48/36dp 二分法(±4dp)、Switch 44×24、复选框 20dp、对勾三段归一化几何、对话框 280–560dp、抽屉把手 32×4 等;
  • 行为(tristate/progress):TriState 复选框状态机(Off→On、Indeterminate→On、On→Off)与进度条 (1+0.35)t−0.35 扫描偏移;
  • 动效(motion/transformOrigin):对话框 400/200ms 强调曲线 (0.05,0.7,0.1,1.0)/(0.3,0,0.8,0.15)、菜单 120/75ms 0.96 缩放、气泡 300/250ms、遮罩 300ms、焦点环 120ms 2dp、按压 0.98,以及弹层 side × alignment → pivot 映射;
  • 断点(breakpoints):compact/medium/expanded/large/extraLarge(0/600/840/1200/1600)与 short/tall(1080)解析;
  • 版本握手:composablesVersion() 返回 fork 版本号,来自真实 Native 调用而非静态文字。

响应包含:

字段作用
ok操作是否成功,失败时 error 带原文。
op回显本次操作(theme / spec / tristate / progress / motion / breakpoints / transformOrigin)。
colors / shapes / shadows / alphas / typographytheme op 的五张令牌表,色彩为 #AARRGGBB 终值,ArkTS 拿到即用。
数值字段spec/progress/motion 各自的尺寸、时长与控制点。

组件渲染语义(手势物理、焦点收集)在上游二进制依赖 composeunstyled 里,不随编译进 .so(见 8.2);数值层全部来自上游源码原值,JVM 侧 19 项 jvmTest 断言交叉验证,证明 ohos .so 里执行的令牌逻辑没有漂移。

1.3 实现适配

维度要求
代码复用上游 25 个 Compose 文件零损耗保存于 src/composeMain(非源码集,不编译);新增 5 个内核文件把上游数值原样提取并逐文件标注出处,不发明任何新值。
平台目标根构建直接声明 ohosArm64 + ohosX64,保留 jvm() 作交叉验证,库模块出 KLIB,example/nativeApp 出双 ABI 动态库。
构建隔离根构建替换为极简 ohos 构建(cmp_qrose 模式);上游 demo/ preview/ apps/ devtools/ packages/ 留盘不进构建。
桥接稳定使用 2 个 C ABI 和 UTF-8 JSON,不传递 Kotlin 对象地址;异常永远不跨 FFI。
依赖纪律内核零第三方依赖(不拉 kotlinx),工具链钉 HarmonyOS Kotlin 2.2.21-1.0.0(与 qrose 同线),上游源文件零改动。
UI 完整五 Tab 覆盖全部桥接入口:主题联动、按钮规格、控件状态机、弹层动效、断点/规格浏览器,每个交互回显 ok <op> 状态行。
仓库规范项目说明、文章、效果图和源码链接统一使用 AtomGit。

本项目的 ArkUI 页面不是 Compose Multiplatform UI 实现。过桥的是组件的「设计的因」(令牌/规格/时长),不是「渲染的果」;ArkTS Demo 拿同一套数值重放组件,这是当前工具链版本下纯 UI 库的可验收路径。

二、实现路线图

第 1 阶段:项目初始化     ── 盘点模块与依赖边界、确认"渲染不可编/数值可编"的拆分、确定交付物
第 2 阶段:目标与依赖打通 ── 根构建替换、eazytec Maven 置前、Kotlin 2.2.21-1.0.0 钉版、Gradle 降 8.14.1
第 3 阶段:内核提取       ── commonMain→composeMain 零损耗迁移、五文件数值内核(令牌/断点/规格/状态机/动效)
第 4 阶段:原生桥接       ── @CName C ABI、C++ N-API、参数校验和内存释放
第 5 阶段:ArkUI 宿主     ── 五 Tab 页面、版本握手、KN 驱动的主题/规格/动效联动
第 6 阶段:示例与验证     ── HAP、设备安装、模拟器运行、自动化截图和效果图

三、逐步实现过程

第 1 阶段:项目初始化

1.1 工程边界
ui/                                   组件库模块(进 ohos 构建,jvm + ohosArm64 + ohosX64)
ui/src/commonMain/                    新增:五文件数值内核(ThemeSpecs / WindowBreakpointsSpecs /
                                          ComponentSpecs / MotionSpecs / ComposablesCore)
ui/src/composeMain/                   上游 25 个 Compose 组件源码,零损耗保存,ohos 不编
ui/src/composeTest/                   上游 TextFieldTest,保存不编
ui/src/jvmTest/                       内核交叉验证断言(19 项)
example/nativeApp/                    Kotlin/Native 动态库(Bridge + MiniJson)
example/harmonyApp/                   DevEco Stage、CMake N-API 和 ArkTS 五 Tab 页面
README.OpenHarmony.md / _CN.md        鸿蒙适配说明(中英双份)
demo/ preview/ apps/ devtools/ …      上游工程与发布链,留盘不进构建

构建拓扑走 cmp_qrose 模式(根构建即 ohos 构建):settings.gradle.kts 只 include :ui 与 :example:nativeApp,rootProject.name = "ohos_composables_ui"。上游的 spotless / AGP 9 / vanniktech 发布插件 / changesets npm 发布链全部随根构建替换剥离——本 fork 不发 Maven Central。

1.2 工具链矩阵
项目配置
Kotlin MultiplatformHarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven)
JDK21(org.gradle.java.home 指向 Temurin 21)
OpenHarmony 目标ohosArm64 + ohosX64(jvm() 保留作交叉验证)
Gradle8.14.1(上游 wrapper 9.5.1 必须降级;2.2.21 线要求 8.x)
依赖内核零第三方依赖,不拉 kotlinx
DevEco product5.1.0(18) 兼容
DevEco Studio5.1.0 Release
ABIarm64-v8a + x86_64
Bundleorg.terminator.ohos.composablesui
export JAVA_HOME="/path/to/jdk-21"
java -version
1.3 源码依赖度盘点
内容文件依赖形态处置
Compose 组件23 个 components/*.ktandroidx.compose.* + composeunstyled 二进制零损耗迁移 composeMain,不编译
主题 DSLtheme/Theme.kt + theme/WindowBreakpoints.ktCompose Color/Shape/Text同上;数值原样提取进内核
可提取数值—纯常量与简单函数新增五文件内核,编 jvm + 双 ohos
上游测试TextFieldTest.ktCompose + assertk迁 composeTest 保存,不编

盘点结论是本次适配的核心判断:每个 @Composable 都由「数值」和「渲染」两部分构成。数值部分——Theme.kt 里 26 个令牌的明/暗 hex、0.33f 禁用透明度、100.dp 触控圆角;Button.kt 里 48.dp ± 4 的尺寸函数;TriStateCheckbox.kt 里 Off→On、Indeterminate→On、On→Off 的循环;Progress.kt 里 (1 + 0.35f) * offset - 0.35f 的扫描公式;AlertDialog.kt 里两组 CubicBezier 控制点——全部不碰 Compose,是纯 Kotlin。渲染部分过不了桥,数值部分编进 KLIB,这就是一个纯 UI 库在鸿蒙上的「内核」。

1.4 页面边界
标题区          COMPOSABLES UI · OPENHARMONY / 版本握手行(内核 0.2.0-ohos.1 · 上游 0.2.0 · Kotlin 2.2.21-1.0.0 · 组件 24)
Tab 切换        主题 / 按钮 / 控件 / 弹层 / 断点·规格
主题 Tab        Light/Dark × Touch/Pointer 四档切换,26 色令牌色板 + 形状半径 + 排版行
按钮 Tab        Small/Regular/Large 三档 × 六种 ButtonStyle,spec 行回显 height/padding/radius
控件 Tab        TriState 复选框(KN 状态机循环)/ Checkbox / Switch / RadioGroup / Slider / 双进度条
弹层 Tab        AlertDialog(强调曲线)/ DropdownMenu(side × alignment → KN pivot)/ BottomSheet / Tooltip 长按
规格 Tab        窗口宽高滑杆 → KN 断点解析,17 组件 spec JSON 原文回显
状态行          最近一次操作结果(如 ok theme light/touch · 26 色彩令牌)

页面启动时先调 composablesVersion() 做握手,再依次拉取主题、按钮规格、动效表与复选框几何。

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

2.1 工具链钉版(为什么是 2.2.21-1.0.0,而不是 KStore 的 2.4.20 线)

KStore 升到 2.4.20-0.1.0-07 是被 coroutines 制品的 stdlib 元数据逼的。本库内核零第三方依赖——不拉 kotlinx、不拉 serialization,桥的 JSON 用手写 MiniJson——没有元数据约束,所以钉回社区最早批准的 HarmonyOS Kotlin 2.2.21-1.0.0(qrose / haze 同线),配套两件事:Gradle Wrapper 从上游 9.5.1 降到 8.14.1(2.2.21 线不支持 Gradle 9.x,这是与 2.4.20 线经验不能互套的地方);JDK 钉 21 写进 gradle.properties。

settings.gradle.kts 把 eazytec 仓库写在最前:

pluginManagement {
    repositories {
        maven("https://maven.eazytec-cloud.com/nexus/repository/maven-public/")
        maven("https://maven.eazytec-cloud.com/nexus/content/groups/harmonyos")
        gradlePluginPortal()
        google()
        mavenCentral()
    }
}

rootProject.name = "ohos_composables_ui"

include(":ui")
include(":example:nativeApp")
2.2 源码集零损耗迁移

上游 ui/src/commonMain 的 25 个 Kotlin 文件整树 git mv 到 ui/src/composeMain,TextFieldTest.kt 同步挪到 ui/src/composeTest。composeMain 不是 Kotlin 源集——ohos/jvm 编译器都看不见它,它的存在只为零损耗保存上游 UI 代码:git 把这批移动全部识别为 100% rename,上游任何改动都能干净地合并进来。

ui/build.gradle.kts 收敛为纯 KMP 模块:

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

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

    sourceSets {
        commonMain.dependencies {}
        val jvmTest by getting {
            dependencies { implementation(kotlin("test")) }
        }
    }
}

实测:

./gradlew :ui:compileKotlinJvm :ui:compileKotlinOhosArm64 :ui:compileKotlinOhosX64   # BUILD SUCCESSFUL
2.3 内核提取的纪律

五文件内核(约 500 行)不是翻译,是誊抄,三条纪律:

  • 每个常量标注上游出处:ThemeSpecs.kt 注明 theme/Theme.kt,ComponentSpecs.kt 列出全部上游文件名,将来上游升级按文件对照重提;
  • 不用任何 JVM/JS/Compose 注解:上游的 @JvmInline value class 换成普通 enum(内核非热路径),@Composable/@Stable 一律不进 commonMain——HarmonyOS Kotlin 认不全这些注解,留着编译期直接失败;
  • 半透明色预合成 ARGB:Color.White.copy(alpha = 0.05f) 这类写法用 argbWithAlpha() 在内核里算成 #0DFFFFFF,ArkTS 拿到即用,两端不需要各自算 alpha,也避免两端舍入不一致。

另有一个 Float 精度细节:进度条偏移 (1+w)t−w 在 Float 下中点不精确,内核改写为等价的 t + w(t−1),让 t=0/t=1 精确命中 −w/1.0,JVM 断言才能用精确相等钉死。

第 3 阶段:桥协议建模

3.1 JSON 契约
com.composables.ui/**(数值内核,jvm + 双 ohos 编译)
        ↓ themeSpec / resolveBreakpoint / nextTriState / overlayTransformOrigin …
Bridge.kt(@CName × 2,扁平 JSON 进出)
        ↓ UTF-8 JSON
N-API composablesVersion() / composablesDispatch()
        ↓
ArkUI Index.ets(五 Tab)

请求与响应:

请求 : {"op":"theme","scheme":"light","mode":"touch"}
       {"op":"spec","component":"button","size":"regular","mode":"touch"}
       {"op":"tristate","state":"off"} …(共 8 个 op)
响应 : {"ok":true,"op":"theme","scheme":"light","mode":"touch",
        "colors":{"backgroundColor":"#FFF4F4F4",…26 项},
        "shapes":{"buttonShape":100,…9 项},
        "shadows":{"raisedShadow":{"radius":16,"color":"#FF000000","offsetX":0,"offsetY":4,"alpha":0.14}},
        "alphas":{"disabledAlpha":0.33,…},"typography":{"body":{"fontSize":17,"lineHeight":24,"weight":"Normal"},…},
        "colorTransitionMillis":180}
       {"ok":false,"error":"unknown op: xxx"}

一个对称设计:theme 的嵌套表(colors/shadows/…)作为原生 JSON 对象嵌入响应,ArkTS 一次 JSON.parse 拿到全部令牌,不做双重转义。

3.2 op 分发
@CName("ComposablesDispatch")
fun composablesDispatch(request: String): String = try {
    val json = MiniJson.parse(request)
    when (val op = json.str("op")) {
        "version" -> composablesVersion()
        "theme" -> themeOp(json)
        "breakpoints" -> breakpointsOp(json)
        "spec" -> specOp(json)
        "tristate" -> tristateOp(json)
        "progress" -> progressOp(json)
        "motion" -> motionOp()
        "transformOrigin" -> transformOriginOp(json)
        else -> errorJson("unknown op: $op")
    }
} catch (t: Throwable) {
    errorJson(t.message ?: t.toString())
}

spec 的 component 覆盖 17 种组件,每种返回自己的字段集(button 的 height/horizontalPadding、switch 的 trackWidth/thumbSize、checkbox 的对勾三段归一化坐标数组……);未知 component 走错误通道,HAP 不崩。

3.3 错误通道

每个导出整体 try/catch,Kotlin 异常永远不跨 FFI;非法 JSON、缺字段、不支持的 op 全部走同一个 ok:false 通道,ArkTS 侧统一渲染到状态行。

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

4.1 三层桥接
ArkTS
  │ JSON string
  ▼
C++ N-API entry(libcomposables_napi.so)
  │ const char*
  ▼
Kotlin/Native C ABI(libohoscomposables.so)
  │ ComposablesDispatch / ComposablesVersion
  ▼
UTF-8 JSON + DisposeString

直接导出 Kotlin 对象会暴露 ABI 和生命周期问题;只导出整数又会让页面复制组件语义,所以选择 C ABI + JSON。

4.2 Kotlin/Native 导出函数
@CName("ComposablesDispatch")
fun composablesDispatch(request: String): String   // 8 个 op 一口进

@CName("ComposablesVersion")
fun composablesVersion(): String                    // 版本握手
C ABI 符号ArkTS 方法覆盖内核
ComposablesDispatchcomposablesDispatch(req)theme / breakpoints / spec ×17 / tristate / progress / motion / transformOrigin
ComposablesVersioncomposablesVersion()fork 版本握手(页面版本行的数据来源)
4.3 C++ N-API 方法分发
napi_property_descriptor desc[] = {
    {"composablesDispatch", nullptr, NapiComposablesDispatch, nullptr, nullptr, nullptr,
     static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr},
    {"composablesVersion", nullptr, NapiComposablesVersion, nullptr, nullptr, nullptr,
     static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr},
};

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

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

add_library(composables_napi SHARED napi_init.cpp)
target_link_libraries(composables_napi PUBLIC
  libace_napi.z.so
  libhilog_ndk.z.so
  ohoscomposables)

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

export const composablesDispatch: (request: string) => string;
export const composablesVersion: () => string;

第 5 阶段:ArkUI 宿主

5.1 版本握手和令牌拉取
aboutToAppear(): void {
  const raw = composablesVersion();
  const res = JSON.parse(raw) as VersionResult;
  this.versionLine = `内核 ${res.version} · 上游 ${res.upstream} · Kotlin ${res.kotlin} · 组件 ${res.components}`;
  this.loadTheme();
  this.loadButtonSpec();
  this.loadMotion();
  this.loadCheckSpec();
  this.resolveBreakpoints();
  this.indTimer = setInterval(() => this.tickIndeterminate(), 33);
}

不定态进度条每帧(33ms)调一次 op=progress 取 offsetFraction——扫描动画本身就是 Native 驱动的,这是「数值过桥」最直观的运行时证明。

5.2 页面交互

五个 Tab 全部由 KN 数据驱动:主题 Tab 切 Light/Dark 后整页背景、面板、按钮、色板联动,Touch/Pointer 切换改变按钮圆角(100↔10)与排版(17↔15sp);按钮 Tab 的六种 ButtonStyle 用令牌配色、KN 高度/内距/圆角布局;控件 Tab 的 TriState 每次点击都过 op=tristate 状态机,对勾按 KN 归一化坐标以 Path 绘制;弹层 Tab 的对话框用 KN 控制点构建 curves.cubicBezierCurve,菜单面板按 op=transformOrigin 返回的 pivot 缩放淡入;规格 Tab 拖宽高滑杆实时解析断点标签,17 个组件的 spec JSON 原文回显。

5.3 对比度和错误边界

每个交互后状态行回显 ok <op> …,操作可追溯;ok:false 时错误原文进状态行。Native 调用本身抛错(理论不该发生)会中断页面构建,桥侧 try/catch 已兜底。

第 6 阶段:示例与验证

6.1 工程结构
example/
├── nativeApp/
│   ├── build.gradle.kts                      双 ABI sharedLib(baseName=ohoscomposables)+ cxxShared + copyKnSo*
│   └── src/ohosMain/kotlin/Bridge.kt         @CName × 2 + MiniJson + Json writer(继承自 cmp_qrose 审核模式)
└── harmonyApp/
    ├── build-profile.json5                   product 引用 signingConfig "default"
    ├── AppScope/app.json5                    bundleName org.terminator.ohos.composablesui、versionName 0.2.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/libohoscomposables_api.h
        ├── src/main/cpp/types/libcomposables_napi/{index.d.ts, oh-package.json5}
        └── src/main/ets/pages/Index.ets      ArkUI 五 Tab 验收页面
6.2 构建、签名和安装
./gradlew :ui:jvmTest                                            # 19 项交叉验证
./gradlew :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64

cd example/harmonyApp
hvigorw assembleHap -p product=default -p buildMode=debug --no-daemon

命令行打包必须配两个环境变量:DEVECO_SDK_HOME 指向 DevEco SDK、JAVA_HOME 指向 DevEco 自带 JBR(系统 JDK 8 会让 PackageHap 阶段直接 spawn java ENOENT 或报不相干的错)。签名在 DevEco Studio 的 File → Project Structure → Signing Configs 中勾选自动生成,证书不进入仓库。

hdc list targets -v
hdc -t <设备序列号> install -r \
  entry/build/default/outputs/default/entry-default-unsigned.hap
hdc -t <设备序列号> shell aa start \
  -a EntryAbility -b org.terminator.ohos.composablesui -m entry

四、完整代码对照

4.1 整体架构

com.composables.ui/**(数值内核,提取自上游源码原值)
    │ ThemeSpecs(26 令牌/形状/阴影/排版)/ WindowBreakpointsSpecs
    │ ComponentSpecs(尺寸数学 + TriState + 进度公式)/ MotionSpecs(时长/曲线/pivot)
Bridge.kt + MiniJson
    │ ComposablesDispatch / ComposablesVersion
libcomposables_napi.so
    │ composablesDispatch / composablesVersion
Index.ets
    │ JSON parse + @State 更新
    ▼
ArkUI 设备页面(五 Tab,按同一套数值重放组件)

上游 composeMain/**(25 个 @Composable 文件)零损耗保存在树里,不参与任何目标编译,等社区 compose.ui ohos 制品与上游版本对齐后接回。

4.2 文件清单

文件职责
settings.gradle.kts极简 ohos 构建、eazytec Maven 置前、ohos_composables_ui、上游模块移出
gradle/libs.versions.tomlKotlin 2.2.21-1.0.0 单条钉版
build.gradle.kts(根)版本 0.2.0-ohos.1、全局禁用 Ohos*Test* 任务
ui/build.gradle.kts纯 KMP:jvm + ohosArm64 + ohosX64,零依赖
ui/src/commonMain/.../ThemeSpecs.kt 等 5 文件数值内核(令牌/断点/规格/动效/版本)
example/nativeApp/build.gradle.kts双 ABI sharedLib、cxxShared 定位 libc++、copyKnSo* 回填
nativeApp/.../Bridge.kt2 个 C ABI、8 个 op 分发、MiniJson、错误通道
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作用
内核themeSpec(scheme, mode)26 令牌 + 形状 + 阴影 + 透明度 + 排版,四档组合
内核resolveWidthBreakpoint / resolveHeightBreakpoint0/600/840/1200/1600 与 0/1080 断点解析
内核buttonHeightDp / buttonHorizontalPaddingDp48/36dp 二分法 ±4dp
内核nextTriState / isTriCheckedOff→On、Indeterminate→On、On→Off
内核ProgressSpec.indeterminateOffsetFraction(1+w)t−w,35% 宽度段扫描
内核overlayTransformOrigin(side, alignment)弹层 pivot 分数映射
NativeComposablesDispatch / ComposablesVersionC ABI JSON 导出
N-APIcomposablesDispatch(req) / composablesVersion()向 ArkTS 暴露能力
ArkTSloadTheme() / cycleTriState() 等触发操作并刷新 UI

4.4 ArkTS 与 Kotlin 的边界

const theme = JSON.parse(composablesDispatch(JSON.stringify({
  op: 'theme', scheme: 'dark', mode: 'touch'
}))) as ThemeResult;
// theme.colors.backgroundColor === '#FF0A0A0A'
// theme.shapes.buttonShape === 100

const tri = JSON.parse(composablesDispatch(JSON.stringify({
  op: 'tristate', state: 'indeterminate'
}))) as TriStateResult;
// tri.next === 'on'(上游 TriStateCheckbox 语义)
val spec = themeSpec(parseScheme(json.str("scheme")), parseMode(json.str("mode")))
val current = when (json.str("state").lowercase()) {
    "on" -> TriState.On
    "indeterminate" -> TriState.Indeterminate
    else -> TriState.Off
}
val next = nextTriState(current)

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

五、关键决策说明

决策 1:提取数值内核而不是等 Compose 工具链或重写组件

社区 CMP 1.9.2 与上游 Compose 1.11 之间隔着 TextFieldState / Navigation 3 / 新 theming DSL 等 API 断层,等待对齐没有时间表;在 ArkTS 里重写 24 个组件则是 fork 设计而非适配。数值提取让「上游改令牌 → fork 重编译 → 鸿蒙侧跟着变」这条链路成立——JVM 断言红即提醒漂移,这是「适配」与「重写」的分界线。

决策 2:工具链钉回 2.2.21-1.0.0,Gradle 降 8.14.1

与 KStore 相反:内核零第三方依赖,没有 kotlinx 制品的 stdlib 元数据约束,不需要 2.4.20 线。钉回 qrose 同线的 2.2.21-1.0.0 最大化复用社区已验证经验;代价是 Gradle Wrapper 必须从 9.5.1 降到 8.14.1——两条工具链线的经验不能互相套用,这条在两个库里各自成立。

决策 3:根构建替换(cmp_qrose 模式),上游工程留盘

上游根构建(spotless / AGP 9 / changesets npm 发布链 / demo / apps / devtools)对鸿蒙没有保留价值,整个替换为极简 ohos 构建;demo/ preview/ apps/ devtools/ packages/ 留盘不进 settings.gradle.kts,它们依赖被剥离的发布链,拿来 sync 必死。

决策 4:载荷是设计决策,不是渲染结果

UI 库的桥不返回图片或组件树:返回图片会让鸿蒙侧失去无障碍、按压态与焦点响应,那才是组件库的命。桥返回「设计的因」——令牌/规格/时长/状态机,渲染由 ArkTS 按数值重放;手势物理(滑杆吸附、抽屉拖拽)在上游二进制依赖里,如实列为已知限制而不伪造。

决策 5:jvmTest 断言钉值 + 上游测试零改动保存

与 KStore「跑上游原测试」不同,组件库上游唯一的测试(TextFieldTest)测的是 Compose 行为,无法在内核上跑。交叉验证改为新写 19 项断言把每个提取值钉死在上游源码字面量上(hex、dp、毫秒、控制点、状态机循环),上游测试原样保存于 composeTest。断言即升级向导:上游改版后重跑,红了的就是漂移点。

六、测试与验证

6.1 测试环境

本次验证使用 Windows(x86_64)、JDK 21(Temurin)、DevEco Studio 5.1.0 Release、OpenHarmony 命令行工具(hvigor / hdc)与 DevEco 模拟器(x86_64,API 兼容 5.1.0(18))。

6.2 JVM 交叉验证

./gradlew :ui:jvmTest

19 项用例全部通过:

测试类用例数钉住什么
ThemeSpecsTest626 令牌集合完整、明/暗 13 组 hex 原值、scrim/ring 的 ARGB alpha 字节(round(0.38×255)=97)、Touch/Pointer 形状与排版分档、两套阴影与禁用 alpha
WindowBreakpointsSpecsTest3阈值表逐项、599/600、839/840、1079/1080 边界行为
ComponentSpecsTest6按钮 44/48/52 与 32/36/40、内距 16/20/24 与 12/16/20、各 minHeight、TriState 循环、进度条端点/中点/时长、18 组组件尺寸
MotionSpecsTest4对话框 400/200/150/100ms + 0.92 + 两组控制点、菜单 120/30/75ms、气泡 300/250ms、焦点环/按压/主题过渡、transform-origin 组合抽查

6.3 HAP 验证

:ui:compileKotlinJvm / compileKotlinOhosArm64 / compileKotlinOhosX64: BUILD SUCCESSFUL
:ui:jvmTest: 19 tests, 0 failures
linkDebugSharedOhosArm64 / linkDebugSharedOhosX64: BUILD SUCCESSFUL
llvm-nm -D: ComposablesDispatch / ComposablesVersion 均为全局 T 符号(双 ABI)
BuildNativeWithCmake: finished(双 ABI 编出 libcomposables_napi.so)
CompileArkTS: finished
PackageHap: finished
BUILD SUCCESSFUL
install bundle successfully
start ability successfully
hilog: version handshake {"ok":true,"version":"0.2.0-ohos.1","upstream":"0.2.0","kotlin":"2.2.21-1.0.0","components":24}

entry-default-unsigned.hap 约 7.1 MB,内含双 ABI 各三件套(libcomposables_napi.so / libohoscomposables.so / libc++_shared.so);KN so arm64 约 3.9MB、x86_64 约 3.6MB(strip 后约 2.0MB / 1.9MB,含 Kotlin/Native 运行时)。

6.4 功能用例

  1. 启动后状态行出现版本握手 内核 0.2.0-ohos.1 · 上游 0.2.0 · Kotlin 2.2.21-1.0.0 · 组件 24(来自真实 Native 调用,hilog 有对应记录);
  2. 主题 Tab:Light/Dark 切换整页联动,backgroundColor 明 #FFF4F4F4 / 暗 #FF0A0A0A,scrim 带 ARGB alpha;Touch/Pointer 切换按钮圆角 100↔10、body 字号 17↔15;
  3. 按钮 Tab:Small/Regular/Large 三档高度 44/48/52(触控)变化,spec 行回显 height=48dp · padding=20dp · radius=100dp;六种 ButtonStyle 配色来自令牌表;
  4. 控件 Tab:TriState 点击循环,状态行回显 ok tristate off -> on checked=true;对勾三段线按归一化坐标绘制;不定态进度条 35% 宽度段 1100ms 循环扫描(每帧调 op=progress);
  5. 弹层 Tab:对话框以 0.92→1 缩放 + 强调减速曲线 400ms 进入;菜单 side/alignment 切换后 pivot= 行回显 KN transformOrigin,面板按该 pivot 缩放淡入;抽屉遮罩 300ms;
  6. 断点/规格 Tab:宽高滑杆拖动,360×780 → compact/short,跨 600/840/1200/1600 逐档变化;17 个组件 spec JSON 原文回显;
  7. 每步操作状态行都有 ok <op> 留痕。

6.5 验证结论

设计令牌内核、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 已完成链路验证。应用在鸿蒙模拟器上成功安装并稳定运行(修复桥接 JSON 缺陷后连续多次启动无闪退),页面以真实 Native 数据渲染了主题令牌、按钮规格、TriState 状态机、进度扫描、弹层动效与窗口断点(见下节效果图)。

七、运行效果

7.1 运行截图

在这里插入图片描述

7.2 命令速查

git clone https://atomgit.com/oh-tpc/composables-ui.git
cd composables-ui
export JAVA_HOME="/path/to/jdk-21"
./gradlew :ui:compileKotlinOhosArm64 :ui:compileKotlinOhosX64              # klib
./gradlew :ui:jvmTest                                                      # 19 项交叉验证
./gradlew :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64
cd example/harmonyApp
export DEVECO_SDK_HOME="<DevEco SDK 路径>"
export JAVA_HOME="<DevEco 自带 JBR 路径>"
hvigorw assembleHap -p product=default -p buildMode=debug --no-daemon
hdc install -r entry/build/default/outputs/default/entry-default-unsigned.hap
hdc shell aa start -a EntryAbility -b org.terminator.ohos.composablesui -m entry
hdc shell hilog | grep Composables

八、遗留问题与改进方向

8.1 踩坑复盘

  1. 只改 ArkTS 页面不算 CMP 适配,必须让数值内核真正编译成 ohosArm64 / ohosX64 动态库,并让页面每个数值都来自 Native;
  2. settings.gradle.kts 里 eazytec 双仓必须排在最前,否则配置期直接 Unresolved reference: ohosArm64;
  3. 上游的 @JvmInline / @Immutable / @Composable 注解在 HarmonyOS Kotlin 上编译期失败,内核一律用普通 enum/class;
  4. Kotlin 类型推断陷阱:alphas 表混入 Long 颜色值会被推断成 Map<String, Number & Comparable<*>>,颜色进 colors 表、alphas 只放 Float;
  5. Float 精度:(1+w)t−w 中点不精确,等价改写 t + w(t−1) 让端点精确,断言才能用精确相等;
  6. 闪退根因是桥侧 JSON,不是 N-API:theme 响应里颜色值未加引号("backgroundColor":#FFF4F4F4)、嵌套对象丢失 {}("colors":"backgroundColor":…),ArkTS JSON.parse 抛 SyntaxError 直接闪退。faultlogger 的 jscrash 报告定位到 loadTheme,再在设备上对响应做十六进制转储(charCodeAt 逐字节打 hilog)拿到铁证——UI 库适配里,桥的 JSON writer 必须被完整解析验证,不能只看编译通过;
  7. 严格 ArkTS 与标准 TS 的 API 差异一次性踩齐:padding({horizontal, vertical}) 不合法(要展开 left/right/top/bottom)、Curve.EASE_OUT 应为 Curve.EaseOut、ScaleOptions 用 centerX/centerY 而非 pivotX/pivotY、LongPressGesture 的 repeat 是 boolean、ScrollAttribute 无 maxHeight、无 FontWeight.SemiBold、cubicBezierCurve 返回 ICurve 与 Curve 枚举不能互赋;
  8. @State border 与组件内置 .border() 属性撞名,严格模式报「not assignable to the same property in base type ‘CustomComponent’」——状态命名先查内置属性表;
  9. hvigor 命令行打包需要 DEVECO_SDK_HOME 指向 DevEco SDK、java 用 DevEco 自带 JBR,否则 PackageHap 阶段 spawn java ENOENT;
  10. HAP 缺当前 ABI 的 so 会报安装失败 9568347,abiFilters 必须双 ABI,每个 ABI 三件套缺一不可;
  11. KN 返回字符串必须 DisposeString 归还运行时,N-API 层不能 free();
  12. Windows 上 hdc 的设备路径会被 Git Bash 转义成 C:/Program Files/Git/data/...,命令前加 MSYS_NO_PATHCONV=1;snapshot_display 只接受 .jpeg 后缀;uitest dumpLayout 的控件树是 {attributes:{text,bounds},children:[]} 递归结构,按 text 找 bounds 中心坐标点击,不写死分辨率。

8.2 已知问题

  • 鸿蒙上没有 Compose 渲染:Button/Tabs/AlertDialog 等 @Composable 仍在 composeMain,等社区 compose.ui ohos 制品与上游版本对齐后接回,数值层届时降级为参考实现;
  • 手势物理没过桥:滑杆步进吸附、抽屉拖拽安放、滚动条滑块几何在上游二进制依赖 composeunstyled 里,不在本仓源码;Demo 滑杆用 ArkUI 原生 Slider,规格(track 6dp / thumb 18dp)来自本仓;
  • 动效是参数级复刻不是曲线级:强调曲线控制点过桥后由 ArkTS curves.cubicBezierCurve 重建,观感接近但不保证与 Compose 逐帧一致;
  • 令牌表是 0.2.0 快照:上游改色板/规格后需重提内核并重跑 19 项断言——这是刻意设计,断言红即提醒漂移;
  • android / js / wasmJs / apple 目标与上游 demo/apps/docs 一并留盘不进构建,本 fork 不发 Maven Central;
  • HAP 安装必须使用与 bundle name org.terminator.ohos.composablesui 匹配的签名。

8.3 未来优化方向

  • 真机(arm64)签名验收与截图补充,形成模拟器 + 真机双矩阵;
  • 把 uitest 自动化点击固化成仓库内脚本(五个 Tab 全量遍历 + 截图归档),替代手工回归;
  • 评估 composeunstyled 独立成仓适配,补齐滑杆吸附与抽屉安放的手势物理;
  • 在 CI 中加入 ohos 编译、ELF 符号检查和 HAP 构建;
  • 若社区 CMP 工具链与上游版本对齐,把 composeMain 接回 commonMain,在 ComposeArkUIViewController 里直接跑组件。

九、总结

9.1 核心难点回顾

上游 composables-ui(25 个 @Composable 文件)
    → 判定"渲染不可编 / 数值可编"的拆分边界
    → commonMain→composeMain 零损耗迁移(git 识别 100% rename)
    → 五文件数值内核(令牌/断点/规格/状态机/动效)
    → 工具链对齐 Kotlin 2.2.21-1.0.0(Gradle 降 8.14.1)
    → 根构建替换(cmp_qrose 模式)
    → ohosArm64 / ohosX64 KLIB + 双 ABI 动态库
    → C ABI(@CName × 2)+ 8 op JSON 载荷
    → C++ N-API(DisposeString 纪律)
    → ArkTS JSON 解析(严格模式 API 差异适配)
    → ArkUI 五 Tab 验收(主题/按钮/控件/弹层/断点规格)
    → 修复桥 JSON 两处缺陷,模拟器稳定运行 + 自动化截图

9.2 封装层次

  • 内核层:提取自上游源码原值的 26 色彩令牌、9 形状、2 阴影、窗口断点、尺寸数学、TriState 状态机、进度公式与动效参数;
  • Native 层:生成双 ABI 动态库并通过 2 个 C ABI 输出 JSON;
  • N-API 层:完成参数检查、字符串转换和 Native 内存释放;
  • ArkTS 层:管理页面状态、JSON 解析、按数值重放组件、状态行留痕;
  • DevEco 层:完成 CMake、HAP、签名、安装和模拟器运行。

9.3 三条经验

  1. 纯 UI 库的适配面是「设计的因」而不是「渲染的果」:组件过桥传令牌/规格/时长/状态机,不传图片——返回图片会让鸿蒙侧失去无障碍、按压态与焦点响应,数值过桥才能让「上游改令牌、鸿蒙跟着变」的链路成立;
  2. 工具链线跟着依赖形态走:KStore 因为拉 kotlinx 制品被 stdlib 元数据逼到 2.4.20 线(Gradle 留 9.x),本库内核零依赖就钉回 2.2.21 线(Gradle 降 8.14.1)——先看依赖再定钉版,不套用上一仓的结论;
  3. 桥的 JSON 必须被「完整解析」验证:编译通过、符号导出都不代表响应合法;本次闪退的两处缺陷(值未引号、嵌套丢大括号)只有设备上 JSON.parse 才暴露。jscrash 报告 + 响应十六进制转储是定位这类问题的最短路径,比反复猜测 N-API 编码问题快一个数量级。

9.4 适配成果

当前适配已完成:

  • Composables UI 0.2.0 设计系统数值层的双 ABI Kotlin/Native 编译(五文件内核,上游 25 个 Compose 源文件零损耗保存于 composeMain,git 全量识别为 rename);
  • 2 个 C ABI 符号和 C++ N-API 包装层(llvm-nm 验证全局 T 符号,双 ABI);
  • DevEco Stage ArkUI 五 Tab 验收页面(主题四档联动、按钮规格、TriState 状态机、弹层动效、断点/规格浏览器、版本握手、状态行留痕);
  • 19 项 JVM 交叉验证全绿 + HAP 构建(双 ABI 六件套 so,约 7.1MB)、设备安装和模拟器验证(含闪退定位修复与自动化截图);
  • 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开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐