开源鸿蒙平台 CMP 三方库「compose-unstyled」适配全流程
本文记录
Compose Unstyled接入 OpenHarmony 的完整过程,覆盖无头 UI 原语库盘点、ohosArm64/ohosX64目标、行为内核抽取、Kotlin/Native 动态库、C ABI/N-API 桥接、ArkUI 真机页面、HAP 构建、签名和真机验收。
Compose Unstyled是 Compose Multiplatform 的无头(headless)UI 原语库,价值不在视觉而在行为契约:detent 怎么吸附、刻度怎么量化、断点怎么解析、弹层贴哪条边。本次适配让这些行为内核进入 OpenHarmony ARM64 运行时,ArkUI 页面作为真机验收宿主,数值与上游commonTest断言逐位一致。
项目地址: AtomGit/oh-tpc/compose-unstyled
上游仓库: composablehorizons/compose-unstyled
开发工具: 华为云码道
环境安装: 环境安装指南
一、背景
1.1 为什么做 OpenHarmony 平台 KMP/CMP 适配
Compose Unstyled 上游(composablehorizons/compose-unstyled 2.10.1)提供 Bottom Sheet、Dialog、Modal、Portal、Dropdown Menu、Tooltip、Slider、Checkbox、Radio Group、Tab Group、Toggle Switch、窗口断点、主题系统等 37 个模块,全部是 @Composable + Modifier + semantics,工具链是 Kotlin 2.4 + Compose Multiplatform 1.12 + AGP 9.1 + Gradle 9.3。KMP 项目把这些行为契约放在 commonMain,由 Android、iOS、桌面、Web 共享;OpenHarmony 应用同样需要同一套行为,但社区鸿蒙工具链是 HarmonyOS Kotlin 2.2.21-1.0.0 + Compose 1.9.2 线,两套版本线不能共用一个 Gradle 构建,也没有任何已审核的三方库适配为 ohos 编译 Compose。
如果只把示例页面改成 ArkTS,页面可以显示组件,却无法证明行为内核已经在 OpenHarmony ARM64 设备上完成编译、链接和执行,也无法证明吸附/刻度/断点数值和 Android 端一致。适配需要同时验证 KMP 内核、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 交付链路。
| 障碍 | 具体问题 |
|---|---|
| 目标缺失 | 必须增加 ohosArm64()/ohosX64() 才能生成 OpenHarmony KLIB。 |
| 工具链不一致 | 上游 Kotlin 2.4 / Compose 1.12 无法与鸿蒙 Kotlin 2.2.21-1.0.0 混编。 |
| 语言边界不同 | ArkTS 不能直接持有 Kotlin 对象,需要 C ABI、C++ N-API 和 JSON。 |
| 行为一致性 | detent 吸附、刻度量化、断点解析必须有可对拍的数值断言。 |
| 仓库体量大 | 37 个库模块 + demo + visual-regressions + website,改根构建审核面太大。 |
| 交付链路复杂 | .so、CMake、N-API、HAP、签名和 hdc 都要单独验收。 |
因此,本项目把边界放在 KMP 目标配置、行为内核抽取和 C ABI/N-API 桥接层。交互语义仍由公共实现负责,ArkTS 只负责调用 Native、解析 JSON 和展示结果。
1.2 库提供的能力
示例提供四个可观察场景、五个行为引擎:
- 底部抽屉(detents):fraction / content / hidden / fully-expanded 四类 detent 的锚点解析,松手按速度阈值(125)优先、位移阈值(56)兜底的目标决策;
- 滑杆(slider):
stepsToTickFractions刻度数组(含端点,长度 steps+2)、snapValueToTick最邻近吸附、键盘六键步进(步长(max-min)/(steps+1)、连续档 1%、Page 键 ×10); - 断点(breakpoints):自定义断点表的
resolve(取最后一个size >= minSize)与isAtLeast排名比较; - 锚定定位(anchored):四边 × 三对齐 × RTL 的浮动定位几何、窗口贴边回拉与
positionAdjustment; - 三态(toggle):On → Off、Off → On、Indeterminate → On 的循环契约(上游委托给 foundation
triStateToggleable)。
引擎模块包含:
| 文件 | 作用 |
|---|---|
SheetDetents.kt | detent 定义、高度解析、锚点表、computeTarget 决策。 |
SliderEngine.kt | 刻度量化、值域换算、键盘步进。 |
BreakpointsEngine.kt | 断点表校验、解析与排名比较。 |
AnchoredEngine.kt | 锚定浮动定位几何与贴边回拉。 |
ToggleEngine.kt | 三态循环。 |
共享层还提供 61 项 JVM 检查:上游 Slider.test.kt 的吸附断言(0.6 不被 9 步吸附、0.7→0.6)、上游 AnchoredPositionTest.kt 的 8 个几何用例、上游 Breakpoints.test.kt 的阈值边界与校验文案、detent 锚点与决策表、三态循环和 JSON 桥端到端往返。
1.3 实现适配
| 维度 | 要求 |
|---|---|
| 代码复用 | 行为内核逐行移植自上游 commonMain,文件头标注出处,算术不变。 |
| 平台目标 | example/nativeApp 增加 ohosArm64 + ohosX64,生成双 ABI 动态库。 |
| 上游零改动 | 根构建与 37 个上游模块 byte-identical,审核面只有新增文件。 |
| 桥接稳定 | 单入口 @CName + UTF-8 JSON,不传递 Kotlin 对象地址。 |
| CMP 兼容 | CMP 应用复用同一行为契约;ArkUI 只负责真机验收。 |
| UI 完整 | 支持拖拽、松手吸附、档位切换、键盘步进、断点矩阵、贴边标记和错误展示。 |
| 仓库规范 | 项目说明、文章、效果图和源码链接统一使用 AtomGit。 |
本项目的 ArkUI 页面不是 Compose Multiplatform UI 实现。CMP 工程可以直接依赖 OpenHarmony 行为内核,在自己的 Compose 页面中获得与 Android/iOS 一致的吸附、刻度和断点行为。
二、实现路线图
第 1 阶段:项目初始化 ── 盘点 37 个模块、引擎候选和 OpenHarmony 交付物
第 2 阶段:目标与依赖打通 ── 独立 example 构建、eazytec 工具链、ohos 双目标
第 3 阶段:行为内核建模 ── 抽取五个引擎、JSON 契约和上游断言钉值
第 4 阶段:原生桥接 ── C ABI、C++ N-API、参数校验和内存释放
第 5 阶段:ArkUI 宿主 ── 四 Tab 页面、手势采样和错误边界
第 6 阶段:示例与验证 ── HAP、签名、设备安装、真机运行和效果图
三、逐步实现过程
第 1 阶段:项目初始化
1.1 工程边界
(仓库根) 上游 37 个模块、根构建:零改动
example/settings.gradle.kts 独立 ohos 构建,rootProject ohos_composeunstyled
example/nativeApp/src/commonMain/ 五个行为引擎 + MiniJson + JSON 分发层
example/nativeApp/src/ohosMain/ @CName 导出
example/nativeApp/src/jvmTest/ 上游 commonTest 期望值交叉验证
example/harmonyApp/ DevEco Stage、N-API 和 ArkTS 四 Tab 页面
README.OpenHarmony.md / _CN.md 鸿蒙适配说明(中文版为主文档)
example 是独立 Gradle 工程,不把 DevEco 工程作为根项目的 Kotlin 子模块,上游 Gradle(9.3.1)与鸿蒙 Gradle(8.14.1)物理隔离、互不知道对方存在。
1.2 工具链矩阵
| 项目 | 配置 |
|---|---|
| Kotlin Multiplatform | OpenHarmony 独立构建使用 2.2.21-1.0.0(eazytec Maven) |
| Gradle | 8.14.1(example/ 内独立 wrapper) |
| JDK | 21 |
| OpenHarmony 目标 | ohosArm64 + ohosX64 |
| DevEco product | 5.1.0(18) |
| DevEco Studio | 5.1.0 Release |
| ABI | arm64-v8a + x86_64 |
| Bundle | com.composeunstyled.ohos |
export JAVA_HOME="/opt/homebrew/opt/openjdk@21" # 或本机 JDK 21 路径
java -version
1.3 页面边界
标题区 COMPOSE UNSTYLED · OPENHARMONY
Tab 区 底部抽屉 / 滑杆 / 断点 / 锚定定位
状态行 每次桥调用的关键返回值(detent、吸附值、断点名、贴边量)
底部抽屉 拖拽手柄 + 隐藏 / Peek / 展开按钮
滑杆 刻度轨道 + 六个键盘步进按钮
断点 宽高输入滑杆 + isAtLeast 矩阵
锚定 触发器 + 弹层 + RTL 开关 + 三态复选框
页面启动时调一次 detents/anchors 解析锚点并落在 Peek 档。
第 2 阶段:目标与依赖打通
2.1 独立构建与插件仓库
example/settings.gradle.kts 两个 eazytec 仓库必须置顶,否则 Gradle 解析到官方 Kotlin,配置期报 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_composeunstyled"
include(":nativeApp")
2.2 加入双目标与 sharedLib
kotlin {
applyDefaultHierarchyTemplate()
jvmToolchain(21)
jvm()
ohosArm64 {
binaries.sharedLib { baseName = "ohosunstyled" }
}
ohosX64 {
binaries.sharedLib { baseName = "ohosunstyled" }
}
}
sharedLib 只放在 nativeApp,不放在库模块,避免每个模块都带一份 Kotlin/Native 运行时。
2.3 构建核心产物
cd example
./gradlew :nativeApp:jvmTest
./gradlew :nativeApp:linkDebugSharedOhosArm64 :nativeApp:linkDebugSharedOhosX64
copyKnSoArm64 / copyKnSoX64 由 link 任务 finalizedBy 触发,准备:
example/harmonyApp/entry/libs/arm64-v8a/libohosunstyled.so (约 8.7 MB)
example/harmonyApp/entry/libs/arm64-v8a/libc++_shared.so
example/harmonyApp/entry/libs/x86_64/libohosunstyled.so (约 7.9 MB,模拟器用)
example/harmonyApp/entry/libs/x86_64/libc++_shared.so
第 3 阶段:行为内核建模(技术难点)
3.1 为什么能抽
上游虽然 100% 是 Compose 代码,但行为契约与 Compose 管线是分离的:computeTarget 是纯数值决策,stepsToTickFractions 是纯数组公式,BreakpointScale.resolve 是纯区间查找。抽取只做三类替换:Dp → Float(单位由调用方定义,ArkTS 传 vp,与 Dp 1:1)、Density.toPx() → 恒等、Compose 状态 → 纯字段 + 返回值。算术一行不改。
3.2 detent 引擎(核心)
class SheetDetent(
val identifier: String,
val calculateDetentHeight: (containerHeight: Float, sheetHeight: Float) -> Float,
) {
companion object {
val FullyExpanded = SheetDetent("fully-expanded") { _, sheetHeight -> sheetHeight }
val Hidden = SheetDetent("hidden") { _, _ -> 0f }
}
}
锚点解析按上游 invalidateDetents 移植:每档算高度(fraction 档乘容器、content 档取内容高、sheet 高度依赖档封顶、FullyExpanded 在其它档超过内容高时长到容器),再 offset = containerHeight - height,同时跟踪 closestDetentToTop 和 maxDetentHeight。
松手决策 computeTarget 是逐行移植的关键(上游 DraggableAnchors<SheetDetent>.computeTarget):
fun computeTarget(
currentOffset: Float,
currentDetent: SheetDetent,
velocity: Float,
positionalThreshold: (totalDistance: Float) -> Float,
velocityThreshold: () -> Float,
): SheetDetent {
val isMoving = abs(velocity) > 0f
val currentPosition = positionOf(currentDetent)
if (currentPosition.isNaN()) return closestAnchor(currentOffset) ?: firstAnchor()
val isMovingDown = if (isMoving) velocity > 0f else currentOffset > currentPosition
if (isMoving.not() && abs(currentOffset - currentPosition) < 0.5f) return currentDetent
if (abs(velocity) >= abs(velocityThreshold())) {
return closestAnchor(currentOffset, searchUpwards = isMovingDown) ?: firstAnchor()
}
// 速度不足:以上/下锚点距离与位移阈值比较后提交或退回
...
}
注意 offset 方向:offset 是容器顶到 sheet 顶的距离,越大越收起;hidden 的 offset 等于容器高。两个方向查找函数语义不同:foundation 的 closestAnchor(searchUpwards=true) 取 position ≥ offset,仓库私有的 closestDetent(movingUp=true) 取 position < offset,均按出处分开移植。
3.3 slider / breakpoints / anchored / toggle 引擎
fun stepsToTickFractions(steps: Int): FloatArray {
return if (steps == 0) floatArrayOf()
else FloatArray(steps + 2) { it.toFloat() / (steps + 1) }
}
fun snapValueToTick(current: Float, tickFractions: FloatArray, min: Float, max: Float): Float {
return tickFractions
.minByOrNull { abs(lerp(min, max, it) - current) }
?.run { lerp(min, max, this) }
?: current
}
steps=4 的刻度是 [0, 0.2, 0.4, 0.6, 0.8, 1](含端点),吸附是最邻近而非四舍五入。断点引擎保留上游四条 require 校验与原文案;anchored 引擎用 isRtl: Boolean 替代 LayoutDirection,贴边后返回 positionAdjustment;toggle 引擎钉住 foundation triStateToggleable 的循环。
3.4 上游断言钉值
jvmTest 把上游 commonTest 期望原样搬过来,例如上游 AnchoredPositionTest.appliesOffsetsBeforeClamping:
@Test
fun appliesOffsetsBeforeClamping() {
val position = calculatePosition(sideOffset = 8, alignmentOffset = 12)
assertThat(position).isEqualTo(IntOffset(x = 112, y = 98))
}
8 个几何用例、滑杆吸附表、断点边界、detent 决策表共 61 项断言全部绿色,证明移植没有让数值漂移。
第 4 阶段:原生桥接(技术难点)
4.1 三层桥接
ArkTS
│ JSON string(domain + op + 字段)
▼
C++ N-API entry
│ const char*
▼
Kotlin/Native C ABI
│ UnstyledBridge → 五个引擎
▼
UTF-8 JSON + explicit free
直接导出 Kotlin 对象会暴露 ABI 和生命周期问题;给每个能力单独导出符号又会让 C 签名随字段演进变化,所以选择单入口 @CName + JSON 分发。
4.2 Kotlin/Native 导出函数
@CName("UnstyledEngine")
fun unstyledEngine(request: String): String {
return UnstyledBridge.handle(request)
}
handle 内部用 try/catch 全包,任何失败变成 {"ok":false,"error":"..."},Kotlin 异常从不跨语言边界;错误文案就是上游 require/check 的原文,方便和 Android 端异常对拍。响应侧的浮点做定点格式化(最多两位小数、永不科学计数法),避免 Kotlin/Native 产出 1.0E-5 打断 JSON.parse。
4.3 C++ N-API 方法分发
static napi_value NapiUnstyledEngine(napi_env env, napi_callback_info info) {
// 参数个数 + typeof 检查(必须为 string)
const std::string request = JsToUtf8(env, args[0]);
const char *raw = UnstyledEngine(request.c_str());
napi_value out = nullptr;
napi_create_string_utf8(env, raw, NAPI_AUTO_LENGTH, &out);
DisposeKnString(raw); // 经 libohosunstyled_symbols()->DisposeString 释放
return out;
}
HarmonyOS CAdapter 不会把 @CName 业务符号挂给 ArkTS,所以 ArkTS 只 import CMake 包装出来的 libunstyled_napi.so。N-API 只负责参数类型、字符串创建和释放,不实现交互语义。
4.4 CMake 和类型声明
add_library(ohosunstyled SHARED IMPORTED)
set_target_properties(ohosunstyled PROPERTIES
IMPORTED_LOCATION ${KN_SO_DIR}/libohosunstyled.so
IMPORTED_NO_SONAME TRUE)
add_library(unstyled_napi SHARED napi_init.cpp)
target_link_options(unstyled_napi PRIVATE "-Wl,--no-undefined" "-Wl,-rpath,\$ORIGIN")
target_link_libraries(unstyled_napi PUBLIC libace_napi.z.so libhilog_ndk.z.so ohosunstyled)
export const unstyledEngine: (request: string) => string;
第 5 阶段:ArkUI 宿主
5.1 松手决策调用
import { unstyledEngine } from 'libunstyled_napi.so';
const raw = unstyledEngine(JSON.stringify({
domain: 'detents', op: 'target', anchors,
current, offset: this.sheetOffset, velocity,
positionalThreshold: 56, velocityThreshold: 125,
}));
const effect = JSON.parse(raw) as DetentsTargetEffect;
锚点在容器尺寸确定后解析一次并缓存,拖拽期间只发 target。PanGesture 的线速度由最后两个采样点差分估算(阈值 125 很低,误差不影响决策)。
5.2 页面交互
页面支持四 Tab 切换:底部抽屉跟手拖拽 + 三档跳转;滑杆档位(连续/2/4/9 步)切换 + 拖拽吸附 + 六键步进;断点宽高输入 + isAtLeast 矩阵;锚定四边 × 三对齐 × RTL + 三态复选框。所有数值来自共享 Kotlin 结果,ArkTS 不重新实现任何吸附或量化。
5.3 对比度和错误边界
Tab 选中态使用黑底白字胶囊,滑杆填充与刻度使用深灰/蓝色,断点芯片绿色表示已达到、灰色表示未达到,贴边弹层带「已贴边」标记。桥返回 ok:false 时状态行显示 失败: <上游报错文案>,页面不崩溃。
第 6 阶段:示例与验证
6.1 工程结构
example/
├── settings.gradle.kts + gradlew + gradle.properties
├── nativeApp/
│ ├── src/commonMain/kotlin/com/composeunstyled/ 五个引擎 + MiniJson + UnstyledBridge
│ ├── src/ohosMain/kotlin/com/composeunstyled/ohos/ @CName("UnstyledEngine")
│ └── src/jvmTest/kotlin/com/composeunstyled/ 61 项交叉验证
└── harmonyApp/
├── AppScope/app.json5 bundleName com.composeunstyled.ohos
├── entry/src/main/cpp/CMakeLists.txt + napi_init.cpp + include/
└── entry/src/main/ets/pages/Index.ets 四 Tab ArkUI 页面
6.2 构建、签名和安装
export JAVA_HOME="/opt/homebrew/opt/openjdk@21"
cd example
./gradlew :nativeApp:linkDebugSharedOhosArm64 :nativeApp:linkDebugSharedOhosX64
然后用 DevEco Studio 打开 example/harmonyApp(不要打开仓库根目录),File → Project Structure → Signing Configs 勾选自动签名(证书不进仓库,signingConfigs 保持空数组),Build HAP:
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 com.composeunstyled.ohos
四、完整代码对照
4.1 整体架构
SheetDetents / SliderEngine / BreakpointsEngine / AnchoredEngine / ToggleEngine
│ 纯 Kotlin 行为内核(上游断言钉值)
▼
UnstyledBridge.kt + @CName("UnstyledEngine")
│ 单入口 JSON 分发
▼
libunstyled_napi.so
│ unstyledEngine(request: string): string
▼
Index.ets
│ JSON parse + 手势 + animateTo
▼
ArkUI 真机页面
4.2 文件清单
| 文件 | 职责 |
|---|---|
example/settings.gradle.kts | 独立 ohos 构建和 eazytec 插件仓库 |
example/gradle.properties | JDK 21、version=2.10.1-ohos.1、konan 开关 |
example/nativeApp/build.gradle.kts | ohosArm64/ohosX64 sharedLib、copyKnSo*、禁用损坏的 ohos 测试任务 |
nativeApp/.../SheetDetents.kt | detent 定义、锚点解析、computeTarget |
nativeApp/.../SliderEngine.kt | 刻度量化与键盘步进 |
nativeApp/.../BreakpointsEngine.kt | 断点表校验与解析 |
nativeApp/.../AnchoredEngine.kt | 浮动定位几何 |
nativeApp/.../ToggleEngine.kt | 三态循环 |
nativeApp/.../UnstyledBridge.kt | JSON 分发、错误包裹、定点格式化 |
nativeApp/.../ohos/Bridge.kt | @CName("UnstyledEngine") 导出 |
nativeApp/src/jvmTest/ | 61 项上游断言交叉验证 |
harmonyApp/.../napi_init.cpp | N-API 导出、参数检查、DisposeString |
harmonyApp/.../CMakeLists.txt | IMPORTED KN so、$ORIGIN rpath、双 ABI |
harmonyApp/.../Index.ets | 四 Tab ArkUI 页面 |
README.OpenHarmony.md / _CN.md | 鸿蒙适配说明(中文版 600 行主文档) |
4.3 关键 API 对照
| 层次 | API | 作用 |
|---|---|---|
| KMP | ResolvedDetentAnchors.fromDetents | 解析每档高度与锚点偏移 |
| KMP | computeTarget | 松手目标决策(速度/位移阈值) |
| KMP | stepsToTickFractions / snapValueToTick | 刻度数组与吸附 |
| KMP | BreakpointScale.breakpointFor / isAtLeast | 断点解析与排名 |
| KMP | calculateFloatingPlacement | 锚定定位与贴边回拉 |
| KMP | ToggleEngine.cycle | 三态循环 |
| Native | UnstyledEngine | 单入口 JSON-in/JSON-out |
| N-API | unstyledEngine | 向 ArkTS 暴露唯一函数 |
| ArkTS | settle(Demo 封装) | 松手时求目标档并 animateTo |
4.4 ArkTS 与 Kotlin 的边界
const effect = JSON.parse(unstyledEngine(JSON.stringify(request))) as DetentsTargetEffect;
@CName("UnstyledEngine")
fun unstyledEngine(request: String): String = UnstyledBridge.handle(request)
两者之间只传输 JSON 字符串,不传输 Kotlin 对象或未校验的动态结构;长度/速度单位由调用方定义(Demo 全程 vp,与 Compose Dp 1:1,上游默认阈值 125/56 字面量直传)。
五、关键决策说明
决策 1:根构建零改动,独立 example 构建
37 个模块的仓库改根构建 diff 面太大;voyager / miuix / PreCompose 的「根零改动」变体已是审核通过的先例,上游源码保持 byte-identical,引擎以复制 + 标注出处的方式进入 example。
决策 2:不为 ohos 编译 Compose
上游 Compose 1.12 / Kotlin 2.4 与社区 Compose 1.9.2 线不能混编,也没有已审核先例;能跨版本存活的是行为数值,抽引擎而不是搬 UI。
决策 3:只抽有测试钉得住的内核
五个引擎的共同点是上游 commonTest 有断言。Portal / Modal 栈、滚动条、主题令牌强依赖组合作用域或纯视觉,没有可对拍数值,不抽——「有测试钉得住才抽」是后续扩展的准入标准。
决策 4:单入口 JSON 作为跨语言契约
一个 @CName 符号 + domain/op 分发,C 签名永不随字段演进变化;错误走 ok:false + 上游文案,浮点走定点格式化,ArkTS 侧 unstyledEngine(string): string 永远不用改。
决策 5:ArkUI 作为验收宿主,CMP 复用行为契约
当前页面验证 OpenHarmony Native 链路与数值一致性;CMP 应用在自己的 Compose UI 中复用同一套 detent / 刻度 / 断点行为,等社区出现与 Compose 1.12 对齐的 ohos 制品再把上游模块整体接回。
六、测试与验证
6.1 测试环境
本次验证使用 macOS(Apple Silicon)、JDK 21(Homebrew openjdk@21)、DevEco Studio 5.1.0 Release、Kotlin/Native ohos 双目标工具链,以及 HarmonyOS 真机(arm64)与 DevEco 模拟器(x86_64)。
6.2 共享内核测试
cd example
./gradlew :nativeApp:jvmTest
61 项断言全部通过:滑杆吸附表(0.6/9 步、0.7→0.6、键盘 ±步 / Home / End / Page)、锚定几何 8 用例((112,98)、RTL (108,98)、贴边 (0,0) 与回拉 (100,190))、断点阈值边界与四条校验文案、detent 锚点表与 computeTarget 决策表(速度档 / 位移档 / 静止档)、三态循环和 JSON 桥端到端往返。
6.3 HAP 验证
CompileArkTS: finished
PackageHap: finished
SignHap: finished
BUILD SUCCESSFUL
install bundle successfully
start ability successfully
nm 检查 libohosunstyled.so:UnstyledEngine 为全局 T 符号;libunstyled_napi.so 的 DT_NEEDED 只含短名(libohosunstyled.so、libace_napi.z.so、libc++_shared.so),无本机绝对路径。
6.4 功能用例
- 底部抽屉:拖拽跟手且被
closestToTop与容器底约束;慢拖过 56 吸上一档、不足退回;快甩按方向越档;三按钮直达指定档; - 滑杆:档位 0/2/4/9 切换刻度数;拖到 0.37 附近落 0.4;键盘 ±步、Home/End、PgUp/PgDn 行为与上游一致;
- 断点:宽高越过 600/840 边界切换断点名;恰好 600 归 medium;isAtLeast 矩阵绿色正确;
- 锚定:四边 × 三对齐组合贴边正确;拖到视口角落弹层被顶回并显示「已贴边」;RTL 下 Start/End 互换;三态复选框按 ✓ → 空 → − → ✓ 循环;
- 桥错误:非法 detent / 断点返回
ok:false与上游文案,页面不崩溃。
6.5 验证结论
Native 动态库、C ABI、N-API、ArkUI 页面和签名 HAP 已完成链路验证。应用在 HarmonyOS 真机上成功安装并进入前台,底部抽屉落在 Peek 档(约 40% 容器高),状态行实时显示当前 detent、offset 与高度,吸附、刻度、断点、贴边数值与上游 commonTest 断言一致。
七、运行效果
7.1 真机截图

