开源鸿蒙平台 CMP 三方库「inklet」适配全流程
本文记录
inklet接入 OpenHarmony 的完整过程,覆盖纯 Compose 组件库盘点、社区 Compose 制品线选型(material3 ohos 变体核验)、整库真实编译、ohosArm64/ohosX64目标、Kotlin/Native 动态库、C ABI/N-API 桥接、ArkUI 真机页面、HAP 构建和真机验收。
inklet是 Drawably 手绘渲染器的原生 Kotlin / Compose Multiplatform 移植(ggoggam/inklet0.3.0,MIT):为 Material 3 组件(Button / 四类 Chip / IconButton / TextField / Slider / Tab / 进度指示器)叠加手绘草图层,核心是带种子随机的Rough几何引擎——Mulberry32与上游 JavaScript 位级兼容,同一 seed 在各平台逐点可复现。本次适配与社区两条既有路线都不同:qrose / day-and-night-switch / snowfall / Shapes 走"提取纯 Kotlin 模型 + composeMain 隔离"(因为它们有可拆的无 UI 内核),KStore 走"kotlinx 制品线真编译"(因为存储引擎只依赖 kotlinx);而inklet是纯 Compose UI 库——全部 13 个 commonMain 文件的公开 API 都是@Composable,且重度 importandroidx.compose.material3.*。社区 Maven 上的 Compose 线1.12.0-0.1.0-07已发布 material3 的ohosArm64/ohosX64klib(本次用.module元数据逐项核验),因此采用整库原样编译 + 几何引擎桥路线(compose-shimmer / compose-ripple-indication / bubbles-compose 已审同款):上游源码零修改编译进.so,Rough与InkletStyle通过扁平 JSON 桥暴露,ArkUI 页面按 Kotlin 算出的折线重绘——引擎即内核,像素归 ArkTS。
项目地址: AtomGit/oh-tpc/inklet
上游仓库: ggoggam/inklet
开发工具: 华为云码道
环境安装: 环境安装指南
一、背景
1.1 为什么做 OpenHarmony 平台 KMP/CMP 适配
inklet 为 Compose Multiplatform 应用提供"手绘外观"这一件事的完整答案:InkletTheme 一次注入笔触参数(roughness 0.3 / boil 0.3 / strokeWidth 1.2dp 与 1200ms 三帧沸腾时钟);Modifier.inkletBorder / inkletSurface / inkletDecoration 把双笔手绘轮廓、涂鸦填充、下划线 / 高亮 / 圈选叠加在原生 Material 3 控件之上——宿主组件继续持有输入、焦点与语义;material3 包再把 Button、四类 Chip、IconButton / IconToggleButton、TextField 容器、Slider、Tab 指示器、线性 / 圆形进度逐个包成开箱即用的手绘组件。KMP 项目把这些能力放在 commonMain,由 Android、iOS、桌面、Web 共享。OpenHarmony 应用同样需要这套手绘能力,但上游构建(Kotlin 2.4.0-RC / Compose 1.11.0 / Gradle 9.4.1 / AGP 9.2.1)不会生成 OpenHarmony 变体。
如果只把示例页改成 ArkTS 手抄一遍图形,页面可以画得像,却无法证明手绘引擎与全部组件源码在 OpenHarmony ARM64 设备上完成编译、链接和执行——手抄的笔画第二天就会和上游漂移。适配需要同时验证几何引擎、Compose 编译链(含 material3)、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 交付链路。
| 障碍 | 具体问题 |
|---|---|
| 目标缺失 | 必须增加 ohosArm64() / ohosX64() 才能生成 OpenHarmony KLIB。 |
| 纯 Compose API | 全部公开 API 是 @Composable,material3 依赖面大(chips / buttons / slider / textField / tabs),没有可拆的纯 Kotlin 子集供"内核提取"。 |
| 制品线依赖 | 需要社区线发布 runtime / ui / foundation / animation / material3 五件套的 ohos 变体,缺一不可解析。 |
| 没有 composition host | 鸿蒙上没有把 Composable 挂上屏幕的宿主(社区线只有 klib),Composable UI 不能直接上屏,需要无头桥 + ArkUI 重绘。 |
| 交叉验证 | 上游 desktopTest 用 compose.desktop.currentOs(插件按操作系统属性装配 skiko native),构建替换后需补位。 |
| 交付链路复杂 | .so、CMake、N-API、HAP、签名和 hdc 都要单独验收。 |
因此,本项目把边界放在:上游 13 个 commonMain 文件的 ohos 编译、根构建替换(cmp_qrose 模式)和 C ABI / N-API 桥接层。手绘几何与组件管线完全由上游原版实现负责,ArkTS 只负责调用 Native、解析 JSON 和按折线重绘。
1.2 库提供的能力
Demo 提供一个真实的手绘会话,对应桥接的全部入口:
- 引擎原语(shape op):
Rough九个几何入口逐个可玩——line / arrow / checkmark / roundedRect / circle / ellipse / dot / scribble / underline,roughness、boil、strokeWidth、cornerRadius 四滑杆实时重生成,"换一个种子"整页重绘; - 沸腾动画:
InkletTheme的 1200ms 三帧时钟镜像为 ArkTS 侧 400ms 轮换,同一图形在三张手绘稿间循环——三张稿子全部由 Kotlin 一次算好随响应返回; - 组件复刻(component op):Button(点击换种子重绘)、带涂鸦填充的 Card、Checkbox / RadioButton(对钩与圆点随状态出现)、IconToggleButton(点亮时填充第一笔轮廓)、Slider(原生承接输入 + 手绘轨迹与圆点)、LinearProgressIndicator(60ms 重生成连续出笔画)、Divider、Assist chip、下划线 / 高亮 / 圈选三种文字装饰;
- 版本握手:
inkletVersion()返回0.3.0-ohos.1,来自真实 Native 调用而非静态文字。
响应字段(component op):
| 字段 | 作用 |
|---|---|
ok / error | 操作是否成功,失败时 error 带原文。 |
frames | 1 或 3 帧沸腾变体,每帧是折线数组 {pts:[x,y,...], closed}——Rough.variants() 原件输出。 |
fills | 每帧的第一遍描笔(库只填充第一笔,避免双笔轮廓接缝)。 |
scribbles | 可选涂鸦填充(inkletSurface(scribble = true) 对应物),ArkTS 裁剪进填充轮廓后以 0.16 透明度描出。 |
strokeWidth | 笔宽回显,ArkTS 描边用。 |
1.3 实现适配
| 维度 | 要求 |
|---|---|
| 代码复用 | 上游 src/commonMain 的 13 个文件零修改编译为 ohos KLIB;全仓唯一新增 Kotlin 源码是 example/nativeApp 桥(Bridge.kt + 手写 MiniJson)。 |
| 平台目标 | 库(根项目)jvm("desktop") + ohosArm64 + ohosX64;example/nativeApp 出双 ABI 动态库。 |
| 构建隔离 | 根构建替换为极简 ohos 构建(cmp_qrose 模式),上游文件 *.upstream 备份;sample / docs / .github / kotlin-js-store 留盘不进构建。 |
| 桥接稳定 | 2 个 C ABI、UTF-8 扁平 JSON、异常永远不跨 FFI。 |
| 依赖纪律 | 换线不换版:ohos 线 Compose 1.12.0-0.1.0-07,desktop 交叉验证线沿用上游 compose 1.11.0 / material3 1.9.0。 |
| UI 完整 | 三页覆盖全部桥入口;真机上每一笔折线都来自 Kotlin 侧,ArkTS 只做与 toPenPath() 相同的中点二次平滑和圆头描边。 |
| 仓库规范 | 项目说明、文章、效果图和源码链接统一使用 AtomGit。 |
二、实现路线图
第 1 阶段:项目初始化 ── 盘点模块与依赖边界、核验社区 Compose 线 material3 ohos 变体、确定交付物
第 2 阶段:目标与依赖打通 ── 根构建替换、eazytec 置前、Kotlin 2.4.20-0.1.0-07 钉版、双线依赖拆分、skiko 补位
第 3 阶段:桥协议建模 ── spec / shape / component 三 op、frames 帧包契约、错误通道
第 4 阶段:原生桥接 ── @CName C ABI、C++ N-API、参数校验和内存释放
第 5 阶段:ArkUI 宿主 ── SketchBook、toPenPath 平滑镜像、400ms 沸腾时钟、60ms 进度重生成
第 6 阶段:示例与验证 ── 双 ABI 链接、HAP、签名、设备安装、真机运行和效果图
三、逐步实现过程
第 1 阶段:项目初始化
1.1 工程边界
src/commonMain/ 上游库源码(进 ohos 构建,jvm("desktop") + ohosArm64 + ohosX64,零改动)
src/commonMain/.../Rough.kt 手绘几何引擎(纯 Kotlin,只依赖 kotlin.math)
src/commonMain/.../InkletTheme.kt 笔触主题 + 1200ms 三帧沸腾时钟
src/commonMain/.../SketchModifier.kt inkletBorder/inkletSurface/inkletDecoration 与 toPenPath 平滑
src/commonMain/.../material3/ 9 个 Material 3 组件包装
example/nativeApp/ Kotlin/Native 动态库(Bridge + MiniJson,本 fork 唯一新增源码)
example/harmonyApp/ DevEco Stage、CMake N-API 和 ArkTS 三页演示
README.OpenHarmony.md / _CN.md 鸿蒙适配说明(中英双份)
README.md / README.upstream.md fork 说明与上游原文
*.upstream 上游构建文件备份(build / settings / gradle.properties)
sample/ docs/ .github/ 上游示例与文档,留盘不进 settings
构建拓扑走 cmp_qrose 模式(根构建即 ohos 构建):settings.gradle.kts 只 include :example:nativeApp——库本身是根项目(保持上游"根项目即库"的布局),rootProject.name = "ohos_inklet"。上游的 vanniktech 发布、dokka、AGP classpath、GitHub workflows 发布段全部随根构建替换剥离——本 fork 不发 Maven Central。
1.2 工具链矩阵
| 项目 | 配置 |
|---|---|
| Kotlin Multiplatform | HarmonyOS Kotlin 2.4.20-0.1.0-07(eazytec Maven) |
| Compose(ohos 线) | 1.12.0-0.1.0-07:runtime / ui / foundation / animation / material3 全部含 ohosArm64 + ohosX64 klib |
| Compose(desktop 验证线) | Maven Central 1.11.0 + material3 1.9.0(上游同线)+ desktop-jvm + skiko-awt-runtime-windows-x64 0.144.6 |
| JDK | 21(org.gradle.java.home=F:/cmp/.tools/jdk-21.0.12.1+1) |
| OpenHarmony 目标 | ohosArm64 + ohosX64(jvm("desktop") 保留作交叉验证) |
| Gradle | 9.6.1(上游 wrapper 9.4.1 升级;2.4.20 线支持 Gradle 9.x) |
| DevEco product | 5.1.0(18) 兼容 |
| DevEco Studio | 5.1.0 Release |
| ABI | arm64-v8a + x86_64 |
| Bundle | org.terminator.ohos.inklet |
1.3 源码依赖度盘点
| 文件 | 依赖形态 | 处置 |
|---|---|---|
Rough.kt | 纯 Kotlin(kotlin.math),零 Compose | 整体编译(桥的引擎面) |
InkletTheme.kt | compose.runtime + animation.core | 整体编译(InkletStyle() 默认值跨桥运行期取回) |
SketchModifier.kt | compose.ui / foundation 图形栈 | 整体编译(sketch() 管线公式在桥内重放) |
material3/ 9 个文件 | material3 大面积 API(chips/buttons/slider/textField/tabs/ripple) | 整体编译(material3 ohos klib 存在是前提) |
sample/ 画廊 | AGP 9.2.1 + wasmJs + iOS + compose-resources | 留盘不进构建 |
全库 expect/actual 为零、平台源码集为零(上游 androidMain 等目录本就不存在,所有目标都从 commonMain 编译)——这决定了 ohos 侧不需要任何 actual 补位,适配面只剩构建与桥。
1.4 页面边界
标题区 INKLET · OPENHARMONY / 版本握手行(手绘 CMP 组件库 · Kotlin/Native ohosArm64/ohosX64)
Tab 引擎 3×3 九宫格(九种几何原语)+ 四滑杆(roughness/boil/strokeWidth/cornerRadius)
+ 换种子按钮 + 沸腾开关 + seed 说明行
Tab 组件 Button / 涂鸦 Card / Checkbox+RadioButton / IconToggleButton / Slider
/ LinearProgressIndicator(60ms) / Divider / Chip / 三种文字装饰 + 桥错误行
Tab 关于 版本握手、InkletStyle 真实默认值、调用链分层、仓库与社区链接
页面启动先调 inkletVersion() 握手再拉 spec op,随后每个画布在自己的 onReady 里触发首次生成。
第 2 阶段:目标与依赖打通
2.1 制品线验证(本适配的成败手)
先查社区 Maven(https://maven.eazytec-cloud.com)上的 Compose 线有没有 material3 的 ohos 制品——这是纯 Compose 组件库路线的 go/no-go 开关。用 .module 元数据逐个验证:
org.jetbrains.compose.material3:material3:1.12.0-0.1.0-07
variants: ohosArm64ApiElements-published / ohosX64ApiElements-published / desktopApiElements-published
org.jetbrains.compose.runtime / ui / foundation / animation:*:1.12.0-0.1.0-07
variants: 同上(ohos 双 ABI 齐全)
两个关键结论:其一,material3 的 ohos klib 存在——inklet 全部组件源码可以原样编译,无需走早期库的 composeMain 隔离路线;其二,该线不发布 *-desktop 子制品,desktop 交叉验证目标必须从 Maven Central 解析上游线(compose 1.11.0 + material3 1.9.0)——换线不换版,两侧共用同一份 commonMain 源码。
2.2 工具链钉版(为什么是 2.4.20-0.1.0-07)
社区两条线并存:qrose / day-and-night 一代用 Kotlin 2.2.21-1.0.0(Gradle 必须降 8.14.1);KStore / compose-shimmer / compose-ripple-indication 这批用 2.4.20-0.1.0-07(Gradle 9.x)。inklet 上游本来就是 Kotlin 2.4.0-RC / Gradle 9.4.1,与 2.4.20 线同代:源码级兼容成本为零,wrapper 只需 9.4.1 → 9.6.1 小步对齐社区先例。settings.gradle.kts 把 eazytec 双仓写在最前,否则配置期直接 Unresolved reference: ohosArm64。
2.3 编译真实引擎
根项目(即库模块)收敛为纯 KMP 模块,Compose 依赖按目标线拆分:
kotlin {
applyDefaultHierarchyTemplate()
jvm("desktop") // 交叉验证用
ohosArm64()
ohosX64()
sourceSets {
commonMain.dependencies { } // 线无关:依赖全部挂目标线
ohosMain.dependencies { // 社区鸿蒙线
api(libs.compose.runtime)
api(libs.compose.ui)
api(libs.compose.foundation)
api(libs.compose.animation)
api(libs.compose.material3)
}
val desktopMain by getting { // 上游同线(Maven Central)
dependencies {
api(libs.compose.runtime.jvm)
api(libs.compose.ui.jvm)
api(libs.compose.foundation.jvm)
api(libs.compose.animation.jvm)
api(libs.compose.material3.jvm) // 1.9.0
}
}
commonTest.dependencies { implementation(kotlin("test")) }
val desktopTest by getting {
dependencies {
implementation(libs.compose.desktop.jvm) // currentOs 等价坐标
implementation(libs.skiko.awt.runtime.windows) // 见 2.4
}
}
}
}
实测:
./gradlew :compileKotlinDesktop :compileKotlinOhosArm64 :compileKotlinOhosX64 # BUILD SUCCESSFUL
上游 13 个 commonMain 文件一个没动,全部编译进三平台。
2.4 测试依赖的处置:currentOs 的 skiko 装配拿不走
上游 desktopTest 声明 implementation(compose.desktop.currentOs)——这个访问器由 org.jetbrains.compose 插件按操作系统属性把 skiko native 配到测试运行时。本构建不应用该插件(鸿蒙线用 org.jetbrains.kotlin.plugin.compose 编译器插件 + 直接坐标),只给 desktop-jvm:1.11.0 坐标时,编译照过,但 43 例场景测试运行期集体报:
java.lang.NoClassDefFoundError: Could not initialize class org.jetbrains.skia.Surface
处置分两步:先查 ui-desktop 1.11.0 的 POM 确认配对 skiko 版本是 0.144.6,再显式补 org.jetbrains.skiko:skiko-awt-runtime-windows-x64:0.144.6。此后 52 项测试(含 ImageComposeScene 真实渲染)零改动全绿。教训:插件用属性规则装配的隐式依赖,是构建替换时最隐蔽的坑——坐标能解析不等于运行时有 native。
第 3 阶段:桥协议建模
3.1 JSON 契约
dev.ggoggam.inklet/**(上游引擎 + 主题 + 组件管线,原样编译)
↓ Rough.* / InkletStyle() 运行期真实调用
Bridge.kt(@CName × 2,扁平 JSON 进出)
↓ UTF-8 JSON
N-API inkletVersion() / inkletDispatch()
↓
ArkUI Index.ets(引擎 / 组件 / 关于 三页)
请求与响应:
请求 : {"op":"spec"}
{"op":"shape","kind":"line|arrow|rect|ellipse|circle|check|dot|scribble",几何字段,"seed","roughness","boil"}
{"op":"component","kind":"rect|ellipse|line|underline|check|dot","w","h","cornerRadius",
"scribble","seed","roughness","boil","strokeWidth","animate"}
响应 : {"ok":true,"frames":[[{pts:[x,y,...],"closed":bool},...],...],"fills":...,"scribbles":...}
{"ok":false,"op":...,"error":...}
3.2 载荷结构为什么是 frames(帧包而不是单帧)
InkletTheme 的沸腾是"三张手绘稿每 1200ms 循环"。桥把 Rough.variants() 的 1/3 帧一次性全部返回,ArkTS 端持有帧数组、每 400ms 本地轮换下标——沸腾动画零逐帧 FFI。进度条这类连续动画则相反:每 60ms 发一次 component 请求按当前进度重生成整段线,用来演示引擎持续出笔画的吞吐。两条通道各取所需:状态动画批量取、连续动画按需取。
数字序列化做两位小数定点化(1.2 不输出 1.19999999999999996),FFI 载荷减半以上;component op 在桥内重放 Modifier.sketch() 的整条管线——小尺寸衰减 min(w,h)/48(line 不衰减)、inset = 2.1·roughness + boil + strokeWidth/2(夹到短边一半)、填充取第一遍描笔——ArkTS 不复制任何公式。
3.3 错误通道
每个导出整体 try/catch,Kotlin 异常永远不跨 FFI;非法 kind、非法参数(RoughOptions 的 require 抛 IllegalArgumentException)一律走同一个 ok:false 通道,ArkTS 侧统一渲染在组件页底部的错误行。
第 4 阶段:原生桥接(技术难点)
4.1 三层桥接
ArkTS
│ JSON string
▼
C++ N-API entry(libinklet_napi.so)
│ const char*
▼
Kotlin/Native C ABI(libohosinklet.so)
│ InkletDispatch / InkletVersion
▼
UTF-8 JSON + DisposeString
直接导出 Kotlin 对象会暴露 ABI 和生命周期问题;只导出布尔又会让页面手抄几何。C ABI + 扁平 JSON 是社区已审口径(qrose / eungabi / compose-shimmer / compose-ripple-indication 同款),本 fork 原样沿用。
4.2 Kotlin/Native 导出函数
@CName("InkletVersion")
fun inkletVersion(): String = "0.3.0-ohos.1"
@CName("InkletDispatch")
fun inkletDispatch(request: String): String // spec / shape / component 一口进
| C ABI 符号 | ArkTS 方法 | 覆盖内核 |
|---|---|---|
InkletDispatch | inkletDispatch(req) | Rough 九入口 + variants 帧包 + sketch() 管线 + InkletStyle() 默认值 |
InkletVersion | inkletVersion() | fork 版本握手(真机"关于"页数据来源) |
桥模块刻意不应用 compose 编译器插件——它只触碰库的非组合面(Rough、RoughOptions、InkletStyle 构造)。
4.3 C++ N-API 方法分发
napi_property_descriptor desc[] = {
{"inkletDispatch", nullptr, NapiInkletDispatch, nullptr, nullptr, nullptr,
static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr},
{"inkletVersion", nullptr, NapiInkletVersion, nullptr, nullptr, nullptr,
static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr},
};
每个回调统一三步:napi_typeof 校验必须是 string、OH_LOG_INFO 打字节数、调 KN 函数并包装返回。KN 返回的字符串必须经 libohosinklet_symbols()->DisposeString 归还 Kotlin 运行时,绝不 free()。libohosinklet_api.h 按社区已审仓库同款手写(只声明 ExportedSymbols 结构与两个业务符号),不拖整份生成头。
4.4 CMake 和类型声明
add_library(ohosinklet SHARED IMPORTED)
set_target_properties(ohosinklet PROPERTIES
IMPORTED_LOCATION ${KN_SO}
IMPORTED_NO_SONAME TRUE)
add_library(inklet_napi SHARED napi_init.cpp)
target_link_libraries(inklet_napi PUBLIC
libace_napi.z.so
libhilog_ndk.z.so
ohosinklet)
OHOS_ARCH 为空或 libohosinklet.so 缺失直接 FATAL_ERROR(提示先跑 link 任务);$ORIGIN rpath 加 POST_BUILD 拷贝保证 NAPI so、KN so、libc++ 三件套同目录,dlopen 时互相可见。Gradle 侧 copyKnSo* 任务把 linkDebugShared* 产物与 ~/.konan/dependencies/<dep>/lib/<triple>/libc++_shared.so 成对落进 entry/libs/{arm64-v8a,x86_64}。
export const inkletDispatch: (request: string) => string;
export const inkletVersion: () => string;
第 5 阶段:ArkUI 宿主
5.1 SketchBook 与渲染管线镜像
页面所有画布归一个 SketchBook 管理(imperative 绘制:onReady 触发生成、put 存结果并重绘、tick() 推进全局沸腾帧下标)。核心是 strokePath() 逐行镜像 toPenPath():
- 开放折线:
moveTo(p0)→ 对中间点quadraticCurveTo(p[i], mid(p[i], p[i+1]))→lineTo(p[n-1]); - 闭合折线(n>2):起点取
mid(last, first),下标模 n 循环quadraticCurveTo——穿过闭合接缝也平滑,与库内实现一致; - 笔触:圆头 cap / 圆角 join,
lineWidth = strokeWidth(响应带回); - 组件绘制次序:先按
fills填充第一笔 → 涂鸦线在填充轮廓clip()内以 0.16 透明度描出 → 最后描frames当前帧——与Modifier.sketch()的onDrawWithContent次序一致。
5.2 页面交互
引擎 Tab 的每次滑杆变化触发 9 次 shape 请求整页重生成;组件 Tab 各元素独立持有 seed 与帧包,点击 Button 换种子重绘、Checkbox / RadioButton / 心形开关切换对钩 / 圆点 / 填充;Slider 演示上游"宿主组件持有输入、草图层只管出墨"的分工——透明原生 Slider 承接手势,手绘轨迹线与圆点在画布上跟随;进度条每 60ms 重生成一次。关于 Tab 展示版本握手、InkletStyle 真实默认值、ArkTS → NAPI → C ABI → Kotlin 引擎 → 社区 Compose 线 分层与仓库 / 社区链接。
5.3 对比度和错误边界
暖纸色底(#F8F5EF)+ 深墨笔色(#2A2520)+ 陶土红点缀(#C2512F),与上游画廊的暖色调一致;ok:false 时错误原文进组件页底部错误行,Native 调用本身抛错(理论不该发生)同样被 try/catch 收进同一通道。
第 6 阶段:示例与验证
6.1 工程结构
example/
├── nativeApp/
│ ├── build.gradle.kts 双 ABI sharedLib(baseName=ohosinklet)+ cxxShared + copyKnSo*
│ └── src/ohosMain/kotlin/example/nativeapp/
│ └── Bridge.kt @CName × 2 + MiniJson(扁平 JSON,继承自 cmp_qrose 审核模式)
└── harmonyApp/
├── build-profile.json5 signingConfigs 留空(签名由 DevEco 自动生成回填)
├── AppScope/app.json5 bundleName org.terminator.ohos.inklet、versionName 0.3.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/libohosinklet_api.h
├── src/main/cpp/types/libinklet_napi/{index.d.ts, oh-package.json5}
└── src/main/ets/pages/Index.ets ArkUI 三页验收(引擎 / 组件 / 关于)
6.2 构建、签名和安装
./gradlew :desktopTest # 52 项交叉验证
./gradlew :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64
cd example/harmonyApp
hvigorw assembleHap -p product=default -p buildMode=debug --no-daemon
命令行调用 hvigor 需要两个环境变量:DEVECO_SDK_HOME 指向 DevEco 的 sdk 目录(未设置或无效报 00303217),打包器还需要 java 在 PATH 上(用 DevEco 自带 JBR,缺了报 spawn java ENOENT)。签名在 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.inklet -m entry
四、完整代码对照
4.1 整体架构
dev.ggoggam.inklet/**(上游 13 个文件原样编译:Rough / InkletTheme / SketchModifier / material3×9)
│ Rough.variants() 三帧包 / InkletStyle() 默认值 / sketch() 管线公式
Bridge.kt + MiniJson
│ InkletDispatch / InkletVersion
libinklet_napi.so
│ inkletDispatch / inkletVersion
Index.ets(SketchBook)
│ JSON parse + strokePath() 中点二次平滑镜像
▼
ArkUI 真机页面(引擎 / 组件 / 关于 三页)
4.2 文件清单
| 文件 | 职责 |
|---|---|
settings.gradle.kts | 极简 ohos 构建、eazytec Maven 置前、ohos_inklet、sample 移出 |
gradle/libs.versions.toml | Kotlin 2.4.20-0.1.0-07 + 双 Compose 线(ohos 1.12.0-0.1.0-07 / jvm 1.11.0+1.9.0)+ skiko 钉版 |
build.gradle.kts(根=库) | 纯 KMP:jvm(“desktop”) + ohosArm64 + ohosX64、双线依赖、版本 0.3.0-ohos.1、全局禁用 Ohos*Test* |
example/nativeApp/build.gradle.kts | 双 ABI sharedLib、cxxShared 定位 libc++、copyKnSo* 回填 |
nativeApp/.../Bridge.kt | 2 个 C ABI、三个 op、frames 帧包、MiniJson、错误通道(唯一新增 Kotlin 源码) |
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 三页验收(SketchBook + 沸腾时钟 + 60ms 进度) |
README.OpenHarmony_CN.md | 鸿蒙适配主文档(19 节) |
*.upstream × 3 + README.upstream.md | 上游构建与说明原样备份 |
4.3 关键 API 对照
| 层次 | API | 作用 |
|---|---|---|
| 引擎 | Rough.line / arrow / roundedRect / ellipse / circle / checkmark / dot / scribble | 手绘折线生成(两遍描笔 + 沸腾抖动) |
| 引擎 | Rough.variants(o){...} | 1/3 帧沸腾变体(桥以 frames 帧包整体返回) |
| 引擎 | RoughOptions(seed, roughness, boil, boilSeed) | 种子与颗粒度;Mulberry32 位级兼容上游 JS |
| 主题 | InkletStyle() | 笔触默认值(spec op 运行期取回真实实例) |
| 管线 | Modifier.sketch() 公式 | 小尺寸衰减 / inset / 填充取首笔 / 涂鸦裁剪(component op 桥内重放) |
| 渲染 | List<PenStroke>.toPenPath() | 中点二次平滑(ArkTS strokePath() 镜像) |
| Native | InkletDispatch / InkletVersion | C ABI JSON 导出 |
| N-API | inkletDispatch(req) / inkletVersion() | 向 ArkTS 暴露能力 |
| ArkTS | SketchBook.put / tick / redraw | 帧包落地、400ms 轮换、画布重绘 |
4.4 ArkTS 与 Kotlin 的边界
const res = JSON.parse(inkletDispatch(JSON.stringify({
op: 'component', kind: 'rect', w: 300, h: 44,
cornerRadius: 12, scribble: 'true', seed: 20261017
}))) as ComponentRes;
// res.ok === true;res.frames.length === 3(沸腾三帧),res.fills 为每帧第一笔
val style = InkletStyle() // 运行期真实默认值
val scale = (minOf(w, h) / 48.0).coerceAtMost(1.0)
val options = RoughOptions(seed, roughness * scale, if (animate) boil * scale else 0.0)
val frames = Rough.variants(options) { o ->
Rough.roundedRect(inset, inset, w - inset * 2, h - inset * 2, cornerRadius, o)
}
两者之间只传输扁平 JSON 字符串(坐标两位小数定点化),不传输 Kotlin 对象或未校验的动态结构。
五、关键决策说明
决策 1:整库原样编译,而不是只提取 Rough 内核
Rough.kt 本可以像 qrose 抽编码器那样单独拎成纯 Kotlin 模块,但那会把 InkletTheme 和 material3 包装隔离在 composeMain 里——inklet 的主体价值是组件,引擎单独存在意义有限。material3 ohos klib 核验通过后,整库编译让组件源码、主题默认值、sketch() 管线全部是上游原件,桥只做薄薄一层 JSON 翻译。
决策 2:工具链钉 Kotlin 2.4.20-0.1.0-07,Gradle 9.4.1 → 9.6.1
上游 Kotlin 2.4.0-RC 与 2.4.20 同代零降级;社区 2.4.20 线支持 Gradle 9.x(KStore / compose-shimmer 先例),wrapper 只需小步升到 9.6.1。2.2.21 旧线必须降 8.14.1 的经验在这里不适用——两条工具链线的经验不能互相套用。
决策 3:根构建替换(cmp_qrose 模式),sample 留盘
上游根构建(AGP 9.2.1 / vanniktech / dokka / wasmJs / iOS / GitHub 发布流)对鸿蒙没有保留价值,整个替换为极简 ohos 构建。sample 画廊需要 AGP + wasmJs + iOS + compose-resources,社区线不发布这些制品,留盘不进 settings.gradle.kts;库保持上游"根项目即库"布局,example:nativeApp 是唯一新模块。
决策 4:双线依赖拆分(换线不换版)+ skiko 补位
ohos 线不发 *-desktop 子制品,jvm 目标必须从 Maven Central 解析上游线——同一份 commonMain 源码,两条制品线各挂各的目标,commonMain 零依赖保持线无关。compose.desktop.currentOs 是插件属性装配的隐式依赖,构建替换后用 desktop-jvm 坐标 + 显式 skiko runtime(0.144.6,按 ui-desktop POM 配对)补位,上游 52 项测试零改动全绿。
决策 5:frames 帧包 + component 管线桥,而不是逐帧取像素
沸腾动画把三帧一次返回、ArkTS 本地 400ms 轮换,动画零逐帧 FFI;component op 在桥内重放 sketch() 的全部公式(衰减 / inset / 填充首笔 / 涂鸦),ArkTS 不复制任何数学——复刻的每一笔都可归因到 Kotlin 侧代码行。这比"每帧取一张位图"(qrose PNG 路线)更贴近组件库的语义:折线是向量,ArkTS 能以任意笔宽 / 颜色 / 平滑参数重描。
六、测试与验证
6.1 测试环境
本次验证使用 Windows 11(x64)、JDK 21(F:/cmp/.tools/jdk-21.0.12.1+1)、Gradle 9.6.1、DevEco Studio 5.1.0 Release(E:\DE\DevEco Studio,含 hvigor / node / JBR / SDK)与鸿蒙真机(arm64)。
6.2 JVM 交叉验证
./gradlew :desktopTest
52 项用例全部通过(上游原测试文件,零改动):
| 测试类 | 用例数 | 钉住什么 |
|---|---|---|
RoughTest(commonTest) | 9 | 引擎数学:种子确定性、采样、抖动幅度、参数校验 |
ControlsTest | 8 | Button / Checkbox / RadioButton / TextField 的原生行为保留 |
ChipsAndIconButtonsTest | 6 | 四类 Chip 与 IconButton / IconToggleButton 插槽 |
ProgressIndicatorTest | 9 | 线性 / 圆形进度的 strokeProgress 部分描线与冻结姿态 |
RippleTest | 4 | 手绘涟漪形状与交互反馈 |
TabsTest | 7 | Tab 指示器 / 缎带指示器几何 |
TextFieldTest | 9 | 浮动标签间隙、焦点 / 错误 / 禁用配色、RTL |
依赖换线没有 API 漂移:ohos 线 1.12.0-0.1.0-07 比 desktop 验证线(上游 1.11.0/1.9.0)更新,两侧编译同一份源码全部通过——"换线不换版"在纯 Compose 库上同样成立。
6.3 HAP 验证
:compileKotlinDesktop / compileKotlinOhosArm64 / compileKotlinOhosX64: BUILD SUCCESSFUL
linkDebugSharedOhosArm64 / linkDebugSharedOhosX64: BUILD SUCCESSFUL
动态符号表核验: InkletDispatch / InkletVersion 在 libohosinklet.so 导出(与 nm_modname、index.d.ts 三处一致)
BuildNativeWithCmake: finished(双 ABI 编出 libinklet_napi.so)
CompileArkTS: finished
PackageHap: finished
BUILD SUCCESSFUL
install bundle successfully(真机 arm64)
entry-default-unsigned.hap 约 39.5 MB;HAP 内 strip 后 libohosinklet.so arm64 约 18.4MB、x86_64 约 18.3MB(debugShared 未 strip 时约 28MB / 26.7MB)——比 KStore 的 5MB 大得多,因为 KN so 里静态链接了 Compose runtime / ui / foundation / animation / material3 整条图形栈;libinklet_napi.so 仅 0.06MB、libc++_shared.so 约 1.3MB。
6.4 功能用例
- 启动后"关于"页
inkletVersion()显示0.3.0-ohos.1(来自真实 Native 调用,非静态文字); specop 返回的 roughness 0.3 / boil 0.3 / strokeWidth 1.2dp 与上游InkletStyle()默认值一致(运行期实例取回);- 引擎页拖动 roughness / boil / strokeWidth / cornerRadius 四滑杆,九宫格即时重生成且笔画明显变化;
- 同一 seed 下笔画稳定(退出重进不变);点"换一个种子"整页重绘;
- 沸腾开关开启时全部图形约每 0.4s 轻微抖动一次(三帧轮换),关闭后静止;
- 组件页 Button / 涂鸦 Card / Checkbox 对钩 / RadioButton 圆点 / 心形开关填充随状态正确出现消失;
- Slider 拖动时圆点沿线移动且轨迹保持手绘;进度条约 4 秒一个循环连续重生成(60ms 一次 FFI);
- 下划线 / 高亮 / 圈选三种文字装饰位置正确;
ok:false错误进组件页错误行。
6.5 验证结论
几何引擎、Compose 编译链(含 material3)、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 已完成链路验证。应用在鸿蒙真机上成功安装并进入前台,组件页完整展示了 Button、涂鸦卡片、勾选 / 单选、心形开关、Slider、进度条、Chip 与三种文字装饰(见下节效果图)——页面上的每一笔折线都由 libohosinklet.so 中的 Kotlin 代码生成。
七、运行效果
7.1 真机截图

7.2 命令速查
git clone https://atomgit.com/oh-tpc/inklet.git
cd inklet
export JAVA_HOME="/path/to/jdk-21"
./gradlew :compileKotlinOhosArm64 :compileKotlinOhosX64 # klib
./gradlew :desktopTest # 52 项交叉验证
./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.inklet -m entry
hdc shell hilog | grep InkletNapi
八、遗留问题与改进方向
8.1 踩坑复盘
- 只改 ArkTS 页面不算 KMP 适配,必须让库真正编译成
ohosArm64/ohosX64动态库; settings.gradle.kts里 eazytec 双仓必须排在pluginManagement与dependencyResolutionManagement最前,否则配置期直接Unresolved reference: ohosArm64;gradlew.bat在 Gradle 启动前就检查JAVA_HOME环境变量——gradle.properties里的org.gradle.java.home救不了它,必须先set JAVA_HOME;- material3 的 ohos 制品存在性是纯 Compose 库的路线开关:核验通过走整库编译,核验不过只能走 composeMain 隔离——先查
.module元数据再写构建; compose.desktop.currentOs是org.jetbrains.compose插件按 OS 属性装配的隐式依赖:换desktop-jvm坐标后编译期全过,运行期才炸NoClassDefFoundError: org.jetbrains.skia.Surface——补skiko-awt-runtime-windows-x64:0.144.6(版本按 ui-desktop 1.11.0 POM 配对)修复;- hvigor 命令行对环境敏感:
DEVECO_SDK_HOME未指到 DevEco sdk 报00303217;PackageHap报spawn java ENOENT是打包器缺 JVM——把 DevEco 自带 JBR 的 bin 加进 PATH; - ArkTS 画布
lineCap / lineJoin在该 SDK 要字符串字面量联合类型('round'),赋LineCapStyle.Round枚举编译报Type is not assignable to CanvasLineCap; - 画布是命令式绘制:状态切换后(如 IconToggle 的填充)重建只会更新插槽选项,必须显式调 redraw,否则画布不动;
- HAP 缺当前 ABI 的 so 会报安装失败
9568347,abiFilters必须双 ABI,每个 ABI 三件套缺一不可(DevEco 模拟器是 x86_64); - KN 返回字符串必须
DisposeString归还运行时,N-API 层不能free(); - 根构建的 gitignore 是根锚定模式,盖不住嵌套目录——DevEco 打开工程后生成的
example/harmonyApp/.hvigor、entry/build、entry/.cxx、oh_modules要单独追加,外加*.so/*.hap; - 签名材料由 DevEco 自动写入
build-profile.json5,仓库内保持signingConfigs: []空数组,证书绝不进入 AtomGit。
8.2 已知问题
- 鸿蒙侧没有 Compose 组合宿主:上游 Composable 已编译进 ohos KLIB 但运行期不被组合,视觉由 ArkUI 按 Kotlin 折线重绘(社区既定口径);
- 上游
strokeProgress部分描线(PathMeasure.getSegment)在 Demo 中以"每 60ms 重生成整段线"近似; - 沸腾时钟节奏(1200ms/3 帧)、双笔振幅(1.5/2.1)、采样步长(8dp)、小尺寸衰减除数(48dp)是镜像常量——它们位于上游组合函数 / 私有体内无法运行期取回,已逐项披露;
- desktopTest 的 Skia natives 钉在 Windows x64(
skiko-awt-runtime-windows-x64),其他平台需换对应 runtime 制品; - TextField / Tab 指示器未进 Demo 页(桥面能力已覆盖,按演示密度取舍;上游行为由 desktopTest 钉住);
- android / ios / wasmJs 目标与
sample画廊剥离(源文件留在树里),发布、dokka 一并剥离,本 fork 不发 Maven Central; - HAP 安装必须使用与 bundle name
org.terminator.ohos.inklet匹配的签名。
8.3 未来优化方向
- 关注社区 Compose 组合宿主进展,宿主落地后 Demo 可直接组合上游 Composable;
- 把 TextField / Tab 指示器补进组件页,Bridge 增加
strokeProgress分段语义(帧包携带路径长度切分点); - 评估
.har形态发布,让 ArkTS 工程直接依赖而不是走 so 三件套; - 在 CI 中加入 ohos 编译、ELF 符号检查和 HAP 构建;
- 补充模拟器(x86_64)验收矩阵和 hdc uitest 自动化截图脚本;
- KN so 体积优化(release strip / 移除未用 material3 符号)。
九、总结
9.1 核心难点回顾
上游 inklet(13 个 commonMain 文件,纯 Compose API)
→ 核验社区 Compose 线 material3 ohos 变体(路线开关)
→ 工具链对齐 Kotlin 2.4.20-0.1.0-07(Gradle 9.4.1 → 9.6.1)
→ 根构建替换(cmp_qrose 模式,库保持根项目布局,sample 留盘)
→ 双线依赖拆分(ohos 1.12.0-0.1.0-07 / jvm 上游线)+ skiko 补位
→ ohosArm64 / ohosX64 KLIB(上游源码零改动,零 actual)
→ C ABI(@CName × 2)+ frames 帧包 + sketch() 管线桥
→ C++ N-API(DisposeString 纪律)
→ ArkTS strokePath() 平滑镜像 + 400ms 沸腾时钟
→ ArkUI 真机三页验收(引擎 / 组件 / 关于)
9.2 封装层次
- 引擎层:上游原版
Rough几何引擎、Mulberry32种子随机、双笔描边与沸腾变体; - 组件层:上游原版
InkletTheme笔触主题、SketchModifier管线与 material3 包装(编译进 KLIB,运行期由桥调用其非组合面); - Native 层:生成双 ABI 动态库并通过 2 个 C ABI 输出帧包 JSON;
- N-API 层:完成参数检查、字符串转换和 Native 内存释放;
- ArkTS 层:SketchBook 帧管理、中点二次平滑镜像、滑杆与状态交互、错误文本;
- DevEco 层:完成 CMake、HAP、签名、安装和真机运行。
9.3 三条经验
- 先验证生态制品线再定适配路线:纯 Compose 库的 go/no-go 是"社区线是否发布 material3 的 ohos klib"——这次核验通过,路线从早期库的"提取内核 + 隔离"切换为"整库原样编译",inklet 因此成为这批适配里源码改动为零的一个;路线选择必须基于当天的制品可达性,而不是历史结论;
- 双线拆分让适配与交叉验证共存:ohos 线承载适配、Maven Central 上游线承载验证,
commonMain零依赖保持线无关——"换线不换版"的纪律让 52 项上游测试零改动全绿;同时要提防插件属性装配的隐式依赖(skiko),构建替换时这类坑编译期不报、运行期才炸; - 复刻的可信度来自渲染管线的逐行镜像:手绘库的验收落点是"每一笔折线都来自 Kotlin"——把
toPenPath()的中点平滑(含闭合接缝)、圆头笔触、填充取首笔、涂鸦裁剪的次序在 ArkTS 侧原样复刻,复刻才不是"画得像"而是"同一份数据的另一种光栅化"。
9.4 适配成果
当前适配已完成:
- inklet
0.3.0真实引擎与全部组件源码的双 ABI Kotlin/Native 编译(上游commonMain13 个文件零重写、零 actual,唯一新增 Kotlin 源码是桥); - 社区 Compose 线
1.12.0-0.1.0-07(runtime / ui / foundation / animation / material3 含 ohos 变体)在纯 Compose 组件库上的完整落地; - 2 个 C ABI 符号和 C++ N-API 包装层(动态符号表核验,与
nm_modname/index.d.ts三处一致); - DevEco Stage ArkUI 三页验收页面(引擎画板、组件复刻、版本握手与真实主题默认值);
- 52 项 JVM 交叉验证全绿(上游原测试文件)+ HAP 构建(双 ABI 六件套 so,约 39.5MB)、签名、设备安装和真机验证;
- 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)