本文记录 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.ktdetent 定义、高度解析、锚点表、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 MultiplatformOpenHarmony 独立构建使用 2.2.21-1.0.0(eazytec Maven)
Gradle8.14.1(example/ 内独立 wrapper)
JDK21
OpenHarmony 目标ohosArm64 + ohosX64
DevEco product5.1.0(18)
DevEco Studio5.1.0 Release
ABIarm64-v8a + x86_64
Bundlecom.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.propertiesJDK 21、version=2.10.1-ohos.1、konan 开关
example/nativeApp/build.gradle.ktsohosArm64/ohosX64 sharedLib、copyKnSo*、禁用损坏的 ohos 测试任务
nativeApp/.../SheetDetents.ktdetent 定义、锚点解析、computeTarget
nativeApp/.../SliderEngine.kt刻度量化与键盘步进
nativeApp/.../BreakpointsEngine.kt断点表校验与解析
nativeApp/.../AnchoredEngine.kt浮动定位几何
nativeApp/.../ToggleEngine.kt三态循环
nativeApp/.../UnstyledBridge.ktJSON 分发、错误包裹、定点格式化
nativeApp/.../ohos/Bridge.kt@CName("UnstyledEngine") 导出
nativeApp/src/jvmTest/61 项上游断言交叉验证
harmonyApp/.../napi_init.cppN-API 导出、参数检查、DisposeString
harmonyApp/.../CMakeLists.txtIMPORTED KN so、$ORIGIN rpath、双 ABI
harmonyApp/.../Index.ets四 Tab ArkUI 页面
README.OpenHarmony.md / _CN.md鸿蒙适配说明(中文版 600 行主文档)

4.3 关键 API 对照

层次API作用
KMPResolvedDetentAnchors.fromDetents解析每档高度与锚点偏移
KMPcomputeTarget松手目标决策(速度/位移阈值)
KMPstepsToTickFractions / snapValueToTick刻度数组与吸附
KMPBreakpointScale.breakpointFor / isAtLeast断点解析与排名
KMPcalculateFloatingPlacement锚定定位与贴边回拉
KMPToggleEngine.cycle三态循环
NativeUnstyledEngine单入口 JSON-in/JSON-out
N-APIunstyledEngine向 ArkTS 暴露唯一函数
ArkTSsettle(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 功能用例

  1. 底部抽屉:拖拽跟手且被 closestToTop 与容器底约束;慢拖过 56 吸上一档、不足退回;快甩按方向越档;三按钮直达指定档;
  2. 滑杆:档位 0/2/4/9 切换刻度数;拖到 0.37 附近落 0.4;键盘 ±步、Home/End、PgUp/PgDn 行为与上游一致;
  3. 断点:宽高越过 600/840 边界切换断点名;恰好 600 归 medium;isAtLeast 矩阵绿色正确;
  4. 锚定:四边 × 三对齐组合贴边正确;拖到视口角落弹层被顶回并显示「已贴边」;RTL 下 Start/End 互换;三态复选框按 ✓ → 空 → − → ✓ 循环;
  5. 桥错误:非法 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 踩坑复盘

  1. 只改 ArkTS 页面不算 KMP 适配,必须真正编译 ohosArm64 动态库;
  2. 大型多模块仓库优先选「根零改动 + 独立 example 构建」变体,审核面只有新增文件;
  3. 移植方向查找函数时认准调用点:foundation closestAnchor(searchUpwards) 与仓库私有 closestDetent(movingUp) 语义相反,都按出处分开移植并各配测试;
  4. FullyExpanded 实际被 sheet 高度封顶(越界长高分支持有 sheet 高度依赖判定而不生效),按真实行为钉测试,不要按直觉写期望;
  5. Kotlin/Native 浮点转字符串可能出科学计数法,响应侧必须定点格式化;
  6. N-API 不负责业务判断,C++ 只做类型检查、字符串转换和释放;
  7. 签名材料必须与本仓库分离,证书不能进入 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 三条经验

  1. 纯 Compose UI 库的适配切入点是「行为数值」而不是「组件外观」——有上游测试钉得住的内核才值得抽;
  2. 用单入口 JSON 和少量 C ABI 代替跨语言对象传递,并明确字符串释放责任;
  3. 分别记录自动测试、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。

参考文档

Logo

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

更多推荐