本文记录
kmp-showcase-compose接入 OpenHarmony 的完整过程,覆盖引导库盘点、纯 Compose UI 库的内核移植边界、ohosArm64/ohosX64目标、Kotlin/Native 动态库、C ABI/N-API 桥接、ArkUI 真机页面、HAP 构建和真机验收。
kmp-showcase-compose是基于 Compose Multiplatform 的新手引导(onboarding showcase)库:ShowcaseController驱动一串ShowcaseStep,全屏遮罩按步骤挖孔高亮目标控件(圆 / 圆角矩形 / 矩形),可选水波纹 / 脉冲圆呼吸动画,Tooltip气泡四方向智能定位并带箭头指向锚点,点击气泡按钮或高亮区前进步骤。本次适配与社区已有两条路线都不同:qrose / haze / PreCompose 走"零依赖内核提取",KStore 走"ohos 制品线齐备后的真编译整库"------而kmp-showcase-compose是纯 Compose UI 库 (全库commonMain都 importandroidx.compose.*,ohos 上既没有 Compose 编译线也没有可绕开的纯引擎),因此走第三条路:内核按原包名移植 + Compose 值类型替换 + 几何在内核算、像素在 ArkTS 画 。状态机、步骤模型、气泡定位、气泡形状的运算逻辑与上游逐字一致,运行在真机的.so里;ArkUI 页面作为验收宿主持零引导状态。
项目地址: AtomGit/oh-tpc/kmp-showcase-compose
上游仓库: suwasto/kmp-showcase-compose
开发工具: 华为云码道
环境安装: 环境安装指南
一、背景
1.1 为什么做 OpenHarmony 平台 KMP/CMP 适配
kmp-showcase-compose 为 Compose Multiplatform 应用提供"高亮引导"这一件事的完整答案:rememberShowcaseController() 一行拿控制器,ShowcaseHost(controller) { ... } 包住整页,Modifier.captureBounds { } 上报目标控件矩形,ShowcaseStep 声明样式与内容,controller.start(steps) 开跑。KMP 项目把这些能力放在 commonMain,由 Android、iOS 共享。OpenHarmony 应用同样需要这套新手引导能力,但上游构建(Kotlin 2.3.0 / Compose 1.9.3 / AGP 8.13.2 / 新版 com.android.kotlin.multiplatform.library 插件)不会生成 OpenHarmony 变体。
如果只把示例页面改成 ArkTS,页面可以显示按钮,却无法证明引导状态机、挖孔几何、气泡定位在 OpenHarmony ARM64 设备上完成编译、链接和执行。适配需要同时验证内核、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 交付链路。
| 障碍 | 具体问题 |
|---|---|
| Compose 编译线缺失 | 全库源码 import androidx.compose.*;社区 HarmonyOS CMP 停在 1.9.2 / Kotlin 2.2.21 线,与上游 Compose 1.9.3 / Kotlin 2.3.0 对不齐,@Composable 无法编进 ohosArm64 / ohosX64。 |
| 没有可绕开的纯引擎 | 不像 qrose 有纯 Kotlin 编码器、KStore 有纯 Kotlin 存储引擎------本库 19 个源文件里 8 个重度 Compose,其余也普遍沾染 Dp / Color / Rect 值类型。 |
| 目标缺失 | 必须增加 ohosArm64() / ohosX64() 才能生成 OpenHarmony 动态库。 |
| 工具链不一致 | 上游 Gradle 8.14.3 / Kotlin 2.3.0 与鸿蒙 Kotlin 2.2.21-1.0.0 无法共存于一个 Gradle 构建。 |
| 内容是 Composable lambda | ShowcaseStep.content: @Composable (Rect) -> Unit 是内核与渲染的结构性接缝,C ABI 过不了 lambda。 |
| 交付链路复杂 | .so、CMake、N-API、HAP、签名和 hdc 都要单独验收。 |
因此,本项目把边界放在:内核(状态机 / 步骤模型 / 样式常量 / 气泡定位 / 气泡形状 / 命中测试)的 ohos 编译、根构建替换(cmp_qrose 模式)和 C ABI/N-API 桥接层。引导语义完全由上游原版实现(移植件运算符级一致)负责,ArkTS 只负责调用 Native、解析 JSON 和绘制结果。
1.2 库提供的能力
Demo 提供一个真实的引导会话,对应桥的全部入口:
- 启动(start) :
controller.start(steps)重建会话回到第 0 步------ArkTS 上报目标矩形与实测内容尺寸,内核 inflate、算挖孔、摆气泡、解箭头; - 点击(tap) :落在膨胀后高亮矩形内且该步
clickAdvances才前进------对应上游detectTapGestures+rect.contains(tapOffset)+step.onClickHighlight(); - 前进(next) :气泡 / 卡片按钮触发
controller.next(),越界自动end()清空; - 结束 / 状态(finish / state):手动结束或重渲染当前步;
- 版本握手(version) :
0.1.0-ohos.1来自真实 Native 调用而非静态文字。
响应携带完整渲染模型:
| 字段组 | 作用 |
|---|---|
dim |
暗场颜色 / 动画开关与时长 / 高亮中心 / 到四角最大半径 / 状态栏裁剪高度------对应上游 Animatable + saveLayer + 缩放圆生长。 |
cutout |
挖孔形状(circle / rounded / rect)、矩形、圆角、半径------对应上游 drawCutout(BlendMode.Clear)。 |
highlight |
水波纹(环数 3、环间距 0.3、基透明度 0.4)或脉冲(0.7↔1.3、透明度 0.3)的全部常量------对应上游 rememberInfiniteTransition。 |
content |
tooltip:气泡多边形顶点(圆角采样 + 箭头三角形)、文案区原点与尺寸;card:居中卡片矩形、内边距、入场延迟与初始缩放。 |
引擎侧完整能力(状态机生命周期、三种样式、四方向定位、窗口钳制、箭头钳制、半开区间命中)全部随移植内核进入 .so,JVM 侧保留 14 项 jvmTest 交叉验证用例,证明 ohos .so 里执行的引导逻辑没有漂移。
1.3 实现适配
| 维度 | 要求 |
|---|---|
| 代码复用 | 内核按原包名移植进 example/nativeApp/commonMain(io.github.suwasto.showcasecompose.core/render/tooltip),纯 Kotlin 文件逐字复制,Compose 值类型文件仅替换类型不改算术;上游 showcasecompose / composeApp / iosApp 模块源码零改动留盘。 |
| 平台目标 | 根构建直接声明 ohosArm64 + ohosX64,保留 jvm() 作交叉验证,example/nativeApp 出双 ABI 动态库。 |
| 构建隔离 | 根构建替换为极简 ohos 构建(cmp_qrose 模式);Compose 模块留盘不进 settings.gradle.kts。 |
| 桥接稳定 | 使用 2 个 C ABI 和 UTF-8 JSON,不传递 Kotlin 对象地址;lambda 不跨 FFI(onClickHighlight 语义收敛为布尔,content 收敛为声明式规格);异常永远不跨 FFI。 |
| 依赖纪律 | 桥模块 commonMain.dependencies 为空,手写 MiniJson,.so 除 KN runtime 外零第三方依赖。 |
| UI 完整 | 单 App 覆盖全部桥接入口:四种引导模式(STANDAR / ANIMATED DIM / WATERDROP RIPPLE / PULSING CIRCLE)、双高亮目标(搜索图标 / Bag 按钮)、气泡与卡片两种内容、Replay 回放。 |
| 仓库规范 | 项目说明、文章、效果图和源码链接统一使用 AtomGit,全仓不出现其他托管平台链接。 |
本项目的 ArkUI 页面不是 Compose Multiplatform UI 实现。引导的渲染层(
ShowcaseOverlay/Tooltip/ShowcaseHost)没有编进 ohos,ArkTS 按内核下发的几何模型重绘------这是"几何在内核、像素在 ArkTS"的分工,与 qrose"像素在 KN 光栅化 PNG"相反,原因见决策 5。
二、实现路线图
text
第 0 阶段:查重 ── oh-tpc 组织内无本库鸿蒙版本;与已收录 ShowcaseLayoutCompose
(布局容器内嵌消息型引导)API 面不重叠,非重复收录
第 1 阶段:项目初始化 ── 19 个源文件逐个盘点 Compose 依赖度、确定"逐字 / 改类型 / 重绘"三分法
第 2 阶段:根构建替换 ── cmp_qrose 模式、eazytec Maven 置前、Kotlin 2.2.21-1.0.0 钉版、Gradle 8.14.1
第 3 阶段:内核移植 ── core/render/tooltip 原包名移植 + Dp/Color/Rect 值类型替换 + 气泡多边形化
第 4 阶段:桥协议建模 ── start/tap/next/finish/state/version JSON 契约、渲染模型、错误通道
第 5 阶段:C ABI → N-API ── @CName × 2、CMake IMPORTED so、napi_init.cpp 包装、DisposeString 纪律
第 6 阶段:ArkUI 宿主 ── 假商城 UI + 四模式引导 + Canvas 遮罩/气泡 + 卡片入场 + 真机验收
三、逐步实现过程
第 1 阶段:项目初始化
1.1 工程边界
text
showcasecompose/ 上游 Compose 库(留盘不进构建,源码零改动)
composeApp/ iosApp/ assets/ 上游示例 App 与素材(留盘不进构建)
example/nativeApp/ Kotlin/Native 动态库(内核移植 + bridge + 测试)
example/harmonyApp/ DevEco Stage、CMake N-API 和 ArkTS 页面
README.md / README.upstream.md fork 主页与上游原文
README.OpenHarmony.md / _CN.md 鸿蒙适配说明(中英双份)
doc/ 本文与效果图
构建拓扑走 cmp_qrose 模式 (根构建即 ohos 构建):settings.gradle.kts 只 include :example:nativeApp,rootProject.name = "ohos_kmpshowcasecompose"。上游的 vanniktech 发布插件、AGP classpath、Maven Central 发布段全部随根构建替换剥离------本 fork 不发 Maven Central。
1.2 工具链矩阵
| 项目 | 配置 |
|---|---|
| Kotlin Multiplatform | HarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven,qrose / haze / ShowcaseLayoutCompose 同线) |
| Gradle | 8.14.1(上游 wrapper 8.14.3 降至社区已审线) |
| JDK | 21(org.gradle.java.home 钉 F:\cmp\.tools\jdk-21.0.12.1+1) |
| OpenHarmony 目标 | ohosArm64 + ohosX64(jvm() 保留作交叉验证) |
| DevEco product | 5.1.0(18) 兼容 |
| DevEco Studio | 26.0.0 Release |
| ABI | arm64-v8a + x86_64 |
| Bundle | org.terminator.ohos.kmpshowcase |
powershell
$env:JAVA_HOME = "F:\cmp\.tools\jdk-21.0.12.1+1"
java -version
1.3 源码依赖度盘点(本适配的成败手)
上游库 19 个 .kt 逐个看 import,得出三分法:
| 处置 | 文件 | Compose 依赖形态 |
|---|---|---|
| 逐字复制(零 import) | tooltip/TooltipDirection.kt、tooltip/ArrowAlignment.kt |
枚举,纯 Kotlin(后者上游即死代码,保留以对齐公共 API 面) |
| 改类型移植(算术逐字一致) | core/ShowcaseShape.kt、core/ShowcaseUtils.kt、core/ShowcaseState.kt、core/ShowcaseController.kt、core/ShowcaseStep.kt、tooltip/TooltipBubbleStyle.kt、tooltip/ReportingTooltipPositionProvider.kt → TooltipPositionMath.kt |
Dp→px Float、Color→打包 ARGB Long、Rect→本地移植、mutableStateListOf/mutableStateOf→普通集合、PopupPositionProvider 接口→纯函数、@Composable 工厂/lambda 删除或规格化 |
| 拆出纯模型 | render/ShowcaseOverlay.kt 的 ShowcaseStyle → render/ShowcaseStyle.kt |
sealed interface 本身是纯数据,只是与渲染器同文件 |
| 几何移植 + ArkTS 重绘 | tooltip/TooltipBubbleShape.kt → tooltip/BubbleGeometry.kt;ShowcaseOverlay 的 dim/cutout/ripple/pulse 常量 → bridge/ShowcaseGeometry.kt |
Shape/Path/Outline、Animatable/Canvas 换成多边形顶点与参数下发 |
| 仅 ArkTS 重绘(不进内核) | render/ShowcaseHost.kt、render/ShowcaseOverlay.kt(渲染半)、tooltip/Tooltip.kt、tooltip/TooltipBubble.kt、tooltip/TooltipUtils.kt、modifier/ShowcaseModifier.kt |
全 Compose 布局 / 绘制 / 手势 |
两个关键盘点结论:其一,全库 Compose 快照态只有 4 个符号 (mutableStateListOf / mutableStateOf / getValue / setValue),集中在 ShowcaseState.kt------内核化改造成本极低;其二,ShowcaseStep.content: @Composable (Rect) -> Unit 是唯一的结构性接缝,上游示例只用它构建 Tooltip 气泡与居中卡片两种内容,规格化后即可完整跨桥。
1.4 页面边界
text
Landing 页 四按钮(STANDAR / ANIMATED DIM / WATERDROP RIPPLE / PULSING CIRCLE)+ 版本握手行
Home 页 FashionStore 假界面:顶栏(☰ / 标题 / 🔍 搜索图标=目标1)、分类 chips、
秋装 banner、2 列商品瀑布流、底部导航(Bag 居中=目标2)
引导覆盖层 全屏 Canvas(暗场 / 挖孔 / 波纹 / 脉冲 / 气泡多边形)+ 文案层(气泡文字)+
卡片层(居中卡片,600ms 后淡入放大)+ 结束后的 Replay/Back
交互 布局就绪自动 op=start;点 Next/Got it! 前进;点高亮区 op=tap;结束后 Replay
页面启动时先调 kmpShowcaseVersion() 做握手,onAreaChange 上报两个目标矩形后自动开始引导。
第 2 阶段:根构建替换与双 ABI 打通
2.1 为什么停在 Kotlin 2.2.21-1.0.0 老线
本库没有 kotlinx 依赖(commonMain 只依赖 Compose 制品),不存在 KStore 那种"制品线逼着升 2.4.20"的动力;反过来,Compose 编译线在鸿蒙社区仍停在 CMP 1.9.2 / 2.2.21 线,升到新线也编不进 @Composable。因此沿用社区已审钉法:Kotlin 2.2.21-1.0.0 + Gradle 8.14.1(上游 wrapper 8.14.3 降一档,对齐 qrose / ShowcaseLayoutCompose 的已审组合)。
settings.gradle.kts 把 eazytec 双仓写在最前,只挂桥模块:
kotlin
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_kmpshowcasecompose"
include(":example:nativeApp")
gradle/libs.versions.toml 从 48 行收缩到 5 行,只剩 kotlin-multiplatform = 2.2.21-1.0.0。
2.2 nativeApp:双 ABI sharedLib + 回填
kotlin
kotlin {
applyDefaultHierarchyTemplate()
jvm() // 交叉验证用
ohosArm64 { binaries.sharedLib { baseName = "ohoskmpshowcase" } }
ohosX64 { binaries.sharedLib { baseName = "ohoskmpshowcase" } }
}
tasks.matching { it.name == "linkDebugSharedOhosArm64" }.configureEach {
finalizedBy("copyKnSoArm64") // .so + libc++_shared.so → harmonyApp/entry/libs/arm64-v8a
}
实测:
bash
./gradlew.bat :example:nativeApp:jvmTest # 14 项全绿
./gradlew.bat :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64
# arm64 → libohoskmpshowcase.so 约 4.3 MB;x86_64 约 3.9 MB(debugShared 未 strip)
测试纪律与 KStore 同款:测试统一 JVM 跑,根构建全局禁用 Ohos*Test* 任务。
第 3 阶段:桥协议建模
3.1 JSON 契约
text
io.github.suwasto.showcasecompose/**(移植内核,原包名)
↓ ShowcaseState / ShowcaseController / TooltipPositionMath / BubbleGeometry
ShowcaseSession + ShowcaseDispatch(扁平 JSON 进出)
↓ UTF-8 JSON
N-API kmpShowcaseVersion() / kmpShowcaseDispatch()
↓
ArkUI Index.ets(Landing + Home + 覆盖层)
请求与响应:
text
请求 : {"op":"version"}
{"op":"start","window":{...},"statusBarPx":132,"tooltipMarginPx":16,"steps":[...]}
{"op":"tap","x":540,"y":2150}
{"op":"next"} / {"op":"finish"} / {"op":"state"}
响应 : {"ok":true,"op":"...","active":true,"index":0,"count":2,
"dim":{...},"cutout":{...},"highlight":{...}|null,"content":{...}|null}
{"ok":false,"error":"..."}
两个不对称设计:尺寸上行、几何下行 ------文字宽高由 ArkTS 用 @ohos.measure 实测后随 contentWidthPx/contentHeightPx 上传(对应 Compose 里 Popup 对气泡的实测布局角色),挖孔 / 气泡多边形 / 卡片位置全部由内核算好下行,ArkTS 拿到什么画什么;渲染模型整帧下行------每次交互响应都携带完整渲染模型,ArkTS 零引导状态,换步即重绘。
3.2 Composable 接缝的规格化
onClickHighlight: () -> Unit = {} 收敛为 clickAdvances: Boolean------上游示例只有 {} 与 { controller.next() } 两种取值,布尔无损承载。content: @Composable (Rect) -> Unit 收敛为声明式规格:
kotlin
sealed class StepContent {
data class Tooltip(direction, title, body, counter, action,
style: TooltipBubbleStyle, contentWidthPx, contentHeightPx) : StepContent()
data class Card(title, body, counter, action, appearDelayMillis,
contentWidthPx, contentHeightPx, innerPaddingPx) : StepContent()
}
lambda 不跨 C ABI,但引导语义一步没少。
3.3 错误通道
每个导出整体 try/catch,Kotlin 异常永远不跨 FFI:
kotlin
@CName("KmpShowcaseDispatch")
fun kmpShowcaseDispatch(request: String): String = ShowcaseDispatch.handle(request)
fun handle(request: String): String = try { dispatchOrThrow(request) }
catch (t: Throwable) { ShowcaseSession.errorBody(t.message ?: t.toString()) }
非法 JSON、缺 op、未知 op、无会话先于 start、start 空步骤,全部走同一个 ok:false 通道,ArkTS 侧统一渲染。
第 4 阶段:原生桥接(技术难点)
4.1 三层桥接
text
ArkTS
│ JSON string
▼
C++ N-API entry(libkmpshowcase_napi.so)
│ const char*
▼
Kotlin/Native C ABI(libohoskmpshowcase.so)
│ KmpShowcaseDispatch / KmpShowcaseVersion
▼
UTF-8 JSON + DisposeString
4.2 Kotlin/Native 导出函数
kotlin
@CName("KmpShowcaseVersion")
fun kmpShowcaseVersion(): String = ShowcaseSession.FORK_VERSION // "0.1.0-ohos.1"
@CName("KmpShowcaseDispatch")
fun kmpShowcaseDispatch(request: String): String = ShowcaseDispatch.handle(request)
| C ABI 符号 | ArkTS 方法 | 覆盖内核 |
|---|---|---|
KmpShowcaseDispatch |
kmpShowcaseDispatch(req) |
会话状态机 + start / tap / next / finish / state 全部分发 |
KmpShowcaseVersion |
kmpShowcaseVersion() |
fork 版本握手(Landing 页版本行的数据来源) |
4.3 C++ N-API 方法分发
cpp
napi_property_descriptor desc[] = {
{"kmpShowcaseDispatch", nullptr, NapiKmpShowcaseDispatch, nullptr, nullptr, nullptr,
static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr},
{"kmpShowcaseVersion", nullptr, NapiKmpShowcaseVersion, nullptr, nullptr, nullptr,
static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr},
};
每个回调统一三步:napi_typeof 校验必须是 string、OH_LOG_INFO 打字节数、调 KN 函数并包装返回。KN 返回的字符串必须经 libohoskmpshowcase_symbols()->DisposeString 归还 Kotlin 运行时,绝不 free()。libohoskmpshowcase_api.h 从 linkDebugSharedOhosArm64 生成的头手工拷入(只声明 ExportedSymbols 结构与两个业务符号)。
4.4 CMake 和类型声明
cmake
add_library(ohoskmpshowcase SHARED IMPORTED)
set_target_properties(ohoskmpshowcase PROPERTIES
IMPORTED_LOCATION ${KN_SO} # entry/libs/${OHOS_ARCH}/libohoskmpshowcase.so
IMPORTED_NO_SONAME TRUE)
add_library(kmpshowcase_napi SHARED napi_init.cpp)
target_link_libraries(kmpshowcase_napi PUBLIC
libace_napi.z.so
libhilog_ndk.z.so
ohoskmpshowcase)
OHOS_ARCH 为空或 KN so 缺失直接 FATAL_ERROR(提示先跑 link 任务);$ORIGIN rpath 加 POST_BUILD 拷贝保证 NAPI so、KN so、libc++ 三件套同目录,dlopen 时互相可见。
typescript
export const kmpShowcaseDispatch: (request: string) => string;
export const kmpShowcaseVersion: () => string;
第 5 阶段:ArkUI 宿主
5.1 目标上报与内容实测
typescript
Text('🔍')
.onAreaChange((_o: Area, n: Area) => {
this.searchRectPx = this.rectFromArea(n); // globalPosition(vp) × density → 窗口 px
this.maybeStart(); // 双目标就绪即 op=start
})
private textWidthVp(text: string, fontSize: number, weight: number): number {
const opts: TextMeasureOpts = { textContent: text, fontSize: `${fontSize}fp`,
fontWeight: weight, fontFamily: 'sans-serif' };
return measure.measureText(opts); // @ohos.measure,气泡/卡片内容实测
}
Area.globalPosition 是 Length 类型,严格 ArkTS 禁止直接算术,先经 lengthToVp() 归一为 number;文字按贪心分词 + 1.5 倍行高估算折行高度,对应上游 Column(padding 12) + title(24sp) + gap16 + body(16sp) + gap24 + counter 行 的内容度量。
5.2 覆盖层绘制
Canvas 承担全部连续像素:暗场(动画期画"半径 × smoothstep(progress)"的生长圆,等价上游 translate/scale 包裹 drawCircle(maxRadius),到 1 切全屏矩形并按 clipTopPx 裁掉状态栏);挖孔用 globalCompositeOperation='destination-out' 对应上游 BlendMode.Clear;水波纹第 i 环 (t/duration + i×0.3) % 1、半径 base + p×maxRadiusPx、透明度 0.4×(1−p);脉冲缩放因子 0.7↔1.3 往返。气泡多边形(内核 BubbleGeometry 圆角 8 段采样 + 箭头三角形)在 Canvas 填充,文字用 Text 组件按 textX/textY/textWidth 绝对定位盖在上面;卡片层 600ms 后 animateTo 淡入放大(初始缩放 0.8 来自上游 scaleIn(initialScale = 0.8f))。
5.3 交互与错误边界
覆盖层 Canvas 的 onClick 把 e.x × density 换算成窗口 px 发 op=tap;点气泡 Next / 卡片按钮走 op=next。ok:false 时错误原文进 hilog;Native 调用本身抛错同样被 try/catch 收进日志。
第 6 阶段:示例与验证
6.1 工程结构
text
example/
├── nativeApp/
│ ├── build.gradle.kts 双 ABI sharedLib(baseName=ohoskmpshowcase)+ copyKnSo*
│ └── src/
│ ├── commonMain/.../showcasecompose/
│ │ ├── core/ Rect / Shape / Utils / State / Controller / Step(移植)
│ │ ├── render/ ShowcaseStyle(拆出)+ StepContent(规格)
│ │ ├── tooltip/ Direction·Alignment(逐字)/ BubbleStyle /
│ │ │ TooltipPositionMath / BubbleGeometry(移植)
│ │ └── bridge/ MiniJson / ShowcaseModel / ShowcaseGeometry /
│ │ ShowcaseSession + ShowcaseDispatch
│ ├── commonTest/.../ShowcaseBridgeTest.kt 14 项交叉验证
│ └── ohosMain/kotlin/Bridge.kt @CName × 2
└── harmonyApp/
├── build-profile.json5 signingConfigs 留空(签名由 DevEco 自动生成回填)
├── AppScope/app.json5 bundleName org.terminator.ohos.kmpshowcase、0.1.0-ohos.1
└── entry/
├── build-profile.json5 externalNativeOptions + abiFilters [arm64-v8a, x86_64]
├── libs/{arm64-v8a,x86_64}/ copyKnSo* 回填三件套(gitignore)
├── src/main/cpp/ CMakeLists / napi_init.cpp / api.h / types 声明包
└── src/main/ets/ EntryAbility(避让区高度)+ pages/Index.ets
6.2 构建、签名和安装
bash
./gradlew.bat :example:nativeApp:jvmTest # 14 项交叉验证
./gradlew.bat :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64
cd example/harmonyApp
ohpm install
hvigorw assembleHap --mode module -p product=default -p buildMode=debug --no-daemon
签名在 DevEco Studio 的 File → Project Structure → Signing Configs 中勾选自动生成,证书写入本机,不进入仓库(仓库内 build-profile.json5 的 signingConfigs 保持空数组)。
bash
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.kmpshowcase -m entry
四、完整代码对照
4.1 整体架构
text
io.github.suwasto.showcasecompose.core/**(移植内核:状态机 / 步骤 / 几何基元)
│ ShowcaseState / ShowcaseController / Rect.inflate
io.github.suwasto.showcasecompose.render/**(样式模型 + 内容规格)
│ ShowcaseStyle / StepContent
io.github.suwasto.showcasecompose.tooltip/**(移植内核:定位与气泡形状)
│ TooltipPositionMath / BubbleGeometry / TooltipBubbleStyle
bridge/**(ShowcaseModel 解析 + ShowcaseGeometry 渲染模型 + ShowcaseSession 会话)
│ KmpShowcaseDispatch / KmpShowcaseVersion
libkmpshowcase_napi.so
│ kmpShowcaseDispatch / kmpShowcaseVersion
Index.ets
│ JSON.parse + Canvas / Text 绘制
▼
ArkUI 真机页面(Landing 四模式 + FashionStore 双目标引导)
4.2 文件清单
| 文件 | 职责 |
|---|---|
settings.gradle.kts |
极简 ohos 构建、eazytec Maven 置前、ohos_kmpshowcasecompose、Compose 模块移出 |
gradle/libs.versions.toml |
收缩为 Kotlin 2.2.21-1.0.0 单插件 |
build.gradle.kts(根) |
版本 0.1.0-ohos.1、全局禁用 Ohos*Test* 任务 |
gradle.properties |
JDK 21 钉版、konan 开关(新增入库) |
nativeApp/.../core/(6 文件) |
上游内核移植:Rect 补位 + Shape/Utils/State/Controller/Step |
nativeApp/.../render/(2 文件) |
ShowcaseStyle 自 Overlay 拆出 + StepContent 规格 |
nativeApp/.../tooltip/(5 文件) |
Direction/Alignment 逐字 + BubbleStyle/PositionMath/BubbleGeometry 移植 |
nativeApp/.../bridge/(4 文件) |
MiniJson 手写 JSON / Model 解析 / Geometry 渲染模型 / Session+Dispatch |
nativeApp/.../ohosMain/Bridge.kt |
2 个 C ABI 绑定(唯一 ohos 源文件) |
nativeApp/.../commonTest/ShowcaseBridgeTest.kt |
14 项 JVM 交叉验证 |
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 四模式验收页面 |
README.OpenHarmony_CN.md |
鸿蒙适配主文档(20 节) |
4.3 关键 API 对照
| 层次 | API | 作用 |
|---|---|---|
| 内核 | ShowcaseState.addStep/next/end/isActive |
引导生命周期(越界自动清空) |
| 内核 | ShowcaseController.start(steps) |
重建会话回到第 0 步 |
| 内核 | Rect.inflate(padding) |
高亮矩形四边外扩(命中与挖孔共用) |
| 内核 | TooltipPositionMath.calculate |
四方向原始偏移 + 窗口钳制 + 箭头中心解算 |
| 内核 | BubbleGeometry.outline |
圆角体多边形 + 箭头三角形(含 radius+arrow 钳制) |
| 模型 | ShowcaseStyle.Standard/WaterDropRipple/PulsingCircle |
挖孔 / 水波纹 / 脉冲三样式(上游常量) |
| 模型 | StepContent.Tooltip/Card |
Composable 内容的声明式规格 |
| Native | KmpShowcaseDispatch / KmpShowcaseVersion |
C ABI JSON 导出 |
| N-API | kmpShowcaseDispatch(req) / kmpShowcaseVersion() |
向 ArkTS 暴露能力 |
| ArkTS | dispatch(op) / onTap(x,y) |
触发操作并整帧重绘 |
4.4 ArkTS 与 Kotlin 的边界
typescript
const r: StepResp = JSON.parse(kmpShowcaseDispatch(JSON.stringify({
op: 'tap', x: 540 * density, y: 2150 * density
}))) as StepResp;
// r.tap === 'inside';该步 clickAdvances=true → r.active===false(最后一步点完自动结束)
kotlin
fun tap(x: Float, y: Float): String {
val step = state.steps[state.currentIndex]
val rect = step.rect.inflate(step.highlightPaddingPx)
val inside = rect.contains(x, y) // 半开区间,与上游 rect.contains 一致
if (inside && step.onClickHighlightAdvances) controller.next()
return response("tap") // 整帧渲染模型下行
}
两者之间只传输扁平 JSON 字符串,不传输 Kotlin 对象、lambda 或未校验的动态结构。
五、关键决策说明
决策 1:内核移植而不是真编译整库
KStore 能真编译整库,是因为社区 Maven 补齐了与上游同版本的 kotlinx ohos 制品线。本库的全部依赖是 androidx.compose.*------社区 CMP 停在 1.9.2 / Kotlin 2.2.21,上游在 1.9.3 / 2.3.0,这条制品线不存在。把纯逻辑按原包名复制进 nativeApp/commonMain(ShowcaseLayoutCompose 同法),既是可审计的复用证据(diff 可逐行核对),又不触碰上游模块源码;代价是内核与上游双份维护,等 Compose 编译线对齐后应重新挂回 KLIB。
决策 2:工具链钉 2.2.21-1.0.0 老线 + Gradle 8.14.1
本库零 kotlinx 依赖,没有"制品线逼着升级"的外力;Compose 编译线在鸿蒙社区停在 2.2.21 一代,升级也无收益。沿用 qrose / ShowcaseLayoutCompose 已审组合(Kotlin 2.2.21-1.0.0 + Gradle 8.14.1 + JDK 21),上游 wrapper 8.14.3 降一档------与 KStore"2.4.20 线留 Gradle 9.x"是两条线的经验,不能互相套用。
决策 3:根构建替换(cmp_qrose 模式),Compose 模块留盘
上游根构建(AGP 8.13.2 classpath、新版 com.android.kotlin.multiplatform.library 库插件、vanniktech Maven Central 发布)对鸿蒙没有保留价值,整个替换为极简 ohos 构建。showcasecompose / composeApp / iosApp 留盘不进 settings.gradle.kts------源码零改动,工具链对齐后按原样接回。
决策 4:lambda 不跨 FFI------clickAdvances 布尔 + content 声明式规格
C ABI 过不了 () -> Unit 和 @Composable。上游示例里 onClickHighlight 只有 {} 与 {controller.next()} 两种取值,布尔无损承载;content 只产出 Tooltip 气泡与居中卡片两种 UI,规格化(文案 + 样式 + ArkTS 实测尺寸)后语义一步没少。凡是"语义可枚举"的 lambda 都值得这样收敛,"语义开放"的才需要回调通道。
决策 5:像素在 ArkTS 生成,几何在内核算
qrose 在 KN 里软件光栅化 PNG,因为条码是静态图;引导是强交互 UI(点击 / 无限动画 / 换步重绘),PNG 方案每帧跨一次 FFI 不现实。折中是:所有判定性几何(挖孔矩形、气泡位置、箭头顶点、卡片矩形)由内核算好随响应下行,ArkTS 只做参数化绘制与 ticker 驱动------动画常量(环间距 0.3、脉冲 0.7↔1.3、时长)与上游同源,但没有在 ArkTS 复制一份上游实现。
六、测试与验证
6.1 测试环境
本次验证使用 Windows(x86_64)、JDK 21、Gradle 8.14.1、DevEco Studio 26.0 命令行工具(hvigor / ohpm / hdc)与鸿蒙真机(arm64)。
6.2 JVM 交叉验证
bash
./gradlew.bat :example:nativeApp:jvmTest
14 项用例全部通过(与 .so 链接的是同一份 commonMain 代码):
| 用例组 | 用例数 | 钉住什么 |
|---|---|---|
| 状态机生命周期 | 3 | 空态非激活、next 越界自动清空、start 重建归零 |
| 几何基元 | 2 | inflate 四边外扩、contains 半开区间边界 |
| 气泡定位 | 2 | Bottom 方向居中偏移 + 箭头中心数值断言、窗口钳制 |
| 气泡形状 | 2 | Top 方向 body 高度排除箭头区、箭头中心 radius+arrow 钳制 |
| 样式解析 | 1 | WaterDropRipple 上游默认值(3 环 / 2000ms / maxRadius 60dp) |
| 协议端到端 | 1 | start→tap 外→next→tap 内→finish 全链(circle 半径 70、active 翻转、结束态全 null) |
| 错误与序列化 | 3 | 非法 JSON / 缺 op / 无会话、版本握手、MiniJson 转义往返 |
6.3 HAP 验证
text
:example:nativeApp:jvmTest: 14 项全绿
linkDebugSharedOhosArm64 / linkDebugSharedOhosX64: BUILD SUCCESSFUL
copyKnSoArm64 / copyKnSoX64: 三件套回填 entry/libs/{arm64-v8a,x86_64}
BuildNativeWithCmake: finished(双 ABI 编出 libkmpshowcase_napi.so)
CompileArkTS: finished
PackageHap / SignHap: finished
BUILD SUCCESSFUL
install bundle successfully
start ability successfully
entry-default-unsigned.hap 约 7.4 MB,内含双 ABI 各三件套(libkmpshowcase_napi.so 约 65 KB / libohoskmpshowcase.so arm64 约 2.2 MB、x86_64 约 2.1 MB(HAP 内 strip 后)/ libc++_shared.so);debugShared 未 strip 时 KN so 分别约 4.3 MB / 3.9 MB。
6.4 功能用例
- Landing 页底部显示
bridge 0.1.0-ohos.1(来自真实 Native 调用,非静态文字); - 进入 Home 后布局就绪自动开始引导,第 1 步挖孔跟随顶栏搜索图标、第 2 步跟随底部 Bag 按钮;
- STANDAR:静态暗场 + 气泡在搜索图标下方,箭头指向图标中心;
- ANIMATED DIM:暗场从高亮中心生长(800ms FastOutSlowIn)后切全屏;
- WATERDROP:圆孔外 3 环青色水波纹循环扩散 + 居中卡片 600ms 后淡入放大;
- PULSING:圆孔外青色描边呼吸圈 0.7↔1.3 往返;
- 点气泡
Next/2/2 Got it!前进步骤;WATERDROP / PULSING 模式点 Bag 高亮区即前进(clickAdvances);点暗场空白处无反应(tap outside); - 结束后出现 Replay / Back,Replay 重发
op=start完整回放。
6.5 验证结论
移植内核、渲染模型、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 已完成链路验证。应用在鸿蒙真机上成功安装并进入前台,页面展示了真实的引导会话:版本握手、双目标挖孔、气泡定位与箭头指向、四种引导样式与前进/回放闭环(见下节效果图)。
七、运行效果
7.1 真机截图

7.2 命令速查
bash
git clone https://atomgit.com/oh-tpc/kmp-showcase-compose.git
cd kmp-showcase-compose
export JAVA_HOME="/path/to/jdk-21"
./gradlew.bat :example:nativeApp:jvmTest # 14 项交叉验证
./gradlew.bat :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64
cd example/harmonyApp
ohpm install
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.kmpshowcase -m entry
hdc shell hilog | grep KmpShowcase
八、遗留问题与改进方向
8.1 踩坑复盘
- 只改 ArkTS 页面不算 KMP 适配,必须让移植内核真正编译成
ohosArm64/ohosX64动态库; settings.gradle.kts里 eazytec 双仓必须排在pluginManagement与dependencyResolutionManagement最前,否则配置期直接Unresolved reference: ohosArm64;Math.PI是java.lang的类------JVM 编译通过、compileKotlinOhosArm64才炸,统一用kotlin.math.PI;String.format是 JVM API,KN 上颜色转十六进制必须手写 nibble 循环;- Kotlin
value class只接受单构造参数,四字段的Rect降级为普通data class(成员语义不变); - 严格 ArkTS 下
Area.globalPosition/Area.width是Length(string | number | Resource),直接算术报Length+Length------先经lengthToVp()归一为 number; import measure from '@ohos.measure'的默认导入不能当命名空间用类型(measure.MeasureOptions报 "only refers to a type"),@kit.ArkUI又没有导出measure------用本地结构等价接口声明 MeasureOptions;- CLI 打 HAP 时 PackageHap 依赖 java,
spawn java ENOENT------JDK 21 进 PATH(DevEco IDE 内置,命令行需自带); - HAP 缺当前 ABI 的 so 报安装失败
9568347,abiFilters必须双 ABI,每个 ABI 三件套缺一不可; - KN 返回字符串必须
DisposeString归还运行时,N-API 层不能free(); - ClickEvent 坐标是 vp、Canvas 画布单位也是 vp,而桥协议全程窗口 px------上报
×density、绘制÷density,漏一处即整体错位; - 上游
.gitignore是根锚定 + 忽略gradle.properties的模式,DevEco 的.hvigor/、oh_modules/、entry/libs/、oh-package-lock.json5要单独追加段,且 fork 的gradle.properties(JDK 钉版)需要!反向规则重新入库。
8.2 已知问题
- Compose 渲染层(
ShowcaseOverlay/Tooltip/ShowcaseHost/captureBounds)未编进 ohos,由 ArkTS 重绘;社区 CMP 工具链对齐后应重新挂回; - Tooltip 的弹簧入场(spring / scale+alpha)以 250ms EaseOut 近似,参数(0.8→1)来自上游
scaleIn(initialScale = 0.8f); - 文字换行以贪心分词 + 1.5 倍行高估算(上游由 Compose 文本布局完成),极端字号需校准;
- 内核以复制方式移植,上游若改内核需手工同步(JVM 用例是漂移闸门);
ArrowAlignment上游即死代码,移植保留未接线;- HAP 安装必须使用与 bundle name
org.terminator.ohos.kmpshowcase匹配的签名。
8.3 未来优化方向
- 等社区 CMP 1.9.3+ / Kotlin 2.3 ohos 编译线,把
showcasecompose模块重新挂回构建出 KLIB,桥退化为纯示例; - 上游新增样式 / 内容类型时先补
ShowcaseModel/Geometry协议再补 JVM 用例; - 评估
.har形态发布,让 ArkTS 工程直接依赖而不是走 so 三件套; - 在 CI 中加入 ohos 编译、ELF 符号检查和 HAP 构建;
- 补充模拟器(x86_64)与其他设备 ABI 的验收矩阵和自动化截图脚本。
九、总结
9.1 核心难点回顾
text
上游 kmp-showcase-compose(19 文件全 Compose)
→ 源码依赖度盘点(4 个逐字 / 7 个改类型 / 8 个重绘)
→ 工具链对齐 Kotlin 2.2.21-1.0.0(Gradle 8.14.1)
→ 根构建替换(cmp_qrose 模式,Compose 模块留盘)
→ 内核原包名移植 + Dp/Color/Rect 值类型替换 + 气泡多边形化
→ C ABI(@CName × 2)+ 尺寸上行/几何下行 JSON 契约
→ C++ N-API(DisposeString 纪律)
→ ArkTS Canvas 遮罩 + Text 气泡 + 卡片入场
→ ArkUI 真机四模式验收(挖孔 / 定位 / 前进 / 回放)
9.2 封装层次
- 内核层:上游原版状态机、步骤模型、样式常量、四方向定位、气泡形状钳制(移植件运算符级一致);
- 桥层:请求解析 + 渲染模型计算 + 会话管理 + 错误通道;
- Native 层:生成双 ABI 动态库并通过 2 个 C ABI 输出 JSON;
- N-API 层:完成参数检查、字符串转换和 Native 内存释放;
- ArkTS 层:目标矩形上报、内容实测、Canvas 绘制、动画 ticker、交互分发;
- DevEco 层:完成 CMake、HAP、签名、安装和真机运行。
9.3 三条经验
- 纯 Compose UI 库的适配面由"依赖度盘点"决定 :全库快照态只有 4 个符号、值类型沾染只有
Dp/Color/Rect三种------盘点清楚了,内核化成本极低;盘点不清,要么过度重写、要么漏掉语义。三分法(逐字 / 改类型 / 重绘)是可复用的盘点产出; - lambda 接缝先看语义可枚举性 :
onClickHighlight两种取值收敛为布尔、content两种 UI 收敛为规格,都比开回调通道便宜且不丢语义;反过来updatesFlow 这类开放语义(KStore)才真的过不了桥; - 像素放哪边由交互形态决定:静态产出(条码)适合 KN 光栅化 PNG,强交互 UI(引导)适合"几何在内核、像素在宿主"------判定逻辑留在可交叉验证的 Kotlin 侧,绘制循环留在宿主动画系统里,两边都不复制对方的实现。
9.4 适配成果
当前适配已完成:
- kmp-showcase-compose
0.1.0(Apache-2.0)内核的双 ABI Kotlin/Native 编译(14 个移植文件,运算符级一致,上游模块源码零改动); - 2 个 C ABI 符号和 C++ N-API 包装层(真机 HAP 内双 ABI 六件套 so 验证);
- DevEco Stage ArkUI 验收页面(四种引导模式、双高亮目标、气泡与卡片两种内容、Replay 回放、版本握手);
- 14 项 JVM 交叉验证全绿 + HAP 构建(约 7.4MB)、签名、设备安装和真机验证;
- 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。