开源鸿蒙平台 CMP 三方库「ComposeHints」适配全流程
本文记录
ComposeHints接入 OpenHarmony 的完整过程,覆盖 UI 引擎盘点、鸿蒙 Compose 制品线选型、纯 Kotlin 内核提取、ohosArm64/ohosX64目标、Kotlin/Native 动态库、C ABI/N-API 桥接、ArkUI 真机页面、HAP 构建和真机验收。
ComposeHints是 Compose Multiplatform 的引导提示 / Tooltip 库:HintHost作根容器,Modifier.hintAnchor()把任意 UI 元素登记为锚点,HintController.show()以挂起函数驱动整段导览——14 种对齐自带智能翻转与 RTL 镜像,遮罩按锚点形状挖孔,孔洞支持 Scale / Follow 两种动画,序列可上一步 / 下一步 / 自动前进,还支持多锚点分组高亮与全屏欢迎提示。
项目地址: AtomGit/oh-tpc/ComposeHints.git
上游仓库: vitoksmile/ComposeHints
开发工具: 华为云码道
环境安装: 环境安装指南
一、背景
1.1 为什么做 OpenHarmony 平台 KMP/CMP 适配
ComposeHints 为 Compose 应用提供"指向某个 UI 元素的提示"这一件事的完整答案:rememberHint {} 用槽位 API 写气泡内容,rememberHintAnchorState(hint) + Modifier.hintAnchor(state, shape) 登记锚点(圆角 / 圆形挖孔),rememberHintController() 在 HintHost 内创建控制器,show(anchor) / show(vararg anchors) / showGroup(anchors) / show(steps) 四种入场方式覆盖单条、序列、分组与嵌套队列,next() / previous() / dismiss() 手动导航,HintProperties 配置返回键关闭、点击外部关闭、对齐、间距、孔洞外扩、自动前进与手工偏移。KMP 项目把这些能力放在 commonMain(9 个文件、零 expect/actual),由 Android、iOS、桌面、wasmJs 共享。OpenHarmony 应用同样需要新手引导 / 功能点注释这套能力,但上游构建(Kotlin 2.3.20 / Gradle 9.4.1 / AGP 9.2.1 新版 KMP library 插件 / CMP 1.10.3)不会生成 OpenHarmony 变体。
| 障碍 | 具体问题 |
|---|---|
| 目标缺失 | 必须增加 ohosArm64() / ohosX64() 才能生成 OpenHarmony KLIB。 |
| 工具链不一致 | 上游 Kotlin 2.3.20 / Gradle 9.4.1 / AGP 9.2.1 与鸿蒙工具链无法共存于一个 Gradle 构建;鸿蒙线 Kotlin 2.2.21-1.0.0 不支持 Gradle 9.x,wrapper 必须降级。 |
| 依赖矩阵 | 库 commonMain 依赖 compose runtime / foundation / material / ui / ui-backhandler——社区 CMP ohos 制品线是 1.9.2,上游是 1.10.3 + Kotlin 2.3.20,版本差一代,混编即失败。 |
| 语言边界不同 | ArkTS 不能直接持有 Kotlin 对象,需要 C ABI、C++ N-API 和 JSON。 |
| UI 语义跨不了桥 | @Composable 内容、suspend show()、AnimationSpec、Brush 是 Kotlin/Compose 侧概念,C ABI 只能同步一来一回。 |
| 交付链路复杂 | .so、CMake、N-API、HAP、签名和 hdc 都要单独验收。 |
因此,本项目把边界放在:布局引擎与序列引擎的纯 Kotlin 提取与 ohos 编译、根构建替换(cmp_qrose 模式)和 C ABI/N-API 桥接层。对齐数学与步进语义完全由移植核心负责且逐行对齐上游,ArkTS 只负责锚点测量、遮罩绘制和把桥返回的坐标画到屏幕上。
1.2 库提供的能力
Demo 提供一个真实的引导提示会话,对应桥接的全部入口:
- 单条提示(show):遮罩挖孔高亮顶栏图标,气泡展示标题 / 正文 /「知道了」,点按钮或遮罩外部结束;
- 三步导览(show × 3 步):顶栏动作 → 内容卡片 → 底部导航,气泡带「步骤 i/total」「上一步」「下一步 / 完成」,中步 5 秒自动前进(对应上游
autoAdvanceDuration),首步演示dismissOnBackPress=false不可返回关闭; - 分组提示(showGroup):一次高亮顶栏与底部导航两个锚点——两个挖孔、气泡按集体包围盒定位;
- 对齐实验:14 种
HintAlignment全量可选,padding / holePadding / offset 步进调节,RTL 开关,气泡页脚显示「请求: X → 实际: Y」与翻转徽标; - 遮罩与全屏:默认黑纱 / 加深黑 / 蓝 / 红与蓝→红线性渐变(对应上游
Brush),挖孔 Scale / Follow 动画切换,fullScreen欢迎提示(无孔、锚定宿主原点 + 偏移)3 秒自动收起; - 交互语义:点击遮罩外部 = 前进(上游
dismissCurrentHintOnClickOutside语义),系统返回键 = 整体关闭(上游dismissAllHintsOnBackClicked语义)。
hintsLayout 响应字段:
| 字段 | 作用 |
|---|---|
ok | 计算是否成功,失败时 error 带原文。 |
x / y | 气泡左上角坐标(已含智能翻转、offset、边界钳制与 RTL 镜像)。 |
alignment / requestedAlignment / flipped | 实际应用的对齐、请求的对齐、是否发生智能翻转。 |
contentRect / holeRects | 气泡矩形与全部挖孔矩形(含 holePadding 外扩)。 |
hintsQueue 响应字段:
| 字段 | 作用 |
|---|---|
ok | 步进是否成功,失败时 error 带原文。 |
index / total | 当前步索引(-1 = 空闲)与总步数,对应上游 currentStepIndex / totalStepsCount。 |
hasNext / hasPrevious / finished | 对应上游同名导航状态与「序列刚结束」标记。 |
anchors | 当前步的锚点 id 列表,ArkTS 据此测量与挖孔。 |
1.3 实现适配
| 维度 | 要求 |
|---|---|
| 代码复用 | 上游 9 个 Compose 文件原样移入 src/composeMain 保留存档(git 识别为 rename,历史可追溯);布局数学与序列状态机逐行移植为无依赖纯 Kotlin 核心(2 个源文件、506 行),语义对齐上游而非重写。 |
| 平台目标 | :hints 声明 jvm() + ohosArm64 + ohosX64 出 KLIB,example/nativeApp 出双 ABI 动态库。 |
| 构建隔离 | 根构建替换为极简 ohos 构建(cmp_qrose 模式);上游 sample 工程与 iOS 目录留盘不进构建。 |
| 桥接稳定 | 使用 2 个 C ABI 和 UTF-8 JSON,不传递 Kotlin 对象地址;异常永远不跨 FFI。 |
| 依赖纪律 | 提取后的内核零第三方依赖——不需要等待任何 Compose/coroutines 制品线,也不引入版本耦合。 |
| UI 完整 | 单页三 Tab 覆盖全部桥接入口:序列、对齐、遮罩与全屏,含翻转徽标与引擎状态行。 |
| 仓库规范 | 项目说明、文章、效果图和源码链接统一使用 AtomGit。 |
本项目的 ArkUI 页面不是 Compose Multiplatform UI 实现。ComposeHints 恰恰相反于 KStore——它的全部价值都在 UI 层,而 UI 层暂时编不过去;ArkTS Demo 用 Canvas 遮罩 + 气泡定位把两个 Kotlin 引擎的输出画出来,是"数学留在 Kotlin、渲染交给 ArkUI"的验收宿主。
二、实现路线图
第 1 阶段:项目初始化 ── 盘点模块与依赖边界、确认 CMP ohos 制品线不可用、确定"内核提取"路线
第 2 阶段:目标与依赖打通 ── 根构建替换、eazytec Maven 置前、Kotlin 2.2.21-1.0.0 钉版、Gradle 8.14.1 降级
第 3 阶段:内核提取 ── HintsContainer 布局数学 + HintController 序列语义 → 纯 Kotlin core
第 4 阶段:桥协议建模 ── HintsLayout / HintsQueue JSON 契约、嵌套 MiniJson、错误通道
第 5 阶段:原生桥接 ── @CName C ABI、C++ N-API、参数校验和内存释放
第 6 阶段:ArkUI 宿主 ── 锚点测量、Canvas 挖孔遮罩、两阶段气泡定位、挖孔动画、HAP 与真机
三、逐步实现过程
第 1 阶段:项目初始化
1.1 工程边界
hints/ 上游库模块(进 ohos 构建,jvm + ohosArm64 + ohosX64)
hints/src/commonMain/.../core/ 新增:HintsGeometry.kt(389 行)+ HintsSequence.kt(117 行)
hints/src/composeMain/ 上游 9 个 Compose 文件原样移入,不参与任何目标编译
hints/api/ 上游 BCV API 快照,留盘(本 fork 无发布流程)
example/nativeApp/ Kotlin/Native 动态库(Bridge + 递归 MiniJson)
example/harmonyApp/ DevEco Stage、CMake N-API 和 ArkTS 页面
sample/ 上游 Android / iOS / Desktop / wasm 示例,留盘不进 settings
README.OpenHarmony.md / _CN.md 鸿蒙适配说明(中英双份,中文主文档 24 节)
doc/ 本文章与真机效果图
构建拓扑走 cmp_qrose 模式(根构建即 ohos 构建):settings.gradle.kts 只 include :hints 与 :example:nativeApp,rootProject.name = "ohos_composehints"。上游的 AGP 9.2.1(新版 com.android.kotlin.multiplatform.library 插件)、binary-compatibility-validator、vanniktech 发布插件、compose 编译器插件与 Sonatype Central 发布配置全部随根构建替换剥离——本 fork 不发 Maven Central。
1.2 工具链矩阵
| 项目 | 配置 |
|---|---|
| Kotlin Multiplatform | HarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven,qrose 审核钉版) |
| JDK | 21(Temurin 21.0.12,org.gradle.java.home 指向 C:\Users\Administrator\.jdks\jdk-21) |
| OpenHarmony 目标 | ohosArm64 + ohosX64(jvm() 保留作交叉验证) |
| Gradle | 8.14.1(上游 wrapper 9.4.1 必须降级——2.2.21 旧线不支持 Gradle 9.x) |
| DevEco product | 5.1.0(18) 兼容 |
| DevEco Studio | 5.1.0 Release |
| ABI | arm64-v8a + x86_64 |
| Bundle | org.terminator.ohos.composehints |
$env:JAVA_HOME = "C:\Users\Administrator\.jdks\jdk-21"
java -version
Windows 注意:
gradle.properties里的org.gradle.java.home要等 wrapper JVM 起来后才生效,gradlew.bat启动自身仍读JAVA_HOME环境变量——两者都要配,否则报JAVA_HOME is not set。
1.3 源码依赖度盘点
| 内容 | 文件数 | 依赖形态 | 处置 |
|---|---|---|---|
| 上游 Compose 层 | 9 | compose runtime / foundation / material / ui / ui-backhandler,含 @Composable、Animatable、自定义 Layout、Path 异或挖孔 | 原样移入 composeMain 存档,不编译 |
| 布局数学(HintsContainer) | — | 纯算术:包围盒、start 边偏移、14 对齐分支、翻转、钳制 | 移植为 HintGeometry |
| 序列状态机(HintController) | — | suspend/mutex + Compose state | 移植语义为 HintSequence(挂起与互斥不移植,见第 8.2 节) |
| 上游 sample 工程 | 3 模块 | AGP / iOS / webpack | 留盘不进构建 |
上游 HintsContainer.kt 的布局数学值得整段说明:它先取当前步全部锚点的集体包围盒,把锚点 x 换算成相对逻辑 start 边的偏移(RTL 时镜像为 hostW - x - w),然后按 14 种对齐逐一计算气泡位置——每个对齐分支都带"放不下就翻转到对侧"的备选逻辑(Top 放不下翻 Bottom、Start 放不下翻 End、TopStart 翻 BottomStart……),加上手工 offset 后整体钳制进宿主边界,最后 placeRelative 在 RTL 下再做一次逻辑→绝对坐标镜像。这段数学与 Compose 类型(Offset / IntSize / Dp)完全解耦——把 Dp 换成 Double、把 placeRelative 换成返回值后处理,就是 HintGeometry.place()。HintController 同理:next() 越界即完成、previous() 首步不动、点击外部是前进不是关闭(dismissCurrentHintOnClickOutside 直接复用 next 的越界逻辑)、返回键走 CancellationException 整体取消——这四条语义在 HintSequence.advance() 里逐条落位。
1.4 页面边界
标题区 COMPOSEHINTS · OPENHARMONY / 副标题行(CMP 提示引导库 · Kotlin/Native ohosArm64/ohosX64)
Tab 切换 序列导览 / 对齐实验 / 遮罩与全屏
序列导览 假应用脚手架(顶栏 2 图标 / 3 内容卡 / 底部导航 3 项)+ 单条 / 三步 / 分组三按钮
对齐实验 14 对齐 chips + padding / holePadding / offset 步进器 + RTL 开关 + 演示锚点 + 翻转徽标
遮罩与全屏 5 种遮罩(含线性渐变)+ Scale / Follow 切换 + 全屏欢迎 + 引擎状态行
状态行 最近一次桥接结果 / 错误原文(ok:false 通道的统一出口)
页面每次步进都把 hintsQueue 的响应写进状态行(index/total/hasNext/hasPrevious/finished),证明驱动 Demo 的是真 Kotlin 引擎而非前端状态变量。
第 2 阶段:目标与依赖打通
2.1 制品线验证(本适配的路线分岔点)
先查社区 Maven(https://maven.eazytec-cloud.com)上 CMP 的 ohos 制品:结论是社区 CMP for OpenHarmony 停在 1.9.2,而 ComposeHints 3.1.1 的公共 API 用到 CMP 1.10.3 + Kotlin 2.3.20 的工具链(AnimatedVisibilityScope、ui-backhandler、AGP 9 新版 KMP 插件)。两条版本线无法共存于一个 Gradle 构建——这正是 qrose 当年拒绝把 rememberQrCodePainter 编进 ohos 的同一个理由。分岔结论:Compose 层不编译,内核提取。这条路的好处是内核零第三方依赖,不需要等待任何 Compose 制品线补齐,也不与上游版本耦合。
2.2 工具链钉版(为什么是 2.2.21-1.0.0 + Gradle 8.14.1)
settings.gradle.kts 把 eazytec 双仓写在 pluginManagement 与 dependencyResolutionManagement 最前:
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_composehints"
include(":hints")
include(":example:nativeApp")
Kotlin 钉 2.2.21-1.0.0(qrose / haze 审核同款)。这条线的代价是 Gradle 必须降到 8.14.1——Kotlin 2.2.x 官方支持上限在 Gradle 8.14 一线,9.x 会配置期失败;这与 KStore 的"2.4.20 线沿用 Gradle 9.6.1"正好相反,两条工具链线的经验不能互相套用。gradle.properties 同步钉住 JDK 21、fork 版本 3.1.1-ohos.1 与 Kotlin/Native 下载策略:
org.gradle.java.home=C:\\Users\\Administrator\\.jdks\\jdk-21
version=3.1.1-ohos.1
kotlin.native.ignoreDisabledTargets=true
kotlin.native.distribution.downloadFromMaven=true
2.3 编译纯 Kotlin 核心
hints/build.gradle.kts 收敛为纯 KMP 模块:
plugins {
alias(libs.plugins.kotlin.multiplatform)
}
kotlin {
applyDefaultHierarchyTemplate()
jvmToolchain(21)
jvm() // 交叉验证用
ohosArm64()
ohosX64()
sourceSets {
// 纯 Kotlin 核心零依赖;上游 Compose UI 在 src/composeMain,不参与编译
commonMain.dependencies {}
}
}
实测(Windows 11 x64、JDK 21):
./gradlew :hints:compileKotlinJvm :hints:compileKotlinOhosArm64 :hints:compileKotlinOhosX64
# BUILD SUCCESSFUL
内核两个文件加起来 506 行,commonMain.dependencies {} 为空——这是"零依赖内核"的字面意义。
第 3 阶段:桥协议建模
3.1 JSON 契约
com.viktormykhailiv.compose.hints.core/**(移植内核,零依赖)
↓ HintGeometry.place() / HintSequence.advance()
Bridge.kt(@CName × 2,嵌套 JSON 进出)
↓ UTF-8 JSON
N-API hintsLayout() / hintsQueue()
↓
ArkUI Index.ets(三 Tab 验收页)
请求与响应:
hintsLayout 请求 : {"anchors":[{"x":..,"y":..,"w":..,"h":..}],
"host":{"w":..,"h":..},"content":{"w":..,"h":..},
"alignment":"Bottom","padding":16,"holePadding":8,
"offset":{"x":0,"y":8},"rtl":false,"fullScreen":false}
hintsLayout 响应 : {"ok":true,"x":..,"y":..,"alignment":"Top",
"requestedAlignment":"Bottom","flipped":true,
"contentRect":{..},"holeRects":[{..}]}
hintsQueue 请求 : {"steps":[[0],[1,2],[3]],"index":0,"action":"next"}
hintsQueue 响应 : {"ok":true,"index":1,"total":3,"hasNext":true,
"hasPrevious":true,"finished":false,"anchors":[2]}
错误 : {"ok":false,"error":"..."}
fullScreen:true 时 anchors 被忽略、holeRects 恒为空、内容锚定宿主原点 + offset——对应上游全屏提示"忽略元素位置、不画孔、相对 HintHost 左上角"的三条文档语义。hintsQueue 的 action 全集是 start / next / previous / outside / back / reset / state,其中 outside 与 next 在引擎内部走同一条越界完成逻辑——上游 dismissCurrentHintOnClickOutside 的原话实现。
3.2 为什么桥上的 MiniJson 必须是递归的
qrose 的 MiniJson 是扁平 key→string 解析器,因为它的请求没有嵌套。ComposeHints 的协议天然带嵌套:anchors 是矩形对象数组、offset 是对象、holeRects 是响应里的数组——扁平解析器直接出局。选择手写一个约 230 行的递归下降 JSON 解析 / 序列化器(对象、数组、字符串转义含 \uXXXX、数字、布尔、null),而不是引 kotlinx.serialization:桥最终编进 .so,序列化运行时插件会显著增大动态库体积,而协议总共两个结构体,手写更便宜。这是 qrose"为什么自写 MiniJson"决策在嵌套场景下的延续。
3.3 错误通道
每个导出整体 try/catch,Kotlin 异常永远不跨 FFI:
@CName("HintsLayout")
fun hintsLayout(request: String): String = try {
/* 解析请求 → HintGeometry.place() → 序列化响应 */
} catch (t: Throwable) {
"""{"ok":false,"error":"..."}"""
}
非法 JSON、缺 anchors / host / content、空锚点列表、非法 action,全部走同一个 ok:false 通道,ArkTS 侧统一渲染进状态行。
第 4 阶段:原生桥接(技术难点)
4.1 三层桥接
ArkTS
│ JSON string
▼
C++ N-API entry(libhints_napi.so)
│ const char*
▼
Kotlin/Native C ABI(libohoshints.so)
│ HintsLayout / HintsQueue
▼
UTF-8 JSON + DisposeString
直接导出 Kotlin 对象会暴露 ABI 和生命周期问题;只导出整数又会让页面复制布局数学,所以选择 C ABI + JSON。
4.2 Kotlin/Native 导出函数
@CName("HintsLayout")
fun hintsLayout(request: String): String // 气泡位置 + 挖孔矩形,一口进
@CName("HintsQueue")
fun hintsQueue(request: String): String // 序列步进状态机
| C ABI 符号 | ArkTS 方法 | 覆盖内核 |
|---|---|---|
HintsLayout | hintsLayout(req) | 集体包围盒 + 14 对齐 + 智能翻转 + offset + 钳制 + RTL 镜像 + holePadding 外扩 |
HintsQueue | hintsQueue(req) | start / next / previous / outside / back / reset 全状态机与导航状态 |
4.3 C++ N-API 方法分发
napi_property_descriptor desc[] = {
{"hintsLayout", nullptr, NapiHintsLayout, nullptr, nullptr, nullptr,
static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr},
{"hintsQueue", nullptr, NapiHintsQueue, nullptr, nullptr, nullptr,
static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr},
};
两个回调共用一个 CallKnStringFn 辅助:napi_typeof 校验必须是 string、OH_LOG_INFO 打字节数、调 KN 函数并包装返回。KN 返回的字符串必须经 libohoshints_symbols()->DisposeString 归还 Kotlin 运行时,绝不 free()。libohoshints_api.h 按社区已审仓库同款手写(只声明 ExportedSymbols 结构与两个业务符号),不拖整份生成头。
4.4 CMake 和类型声明
add_library(ohoshints SHARED IMPORTED)
set_target_properties(ohoshints PROPERTIES
IMPORTED_LOCATION ${KN_SO}
IMPORTED_NO_SONAME TRUE)
add_library(hints_napi SHARED napi_init.cpp)
target_link_libraries(hints_napi PUBLIC
libace_napi.z.so
libhilog_ndk.z.so
ohoshints)
OHOS_ARCH 为空或 libohoshints.so 缺失直接 FATAL_ERROR(提示先跑 link 任务);$ORIGIN rpath 加 POST_BUILD 拷贝保证 NAPI so、KN so、libc++ 三件套同目录,dlopen 时互相可见。
export const hintsLayout: (request: string) => string;
export const hintsQueue: (request: string) => string;
第 5 阶段:ArkUI 宿主
5.1 锚点测量与坐标对齐
被引导的 UI 元素打上 .id('anchor_xxx'),触发时用 componentUtils.getRectangleById() 取矩形:该 API 的 size 与 globalOffset 都是像素,先 px2vp() 折算,再减去页面根容器(.id('page_root'),同样方式测量)的全局偏移,得到宿主相对 vp 矩形——与桥的单位约定对齐。锚点不在屏幕上(切 Tab 后)会被跳过,整步无锚点则直接结束序列,不产生非法调用。
5.2 遮罩挖孔与气泡两阶段定位
遮罩是一块盖在内容之上的 Canvas(成员持 CanvasRenderingContext2D):先铺满遮罩色(fillStyle 或 createLinearGradient 对应上游 Color / Brush),再切 globalCompositeOperation = 'destination-out' 逐孔填圆角矩形(r=16vp,对应上游锚点 shape),最后切回 source-over。层上挂 hitTestBehavior(HitTestMode.Block)——对应上游"提示显示期间拦截底层触摸";点击遮罩触发 outside 动作(仅当前提示允许时)。
气泡定位存在鸡生蛋问题:hintsLayout 需要内容尺寸,而尺寸要布局后才知道。解法是两阶段:气泡先以 opacity(0) 落在 (0,0) 参与布局,onAreaChange 捕获实测宽高后调桥拿 x/y,再置位坐标与 opacity(1)——带 measuring 守卫防止测量环,每步内容变化后重走一遍。挖孔过渡用 getUIContext().createAnimator()(300ms EaseInOut):Follow 模式从上一步矩形逐帧插值到新矩形(对应上游锚点 Follow 动画),Scale 模式从各孔中心由零放大(对应上游 Scale 动画),onFrame 里同步重绘 Canvas。
5.3 交互语义与真机编译修正
系统返回键由页面 onBackPress() 承接:序列活跃且当前提示允许时调 back 动作整体关闭,返回 true;自动前进用 setTimeout 承接,任何手动导航 / 关闭 / 切 Tab 都会清掉定时器。
真机首次 CompileArkTS 暴露两个严格模式问题,都已修复:
.position(x, y)双参数不合法——ArkUI 的position只接收单个Position对象,改为.position({ x: this.bubbleX, y: this.bubbleY });- 本 SDK(5.1.0(18))的
AnimatorResult没有stop(),只有play / pause / reset——改为pause()(每次过渡重建 animator 并丢弃旧引用,语义等价)。
第 6 阶段:示例与验证
6.1 工程结构
example/
├── nativeApp/
│ ├── build.gradle.kts 双 ABI sharedLib(baseName=ohoshints)+ cxxShared + copyKnSo*
│ └── src/ohosMain/kotlin/example/nativeapp/
│ ├── Bridge.kt @CName × 2(162 行)
│ └── MiniJson.kt 递归 JSON 解析 / 序列化(236 行)
└── harmonyApp/
├── build-profile.json5 signingConfigs 留空(签名由 DevEco 自动生成回填)
├── AppScope/app.json5 bundleName org.terminator.ohos.composehints、versionName 3.1.1-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/libohoshints_api.h
├── src/main/cpp/types/libhints_napi/{index.d.ts, oh-package.json5}
└── src/main/ets/pages/Index.ets ArkUI 三 Tab 验收页面(1307 行)
6.2 构建、签名和安装
$env:JAVA_HOME = "C:\Users\Administrator\.jdks\jdk-21"
./gradlew :hints:compileKotlinJvm :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64
cd example/harmonyApp
hvigorw assembleHap -p product=default -p buildMode=debug --no-daemon
签名在 DevEco Studio 的 File → Project Structure → Signing Configs 中勾选自动生成,证书材料由 IDE 回填 build-profile.json5,不进入仓库(仓库内保持空数组)。
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.composehints -m entry
四、完整代码对照
4.1 整体架构
com.viktormykhailiv.compose.hints.core/**(移植内核,506 行,零依赖)
│ HintGeometry.place()(14 对齐 + 翻转 + RTL + 钳制)
│ HintSequence.start()/advance()(步进状态机)
example.nativeapp / Bridge.kt + MiniJson.kt
│ HintsLayout / HintsQueue
libhints_napi.so
│ hintsLayout / hintsQueue
Index.ets
│ 锚点测量 + Canvas 挖孔 + 两阶段气泡定位 + 动画
▼
ArkUI 真机页面(三 Tab 验收)
4.2 文件清单
| 文件 | 职责 |
|---|---|
settings.gradle.kts | 极简 ohos 构建、eazytec Maven 置前、ohos_composehints、sample 移出 |
gradle/libs.versions.toml | Kotlin 2.2.21-1.0.0 单一钉版 |
build.gradle.kts(根) | 版本 3.1.1-ohos.1、全局禁用 Ohos*Test* 任务 |
hints/build.gradle.kts | 纯 KMP:jvm + ohosArm64 + ohosX64,零依赖 |
hints/src/commonMain/.../core/HintsGeometry.kt | 布局引擎(389 行,HintsContainer 数学逐行移植) |
hints/src/commonMain/.../core/HintsSequence.kt | 序列引擎(117 行,HintController 语义移植) |
hints/src/composeMain/ | 上游 9 个 Compose 文件原样存档(rename 保留历史) |
example/nativeApp/build.gradle.kts | 双 ABI sharedLib、cxxShared 定位 libc++、copyKnSo* 回填 |
nativeApp/.../Bridge.kt / MiniJson.kt | 2 个 C ABI、递归 JSON、错误通道 |
harmonyApp/.../cpp/napi_init.cpp | N-API 导出、参数检查、DisposeString |
harmonyApp/.../cpp/CMakeLists.txt | IMPORTED 引入 KN so、$ORIGIN rpath、POST_BUILD 三件套 |
harmonyApp/.../ets/pages/Index.ets | ArkUI 三 Tab 验收页面(1307 行) |
README.OpenHarmony.md / _CN.md | 鸿蒙适配说明(英文摘要 82 行 / 中文主文档 534 行 24 节) |
4.3 关键 API 对照
| 层次 | API | 作用 |
|---|---|---|
| 上游 | HintHost { content } | 根容器 + 遮罩层宿主 → ArkUI 页面根 Stack + Canvas |
| 上游 | Modifier.hintAnchor(state, shape) | 锚点登记与挖孔形状 → .id() + componentUtils.getRectangleById |
| 上游 | rememberHint(properties) { content } | 气泡槽位 → Demo 气泡构建器(两阶段测量) |
| 上游 | HintController.show / showGroup / show(steps) | 入场 → hintsQueue 的 steps / [[id,id]] 建模 |
| 上游 | next() / previous() / dismiss() | 导航 → action: next / previous / back |
| 上游 | currentStepIndex / totalStepsCount / hasNext / hasPrevious | 状态 → 响应同名字段(Demo 状态行直接渲染) |
| 上游 | HintProperties(alignment / padding / holePadding / offset / autoAdvanceDuration / dismissOn*) | 配置 → hintsLayout 请求字段 + 宿主侧 setTimeout / onBackPress / onClick |
| 上游 | HintAlignment × 14 | 对齐 → alignment 线名(含智能翻转,flipped 字段回显) |
| 上游 | HintAnchorAnimationMode.Scale / Follow | 孔洞动画 → animator 两种插值模式 |
| 上游 | overlay: Color / Brush | 遮罩 → Canvas fillStyle / createLinearGradient |
| 内核 | HintGeometry.place() | 集体包围盒 + 14 分支 + 翻转 + 钳制 + RTL |
| 内核 | HintSequence.advance() | 步进状态机(outside=前进、back=整体关闭) |
| Native | HintsLayout / HintsQueue | C ABI JSON 导出 |
| N-API | hintsLayout(req) / hintsQueue(req) | 向 ArkTS 暴露能力 |
4.4 ArkTS 与 Kotlin 的边界
const raw: string = hintsLayout(JSON.stringify({
anchors: [{ x: rect.x, y: rect.y, w: rect.w, h: rect.h }],
host: { w: this.hostW, h: this.hostH },
content: { w: bubbleW, h: bubbleH },
alignment: spec.alignment, padding: spec.padding,
holePadding: spec.holePadding,
offset: { x: spec.offsetX, y: spec.offsetY },
rtl: spec.rtl, fullScreen: spec.fullScreen
}));
const res = JSON.parse(raw) as LayoutOk; // res.x / res.y 即气泡左上角
this.bubbleX = res.x ?? 0;
this.bubbleY = res.y ?? 0;
@CName("HintsLayout")
fun hintsLayout(request: String): String = try {
val placement = HintGeometry.place(
anchors = parseAnchors(root), host = parseHost(root),
content = parseContent(root), properties = parseProperties(root))
MiniJson.stringify(mapOf("ok" to true, "x" to placement.x, "y" to placement.y, /* ... */))
} catch (t: Throwable) { fail(t) }
两者之间只传输 JSON 字符串,不传输 Kotlin 对象或未校验的动态结构;对齐、翻转、钳制、RTL 全部发生在 Kotlin 侧,ArkTS 不复制任何布局数学。
五、关键决策说明
决策 1:内核提取而不是真编译 Compose 层
KStore 能真编译整库,前提是它依赖的 kotlinx 四件套在社区 Maven 上有与上游同版本的 ohos 制品。ComposeHints 的依赖是 Compose 本身——社区 CMP ohos 线停在 1.9.2,上游需要 1.10.3 + Kotlin 2.3.20,把 Compose 层编过 ohos 等于先给 Compose 做版本降级移植,风险远超一个三方库适配的边界。提取的内核(布局数学 + 序列状态机)是这个库里唯一"与渲染无关但值得保真"的部分:数学留在 Kotlin,渲染交给 ArkUI,等社区 CMP 追上版本线后可以把 composeMain 接回 commonMain(见 8.3)。
决策 2:工具链钉 2.2.21-1.0.0,Gradle 降 8.14.1
qrose / haze 审核通过的钉法。这条线的硬约束是 Gradle 9.x 不可用——上游 wrapper 9.4.1 必须降级 8.14.1,AGP 9.2.1 与全部 Compose 插件随根构建替换剥离。与 KStore 的"2.4.20 线留 9.6.1"相反:选择哪条工具链线由依赖矩阵决定(内核提取零依赖 → 老线稳定优先;真编译新版 kotlinx → 新线被迫跟进),两条线的 Gradle 经验不能互相套用。
决策 3:根构建替换(cmp_qrose 模式),sample 留盘
上游根构建(AGP 9 新 KMP 插件、BCV、compose 编译器、vanniktech 发布、Sonatype Central)对鸿蒙没有保留价值,整个替换为极简 ohos 构建。上游 sample/(Android / iOS / Desktop / wasm 示例)与 hints/api/(BCV 快照)留盘不进 settings.gradle.kts——fork 的存在方式只有一种,上游资产一种都不删。
决策 4:递归 MiniJson,不引 kotlinx.serialization
协议带嵌套(anchors 数组、offset 对象、holeRects 数组),qrose 的扁平解析器不够用;手写 236 行递归下降实现,换取 .so 里不背序列化运行时。这是"桥协议最小化"的一贯纪律:两个结构体的序列化不值得一个运行时插件。
决策 5:语义忠实移植,挂起与互斥不移植
show() 的 suspend/mutex 存在的意义是在 Compose 里序列化并发入场并挂起等待播完;ArkTS 宿主一次只驱动一个序列,锚点经 componentUtils 同步可测,"等待 attach 的 5×20ms 重试"也没有对应场景。保留的是语义(outside=前进、back=整体关闭、next 越界即完成、previous 首步不动、index -1 空闲、空步进抛 Nothing to show),不保留的是 Compose 并发原语——Demo 的每个按钮走的都是与上游逐条对齐的状态转移。
六、测试与验证
6.1 测试环境
本次验证使用 Windows 11 x64、JDK 21(Temurin 21.0.12,C:\Users\Administrator\.jdks\jdk-21)、DevEco Studio 5.1.0 Release(product 5.1.0(18))、OpenHarmony 命令行工具(hvigor / hdc)与鸿蒙真机(arm64)。
6.2 编译与符号验证
:hints:compileKotlinJvm / compileKotlinOhosArm64 / compileKotlinOhosX64: BUILD SUCCESSFUL
:example:nativeApp:linkDebugSharedOhosArm64 / linkDebugSharedOhosX64: BUILD SUCCESSFUL(BUILD SUCCESSFUL in 53s)
copyKnSoArm64 / copyKnSoX64: 三件套回填 entry/libs/{arm64-v8a,x86_64}
llvm-nm -D: HintsLayout / HintsQueue / libohoshints_symbols 均为全局 T 符号(双 ABI 一致)
产物尺寸(debugShared,未 strip):libohoshints.so arm64 3,742,408 字节 / x86_64 3,441,760 字节,配套 libc++_shared.so 分别 1,850,184 / 1,731,432 字节(来自 ~/.konan/dependencies 的 per-triple 匹配拷贝)。
6.3 HAP 验证
CompileArkTS: 首轮 2 个错误(position 双参数 / AnimatorResult.stop)→ 修复后 finished
BuildNativeWithCmake: finished(双 ABI 编出 libhints_napi.so)
PackageHap: finished
BUILD SUCCESSFUL
install bundle successfully
start ability successfully
6.4 功能用例
- 「序列导览」单条提示:遮罩挖孔高亮顶栏图标,气泡显示标题 / 正文 /「知道了」,点按钮结束;
- 三步导览:步骤计数「i/total」正确,「上一步」仅
hasPrevious时出现,末步按钮变「完成」,中步 5 秒后自动前进且气泡标注倒计时提示; - 分组提示:顶栏与底部导航同时出现两个挖孔,气泡按集体包围盒定位;
- 首步
dismissOnBackPress=false:按返回键不关闭(对应上游属性语义),后续步可关; - 点击遮罩外部:序列前进一步(不是关闭);序列中按返回键:整体关闭;
- 「对齐实验」:把演示锚点放在演示区顶部,选
Top系对齐触发翻转到Bottom系,气泡页脚显示「请求: Top → 实际: Bottom」+ 翻转徽标;RTL 开关后 Start/End 系镜像; - 「遮罩与全屏」:五种遮罩(含蓝→红线性渐变)即时生效;Scale / Follow 切换后步进可见孔洞生长 / 滑动差异;全屏欢迎提示无孔、位于宿主左上 + 偏移,3 秒自动收起;
- 状态行每步显示
hintsQueue响应(index/total/hasNext/hasPrevious/finished),错误注入时显示ok:false原文。
6.5 验证结论
布局引擎、序列引擎、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 已完成链路验证。应用在鸿蒙真机上成功安装并进入前台,页面展示了真实的引导会话:挖孔遮罩、智能翻转、序列导航、分组高亮与全屏提示全部由 Kotlin 引擎驱动(见下节效果图)。
七、运行效果
7.1 真机截图