7.2 命令速查
git clone https://atomgit.com/oh-tpc/compose-unstyled.git
cd compose-unstyled/example
export JAVA_HOME="/opt/homebrew/opt/openjdk@21"
./gradlew :nativeApp:jvmTest
./gradlew :nativeApp:linkDebugSharedOhosArm64 :nativeApp:linkDebugSharedOhosX64
# DevEco Studio 打开 example/harmonyApp → 自动签名 → Build HAP
hdc install -r entry/build/default/outputs/default/entry-default-signed.hap
hdc shell aa start -a EntryAbility -b com.composeunstyled.ohos
八、遗留问题与改进方向
8.1 踩坑复盘
- 只改 ArkTS 页面不算 KMP 适配,必须真正编译
ohosArm64动态库; - 大型多模块仓库优先选「根零改动 + 独立 example 构建」变体,审核面只有新增文件;
- 移植方向查找函数时认准调用点:foundation
closestAnchor(searchUpwards)与仓库私有closestDetent(movingUp)语义相反,都按出处分开移植并各配测试; - FullyExpanded 实际被 sheet 高度封顶(越界长高分支持有 sheet 高度依赖判定而不生效),按真实行为钉测试,不要按直觉写期望;
- Kotlin/Native 浮点转字符串可能出科学计数法,响应侧必须定点格式化;
- N-API 不负责业务判断,C++ 只做类型检查、字符串转换和释放;
- 签名材料必须与本仓库分离,证书不能进入 AtomGit。
8.2 已知问题
- HarmonyOS 上没有 Compose UI,当前 Demo 是「行为内核 + ArkUI 渲染」;
- 只抽取五个数值内核,Portal / Modal / 滚动条 / 主题仍是 Compose 侧专有;
- detent 动画由 ArkTS
animateTo驱动,引擎只负责「目标选哪个」,视觉曲线由 ArkUI 决定; - PanGesture 线速度由采样差分估算;
positionalThreshold按常量传递(上游默认实现也是常量 56.dp);- CAdapter 生成的
api.cpp存在 clang 警告,属鸿蒙 Kotlin 工具链问题,不影响链接。
8.3 未来优化方向
- 抽取更多有测试钉值的行为内核(如 drawer 锚点、tab-group 焦点漫游);
- 上游版本升级时以
jvmTest对着新上游断言重跑,红即抽漏; - 若社区出现与 Compose 1.12 对齐的 ohos
compose.ui制品,把上游模块整体接入ComposeArkUIViewController,引擎降级为对照基准; - 在 CI 中加入 Native 链接、ELF 检查和 HAP 构建;
- 补充不同设备 ABI、API 版本和手势行为的验收矩阵。
九、总结
9.1 核心难点回顾
KMP commonMain 行为契约(detent / 刻度 / 断点 / 贴边 / 三态)
→ 逐行抽取为纯 Kotlin 引擎(上游断言钉值)
→ ohosArm64/ohosX64 Kotlin/Native
→ C ABI 单入口 JSON
→ C++ N-API
→ ArkTS JSON 解析
→ ArkUI 真机页面
9.2 封装层次
- KMP 层:提供 detent 决策、刻度量化、断点解析、锚定几何和三态循环;
- Native 层:生成双 ABI 动态库并通过单一 C ABI 输出 JSON;
- N-API 层:完成参数检查、字符串转换和 Native 内存释放;
- ArkTS 层:管理手势采样、锚点缓存、动画和错误展示;
- DevEco 层:完成 CMake、HAP、签名、安装和真机运行。
9.3 三条经验
- 纯 Compose UI 库的适配切入点是「行为数值」而不是「组件外观」——有上游测试钉得住的内核才值得抽;
- 用单入口 JSON 和少量 C ABI 代替跨语言对象传递,并明确字符串释放责任;
- 分别记录自动测试、Native 链接、HAP 构建和真机运行,避免把编译成功误认为设备链路可用。
9.4 适配成果
当前适配已完成:
example/nativeApp的ohosArm64+ohosX64双 ABI 独立构建;- 五个行为引擎与上游
commonTest断言的 61 项交叉验证; - 单入口
@CName("UnstyledEngine")和 C++ N-API; - DevEco Stage ArkUI 四 Tab 验收页面;
- HAP 构建、自动签名、设备安装和真机验证;
- 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)