本文记录 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 MultiplatformHarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven,qrose 审核钉版)
JDK21(Temurin 21.0.12,org.gradle.java.home 指向 C:\Users\Administrator\.jdks\jdk-21)
OpenHarmony 目标ohosArm64 + ohosX64(jvm() 保留作交叉验证)
Gradle8.14.1(上游 wrapper 9.4.1 必须降级——2.2.21 旧线不支持 Gradle 9.x)
DevEco product5.1.0(18) 兼容
DevEco Studio5.1.0 Release
ABIarm64-v8a + x86_64
Bundleorg.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 层9compose 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 方法覆盖内核
HintsLayouthintsLayout(req)集体包围盒 + 14 对齐 + 智能翻转 + offset + 钳制 + RTL 镜像 + holePadding 外扩
HintsQueuehintsQueue(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 暴露两个严格模式问题,都已修复:

  1. .position(x, y) 双参数不合法——ArkUI 的 position 只接收单个 Position 对象,改为 .position({ x: this.bubbleX, y: this.bubbleY });
  2. 本 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.tomlKotlin 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.kt2 个 C ABI、递归 JSON、错误通道
harmonyApp/.../cpp/napi_init.cppN-API 导出、参数检查、DisposeString
harmonyApp/.../cpp/CMakeLists.txtIMPORTED 引入 KN so、$ORIGIN rpath、POST_BUILD 三件套
harmonyApp/.../ets/pages/Index.etsArkUI 三 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=整体关闭)
NativeHintsLayout / HintsQueueC ABI JSON 导出
N-APIhintsLayout(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 功能用例

  1. 「序列导览」单条提示:遮罩挖孔高亮顶栏图标,气泡显示标题 / 正文 /「知道了」,点按钮结束;
  2. 三步导览:步骤计数「i/total」正确,「上一步」仅 hasPrevious 时出现,末步按钮变「完成」,中步 5 秒后自动前进且气泡标注倒计时提示;
  3. 分组提示:顶栏与底部导航同时出现两个挖孔,气泡按集体包围盒定位;
  4. 首步 dismissOnBackPress=false:按返回键不关闭(对应上游属性语义),后续步可关;
  5. 点击遮罩外部:序列前进一步(不是关闭);序列中按返回键:整体关闭;
  6. 「对齐实验」:把演示锚点放在演示区顶部,选 Top 系对齐触发翻转到 Bottom 系,气泡页脚显示「请求: Top → 实际: Bottom」+ 翻转徽标;RTL 开关后 Start/End 系镜像;
  7. 「遮罩与全屏」:五种遮罩(含蓝→红线性渐变)即时生效;Scale / Follow 切换后步进可见孔洞生长 / 滑动差异;全屏欢迎提示无孔、位于宿主左上 + 偏移,3 秒自动收起;
  8. 状态行每步显示 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 踩坑复盘

  1. 只改 ArkTS 页面不算 KMP 适配,必须让 :hints 真正编译成 ohosArm64 / ohosX64 目标——哪怕内核只有 506 行;
  2. settings.gradle.kts 里 eazytec 双仓必须排在 pluginManagement 与 dependencyResolutionManagement 最前,否则配置期直接 Unresolved reference: ohosArm64;
  3. Kotlin 2.2.21-1.0.0 老线不支持 Gradle 9.x:上游 wrapper 9.4.1 必须降到 8.14.1,这条与"2.4.20 新线留 Gradle 9"的经验相反,按工具链线区分对待;
  4. Windows 上 gradle.properties 的 org.gradle.java.home 救不了 gradlew.bat 启动:wrapper JVM 只认 JAVA_HOME 环境变量,脚本层必须先 export;
  5. .position(x, y) 双参数写法 ArkTS 严格模式直接编译失败——position 只接收单个 Position 对象 { x, y };
  6. 本 SDK(5.1.0(18))的 AnimatorResult 没有 stop(),只有 play / pause / reset;过渡动画用 pause() 中断并丢弃引用即可;
  7. componentUtils.getRectangleById() 返回的 size 与偏移都是像素,必须 px2vp() 折算后再进桥,否则挖孔与气泡在高密度屏上整体错位;
  8. ArkTS 不能直接 import Kotlin/Native 产物——必须 CMake N-API 包装,且 CMake 目标名(nm_modname)必须等于 ArkTS 的 import 名(libhints_napi.so ↔ hints_napi);
  9. HAP 缺当前 ABI 的 so 会报安装失败 9568347,abiFilters 必须双 ABI,每个 ABI 三件套(NAPI so / KN so / libc++_shared.so)缺一不可;
  10. libc++_shared.so 必须取自 ~/.konan/dependencies 里与 triple 匹配的那份(aarch64-linux-ohos / x86_64-linux-ohos),copyKnSo* 任务按 triple 自动定位;
  11. *.so 全局 gitignore:新克隆必须先跑 Gradle link 任务再 hvigorw assembleHap,CMakeLists 会在 so 缺失时给出明确 FATAL_ERROR 指路;
  12. 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 三条经验

  1. UI 库适配的正确切面是"数学与状态机留在 Kotlin,渲染交给 ArkUI":ComposeHints 里真正值得保真的不是 Composable 而是对齐 / 翻转 / 步进语义——它们决定多端行为一致;切面找对了,桥协议(一个布局请求 + 一个步进请求)自然就小;
  2. 路线选择基于当天的制品可达性:KStore 遇到的是"制品线已补齐",于是真编译整库;ComposeHints 遇到的是"CMP 线差一代",于是内核提取——同一个社区里两条相反路线都是正确答案,判据只有制品矩阵;
  3. 分层验收让"没跑 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 + CMake libhints_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。

参考文档

Logo

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

更多推荐