7.2 命令速查
git clone https://atomgit.com/oh-tpc/ComposeHints.git
cd ComposeHints
export JAVA_HOME="C:/Users/Administrator/.jdks/jdk-21" # Windows PowerShell 用 $env:JAVA_HOME=...
./gradlew :hints:compileKotlinJvm :hints:compileKotlinOhosArm64 :hints:compileKotlinOhosX64
./gradlew :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64
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.composehints -m entry
hdc shell hilog | grep Hints
八、遗留问题与改进方向
8.1 踩坑复盘
- 只改 ArkTS 页面不算 KMP 适配,必须让
:hints真正编译成ohosArm64/ohosX64目标——哪怕内核只有 506 行; settings.gradle.kts里 eazytec 双仓必须排在pluginManagement与dependencyResolutionManagement最前,否则配置期直接Unresolved reference: ohosArm64;- Kotlin
2.2.21-1.0.0老线不支持 Gradle 9.x:上游 wrapper 9.4.1 必须降到 8.14.1,这条与"2.4.20 新线留 Gradle 9"的经验相反,按工具链线区分对待; - Windows 上
gradle.properties的org.gradle.java.home救不了gradlew.bat启动:wrapper JVM 只认JAVA_HOME环境变量,脚本层必须先 export; .position(x, y)双参数写法 ArkTS 严格模式直接编译失败——position只接收单个Position对象{ x, y };- 本 SDK(5.1.0(18))的
AnimatorResult没有stop(),只有play / pause / reset;过渡动画用pause()中断并丢弃引用即可; componentUtils.getRectangleById()返回的size与偏移都是像素,必须px2vp()折算后再进桥,否则挖孔与气泡在高密度屏上整体错位;- ArkTS 不能直接
importKotlin/Native 产物——必须 CMake N-API 包装,且 CMake 目标名(nm_modname)必须等于 ArkTS 的 import 名(libhints_napi.so↔hints_napi); - HAP 缺当前 ABI 的 so 会报安装失败
9568347,abiFilters必须双 ABI,每个 ABI 三件套(NAPI so / KN so / libc++_shared.so)缺一不可; libc++_shared.so必须取自~/.konan/dependencies里与 triple 匹配的那份(aarch64-linux-ohos/x86_64-linux-ohos),copyKnSo*任务按 triple 自动定位;*.so全局 gitignore:新克隆必须先跑 Gradle link 任务再hvigorw assembleHap,CMakeLists 会在 so 缺失时给出明确 FATAL_ERROR 指路;- KN 返回字符串必须经
libohoshints_symbols()->DisposeString归还运行时,N-API 层不能free();签名材料由 DevEco 回填build-profile.json5,提交前用git update-index --skip-worktree保护,证书绝不进入 AtomGit。
8.2 已知问题
- Compose 层未编译进 ohos:社区 CMP for OpenHarmony 是 1.9.2,上游需要 1.10.3 + Kotlin 2.3.20,两条线暂不可合并;Android / iOS / Desktop / wasmJs 消费者请继续使用上游制品;
:hints的 jvm 产物现在只含内核(composeMain不参与编译),上游 Compose API 以源码形式存档于hints/src/composeMain,git 历史完整可溯;- 上游
show()的挂起等待与 mutex 串行化不跨桥(ArkTS 宿主单序列驱动,无对应场景);autoAdvanceDuration与两条 dismiss 策略由宿主侧 setTimeout / onBackPress / onClick 实现; - 上游
overlay: Brush任意画刷能力在 Demo 里以纯色 + 线性渐变两种形式呈现,任意自定义画刷需在 ArkTS Canvas 侧扩展; - 上游
hints/api/的 BCV 快照与当前源集结构不再对应(无发布流程,仅存档); componentUtils.getRectangleById在 API 18 起有 deprecation 警告(功能正常);- HAP 安装必须使用与 bundle name
org.terminator.ohos.composehints匹配的签名。
8.3 未来优化方向
- 跟踪社区 CMP for OpenHarmony 版本线:追到 1.10.x 一线后评估把
composeMain接回commonMain,桥退化为无 Compose 环境的 fallback; - 为内核补 JVM 单元测试矩阵(14 对齐 × 翻转 × RTL × 钳制的期望值表),形成与上游行为的持续交叉验证;
- 评估
.har形态发布,让 ArkTS 工程直接依赖而不是走 so 三件套; - 在 CI 中加入 ohos 编译、ELF 符号检查和 HAP 构建;
- 补充模拟器(x86_64)与其他设备 ABI 的验收矩阵和自动化截图脚本。
九、总结
9.1 核心难点回顾
上游 ComposeHints 3.1.1(9 个纯 Compose 文件,commonMain)
→ 确认 CMP ohos 制品线不可用(1.9.2 vs 上游 1.10.3)→ 选定内核提取路线
→ 工具链钉 Kotlin 2.2.21-1.0.0(qrose 审核线)+ Gradle 8.14.1 降级
→ 根构建替换(cmp_qrose 模式,sample 留盘)
→ 布局数学 + 序列状态机移植为 506 行零依赖内核(jvm + 双 ohos KLIB)
→ C ABI(@CName × 2)+ 递归 MiniJson
→ C++ N-API(DisposeString 纪律)
→ ArkTS 锚点测量 + Canvas 挖孔 + 两阶段气泡定位 + Follow/Scale 动画
→ ArkUI 三 Tab 真机验收(序列 / 对齐 / 遮罩全屏)
9.2 封装层次
- 内核层:
HintGeometry(集体包围盒、14 对齐、智能翻转、RTL 镜像、边界钳制)与HintSequence(步进状态机); - Native 层:生成双 ABI 动态库并通过 2 个 C ABI 输出 JSON;
- N-API 层:完成参数检查、字符串转换和 Native 内存释放;
- ArkTS 层:锚点测量(px→vp)、Canvas 遮罩与挖孔动画、气泡两阶段测量与定位、返回键 / 点击外部 / 自动前进的宿主语义;
- DevEco 层:完成 CMake、HAP、签名、安装和真机运行。
9.3 三条经验
- UI 库适配的正确切面是"数学与状态机留在 Kotlin,渲染交给 ArkUI":ComposeHints 里真正值得保真的不是 Composable 而是对齐 / 翻转 / 步进语义——它们决定多端行为一致;切面找对了,桥协议(一个布局请求 + 一个步进请求)自然就小;
- 路线选择基于当天的制品可达性:KStore 遇到的是"制品线已补齐",于是真编译整库;ComposeHints 遇到的是"CMP 线差一代",于是内核提取——同一个社区里两条相反路线都是正确答案,判据只有制品矩阵;
- 分层验收让"没跑 UI"也可信:compile(KLIB)/ link(双 ABI so)/ symbol(llvm-nm 全局 T)/ function(JSON 契约)/ device(真机三 Tab)逐层取证,真机只验证最后一层,前面每一层都有独立证据。
9.4 适配成果
当前适配已完成:
- ComposeHints
3.1.1布局引擎与序列引擎的双 ABI Kotlin/Native 编译(内核 2 文件 506 行,零第三方依赖;上游 9 个 Compose 文件 rename 存档于composeMain); - 2 个 C ABI 符号和 C++ N-API 包装层(
llvm-nm验证双 ABI 全局 T 符号:HintsLayout/HintsQueue/libohoshints_symbols); - DevEco Stage ArkUI 三 Tab 验收页面(1307 行:序列导览 / 对齐实验 / 遮罩与全屏,含翻转徽标与引擎状态行);
- 双 ABI 六件套 so 交付(arm64
libohoshints.so约 3.7MB / x86_64 约 3.4MB + 各自匹配的libc++_shared.so+ CMakelibhints_napi.so)、HAP 构建、签名、设备安装和真机验证; - 中英双份 OpenHarmony README(英文摘要 82 行 / 中文主文档 534 行 24 节)、本文与真机效果图,全部收纳在 AtomGit/oh-tpc/ComposeHints。
适配过程 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)