本文记录 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/coachmark 4.0.0(本 fork 4.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 MultiplatformHarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven)
JDK21(Temurin 21.0.12,org.gradle.java.home 直指)
OpenHarmony 目标ohosArm64 + ohosX64(jvm() 保留作交叉验证)
Gradle8.14.1(wrapper 升级;2.2.21 线的配套版本)
上游源代Compose 1.7.0 / Kotlin 2.0.0 / AGP 8.2.2 / Gradle 8.2(仅作镜像参照,不参与构建)
DevEco product5.1.0(18) 兼容
DevEco Studio5.1.0 Release(5.1.0(18) SDK)
ABIarm64-v8a + x86_64
Bundleorg.terminator.ohos.coachmark
$env:JAVA_HOME = "C:\Program Files\Eclipse Adoptium\jdk-21.0.12.101-hotspot"
java -version
1.3 源码依赖度盘点
模块文件数依赖形态处置
UnifyCoachmark.kt + scope/3CompositionLocal、mutableStateOf、onGloballyPositioned停放 composeMain,状态机镜像进 commonMain
overlay/3drawBehind、BlendMode.DstOut、自定义 Layout/Measurable停放,摆放与挖孔几何镜像
shape/ + ui/4GenericShape、Path、animateFloatAsState停放,气泡几何镜像(SVG path 输出)
model/ + util/11Dp/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 方法覆盖内核
CoachMarkDispatchcoachMarkDispatch(req)状态机 + 摆放/挖孔/气泡几何 + 快照
CoachMarkVersioncoachMarkVersion()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 量文本 → balloon op 拿 SVG path + 总尺寸 + 内容偏移 → place op 拿 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.tomlKotlin 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/*.kt33 项语义钉子(14 状态机 + 11 摆放 + 8 气泡)
example/nativeApp/build.gradle.kts双 ABI sharedLib、cxxShared 定位 libc++、copyKnSo* 回填
nativeApp/.../Bridge.kt2 个 C ABI、八 op 分发、MiniJson、错误通道
ohos/.../cpp/napi_init.cppN-API 导出、参数检查、DisposeString
ohos/.../cpp/CMakeLists.txtIMPORTED 引入 KN so、$ORIGIN rpath、POST_BUILD 三件套
ohos/.../ets/pages/Index.etsArkUI 验收页面(挖孔遮罩 + 库几何气泡)
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 + 内容偏移
NativeCoachMarkDispatch / CoachMarkVersionC ABI JSON 导出
N-APIcoachMarkDispatch(req) / coachMarkVersion()向 ArkTS 暴露能力
ArkTSregisterOne / 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 项用例全部通过:

测试类用例数钉住什么
CoachMarkCoreTest14putItem 的 Int/Float padding 数学、未知 key 错误原文、GoNext/GoPrevious 越界消隐、DismissAll/None、双重 updateVisibleItem 级联(show/hide 后 last 为 null、点击切换保留 last)、策略回调
OverlayGeometryTest11五个摆放公式(含负偏移 shr 1)、三个 maxWidth 公式、<30px fail-soft → Infinity、挖孔矩形/圆角、Shape.None → null
BalloonGeometryTest8四方向箭头 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 功能用例

  1. 启动后版本行显示 coachMarkVersion() → 4.0.0-ohos.1(真实 Native 调用);
  2. Show 后遮罩出现,Text1 周围挖出 12 圆角孔(含 8vp 扩孔),库默认色气泡出现在 Text1 下方、箭头朝上,文字 “This is tooltip for Text1 (1/4)”;
  3. 连点遮罩:Text1 → Text2(气泡移上方、箭头朝下)→ Text3(右侧、箭头朝左)→ Button(左侧、箭头朝右)→ 再点消隐(GoNext 越界语义);
  4. 策略切 GoPrevious 再 Show:首项往回点一次即消隐(index=-1),此后点击 no-op,重新 Show 才能恢复;
  5. 策略切 DismissAll:一点全消;切 None:点不动;
  6. 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 踩坑复盘

  1. 只改 ArkTS 页面不算 KMP 适配,必须让 :coachmark 真正编译成 ohosArm64 / ohosX64 KLIB 并链接出动态库;
  2. settings.gradle.kts 里 eazytec 双仓必须排在 pluginManagement 与 dependencyResolutionManagement 最前,否则配置期直接 Unresolved reference: ohosArm64;
  3. 工具链经验按线区分:上游 Compose 1.7 / Kotlin 2.0 与 SpotlightOnboarding 同代,走 2.2.21-1.0.0 + Gradle 8.14.1;KStore 的 2.4.20 + Gradle 9.x 经验不适用本仓;
  4. String.format 是 JVM 专属——commonMain 里 path 数字格式化必须手写,否则 ohos KLIB 编译失败;
  5. 上游带自定义 setter 的属性级联是最容易镜像错的点:hide() 触发的外层 setter 先清字段、置 index(间接触发内层 setter 调一次 updateVisibleItem)、再显式调一次——双重执行使 show/hide 后 last 必为 null;单遍实现会残留 last,本仓开发中被 jvmTest 三个用例当场抓住;
  6. Kotlin 运算符优先级陷阱:startY + (a - b) shr 1 实际是 (startY + a - b) shr 1,与上游 startY + ((a - b) shr 1) 不同——测试断言必须显式括号;
  7. 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 方法以非空参数传入;
  8. ctx.roundRect 要求 SDK 20,工程兼容 5.1.0(18):Canvas 圆角矩形用 arcTo(API 7+)手绘;
  9. 桥返回 #AARRGGBB(ArkUI 习惯),Canvas fillStyle 按 CSS 解析为 #RRGGBBAA——统一转 rgba() 规避色序歧义;
  10. ComponentUtils.getRectangleById 的 windowOffset 是 px、onAreaChange 的 Area 是 vp:注册几何统一换算回 vp(≈上游 dp),Canvas 用"根 vp 宽 / 画布 px 宽"自校准密度;
  11. KN 返回字符串必须 DisposeString 归还运行时,N-API 层不能 free();
  12. build-profile.json5 的 signingConfigs 缺 material 字段会被 hvigor schema 拒绝——仓库内留空数组,自动签名由 IDE 回填,证书绝不进仓。

8.2 已知问题

  • 鸿蒙侧无 Compose Composable:遮罩、挖孔、气泡全部由 ArkTS Canvas/Path 按"库核心计算的几何"重绘(SpotlightOnboarding / qrose 同款路线);
  • 上游 Modifier / CompositionLocal / SnapshotState / AnimationSpec 体系不跨桥:注册由 ArkTS onAreaChange + 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 三条经验

  1. 厚 UI 库的适配价值在"语义镜像的完整度":coachmark 的摆放约束、fail-soft、箭头几何这些"看不见的规则"全部进了纯 Kotlin 核心,ArkTS 只是执笔者——Demo 验收的每一条都是库的行为;
  2. 上游带自定义 setter 的属性级联必须逐行 trace 再镜像:_visibleTooltips 清空触发 index setter 再显式调用的双重执行,决定 last 的生死;凭直觉单遍实现必错,而 33 项 jvmTest 就是抓这种偏差的闸门;
  3. 工具链版本跟着"上游年代 + 社区先例"走而不是越高越好:上游 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。

参考文档

Logo

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

更多推荐