开源鸿蒙平台 KMP 三方库「tagcloud」适配全流程
本文记录
tagcloud接入 OpenHarmony 的完整过程,覆盖 3D 标签云库盘点、鸿蒙 Kotlin 工具链钉版、数学内核编译、ohosArm64/ohosX64目标、Kotlin/Native 动态库、C ABI/N-API 桥接、ArkUI 真机页面、HAP 构建和真机验收。
tagcloud是基于 Compose Multiplatform 的 3D 标签云库:FibonacciLattice把标签均匀铺在球面上,四元数驱动整体旋转,拖拽手势被映射为球面向量间的旋转增量,标签按深度缩放与淡出。本次适配与 KStore 的"制品线齐备——真编译整库"路线不同:tagcloud 的公共 API 面就是 Compose(TagCloudComposable +rememberTagCloudState),上游快照(Compose 1.5 / Kotlin 1.9)与鸿蒙 CMP 1.9.2(HarmonyOS Kotlin2.2.21-1.0.0)两条工具链无法共存于一个 Gradle 构建。但这个库的几何引擎恰好零依赖——Vector3/Quaternion/FibonacciLattice是纯 Kotlin,因此被原样编译成 OpenHarmony 双 ABI 动态库,ArkUI 页面通过 N-API 调用真实上游数学内核渲染球面标签云,ArkTS 侧零几何复制。这条路即社区早期已过审的 cmp_qrose / Vico / Reorderable 的"零依赖内核提取"路线。
项目地址: AtomGit/oh-tpc/tagcloud.git
上游仓库: oleksandrbalan/tagcloud
开发工具: 华为云码道
环境安装: 环境安装指南
一、背景
1.1 为什么做 OpenHarmony 平台 KMP/CMP 适配
tagcloud 为 Kotlin Multiplatform 应用提供"标签云"这一件事的完整答案:TagCloud(state) { items(labels) { ... } } 一个 Composable 把 N 个标签均匀铺上球面,rememberTagCloudState 暴露旋转状态,拖拽手势实时旋转,tagCloudItemFade / tagCloudItemScaleDown 让远处的标签缩小淡出,TagCloudState.rotateBy / animateRotateTo 支持程序化旋转与动画。KMP 项目把这些能力放在 commonMain,由 Android、iOS、桌面共享。OpenHarmony 应用同样需要这套 3D 标签云能力,但上游构建(Compose 1.5 / Kotlin 1.9 / AGP 8.1)不会生成 OpenHarmony 变体。
如果只把示例页面改成 ArkTS,页面可以画出标签,却无法证明上游几何引擎在 OpenHarmony ARM64 设备上完成编译、链接和执行。适配需要同时验证分布器、四元数旋转、球面投影、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 交付链路。
| 障碍 | 具体问题 |
|---|---|
| 目标缺失 | 必须增加 ohosArm64() / ohosX64() 才能生成 OpenHarmony KLIB。 |
| 工具链不一致 | 上游 Compose 1.5 / Kotlin 1.9 / AGP 8.1 与鸿蒙 CMP 1.9.2 / HarmonyOS Kotlin 2.2.21-1.0.0 无法共存于一个 Gradle 构建。 |
| 依赖矩阵 | 库的 API 面整体依赖 androidx.compose.*(runtime / foundation / ui / animation)与 coroutines,Compose 层在鸿蒙线不可解析。 |
| 语言边界不同 | ArkTS 不能直接持有 Kotlin 对象,需要 C ABI、C++ N-API 和 JSON。 |
| 手势语义跨不了桥 | TagCloudState 的 Animatable / suspend 动画是 Kotlin 侧概念,C ABI 只能同步一来一回。 |
| 交付链路复杂 | .so、CMake、N-API、HAP、签名和 hdc 都要单独验收。 |
因此,本项目把边界放在:零依赖数学内核的 ohos 编译、根构建替换(cmp_qrose 模式)和 C ABI/N-API 桥接层。球的几何完全由上游原版代码负责,ArkTS 只负责调用 Native、解析 JSON 和绘制标签。
1.2 库提供的能力
Demo 提供一个真实的标签云会话,对应桥接的全部入口:
- 创建(create):
FibonacciLattice(count)把 N 个标签均匀铺满单位球面(上游TagCloudScope.items不传坐标时的默认分布器),旋转重置为单位四元数; - 拖拽(drag):两次触点映射为球面向量,
Quaternion.create(from, to)求旋转增量并按全局轴左乘——与上游TagCloud.rotateGesture+rotateBy(globalAxis = true)同一套公式; - 步进旋转(rotate):按角度/轴旋转(
TagCloudState.rotateBy的桥等价物),驱动自动旋转; - 重置(reset):回到
Quaternion.Identity; - 重投影(layout):尺寸变化时保持当前旋转重新投影(旋转屏 / 折叠屏);
- 版本握手:
tagcloudVersion()返回 fork 版本号,来自真实 Native 调用而非静态文字。
响应包含:
| 字段 | 作用 |
|---|---|
ok | 操作是否成功,失败时 error 带原文。 |
op / count / radius | 回显本次操作、标签数与投影半径。 |
rotation | 当前四元数 {w, x, y, z},状态行实时回显。 |
items | 全部标签的投影结果,按 z 升序(由远及近)。 |
items[].x/y | 标签中心 x = R(1+cx)、y = R(1-cy)(上游 getItemOffset 同款投影)。 |
items[].z/s/a | 深度、缩放、透明度;s/a 复刻上游 rescale 曲线(最远 0.5 / 0.25)。 |
引擎侧完整能力(Quaternion 的 180° 反向边界处理、Vector3.rotate 旋转矩阵展开、晶格均匀分布、深度渲染曲线)全部在上游 commonMain 里原样随编译进入 .so,JVM 侧保留 compileKotlinJvm 同源双编译交叉验证,证明 ohos .so 里执行的几何逻辑没有漂移。
1.3 实现适配
| 维度 | 要求 |
|---|---|
| 代码复用 | tagcloud 的 3 个数学文件(Vector3 / Quaternion / FibonacciLattice)原样编译,不提取、不重写;全仓零新增 Kotlin 源文件、零 expect/actual(比 KStore 的 8 行 dispatcher actual 还少)。 |
| 平台目标 | 根构建直接声明 ohosArm64 + ohosX64,保留 jvm() 作交叉验证,库模块出 KLIB,example/nativeApp 出双 ABI 动态库。 |
| 构建隔离 | 根构建替换为极简 ohos 构建(cmp_qrose 模式);Compose 层 5 个文件 git mv 到 src/composeMain 留盘不进构建;demo / desktopdemo / androiddemo / iosdemo / build-logic / config 留盘。 |
| 桥接稳定 | 使用 2 个 C ABI 和 UTF-8 JSON,不传递 Kotlin 对象地址;异常永远不跨 FFI。 |
| 依赖纪律 | 内核零第三方依赖,只需 HarmonyOS Kotlin 发行版本身;不碰 kotlinx 四件套,不碰 Compose。 |
| UI 完整 | 单页覆盖全部桥接入口:拖拽旋转、自动旋转、重置、16/32/64/128 数量切换、四元数状态行、操作日志和版本握手。 |
| 仓库规范 | 项目说明、文章、效果图和源码链接统一使用 AtomGit。 |
本项目的 ArkUI 页面不是 Compose Multiplatform UI 实现。tagcloud 的 Compose 层因工具链不可混编而隔离在
composeMain;ArkTS Demo 负责把桥暴露的球面几何画出来——每次手势/步进,Kotlin 引擎返回全部标签的投影、缩放与透明度,ArkUI 按由远及近的数组顺序堆叠绘制。
二、实现路线图
第 1 阶段:项目初始化 ── 盘点模块与依赖边界、确认 Compose 隔离面、确定交付物
第 2 阶段:目标与依赖打通 ── 根构建替换、eazytec Maven 置前、Kotlin 2.2.21-1.0.0 钉版、Gradle 8.14.1
第 3 阶段:桥协议建模 ── 会话状态、create/drag/rotate/reset/layout JSON 契约、深度渲染曲线
第 4 阶段:原生桥接 ── @CName C ABI、C++ N-API、参数校验和内存释放
第 5 阶段:ArkUI 宿主 ── 手势转发、markAnchor 中心定位、自动旋转、四元数状态行和日志
第 6 阶段:示例与验证 ── HAP、签名、设备安装、真机运行和效果图
三、逐步实现过程
第 1 阶段:项目初始化
1.1 工程边界
tagcloud/ 上游几何内核(进 ohos 构建,jvm + ohosArm64 + ohosX64)
tagcloud/src/commonMain/ math/(Vector3、Quaternion+rotate)+ distribution/(FibonacciLattice)
tagcloud/src/composeMain/ 上游 Compose 层 5 文件(TagCloud/State/Scopes/RotationModifier),留盘不进构建
example/nativeApp/ Kotlin/Native 动态库(Bridge + MiniJson + 会话)
example/harmonyApp/ DevEco Stage、CMake N-API 和 ArkTS 页面
README.OpenHarmony.md / _CN.md 鸿蒙适配说明(中英双份)
README.upstream.md 上游原文保存
demo/ desktopdemo/ androiddemo/ 上游 Compose 示例,留盘不进 settings
iosdemo/ build-logic/ config/ 上游构建插件与静态检查配置,留盘不进 settings
构建拓扑走 cmp_qrose 模式(根构建即 ohos 构建):settings.gradle.kts 只 include :tagcloud 与 :example:nativeApp,rootProject.name = "ohos_tagcloud"。上游的 detekt / spotless / vanniktech 发布插件、AGP、build-logic 组合插件全部随根构建替换剥离——本 fork 不发 Maven Central。
1.2 工具链矩阵
| 项目 | 配置 |
|---|---|
| Kotlin Multiplatform | HarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven,cmp_qrose 同款钉版) |
| JDK | 21(org.gradle.java.home 指向 Adoptium jdk-21.0.12.101-hotspot) |
| OpenHarmony 目标 | ohosArm64 + ohosX64(jvm() 保留作交叉验证) |
| Gradle | 8.14.1(wrapper 从上游 8.3 升级;2.2.21 线要求 8.14.x) |
| DevEco product | 5.1.0(18) 兼容 |
| DevEco Studio | 26.0.0 Release |
| ABI | arm64-v8a + x86_64 |
| Bundle | org.terminator.ohos.tagcloud |
export JAVA_HOME="/path/to/jdk-21" # Windows: C:\Program Files\Eclipse Adoptium\jdk-21.0.12.101-hotspot
java -version
1.3 源码依赖度盘点
| 文件 | 依赖形态 | 处置 |
|---|---|---|
math/Vector3.kt | 纯 Kotlin(kotlin.math.sqrt),零第三方依赖 | 整体编译(jvm + 双 ohos) |
math/Quaternion.kt(含 Vector3.rotate 扩展) | 纯 Kotlin,含 180° 反向旋转边界处理 | 整体编译 |
distribution/FibonacciLattice.kt | 纯 Kotlin,黄金角均匀球面分布 | 整体编译 |
TagCloud.kt | Compose Layout / 手势 / coroutines | git mv 至 composeMain,不进构建 |
TagCloudState.kt | Compose Animatable / mutableStateOf / rememberSaveable | git mv 至 composeMain |
TagCloudScope.kt / TagCloudItemScope.kt | Compose @Composable / graphicsLayer | git mv 至 composeMain |
RotationModifier.kt | Compose pointerInput 拖拽手势 | git mv 至 composeMain |
expect 声明全库 0 个:几何内核连 dispatchers 都不需要。这是本批适配里最干净的一座库——KStore 还要补 8 行 ohos dispatcher actual,tagcloud 连这 8 行都省了。上游写在 Compose 文件里的两段纯算术(投影 getItemOffset、手势球面映射 getSphereCoordinates)按 cmp_qrose 的 Rasterizer 先例在 example/nativeApp 桥内按同款公式复刻(见 3.1 节),ArkTS 侧零数学。
1.4 页面边界
标题区 TAGCLOUD · OPENHARMONY / 版本副标题(CMP 3D 标签云 · Kotlin/Native ohosArm64/ohosX64)
Tab 切换 标签云 / 关于
控制区 标签数量 chips:16 / 32 / 64 / 128
云区 正方形 Stack(aspectRatio 1),ForEach 渲染标签 Text:
.position(引擎投影中心) .markAnchor('-50%','-50%') .scale(s) .opacity(a) .zIndex(z)
操作区 重置 / 自动旋转(30ms × 0.5° 绕 Y 步进)按钮
状态行 标签数 + 当前四元数 q(w=…, x=…, y=…, z=…)
日志区 带时间戳的操作日志(最新在前,上限 6 条)
关于区 版本握手、库简介、架构分层、操作说明和仓库链接
页面启动时先调 tagcloudVersion() 做握手,云区 onAreaChange 量出尺寸后注入半径开球。
第 2 阶段:目标与依赖打通
2.1 边界验证(本适配的成败手)
KStore 适配的成败手是 kotlinx 四件套的 ohos 制品线;tagcloud 的成败手是另一道题——上游 API 面就是 Compose:
io.github.oleksandrbalan:tagcloud:1.1.0
api(compose.runtime) / api(compose.foundation) ← 上游模块声明
上游快照:Compose 1.5 / Kotlin 1.9 / AGP 8.1
鸿蒙线: CMP 1.9.2 / HarmonyOS Kotlin 2.2.21-1.0.0
两条工具链无法共存于一个 Gradle 构建(cmp_qrose 适配时已验证并过审的同款结论)。但盘点依赖后发现:库的全部行为内核——分布、旋转、投影——恰好不碰任何 Compose API,是 commonMain 里三个零依赖纯 Kotlin 文件。制品可达性检查因此从"有没有 Compose ohos 变体"变成"内核是否零依赖",后者天然成立。再抽内核就不是权宜而是正确答案:编进去的每一个字节都是上游原件。
2.2 工具链钉版(为什么是 2.2.21-1.0.0)
内核零第三方依赖,不需要跟着任何 kotlinx 制品线走版本,因此钉社区标准线 HarmonyOS Kotlin 2.2.21-1.0.0(qrose / haze 同款)。这条线要求 Gradle 8.14.x——上游 wrapper 是 8.3,必须升级(wrapper jar / gradlew 与 cmp_qrose 同源替换)。JDK 走 21。
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()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.PREFER_PROJECT)
repositories { /* 同上 */ }
}
rootProject.name = "ohos_tagcloud"
include(":tagcloud")
include(":example:nativeApp")
2.3 编译真实内核
tagcloud/build.gradle.kts 收敛为纯 KMP 模块:
plugins {
alias(libs.plugins.kotlin.multiplatform)
}
kotlin {
applyDefaultHierarchyTemplate()
jvmToolchain(21)
jvm() // 交叉验证用
ohosArm64()
ohosX64()
sourceSets {
commonMain.dependencies {}
}
}
commonMain.dependencies {} 是空的——不是删漏了,是内核真的零依赖。实测:
./gradlew :tagcloud:compileKotlinJvm :tagcloud:compileKotlinOhosArm64 :tagcloud:compileKotlinOhosX64
# BUILD SUCCESSFUL
三个数学文件一个字符未动,零新增源码,零 expect/actual。
2.4 测试依赖的处置:上游没有测试
上游仓库没有测试目录(既无 commonTest 也无 androidTest),KStore 式"上游测试零改动跑 desktopTest"在本库无对象可跑。处置改为同源双编译交叉验证:jvm() 与 ohosArm64()/ohosX64() 编译的是同一份 commonMain(math + distribution),两套工具链对同一份源码的编译通过即为"内核没漂"的最低门槛,几何正确性由真机功能用例(6.4 节)兜底。根构建仍全局禁用 ohos 测试任务(防御性保留社区纪律):
subprojects {
tasks.matching { it.name.contains("Ohos", ignoreCase = true) && it.name.contains("Test") }
.configureEach { enabled = false }
}
第 3 阶段:桥协议建模
3.1 JSON 契约
eu.wewox.tagcloud.math / distribution(上游几何引擎,原样编译)
↓ FibonacciLattice / Quaternion.create / Vector3.rotate
Bridge.kt(@CName × 2,扁平 JSON 进出)
↓ UTF-8 JSON
N-API tagcloudDispatch() / tagcloudVersion()
↓
ArkUI Index.ets(Stack + position + markAnchor,零数学,纯渲染)
会话状态(标签球面坐标 + 当前四元数)保存在 Kotlin 侧单例——ArkTS 不持有 Kotlin 对象,也避免每次手势重传全量标签坐标。请求与响应:
请求 : {"op":"create","count":64,"radius":300}
{"op":"drag","fromX":180.5,"fromY":402.0,"toX":240.0,"toY":380.0,"radius":300}
{"op":"rotate","angle":0.5,"axis":"y","global":true,"radius":300}
{"op":"reset","radius":300}
{"op":"layout","radius":336}
响应 : {"ok":true,"op":"layout","count":64,"radius":300,"version":"1.1.0-ohos.1",
"rotation":{"w":0.912,"x":0.021,"y":0.403,"z":-0.044},
"items":[{"i":12,"x":431.2,"y":96.4,"z":0.977,"s":0.988,"a":0.982}, …]}
{"ok":false,"error":"call op=create first"}
三个关键设计:
- 由远及近排序:
items按z升序返回,ArkTS 按数组顺序渲染即天然满足画家算法堆叠,每项再带zIndex(z)双保险; - 深度曲线在引擎侧算:
s = rescale(z, 0.5)、a = rescale(z, 0.25),公式逐字复刻上游TagCloudItemScope的私有rescale((((z+1)*(1-min))/2)+min)——即tagCloudItemScaleDown()/tagCloudItemFade()默认值,ArkTS 不重复实现; - 投影即中心点:
x = R(1+cx)、y = R(1-cy)(上游Placeable.getItemOffset同款),ArkTS 用.markAnchor('-50%','-50%')以中心对齐,免去测量每个标签尺寸。
3.2 手势数学为什么忠实
op=drag 不是"方向键"式的近似旋转,而是上游手势链路的逐事件等价:
// 复刻上游 TagCloud.kt 的 Offset.getSphereCoordinates(私有 → 桥侧同款公式)
private fun sphereVector(x: Float, y: Float, radius: Float): Vector3 {
val dx = (x - radius) / radius
val dy = (y - radius) / radius * (-1f)
val dz = sqrt(1 - dx * dx - dy * dy).takeUnless { it.isNaN() } ?: 0f
return Vector3(dx, dy, dz).normalized()
}
// 复刻上游 rotateGesture + rotateBy(globalAxis = true)
val from = sphereVector(fromX, fromY, radius)
val to = sphereVector(toX, toY, radius)
session.rotation = Quaternion.create(from, to) * session.rotation
Quaternion.create(from, to) 内含上游的 180° 反向边界处理(点积 < -0.999999 时用正交轴构造 w=0 四元数),这些边界随原样编译直接生效。
3.3 错误通道
每个导出整体 try/catch,Kotlin 异常永远不跨 FFI:
@CName("TagCloudDispatch")
fun tagCloudDispatch(request: String): String = try {
/* op 分发 */
} catch (t: Throwable) {
"""{"ok":false,"error":${jsonString(t.message ?: t.toString())}}"""
}
非法 JSON、缺 op、未 create 先操作、不支持的 op,全部走同一个 ok:false 通道,ArkTS 侧统一渲染进状态行与日志区。
第 4 阶段:原生桥接(技术难点)
4.1 三层桥接
ArkTS
│ JSON string
▼
C++ N-API entry(libtagcloud_napi.so)
│ const char*
▼
Kotlin/Native C ABI(libohostagcloud.so)
│ TagCloudDispatch / TagCloudVersion
▼
UTF-8 JSON + DisposeString
直接导出 Kotlin 对象会暴露 ABI 和生命周期问题;只导出整数又会让页面复制几何,所以选择 C ABI + JSON。
4.2 Kotlin/Native 导出函数
@CName("TagCloudDispatch")
fun tagCloudDispatch(request: String): String // create/drag/rotate/reset/layout 一口进
@CName("TagCloudVersion")
fun tagCloudVersion(): String = "1.1.0-ohos.1" // 版本握手
| C ABI 符号 | ArkTS 方法 | 覆盖内核 |
|---|---|---|
TagCloudDispatch | tagcloudDispatch(req) | 会话 + FibonacciLattice 分布 + Quaternion 手势/步进旋转 + 投影与深度曲线 |
TagCloudVersion | tagcloudVersion() | fork 版本握手(真机版本行的数据来源) |
4.3 C++ N-API 方法分发
napi_property_descriptor desc[] = {
{"tagcloudDispatch", nullptr, NapiTagCloudDispatch, nullptr, nullptr, nullptr,
static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr},
{"tagcloudVersion", nullptr, NapiTagCloudVersion, nullptr, nullptr, nullptr,
static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr},
};
每个回调统一三步:napi_typeof 校验必须是 string、OH_LOG_INFO 打字节数、调 KN 函数并包装返回。KN 返回的字符串必须经 libohostagcloud_symbols()->DisposeString 归还 Kotlin 运行时,绝不 free()。libohostagcloud_api.h 按社区已审仓库同款手写(只声明 ExportedSymbols 结构与两个业务符号),不拖整份生成头。
4.4 CMake 和类型声明
add_library(ohostagcloud SHARED IMPORTED)
set_target_properties(ohostagcloud PROPERTIES
IMPORTED_LOCATION ${KN_SO}
IMPORTED_NO_SONAME TRUE)
add_library(tagcloud_napi SHARED napi_init.cpp)
target_link_libraries(tagcloud_napi PUBLIC
libace_napi.z.so
libhilog_ndk.z.so
ohostagcloud)
OHOS_ARCH 为空或 libohostagcloud.so 缺失直接 FATAL_ERROR(提示先跑 link 任务);$ORIGIN rpath 加 POST_BUILD 拷贝保证 NAPI so、KN so、libc++ 三件套同目录,dlopen 时互相可见。
export const tagcloudDispatch: (request: string) => string;
export const tagcloudVersion: () => string;
第 5 阶段:ArkUI 宿主
5.1 注入尺寸和触发操作
aboutToAppear(): void {
const handshake: string = tagcloudVersion(); // 版本握手:来自真实 Native 调用
this.version = handshake;
this.pushLog(`version → ${this.version}`);
}
private onCloudArea(oldArea: Area, newArea: Area): void {
const size = Math.floor(Math.min(newArea.width as number, newArea.height as number));
this.originX = newArea.globalPosition.x as number; // 手势坐标换算原点
this.originY = newArea.globalPosition.y as number;
if (!this.created || first) this.createCloud(); // 首次量出尺寸 → create
else if (changed) this.relayout(); // 尺寸变化 → 保旋转重投影
}
云区挂 priorityGesture(PanGesture({ fingers: 1, distance: 1 })):onActionStart 记录触点全局坐标(event.fingerList[0].globalX/globalY),onActionUpdate 把 (last → current) 换算为云区局部坐标后发 op=drag——与上游 RotationModifier 的 onRotate(previous, current) 逐事件语义一致。priorityGesture 保证球面拖拽优先于外层 Scroll。
5.2 页面交互
标签云 Tab 自上而下:数量 chips(16/32/64/128)、正方形云区(彩色圆角标签由远及近堆叠,近大而实、远小而淡)、重置/自动旋转双按钮(深灰/绿红语义切换)、四元数状态行与操作日志。每条日志带 [HH:mm:ss] 时间戳(最新在前、上限 6 条)。关于 Tab 展示版本握手行、库简介、ArkTS → NAPI → C ABI → Kotlin 引擎 分层说明与 AtomGit 仓库 / 社区链接。
自动旋转用 30ms 定时器每次 op=rotate 0.5°绕 Y 轴——TagCloudState.animateRotateTo 的动画语义不跨 C ABI(见 8.2),由步进近似,四元数连续变化在状态行肉眼可见。
5.3 对比度和错误边界
重置深灰、自动旋转绿(运行中变红"停止旋转"),语义与状态对齐;ok:false 时错误原文进状态行与日志。Native 调用本身抛错(理论不该发生)同样被 try/catch 收进日志区。
第 6 阶段:示例与验证
6.1 工程结构
example/
├── nativeApp/
│ ├── build.gradle.kts 双 ABI sharedLib(baseName=ohostagcloud)+ copyKnSo*
│ └── src/ohosMain/kotlin/Bridge.kt @CName × 2 + MiniJson + 会话状态(继承自 cmp_qrose 审核模式)
└── harmonyApp/
├── build-profile.json5 signingConfigs 留空(签名由 DevEco 自动生成回填)
├── AppScope/app.json5 bundleName org.terminator.ohos.tagcloud、versionName 1.1.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/libohostagcloud_api.h
├── src/main/cpp/types/libtagcloud_napi/{index.d.ts, oh-package.json5}
└── src/main/ets/pages/Index.ets ArkUI 验收页面
6.2 构建、签名和安装
./gradlew :tagcloud:compileKotlinJvm # 同源双编译交叉验证
./gradlew :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 中勾选自动生成,证书写入本机 ~/.ohos/config,不进入仓库(仓库内 build-profile.json5 的 signingConfigs 保持空数组,勾选自动签名后由 IDE 回填带 material 的完整配置)。
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.tagcloud -m entry
四、完整代码对照
4.1 整体架构
eu.wewox.tagcloud.math / distribution(上游几何引擎原样编译)
│ FibonacciLattice 均匀分布 / Quaternion 旋转(含 180° 边界)/ Vector3.rotate
Bridge.kt + MiniJson + 会话状态
│ TagCloudDispatch / TagCloudVersion
libtagcloud_napi.so
│ tagcloudDispatch / tagcloudVersion
Index.ets
│ JSON parse + @State 更新 + Stack/position/markAnchor 渲染
▼
ArkUI 真机页面(单页滚动)
4.2 文件清单
| 文件 | 职责 |
|---|---|
settings.gradle.kts | 极简 ohos 构建、eazytec Maven 置前、ohos_tagcloud、demo 系列移出 |
gradle/libs.versions.toml | 只剩 HarmonyOS Kotlin 2.2.21-1.0.0 一个钉版 |
build.gradle.kts(根) | 版本 1.1.0-ohos.1、全局禁用 Ohos*Test* 任务 |
tagcloud/build.gradle.kts | 纯 KMP:jvm + ohosArm64 + ohosX64,commonMain 零依赖 |
tagcloud/src/composeMain/** | 上游 Compose 层 5 文件留盘(git mv,100% rename 保留历史) |
example/nativeApp/build.gradle.kts | 双 ABI sharedLib、cxxShared 定位 libc++、copyKnSo* 回填 |
nativeApp/.../Bridge.kt | 2 个 C ABI、会话状态、MiniJson、深度曲线复刻、错误通道 |
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 | 鸿蒙适配主文档(17 节) |
4.3 关键 API 对照
| 层次 | API | 作用 |
|---|---|---|
| 引擎 | FibonacciLattice(count).item(i) | 黄金角把 N 个标签无聚簇铺满球面 |
| 引擎 | Quaternion.create(from, to) | 两球面向量间的旋转增量(含 180° 边界) |
| 引擎 | Quaternion.create(angle, axis) × times | 步进旋转与复合(全局轴左乘) |
| 引擎 | Vector3.rotate(q) | 旋转矩阵展开,逐标签投影 |
| 桥 | op=create / drag / rotate / reset / layout | 会话全生命周期 |
| 桥 | rescale(z, min) 复刻 | 深度缩放/透明度曲线(0.5 / 0.25) |
| Native | TagCloudDispatch / TagCloudVersion | C ABI JSON 导出 |
| N-API | tagcloudDispatch(req) / tagcloudVersion() | 向 ArkTS 暴露能力 |
| ArkTS | dispatch(op) / onCloudArea / Pan 转发 | 触发操作并刷新 UI |
4.4 ArkTS 与 Kotlin 的边界
const res = JSON.parse(tagcloudDispatch(JSON.stringify({
op: 'drag',
fromX: lastX - originX, fromY: lastY - originY,
toX: x - originX, toY: y - originY,
radius: this.radius
}))) as DispatchOk;
// res.ok === true;items 按由远及近排序,逐项 position/markAnchor/scale/opacity/zIndex
val from = sphereVector(json.num("fromX"), json.num("fromY"), radius)
val to = sphereVector(json.num("toX"), json.num("toY"), radius)
session.rotation = Quaternion.create(from, to) * session.rotation
return opLayout(radius.toInt()) // 全量投影 + 深度曲线 + 由远及近排序
两者之间只传输扁平 JSON 字符串,不传输 Kotlin 对象或未校验的动态结构。
五、关键决策说明
决策 1:编几何内核而不是等 Compose 对齐
tagcloud 的 API 面就是 Compose,上游 Compose 1.5 / Kotlin 1.9 与鸿蒙 CMP 1.9.2 / HarmonyOS Kotlin 2.2.21 不可混编——这不是依赖缺制品,是两条编译器工具链线。而库的灵魂(分布 + 旋转 + 投影)恰好是零依赖纯 Kotlin。把几何内核编进 .so,"这个标签云是不是 tagcloud"由真机上的上游代码回答;ArkTS 侧零数学,不存在两套几何各说各话的漂移风险。
决策 2:钉社区标准线 Kotlin 2.2.21-1.0.0,Gradle 升 8.14.1
内核不依赖 kotlinx 四件套,没有 KStore 那种"stdlib 元数据反推 2.4.20"的压力,直接钉社区已审标准线 2.2.21-1.0.0(qrose / haze 同款)。这条线要求 Gradle 8.14.x,上游 wrapper 8.3 必须升级——wrapper jar 与 gradlew 脚本与 cmp_qrose 同源替换,避免 8.3 的 jar 拉 8.14.1 发行版的边角问题。
决策 3:根构建替换(cmp_qrose 模式),Compose 层与 demo 系列留盘
上游根构建(detekt / spotless / AGP / vanniktech 发布 / build-logic 组合插件)对鸿蒙没有保留价值,整个替换为极简 ohos 构建。Compose 层 5 个文件 git mv 到 composeMain——不删除、不重写,git 历史以 100% rename 完整保留,上游工具链恢复 include 即可复编。demo / desktopdemo / androiddemo / iosdemo 同理留盘不进 settings。
决策 4:会话状态在 Kotlin 侧 + items 由远及近返回 + 深度曲线在引擎侧算
会话(球面坐标 + 四元数)放 Kotlin 单例:ArkTS 不持有 Kotlin 对象,手势也不必每次重传标签坐标。响应 items 按 z 升序,ArkTS 按数组顺序渲染即满足画家算法,zIndex(z) 双保险。缩放/透明度用上游私有 rescale 公式在引擎侧算好(s/a 字段),ArkTS 拿到即用——上游渲染语义(0.5 / 0.25 曲线)与 ArkUI 实现解耦。
决策 5:同源双编译交叉验证
上游没有测试目录,KStore 的"51 项 desktopTest 跑上游原测试"无对象可跑。改为 jvm() 与 ohosArm64()/ohosX64() 编译同一份 commonMain——两套工具链对同一份源码编译通过,加上真机功能用例(拖拽方向正确性、180° 边界、分布均匀性)兜底几何正确性。全局禁用 Ohos*Test* 任务作为社区纪律防御性保留。
六、测试与验证
6.1 测试环境
本次验证使用 Windows(x86_64)、JDK 21(Adoptium jdk-21.0.12.101-hotspot)、DevEco Studio 26.0、OpenHarmony 命令行工具(hvigor / hdc)与鸿蒙真机(arm64)。
6.2 JVM 交叉验证
./gradlew :tagcloud:compileKotlinJvm
commonMain(math + distribution 三个文件)在 JVM 与 ohos 双 ABI 上编译的是同一份源码;上游无测试可跑,同源双编译即"内核没漂"的编译期闸门。
6.3 HAP 验证
:tagcloud:compileKotlinJvm / compileKotlinOhosArm64 / OhosX64: BUILD SUCCESSFUL
linkDebugSharedOhosArm64 / linkDebugSharedOhosX64: BUILD SUCCESSFUL
llvm-nm -D: TagCloudDispatch / TagCloudVersion / libohostagcloud_symbols 均为全局 T 符号(双 ABI)
BuildNativeWithCmake / BuildNativeWithNinja / DoNativeStrip: finished(双 ABI 编出 libtagcloud_napi.so)
CompileArkTS: finished
PackageHap: finished
BUILD SUCCESSFUL
install bundle successfully
start ability successfully
entry-default-unsigned.hap 约 6.8 MB,内含双 ABI 各三件套(libtagcloud_napi.so / libohostagcloud.so / libc++_shared.so);HAP 内 strip 后 KN so arm64 约 1.96MB、x86_64 约 1.91MB(debugShared 未 strip 时分别约 3.7MB / 3.4MB,含 Kotlin/Native 运行时),NAPI 包装层每个 ABI 约 65KB。
6.4 功能用例
- 启动后日志出现
version → 1.1.0-ohos.1(来自真实 Native 调用,非静态文字); - 云区量出尺寸后自动
create,64 个彩色标签铺满球面,无聚簇(斐波那契晶格); - 拖动球面:标签云随手势旋转,拖拽方向与旋转方向一致(球面手势数学与上游一致);
- 旋转过程中近处标签大而不透明、远处标签缩到约 0.5、淡到约 0.25(引擎侧 rescale 曲线);
- 前后标签遮挡关系正确(由远及近排序 + zIndex);
- 自动旋转开启后绕 Y 轴连续自转,状态行四元数 w/x/y/z 连续变化,停止后数值停住;
- 重置后回到初始视角(w≈1,x/y/z≈0);
- 切换 16/32/64/128:标签即时重铺且仍均匀;
- 旋转屏幕 / 界面尺寸变化后,标签保持当前旋转重新投影(op=layout 保旋转重排);
- 每步操作按时间戳滚动留痕。
6.5 验证结论
几何引擎、Kotlin/Native 运行时、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 已完成链路验证。应用在鸿蒙真机上成功安装并进入前台,页面展示了真实的标签云会话:版本握手、球面均匀分布、拖拽旋转、深度渲染曲线、自动旋转、重置与数量切换(见下节效果图)。
七、运行效果
7.1 真机截图

7.2 命令速查
git clone https://atomgit.com/oh-tpc/tagcloud.git
cd tagcloud
export JAVA_HOME="/path/to/jdk-21"
./gradlew :tagcloud:compileKotlinJvm # 同源双编译
./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.tagcloud -m entry
hdc shell hilog | grep TagCloud
八、遗留问题与改进方向
8.1 踩坑复盘
- 只改 ArkTS 页面不算 KMP 适配,必须让
:tagcloud的几何内核真正编译成ohosArm64/ohosX64动态库; settings.gradle.kts里 eazytec 双仓必须排在pluginManagement与dependencyResolutionManagement最前,否则配置期直接Unresolved reference: ohosArm64;gradlew.bat自举需要JAVA_HOME环境变量,org.gradle.java.home要等 JVM 起来才生效——先设环境变量再跑 wrapper;- 2.2.21 工具链线要求 Gradle 8.14.x,上游 8.3 的 wrapper 必须升级,且 wrapper jar / gradlew 脚本一并替换;
String.format在 Kotlin/Native 不可用——桥内数值格式化改为手写定点格式化(3 位小数);- 命令行 hvigor 需要
NODE_HOME(DevEco 自带 node)+DEVECO_SDK_HOME+java在 PATH,缺java时PackageHap报00308018 spawn java ENOENT; - ArkTS 严格模式:未验证模块导入(
libtagcloud_napi.so)的返回值是隐式 any,必须显式标注const raw: string = ...; GestureEvent上没有globalX/globalY(已从 SDK 移除),触点坐标只能走event.fingerList[0].globalX/globalY;.gesture()第二参数是GestureMask不是优先级(枚举值是NORMAL/PRIORITY);让子组件手势赢过外层 Scroll 要用独立的.priorityGesture();- HAP 缺当前 ABI 的 so 会报安装失败
9568347,abiFilters必须双 ABI,每个 ABI 三件套缺一不可; build-profile.json5的signingConfigs条目缺material字段会被 hvigor schema 拒绝(报00303038)——仓库内留空数组且 product 不引用签名,勾选自动签名后由 IDE 回填完整配置;- KN 返回字符串必须
DisposeString归还运行时,N-API 层不能free(); - 根
.gitignore的build用非锚定写法盖住嵌套目录(example/nativeApp/build等);entry/libs与*.so一并忽略,产物不入库;Compose 层迁移用git mv保住 100% rename 历史。
8.2 已知问题
TagCloudState的 Compose 动画语义(Animatable/animateRotateTo/rememberSaveable)不跨 C ABI:桥是同步一次一调用,演示以 30ms 定时器步进近似连续动画;- fling(甩动惯性)未桥接——上游 TODO 列表本身也还没有 fling;
- 标签点击回调未桥接(演示云只读),
TagCloudScope的layer(多层球面)与单项rotation参数未逐一开桥,需要时按op扩展; - android / ios / desktop 目标与 demo 系列剥离(源文件留在树里),发布、静态检查一并剥离,本 fork 不发 Maven Central;
- 上游无单元测试,交叉验证采用"JVM 与 ohos 编译同一份 commonMain"+ 真机功能用例,非 desktopTest 式闸门;
- HAP 安装必须使用与 bundle name
org.terminator.ohos.tagcloud匹配的签名。
8.3 未来优化方向
- 把
layer(多层球面)与单项rotation参数开进op=create(复刻上游"球内粒子"示例); - N-API 线程安全回表推送连续动画帧(替代 ArkTS 定时器步进);
- fling 惯性(上游实现后同步桥接);
- 标签点击回调桥接;
- 评估
.har形态发布,让 ArkTS 工程直接依赖而不是走 so 三件套; - 在 CI 中加入 ohos 编译、ELF 符号检查和 HAP 构建;补充模拟器(x86_64)验收矩阵和自动化截图脚本。
九、总结
9.1 核心难点回顾
上游 tagcloud(Compose 1.5 / Kotlin 1.9 的 CMP 库)
→ 确认 API 面=Compose、几何内核=零依赖的切面(决策成败手)
→ 工具链对齐 HarmonyOS Kotlin 2.2.21-1.0.0(Gradle 8.14.1)
→ 根构建替换(cmp_qrose 模式,Compose 层 git mv 留盘)
→ ohosArm64 / ohosX64 KLIB + 零新增源码
→ C ABI(@CName × 2)+ 会话 JSON 契约 + 深度曲线引擎侧复刻
→ C++ N-API(DisposeString 纪律)
→ ArkTS 手势转发 + markAnchor 中心渲染
→ ArkUI 真机单页验收(拖拽 / 自动旋转 / 重置 / 数量切换 / 握手)
9.2 封装层次
- 引擎层:上游原版
FibonacciLattice均匀分布、Quaternion旋转(含 180° 边界)、Vector3.rotate投影; - 会话层:桥内单例持有球面坐标与当前四元数,一次一调用;
- Native 层:双 ABI 动态库 + 2 个 C ABI 输出 JSON;
- N-API 层:参数检查、字符串转换和 Native 内存释放;
- ArkTS 层:手势转发、JSON 解析、@State 更新与由远及近渲染;
- DevEco 层:CMake、HAP、签名、安装和真机运行。
9.3 三条经验
- UI 库适配的正确切面是"行为内核"而不是"渲染外壳":tagcloud 值钱的是分布 + 旋转几何,恰好零依赖——切面找对,ArkTS 只剩画标签;切面找错,就是用 ArkTS 重写一遍 tagcloud,过不了审核也守不住上游;
- 工具链线决定 Gradle 版本,而不是反过来:2.2.21 线要 8.14.1 就升 wrapper(连 jar 一起换),2.4.20 线可留 9.x——先定 Kotlin 钉版,再校 Gradle,最后动 wrapper;
- 无测试库的交叉验证退而求其次是同源双编译:同一份
commonMain让 JVM 与 ohos 工具链各编一遍,配合真机几何用例(方向一致性、180° 边界、均匀性)兜底——验证形式可以降级,"内核没漂"这件事不能缺席。
9.4 适配成果
当前适配已完成:
- tagcloud
1.1.0几何内核的双 ABI Kotlin/Native 编译(上游commonMain文件零改动,全仓零新增 Kotlin 源文件、零 expect/actual); - Compose 层 5 文件
git mv隔离留盘(100% rename 保留历史),demo / build-logic / config 留盘不进构建; - 2 个 C ABI 符号和 C++ N-API 包装层(
llvm-nm验证全局 T 符号,双 ABI); - DevEco Stage ArkUI 单页验收页面(拖拽旋转、自动旋转、重置、数量切换、四元数状态行、操作日志、版本握手);
- 同源双编译交叉验证 + HAP 构建(双 ABI 六件套 so,约 6.8MB)、签名、设备安装和真机验证;
- 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)