开源鸿蒙平台 CMP 三方库「Coachmark」适配全流程
本文记录
coachmark(UnifyCoachmark)接入 OpenHarmony 的完整过程,覆盖厚 UI 引导库的语义盘点、鸿蒙工具链选型、上游 Compose 源集停放、ohosArm64/ohosX64目标、Kotlin/Native 动态库、C ABI/N-API 桥接、ArkUI 真机页面、HAP 构建和真机验收。
coachmark是基于 Compose Multiplatform 的新手引导库:在页面根部包一层UnifyCoachmark { },给任意控件挂Modifier.enableCoachMark(key, toolTipPlacement, highlightedViewConfig),scope.show(key1, key2, …)即可逐个展示——暗色遮罩为高亮控件挖圆角孔(padding 扩孔 /Shape.None不挖),Balloon气泡带四方向箭头按Start/End/Top/Bottom/ScreenCenter摆放,点击遮罩按GoNext/GoPrevious/DismissAll/None策略流转,500ms tween 交叉淡出。本次适配与纯引擎类库(KStore 真编译整库)有本质区别:上游 21 个源文件 100% 依赖androidx.compose,社区鸿蒙 CMP 工具链(1.9.2 / Kotlin 2.2.21)编不了这代(Compose 1.7.0 / Kotlin 2.0.0)源码集,因此Compose 层原样停放到composeMain不编译,状态机与三套几何(摆放、挖孔、气泡)在commonMain用纯 Kotlin 逐条镜像,一个带完整引导语义的内核运行在真机上,ArkUI 页面按内核算出的每个坐标与 path 渲染验收。
项目地址: AtomGit/oh-tpc/coachmark.git
上游仓库: ankitk77/coachmark
上游基线:
ankitk77/coachmark4.0.0(本 fork4.0.0-ohos.1)。
开发工具: 华为云码道
环境安装: 环境安装指南
一、背景
1.1 为什么做 OpenHarmony 平台 KMP/CMP 适配
coachmark 为 Compose Multiplatform 应用提供"新手引导"这一件事的完整答案:UnifyCoachmark 根包裹一行接入,enableCoachMark 标记任意控件,show(vararg keys) 装载序列;DimOverlayEffect 负责遮罩与挖孔(highlightActualView 用 BlendMode.DstOut 抠掉 HighlightedViewConfig.Shape.Rect 的圆角矩形),OverlayLayout 负责气泡的测量与摆放(maxWidth 约束 + 五种 placement 公式),Balloon/Arrow 负责气泡形状(圆角矩形 + 箭头三角,bias 控制箭头位置),CoachMarkScopeImpl 负责序列状态机。KMP 项目把这些能力放在 commonMain,由 Android、iOS、Desktop、JS 共享。OpenHarmony 应用同样需要这套引导能力,但上游构建(Kotlin 2.0.0 / Compose 1.7.0 / AGP 8.2.2 / Gradle 8.2)不会生成 OpenHarmony 变体。
如果只把示例页面改成 ArkTS,页面能显示按钮,却无法证明引导状态机、摆放约束、气泡几何在 OpenHarmony ARM64 设备上完成编译、链接和执行。适配需要同时验证状态机、三套几何、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 交付链路。
| 障碍 | 具体问题 |
|---|---|
| 目标缺失 | 必须增加 ohosArm64() / ohosX64() 才能生成 OpenHarmony KLIB。 |
| 工具链不一致 | 上游 Kotlin 2.0.0 / Compose 1.7.0 / AGP 8.2.2 与鸿蒙工具链无法共存于一个 Gradle 构建。 |
| Compose 源集编不进 | 全部 21 个文件 import androidx.compose(mutableStateOf、CompositionLocal、onGloballyPositioned、Layout/Measurable、GenericShape、AnimationSpec),社区鸿蒙 CMP 1.9.2 线对不齐这一代。 |
| 语言边界不同 | ArkTS 不能直接持有 Kotlin 对象,需要 C ABI、C++ N-API 和 JSON。 |
| UI 语义跨不了桥 | Modifier、SnapshotState、逐帧 alpha 动画是 Kotlin 侧概念,C ABI 只能同步一来一回。 |
| 交付链路复杂 | .so、CMake、N-API、HAP、签名和 hdc 都要单独验收。 |
因此,本项目把边界放在:引导状态机与摆放/挖孔/气泡三套几何的 ohos 编译、根构建替换(cmp_qrose 模式)和 C ABI/N-API 桥接层。引导语义完全由镜像自上游的纯 Kotlin 内核负责,ArkTS 只负责按内核返回的坐标和 path 绘制并上报布局。
1.2 库提供的能力
Demo 提供一个真实的引导会话,对应桥接的全部入口:
- 目标注册(putItem):镜像
Modifier.enableCoachMark的onGloballyPositioned——高亮 padding 按上游 Int/Float 数学烘进布局(width = size + padL + padR、startX = pos.x - padL),布局变化自动重新注册; - 序列展示(show):
Text1 → Text2 → Text3 → Button四站引导,placement 各不相同(Bottom/Top/End/Start); - 遮罩挖孔:孔矩形与圆角由
HighlightGeometry计算(含 8vp 扩孔、12 圆角),Shape.None语义对应 hole 为 null; - 气泡摆放(place + balloon):气泡的 maxWidth 约束、五种摆放公式、箭头四方向三角顶点全部由库核心计算,ArkTS
Path直接描库返回的 SVG path; - 策略流转(policy + click):
GoNext(默认)逐项推进、越界消隐;GoPrevious首项回退消隐;DismissAll一点全关;None不动——逐条对齐上游CoachMarkScopeImpl.onOverlayClicked; - 版本握手:
coachMarkVersion()返回 fork 版本号4.0.0-ohos.1,来自真实 Native 调用而非静态文字。
响应是全量快照:
| 字段 | 作用 |
|---|---|
ok / op / error | 操作是否成功、本次操作名、失败原文(Kotlin 异常不跨 FFI)。 |
visible / index / count | 当前是否有可见项、序列位置与长度。 |
key / placement / hole | 当前项的 key、摆放方向、挖孔矩形(left/top/right/bottom/radius)。 |
lastKey / lastHole | 上一个可见项(上游交叉淡出语义的镜像)。 |
scrim / balloonBg / animationMs / clickEvent | 库默认值(#BF000000、#FFA4A4EB、500ms、GoNext),页面颜色与动画时长由库核心决定。 |
上游引擎侧完整能力(自定义 overlayEffect、自定义 AnimationSpec、tooltip 尾随 lambda、enableCoachMark 级 tooltip)全部留在 composeMain 源文件里随编译链路保存,33 项 jvmTest 交叉验证用例钉住镜像内核与上游语义一致。
1.3 实现适配
| 维度 | 要求 |
|---|---|
| 语义复用 | 状态机与三套几何在 commonMain 用纯 Kotlin 逐条镜像上游行为(含 _visibleTooltips 自定义 setter 的双重 updateVisibleItem 级联),不重写、不发明新语义。 |
| 平台目标 | 根构建直接声明 ohosArm64 + ohosX64,保留 jvm 作交叉验证,库模块出 KLIB,example/nativeApp 出双 ABI 动态库。 |
| 构建隔离 | 根构建替换为极简 ohos 构建(cmp_qrose 模式);上游 demo/(Android/iOS 示例)与 convention-plugins/(vanniktech 发布链)留盘不进构建。 |
| 桥接稳定 | 使用 2 个 C ABI 和 UTF-8 JSON,不传递 Kotlin 对象地址;异常永远不跨 FFI。 |
| UI 完整 | 单页覆盖全部桥接入口:四目标注册、遮罩挖孔、库几何气泡、四策略流转、状态行、操作日志和版本握手。 |
| 仓库规范 | 项目说明、文章、效果图和源码链接统一使用 AtomGit。 |
本项目的 ArkUI 页面不是 Compose Multiplatform UI 实现。上游 UI 层(
DimOverlayEffect/OverlayLayout/Balloon)停放在src/composeMain不编译;ArkTS Demo 用库核心计算的几何重绘遮罩与气泡——这是 CPF-KMP-CMP 社区对厚 UI 库已过审的路线(SpotlightOnboarding / qrose 同款)。
二、实现路线图
第 1 阶段:项目初始化 ── 盘点模块与依赖边界、确认上游 Compose 源集无法进 ohos 构建、确定交付物
第 2 阶段:目标与依赖打通 ── 根构建替换、eazytec Maven 置前、Kotlin 2.2.21-1.0.0 钉版、Gradle 8.14.1
第 3 阶段:语义内核建模 ── 状态机规则逐条 trace、摆放/挖孔/气泡三套几何翻译、默认值镜像
第 4 阶段:原生桥接 ── @CName C ABI、C++ N-API、参数校验和内存释放
第 5 阶段:ArkUI 宿主 ── Canvas 挖孔遮罩、库几何驱动的 Balloon 气泡、策略流转
第 6 阶段:示例与验证 ── 33 项 jvmTest、双 ABI so、HAP、签名、真机运行和效果图
三、逐步实现过程
第 1 阶段:项目初始化
1.1 工程边界
coachmark/ 上游 Compose 源集整体停放到 composeMain(进树不进构建)
coachmark/src/composeMain/ 上游 21 个文件原样迁移(UnifyCoachmark/scope/overlay/shape/ui/util)
coachmark/src/commonMain/ 新写:纯 Kotlin 内核(状态机 + 三套几何 + 默认值,9 个文件)
coachmark/src/commonTest/ 新写:33 项语义钉子(jvmTest 运行,ohos 测试全局禁用)
example/nativeApp/ Kotlin/Native 动态库(Bridge + MiniJson)
example/ohos/ DevEco Stage、CMake N-API 和 ArkTS 页面
demo/ · convention-plugins/ 上游 Android/iOS 示例与发布插件,留盘不进 settings
README.OpenHarmony.md / _CN.md 鸿蒙适配说明(中英双份)
README.upstream.md 上游原文保存
doc/ 本文章与效果图
构建拓扑走 cmp_qrose 模式(根构建即 ohos 构建):settings.gradle.kts 只 include :coachmark 与 :example:nativeApp,rootProject.name = "ohos_coachmark"。上游的 vanniktech 发布插件、GPG 签名、AGP classpath 全部随根构建替换剥离——本 fork 不发 Maven Central。
1.2 工具链矩阵
| 项目 | 配置 |
|---|---|
| Kotlin Multiplatform | HarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven) |
| JDK | 21(Temurin 21.0.12,org.gradle.java.home 直指) |
| OpenHarmony 目标 | ohosArm64 + ohosX64(jvm() 保留作交叉验证) |
| Gradle | 8.14.1(wrapper 升级;2.2.21 线的配套版本) |
| 上游源代 | Compose 1.7.0 / Kotlin 2.0.0 / AGP 8.2.2 / Gradle 8.2(仅作镜像参照,不参与构建) |
| DevEco product | 5.1.0(18) 兼容 |
| DevEco Studio | 5.1.0 Release(5.1.0(18) SDK) |
| ABI | arm64-v8a + x86_64 |
| Bundle | org.terminator.ohos.coachmark |
$env:JAVA_HOME = "C:\Program Files\Eclipse Adoptium\jdk-21.0.12.101-hotspot"
java -version
1.3 源码依赖度盘点
| 模块 | 文件数 | 依赖形态 | 处置 |
|---|---|---|---|
UnifyCoachmark.kt + scope/ | 3 | CompositionLocal、mutableStateOf、onGloballyPositioned | 停放 composeMain,状态机镜像进 commonMain |
overlay/ | 3 | drawBehind、BlendMode.DstOut、自定义 Layout/Measurable | 停放,摆放与挖孔几何镜像 |
shape/ + ui/ | 4 | GenericShape、Path、animateFloatAsState | 停放,气泡几何镜像(SVG path 输出) |
model/ + util/ | 11 | Dp/Density/PaddingValues/AnimationSpec | 停放,默认值与枚举镜像 |
demo/(shared/android/ios) | — | AGP + Compose 应用工程 | 留盘不进构建 |
上游状态机的全部可移植规则在镜像时逐条 trace(详见 3.3),其中最容易错的一处:_visibleTooltips 与 _visibleTooltipIndex 两个属性都带自定义 setter,hide() 触发的外层 setter 会先把字段清空、再把 index 置 0(间接触发内层 setter 的 updateVisibleItem)、最后又显式调一次 updateVisibleItem——双重执行使第二次的 last = current 读到第一次已置空的 current,所以上游 show/hide 之后 last 必为 null;而点击翻页只走内层 setter 单次执行,last 保留旧项用于交叉淡出。镜像内核照此复刻,并被 jvmTest 抓住过一次单遍实现的偏差(见 8.1)。
1.4 页面边界
标题区 UNIFYCOACHMARK / 版本握手行(coachMarkVersion() 真实调用)
目标区 Text1(Bottom) · Text2(Top) · Text3(End) · Button(Start) 四张卡片,8vp 扩孔 + 12 圆角
主按钮 Show coachmark → registerAll + show("Text1,Text2,Text3,Button")
策略区 GoNext / GoPrevious / DismissAll / None 单选 → policy
遮罩层 Canvas 挖孔遮罩 + 库几何气泡(箭头方向随 placement 翻转),点击流转
状态行/日志区 index/count、policy、每步 dispatch 留痕(最新在前,上限 50 条)
关于区 库简介、调用链分层、仓库与社区链接(AtomGit)
页面启动先做版本握手,目标卡片经 onAreaChange + ComponentUtils.getRectangleById 上报窗口坐标注册进内核。
第 2 阶段:目标与依赖打通
2.1 路线判定:厚 UI 库走语义镜像而不是真编译整库
先验证生态制品线:社区 Maven(https://maven.eazytec-cloud.com)上没有与上游对齐的 compose.ui ohos 制品,CPF 社区 CMP 线是 1.9.2 / Kotlin 2.2.21,读不了也编不了上游 Compose 1.7.0 / Kotlin 2.0.0 的源码集。结论与 SpotlightOnboarding(上游 Compose 1.7.3 / Kotlin 2.1.10)一致:Compose 层停放、语义层镜像。coachmark 比 SpotlightOnboarding 多出两块必须镜像的 UI 语义——OverlayLayout 的 measure+place(摆放约束)和 Balloon/Arrow 的形状数学——本仓把这两块也全部翻译成纯 Kotlin,ArkTS 拿到的每个坐标和 path 都来自库核心。
2.2 工具链钉版(为什么是 2.2.21-1.0.0 + Gradle 8.14.1)
本仓镜像内核零第三方依赖(不需要 kotlinx ohos 制品线),钉版完全跟着"上游源代年代 + 社区已审先例"走:上游是 Kotlin 2.0.0 / Compose 1.7.0,与 SpotlightOnboarding 同代,走 HarmonyOS Kotlin 2.2.21-1.0.0(qrose / haze / SpotlightOnboarding 已批的钉法);Gradle 配套升到 8.14.1。KStore 的 2.4.20 线配 Gradle 9.x 的经验不能搬——两条工具链线的 Gradle 要求不同。
settings.gradle.kts 把 eazytec 仓库写在最前:
pluginManagement {
repositories {
maven("https://maven.eazytec-cloud.com/nexus/repository/maven-public/")
maven("https://maven.eazytec-cloud.com/nexus/content/groups/harmonyos")
gradlePluginPortal()
google()
mavenCentral()
}
}
rootProject.name = "ohos_coachmark"
include(":coachmark")
include(":example:nativeApp")
2.3 编译语义内核
coachmark/build.gradle.kts 收敛为纯 KMP 模块:
plugins {
alias(libs.plugins.kotlin.multiplatform)
}
kotlin {
applyDefaultHierarchyTemplate()
jvmToolchain(21)
jvm() // 交叉验证用
ohosArm64()
ohosX64()
sourceSets {
commonMain.dependencies {}
commonTest.dependencies {
implementation(kotlin("test"))
}
}
}
// 上游 Compose API(UnifyCoachmark、CoachMarkScope、DimOverlayEffect、OverlayLayout、
// Balloon、Arrow 等 21 个文件)停放在 src/composeMain,不接入任何 target:
// 社区鸿蒙 CMP(1.9.2 / Kotlin 2.2.21)编不了上游 Compose 1.7.0 / Kotlin 2.0.0 源集。
// commonMain 承载平台中性的状态机 + 摆放/挖孔/气泡几何,由 example/nativeApp 的桥驱动。
实测:
./gradlew :coachmark:compileKotlinOhosArm64 :coachmark:compileKotlinOhosX64 # BUILD SUCCESSFUL
2.4 测试依赖的处置
commonTest 只依赖 kotlin("test")(ohos 变体齐全),无需特殊处置;根构建沿用社区纪律全局禁用 ohos 测试任务,语义验证统一在 JVM 跑:
subprojects {
tasks.matching { it.name.contains("Ohos", ignoreCase = true) && it.name.contains("Test") }
.configureEach { enabled = false }
}
第 3 阶段:语义内核建模(技术难点)
3.1 状态机(CoachMarkCore 镜像 CoachMarkScopeImpl)
class CoachMarkCore(
private val onOverlayClicked: (key: String) -> OverlayClickEvent = { CoachMarkDefaults.OVERLAY_CLICK_EVENT },
) {
fun putItem(key, placement, viewX, viewY, viewWidth, viewHeight,
padStart, padTop, padEnd, padBottom, cornerRadius, highlighted)
fun show(vararg keys: String) // 未知 key 抛上游原文 NotImplementedError
fun hide()
fun onOverlayClicked(eventOverride: OverlayClickEvent? = null): OverlayClickEvent
val currentTarget / lastTarget / currentIndex / sequenceCount / isShowing
}
逐条对齐的规则:
| 上游行为 | 镜像行为 |
|---|---|
show 未知 key 抛 NotImplementedError("definition for key $key not found, please provide key by using [Modifier.enableCoachMark]") | 逐字相同 |
_visibleTooltips setter 双重 updateVisibleItem 级联 | show/hide 内两次调用,last 必为 null |
点击 GoNext:index++,越界 getOrNull 为 null → 消隐;旧项保留为 last(交叉淡出) | 相同 |
点击 GoPrevious 首项:index=-1 → 消隐;此后点击早退 no-op,仅 show 可重启 | 相同 |
DismissAll → hide();None → 不动 | 相同 |
onGloballyPositioned 注册:width = size.width + padL + padR(Int)、startX = pos.x - padL(Float) | putItem 相同的 Int/Float 数学 |
3.2 三套几何
摆放(OverlayGeometry 镜像 OverlayLayout measure+place):
maxWidth:Start → startX − gap;End → containerW − gap − endX;其余 → containerW − 2×gap
计算结果 < 30px(TOOLTIP_MAX_WIDTH_OVERRIDE_PX)→ 放宽为 Infinity(fail-soft)
place: Start → (startX − tooltipW, startY + (h−th) shr 1)
End → (endX, 同上)
Top → (startX + (w−tw) shr 1, startY − tooltipH)
Bottom→ (同 x, endY)
ScreenCenter → 屏幕居中
挖孔(HighlightGeometry 镜像 highlightActualView):layout 已含 padding,孔即 (startX, startY, endX, endY) 圆角矩形;highlighted=false 返回 null(Shape.None)。
气泡(BalloonGeometry 镜像 Balloon + Arrow):箭头把自身尺寸记为定向 padding(Arrow.Top.topPadding = height……),总尺寸 = 内容 + 箭头 padding + 气泡 padding(横 8 竖 6)两侧;形状 = 圆角矩形(内缩箭头 padding)+ 箭头三角两个闭合子路径,顶点公式与上游 Arrow.draw 逐点一致,输出为一条 SVG path 字符串供 ArkTS Path().commands() 直接消费。
3.3 错误通道
每个导出整体 try/catch,Kotlin 异常永远不跨 FFI:未知 op、非法枚举名、show 未知 key、place 无可见项,统一 {"ok":false,"op":…,"error":"原文"}。
第 4 阶段:原生桥接
4.1 三层桥接
ArkTS
│ JSON string
▼
C++ N-API entry(libcoachmark_napi.so)
│ const char*
▼
Kotlin/Native C ABI(libohoscoachmark.so)
│ CoachMarkDispatch / CoachMarkVersion
▼
UTF-8 JSON + DisposeString
4.2 Kotlin/Native 导出函数
@CName("CoachMarkDispatch")
fun coachMarkDispatch(request: String): String // putItem/show/hide/policy/click/place/balloon/snapshot 一口进
@CName("CoachMarkVersion")
fun coachMarkVersion(): String = "4.0.0-ohos.1" // 版本握手
| C ABI 符号 | ArkTS 方法 | 覆盖内核 |
|---|---|---|
CoachMarkDispatch | coachMarkDispatch(req) | 状态机 + 摆放/挖孔/气泡几何 + 快照 |
CoachMarkVersion | coachMarkVersion() | fork 版本握手(真机版本行的数据来源) |
place 与 balloon 是纯几何查询(不依赖会话状态的部分),show/click/hide/policy/snapshot 返回全量快照——scrim/balloonBg/animationMs/clickEvent 全部来自库常量,页面的颜色与动画时长由库核心决定。
4.3 C++ N-API 方法分发
napi_property_descriptor desc[] = {
{"coachMarkDispatch", nullptr, NapiCoachMarkDispatch, nullptr, nullptr, nullptr,
static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr},
{"coachMarkVersion", nullptr, NapiCoachMarkVersion, nullptr, nullptr, nullptr,
static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr},
};
每个回调统一三步:napi_typeof 校验必须是 string、OH_LOG_INFO 打字节数、调 KN 函数并包装返回。KN 返回的字符串必须经 libohoscoachmark_symbols()->DisposeString 归还 Kotlin 运行时,绝不 free()。libohoscoachmark_api.h 按社区已审仓库同款手写(只声明 ExportedSymbols 结构与两个业务符号)。
4.4 CMake 和类型声明
add_library(ohoscoachmark SHARED IMPORTED)
set_target_properties(ohoscoachmark PROPERTIES
IMPORTED_LOCATION ${KN_SO}
IMPORTED_NO_SONAME TRUE)
add_library(coachmark_napi SHARED napi_init.cpp)
target_link_libraries(coachmark_napi PUBLIC
libace_napi.z.so
libhilog_ndk.z.so
ohoscoachmark)
OHOS_ARCH 为空或 libohoscoachmark.so 缺失直接 FATAL_ERROR(提示先跑 link 任务);$ORIGIN rpath 加 POST_BUILD 拷贝保证 NAPI so、KN so、libc++ 三件套同目录,dlopen 时互相可见。
export const coachMarkDispatch: (request: string) => string;
export const coachMarkVersion: () => string;
第 5 阶段:ArkUI 宿主
5.1 注册与触发
aboutToAppear(): void {
this.versionLine = `${coachMarkVersion()} · Kotlin/Native ohosArm64/ohosX64`; // 版本握手
}
private registerOne(spec: TargetSpec, area?: Area): void {
const rect = componentUtils.getRectangleById(spec.key);
const x = (rect.windowOffset.x - rootRect.windowOffset.x) / this.density; // px → vp
// putItem:镜像 enableCoachMark + onGloballyPositioned
}
private opShow(): void {
const req: ShowRequest = { op: 'show', keys: 'Text1,Text2,Text3,Button' };
const s = this.callSnapshot('show', JSON.stringify(req));
}
5.2 遮罩与气泡渲染
- 遮罩:Canvas 先铺
snapshot.scrim(库默认#BF000000),再globalCompositeOperation = 'destination-out'按快照hole挖圆角孔(arcTo手绘圆角矩形,兼容 5.1.0(18)),镜像上游drawRect + BlendMode.DstOut; - 气泡:
MeasureText.measureTextSize量文本 →balloonop 拿 SVG path + 总尺寸 + 内容偏移 →placeop 拿 x/y →Path().commands(path)+Text绝对定位;箭头 side 按 placement 翻转(Bottom→Top、Top→Bottom、End→Start、Start→End、ScreenCenter→Bottom); - 流转:点击遮罩/气泡 →
click→ 新快照 → 遮罩整体 500ms(库animationMs)淡入淡出。
5.3 对比度和错误边界
主操作深绿按钮白字,策略单选即时生效;ok:false 时错误原文进状态行与日志。Native 调用本身抛错同样被 try/catch 收进日志区。
第 6 阶段:示例与验证
6.1 工程结构
example/
├── nativeApp/
│ ├── build.gradle.kts 双 ABI sharedLib(baseName=ohoscoachmark)+ copyKnSo*
│ └── src/ohosMain/kotlin/org/terminator/ohos/coachmark/
│ └── Bridge.kt @CName × 2 + MiniJson(扁平 JSON,继承自 cmp_qrose 审核模式)
└── ohos/
├── build-profile.json5 signingConfigs 留空(签名由 DevEco 自动生成回填)
├── AppScope/app.json5 bundleName org.terminator.ohos.coachmark、versionName 4.0.0-ohos.1
└── entry/
├── build-profile.json5 externalNativeOptions + abiFilters [arm64-v8a, x86_64]
├── libs/{arm64-v8a,x86_64}/ linkDebugShared* 经 copyKnSo* 回填的三件套(gitignore)
├── src/main/cpp/CMakeLists.txt IMPORTED 引入 KN so、$ORIGIN rpath
├── src/main/cpp/napi_init.cpp N-API 导出、参数检查、DisposeString
├── src/main/cpp/include/libohoscoachmark_api.h
├── src/main/cpp/types/libcoachmark_napi/{index.d.ts, oh-package.json5}
└── src/main/ets/pages/Index.ets ArkUI 验收页面
6.2 构建、签名和安装
./gradlew :coachmark:jvmTest # 33 项交叉验证
./gradlew :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64
cd example/ohos
hvigorw assembleHap -p product=default -p buildMode=debug --no-daemon
签名在 DevEco Studio 的 File → Project Structure → Signing Configs 中勾选自动生成,证书写入本机不进入仓库(仓库内 build-profile.json5 的 signingConfigs 保持空数组)。
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.coachmark -m entry
四、完整代码对照
4.1 整体架构
com.pseudoankit.coachmark/**(镜像自上游 commonMain 的纯 Kotlin 内核)
│ CoachMarkCore 状态机(show/hide/click 策略、双重 updateVisibleItem 级联)
│ OverlayGeometry 摆放(maxWidth 三公式 + <30 fail-soft + shr 1 居中)
│ HighlightGeometry 挖孔 / BalloonGeometry 气泡(SVG path 输出)
│ CoachMarkDefaults 默认值(#BF000000 / #FFA4A4EB / 500ms / GoNext)
coachmark/src/composeMain/**(上游 Compose API 原样停放,不编译)
Bridge.kt + MiniJson
│ CoachMarkDispatch / CoachMarkVersion
libcoachmark_napi.so
│ coachMarkDispatch / coachMarkVersion
Index.ets
│ Canvas 挖孔 + Path 描库几何气泡 + JSON parse + @State 更新
▼
ArkUI 真机页面(单页滚动)
4.2 文件清单
| 文件 | 职责 |
|---|---|
settings.gradle.kts | 极简 ohos 构建、eazytec Maven 置前、ohos_coachmark、demo/convention-plugins 移出 |
gradle/libs.versions.toml | Kotlin 2.2.21-1.0.0 单条钉版(无 Compose/AGP/vanniktech) |
build.gradle.kts(根) | 版本 4.0.0-ohos.1、全局禁用 Ohos*Test* 任务 |
coachmark/build.gradle.kts | 纯 KMP:jvm + ohosArm64 + ohosX64,composeMain 停放说明 |
coachmark/src/commonMain/.../CoachMarkCore.kt | 状态机镜像(putItem/show/hide/onOverlayClicked) |
coachmark/src/commonMain/.../geometry/*.kt | 摆放/挖孔/气泡三套几何 |
coachmark/src/commonMain/.../CoachMarkDefaults.kt | 默认值镜像(ARGB + CSS 双形态) |
coachmark/src/commonTest/*.kt | 33 项语义钉子(14 状态机 + 11 摆放 + 8 气泡) |
example/nativeApp/build.gradle.kts | 双 ABI sharedLib、cxxShared 定位 libc++、copyKnSo* 回填 |
nativeApp/.../Bridge.kt | 2 个 C ABI、八 op 分发、MiniJson、错误通道 |
ohos/.../cpp/napi_init.cpp | N-API 导出、参数检查、DisposeString |
ohos/.../cpp/CMakeLists.txt | IMPORTED 引入 KN so、$ORIGIN rpath、POST_BUILD 三件套 |
ohos/.../ets/pages/Index.ets | ArkUI 验收页面(挖孔遮罩 + 库几何气泡) |
README.OpenHarmony_CN.md | 鸿蒙适配主文档(12 章节) |
4.3 关键 API 对照
| 层次 | API | 作用 |
|---|---|---|
| 内核 | CoachMarkCore.putItem | 注册目标,padding 烘进 layout(镜像 enableCoachMark) |
| 内核 | CoachMarkCore.show / hide | 装/卸引导序列(含 setter 级联语义) |
| 内核 | CoachMarkCore.onOverlayClicked | 策略流转(GoNext/GoPrevious/DismissAll/None) |
| 内核 | OverlayGeometry.maxTooltipWidth / place | 气泡宽度约束与五种摆放公式 |
| 内核 | HighlightGeometry.hole | 挖孔矩形(Shape.None → null) |
| 内核 | BalloonGeometry.layout | 气泡总尺寸 + SVG path + 内容偏移 |
| Native | CoachMarkDispatch / CoachMarkVersion | C ABI JSON 导出 |
| N-API | coachMarkDispatch(req) / coachMarkVersion() | 向 ArkTS 暴露能力 |
| ArkTS | registerOne / opShow / opClick / layoutBalloon | 注册、触发、流转与气泡重摆 |
4.4 ArkTS 与 Kotlin 的边界
// 注册目标(镜像 enableCoachMark,坐标 vp ≈ 上游 dp)
coachMarkDispatch(JSON.stringify({
op: 'putItem', key: 'Text1', placement: 'Bottom',
x: 16, y: 320, w: 343, h: 44,
padL: 8, padT: 8, padR: 8, padB: 8, cornerRadius: 12, highlight: true,
}));
// 展示并流转——每次响应都是全量快照,hole 与默认值由库核心计算
const snap = JSON.parse(coachMarkDispatch(JSON.stringify({ op: 'show', keys: 'Text1,Text2' })));
// snap.hole === { key:'Text1', left:8, top:312, right:351, bottom:368, radius:12 }
// 气泡摆放:库核心按上游 OverlayLayout 公式计算
val point = OverlayGeometry.place(target, tooltipWidth, tooltipH, containerW, containerH)
// 气泡形状:库核心按上游 Balloon + Arrow 顶点生成 SVG path
val layout = BalloonGeometry.layout(contentW, contentH, side, arrowW, arrowH, bias, radius, …)
两者之间只传输扁平 JSON 字符串,不传输 Kotlin 对象或未校验的动态结构。
五、关键决策说明
决策 1:语义镜像而不是真编译整库
上游 21 个文件 100% 依赖 androidx.compose,社区 Maven 没有对齐的 compose.ui ohos 制品;引导库的价值恰恰在"看不见的规则"(摆放约束、fail-soft 放宽、箭头顶点、状态机边界),把这些规则镜像成纯 Kotlin 比抽掉 UI 的空壳更完整。Compose 层停放不删,等社区 CMP 工具链与 Compose 1.7 对齐后可整体接回。
决策 2:工具链钉 2.2.21-1.0.0 + Gradle 8.14.1,跟上游年代与已审先例走
镜像内核零第三方依赖,不需要 kotlinx ohos 制品线,钉版依据是上游源代年代(Compose 1.7.0 / Kotlin 2.0.0,与 SpotlightOnboarding 同代)与社区已批钉法(qrose / SpotlightOnboarding 的 2.2.21-1.0.0 + Gradle 8.14.1)。KStore 因 kotlinx 制品线需要 2.4.20 + Gradle 9.x——两条工具链线的经验不能互相套用。
决策 3:根构建替换(cmp_qrose 模式),demo 与发布链留盘
上游根构建(AGP classpath、vanniktech 发布、GPG 签名)对鸿蒙没有保留价值,整个替换为极简 ohos 构建。demo/(AGP + Compose 应用工程)与 convention-plugins/(发布插件)留盘不进 settings.gradle.kts——它们的存在方式只有这一种。
决策 4:几何走纯函数 op,渲染全按库返回值
place 与 balloon 是与 会话无关的纯几何查询:ArkTS 量完文本尺寸后先 balloon 拿 path 与总尺寸,再 place 拿 x/y——气泡的每个顶点、每个坐标都来自库核心,页面只负责描。这保证 Demo 验收的是"库的几何"而不是"页面的几何"。
决策 5:desktop 式 jvm 交叉验证 + 测试零含糊
内核逻辑在 commonMain,JVM 与 ohos 跑同一份代码;33 项 jvmTest 钉的是上游源码的逐条规则(含双重 setter 级联、负偏移 shr 1、fail-soft 阈值),ohos 测试任务全局禁用——镜像内核的正确性由 JVM 证明,鸿蒙侧只承担编译与链接。
六、测试与验证
6.1 测试环境
本次验证使用 Windows(x86_64)、JDK 21(Temurin 21.0.12)、DevEco Studio 5.1.0 Release、OpenHarmony 命令行工具(hvigor / hdc)与 HarmonyOS 真机(arm64)。
6.2 JVM 交叉验证
./gradlew :coachmark:jvmTest
33 项用例全部通过:
| 测试类 | 用例数 | 钉住什么 |
|---|---|---|
CoachMarkCoreTest | 14 | putItem 的 Int/Float padding 数学、未知 key 错误原文、GoNext/GoPrevious 越界消隐、DismissAll/None、双重 updateVisibleItem 级联(show/hide 后 last 为 null、点击切换保留 last)、策略回调 |
OverlayGeometryTest | 11 | 五个摆放公式(含负偏移 shr 1)、三个 maxWidth 公式、<30px fail-soft → Infinity、挖孔矩形/圆角、Shape.None → null |
BalloonGeometryTest | 8 | 四方向箭头 padding、measure 加 padding 链、圆角矩形子路径坐标、四方向箭头顶点、零圆角无弧命令、默认值常量 |
开发过程中这组测试抓住过一次真实偏差:镜像 hide() 时只跑了一遍 updateVisibleItem,last 残留——jvmTest 三个用例当场失败,逼着把上游 setter 级联逐行 trace 清楚(见 8.1 第 5 条)。
6.3 HAP 验证
:coachmark:compileKotlinOhosArm64 / OhosX64: BUILD SUCCESSFUL
linkDebugSharedOhosArm64 / linkDebugSharedOhosX64: BUILD SUCCESSFUL
llvm-nm -D: CoachMarkDispatch / CoachMarkVersion / libohoscoachmark_symbols 均为全局 T 符号
BuildNativeWithCmake: finished(双 ABI 编出 libcoachmark_napi.so)
CompileArkTS: finished(修复两处 ArkTS strict 类型问题后)
PackageHap: finished
BUILD SUCCESSFUL
install bundle successfully
start ability successfully
libohoscoachmark.so debugShared:arm64 约 3.6MB、x86_64 约 3.3MB(含 Kotlin/Native 运行时);每个 ABI 目录三件套(NAPI so、KN so、libc++)由 copyKnSo* 自动回填。
6.4 功能用例
- 启动后版本行显示
coachMarkVersion() → 4.0.0-ohos.1(真实 Native 调用); - Show 后遮罩出现,Text1 周围挖出 12 圆角孔(含 8vp 扩孔),库默认色气泡出现在 Text1 下方、箭头朝上,文字 “This is tooltip for Text1 (1/4)”;
- 连点遮罩:Text1 → Text2(气泡移上方、箭头朝下)→ Text3(右侧、箭头朝左)→ Button(左侧、箭头朝右)→ 再点消隐(GoNext 越界语义);
- 策略切 GoPrevious 再 Show:首项往回点一次即消隐(index=-1),此后点击 no-op,重新 Show 才能恢复;
- 策略切 DismissAll:一点全消;切 None:点不动;
- Hide / Snapshot 行为与状态行一致;每步操作日志带时间戳滚动留痕。
6.5 验证结论
语义内核、三套几何、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 已完成链路验证。应用在 HarmonyOS 真机上成功安装并进入前台,页面展示了真实的引导会话:版本握手、四站挖孔与库几何气泡、策略流转与越界消隐(见下节效果图)。
七、运行效果
7.1 真机截图

7.2 命令速查
git clone https://atomgit.com/oh-tpc/coachmark.git
cd coachmark
export JAVA_HOME="/path/to/jdk-21"
./gradlew :coachmark:jvmTest # 33 项交叉验证
./gradlew :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64
cd example/ohos
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.coachmark -m entry
hdc shell hilog | grep CoachMark
八、遗留问题与改进方向
8.1 踩坑复盘
- 只改 ArkTS 页面不算 KMP 适配,必须让
:coachmark真正编译成ohosArm64/ohosX64KLIB 并链接出动态库; settings.gradle.kts里 eazytec 双仓必须排在pluginManagement与dependencyResolutionManagement最前,否则配置期直接Unresolved reference: ohosArm64;- 工具链经验按线区分:上游 Compose 1.7 / Kotlin 2.0 与 SpotlightOnboarding 同代,走 2.2.21-1.0.0 + Gradle 8.14.1;KStore 的 2.4.20 + Gradle 9.x 经验不适用本仓;
String.format是 JVM 专属——commonMain 里 path 数字格式化必须手写,否则 ohos KLIB 编译失败;- 上游带自定义 setter 的属性级联是最容易镜像错的点:
hide()触发的外层 setter 先清字段、置 index(间接触发内层 setter 调一次updateVisibleItem)、再显式调一次——双重执行使 show/hide 后last必为 null;单遍实现会残留last,本仓开发中被 jvmTest 三个用例当场抓住; - Kotlin 运算符优先级陷阱:
startY + (a - b) shr 1实际是(startY + a - b) shr 1,与上游startY + ((a - b) shr 1)不同——测试断言必须显式括号; - ArkTS strict 模式两连坑:其一,
MeasureText.measureTextSize返回的TextSizeResult.width/height是可选Length(string | number | Resource | undefined),不能直接当 number 用,需统一收敛转换;其二,build()的if分支只允许 UI 组件语法,固化 null 收窄的const声明会报Only UI component syntax can be written here——渲染块要抽成@Builder方法以非空参数传入; ctx.roundRect要求 SDK 20,工程兼容 5.1.0(18):Canvas 圆角矩形用arcTo(API 7+)手绘;- 桥返回
#AARRGGBB(ArkUI 习惯),CanvasfillStyle按 CSS 解析为#RRGGBBAA——统一转rgba()规避色序歧义; ComponentUtils.getRectangleById的windowOffset是 px、onAreaChange的 Area 是 vp:注册几何统一换算回 vp(≈上游 dp),Canvas 用"根 vp 宽 / 画布 px 宽"自校准密度;- KN 返回字符串必须
DisposeString归还运行时,N-API 层不能free(); build-profile.json5的signingConfigs缺material字段会被 hvigor schema 拒绝——仓库内留空数组,自动签名由 IDE 回填,证书绝不进仓。
8.2 已知问题
- 鸿蒙侧无 Compose Composable:遮罩、挖孔、气泡全部由 ArkTS Canvas/Path 按"库核心计算的几何"重绘(SpotlightOnboarding / qrose 同款路线);
- 上游
Modifier/CompositionLocal/SnapshotState/AnimationSpec体系不跨桥:注册由 ArkTSonAreaChange+getRectangleById主动上报,动画用库默认 500ms 在 ArkTS 侧近似; - 上游 current/previous 双孔 + 双气泡交叉淡出的逐帧 alpha 不跨 C ABI:Demo 只画当前孔与当前气泡,切换由遮罩整体淡入淡出近似;
ScreenCenter摆放箭头固定向下(上游可配任意 Arrow,Demo 不暴露);getRectangleById/measureTextSize有 deprecation 警告(当前 SDK 功能正常,与已审仓库同类记录一致);- android / js / apple 目标随上游源集一并停放,发布链剥离,本 fork 不发 Maven Central。
8.3 未来优化方向
- 社区 CMP 工具链与 Compose 1.7 对齐后,把
composeMain接回 ohos 构建,ArkTS 渲染层整体替换为真 CMP UI; - 把逐帧 alpha 经 N-API 线程安全回表,实现真正的双气泡交叉淡出;
- 桥补自定义 overlayEffect / AnimationSpec 的参数化入口;
- 在 CI 中加入 ohos 编译、ELF 符号检查和 HAP 构建;
- 补充真机(x86_64)与其他设备 ABI 的验收矩阵和自动化截图脚本。
九、总结
9.1 核心难点回顾
上游 coachmark 4.0.0(100% Compose UI,21 文件)
→ 判定路线:厚 UI 库按 SpotlightOnboarding / qrose 模式镜像语义
→ 根构建替换(cmp_qrose 模式,Kotlin 2.2.21-1.0.0 + Gradle 8.14.1 + JDK 21)
→ commonMain:CoachMarkCore 状态机(含双重 updateVisibleItem 级联)
+ OverlayGeometry 摆放(maxWidth 三公式 + <30 fail-soft + shr 1 居中)
+ HighlightGeometry 挖孔 + BalloonGeometry 气泡(SVG path 输出)
→ commonTest 33 项钉语义(jvmTest 全绿,ohos 测试全局禁用)
→ nativeApp:@CName × 2 + MiniJson,八 op 一入口
→ ohos:CMake NAPI(DisposeString 纪律)+ ArkTS 挖孔遮罩 + 库几何气泡
→ 真机验收:四站引导流转 + 策略边界 + 版本握手全通过
9.2 封装层次
- 内核层:镜像自上游的状态机、摆放/挖孔/气泡三套几何与默认值;
- Native 层:生成双 ABI 动态库并通过 2 个 C ABI 输出 JSON;
- N-API 层:完成参数检查、字符串转换和 Native 内存释放;
- ArkTS 层:管理页面状态、文本测量、几何查询、日志与错误文本;
- DevEco 层:完成 CMake、HAP、签名、安装和真机运行。
9.3 三条经验
- 厚 UI 库的适配价值在"语义镜像的完整度":coachmark 的摆放约束、fail-soft、箭头几何这些"看不见的规则"全部进了纯 Kotlin 核心,ArkTS 只是执笔者——Demo 验收的每一条都是库的行为;
- 上游带自定义 setter 的属性级联必须逐行 trace 再镜像:
_visibleTooltips清空触发 index setter 再显式调用的双重执行,决定last的生死;凭直觉单遍实现必错,而 33 项 jvmTest 就是抓这种偏差的闸门; - 工具链版本跟着"上游年代 + 社区先例"走而不是越高越好:上游 Compose 1.7 / Kotlin 2.0 与 SpotlightOnboarding 同代就钉 2.2.21 线,KStore 因 kotlinx 制品线钉 2.4.20 线——两条线的 Gradle 要求不同,经验不能互相套用。
9.4 适配成果
当前适配已完成:
- coachmark
4.0.0引导语义的双 ABI Kotlin/Native 编译(状态机 + 摆放/挖孔/气泡三套几何,33 项 jvmTest 全绿); - 上游 Compose API 21 个文件停放到
composeMain完整保留(工具链对齐后可接回); - 2 个 C ABI 符号和 C++ N-API 包装层(
llvm-nm验证全局 T 符号); - DevEco Stage ArkUI 单页验收页面(四目标注册、挖孔遮罩、库几何气泡、四策略流转、版本握手、操作日志);
- HAP 构建(双 ABI 六件套 so)、签名、设备安装和真机验证;
- 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)