本文记录 inklet 接入 OpenHarmony 的完整过程,覆盖纯 Compose 组件库盘点、社区 Compose 制品线选型(material3 ohos 变体核验)、整库真实编译、ohosArm64 / ohosX64 目标、Kotlin/Native 动态库、C ABI/N-API 桥接、ArkUI 真机页面、HAP 构建和真机验收。

inklet 是 Drawably 手绘渲染器的原生 Kotlin / Compose Multiplatform 移植(ggoggam/inklet 0.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,且重度 import androidx.compose.material3.*。社区 Maven 上的 Compose 线 1.12.0-0.1.0-07 已发布 material3 的 ohosArm64 / ohosX64 klib(本次用 .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 带原文。
frames1 或 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 MultiplatformHarmonyOS 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
JDK21(org.gradle.java.home=F:/cmp/.tools/jdk-21.0.12.1+1)
OpenHarmony 目标ohosArm64 + ohosX64(jvm("desktop") 保留作交叉验证)
Gradle9.6.1(上游 wrapper 9.4.1 升级;2.4.20 线支持 Gradle 9.x)
DevEco product5.1.0(18) 兼容
DevEco Studio5.1.0 Release
ABIarm64-v8a + x86_64
Bundleorg.terminator.ohos.inklet
1.3 源码依赖度盘点
文件依赖形态处置
Rough.kt纯 Kotlin(kotlin.math),零 Compose整体编译(桥的引擎面)
InkletTheme.ktcompose.runtime + animation.core整体编译(InkletStyle() 默认值跨桥运行期取回)
SketchModifier.ktcompose.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 方法覆盖内核
InkletDispatchinkletDispatch(req)Rough 九入口 + variants 帧包 + sketch() 管线 + InkletStyle() 默认值
InkletVersioninkletVersion()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.tomlKotlin 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.kt2 个 C ABI、三个 op、frames 帧包、MiniJson、错误通道(唯一新增 Kotlin 源码)
harmonyApp/.../cpp/napi_init.cppN-API 导出、参数检查、DisposeString
harmonyApp/.../cpp/CMakeLists.txtIMPORTED 引入 KN so、$ORIGIN rpath、POST_BUILD 三件套
harmonyApp/.../ets/pages/Index.etsArkUI 三页验收(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() 镜像)
NativeInkletDispatch / InkletVersionC ABI JSON 导出
N-APIinkletDispatch(req) / inkletVersion()向 ArkTS 暴露能力
ArkTSSketchBook.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引擎数学:种子确定性、采样、抖动幅度、参数校验
ControlsTest8Button / Checkbox / RadioButton / TextField 的原生行为保留
ChipsAndIconButtonsTest6四类 Chip 与 IconButton / IconToggleButton 插槽
ProgressIndicatorTest9线性 / 圆形进度的 strokeProgress 部分描线与冻结姿态
RippleTest4手绘涟漪形状与交互反馈
TabsTest7Tab 指示器 / 缎带指示器几何
TextFieldTest9浮动标签间隙、焦点 / 错误 / 禁用配色、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 功能用例

  1. 启动后"关于"页 inkletVersion() 显示 0.3.0-ohos.1(来自真实 Native 调用,非静态文字);
  2. spec op 返回的 roughness 0.3 / boil 0.3 / strokeWidth 1.2dp 与上游 InkletStyle() 默认值一致(运行期实例取回);
  3. 引擎页拖动 roughness / boil / strokeWidth / cornerRadius 四滑杆,九宫格即时重生成且笔画明显变化;
  4. 同一 seed 下笔画稳定(退出重进不变);点"换一个种子"整页重绘;
  5. 沸腾开关开启时全部图形约每 0.4s 轻微抖动一次(三帧轮换),关闭后静止;
  6. 组件页 Button / 涂鸦 Card / Checkbox 对钩 / RadioButton 圆点 / 心形开关填充随状态正确出现消失;
  7. Slider 拖动时圆点沿线移动且轨迹保持手绘;进度条约 4 秒一个循环连续重生成(60ms 一次 FFI);
  8. 下划线 / 高亮 / 圈选三种文字装饰位置正确;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 踩坑复盘

  1. 只改 ArkTS 页面不算 KMP 适配,必须让库真正编译成 ohosArm64 / ohosX64 动态库;
  2. settings.gradle.kts 里 eazytec 双仓必须排在 pluginManagement 与 dependencyResolutionManagement 最前,否则配置期直接 Unresolved reference: ohosArm64;
  3. gradlew.bat 在 Gradle 启动前就检查 JAVA_HOME 环境变量——gradle.properties 里的 org.gradle.java.home 救不了它,必须先 set JAVA_HOME;
  4. material3 的 ohos 制品存在性是纯 Compose 库的路线开关:核验通过走整库编译,核验不过只能走 composeMain 隔离——先查 .module 元数据再写构建;
  5. 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 配对)修复;
  6. hvigor 命令行对环境敏感:DEVECO_SDK_HOME 未指到 DevEco sdk 报 00303217;PackageHap 报 spawn java ENOENT 是打包器缺 JVM——把 DevEco 自带 JBR 的 bin 加进 PATH;
  7. ArkTS 画布 lineCap / lineJoin 在该 SDK 要字符串字面量联合类型('round'),赋 LineCapStyle.Round 枚举编译报 Type is not assignable to CanvasLineCap;
  8. 画布是命令式绘制:状态切换后(如 IconToggle 的填充)重建只会更新插槽选项,必须显式调 redraw,否则画布不动;
  9. HAP 缺当前 ABI 的 so 会报安装失败 9568347,abiFilters 必须双 ABI,每个 ABI 三件套缺一不可(DevEco 模拟器是 x86_64);
  10. KN 返回字符串必须 DisposeString 归还运行时,N-API 层不能 free();
  11. 根构建的 gitignore 是根锚定模式,盖不住嵌套目录——DevEco 打开工程后生成的 example/harmonyApp/.hvigor、entry/build、entry/.cxx、oh_modules 要单独追加,外加 *.so / *.hap;
  12. 签名材料由 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 三条经验

  1. 先验证生态制品线再定适配路线:纯 Compose 库的 go/no-go 是"社区线是否发布 material3 的 ohos klib"——这次核验通过,路线从早期库的"提取内核 + 隔离"切换为"整库原样编译",inklet 因此成为这批适配里源码改动为零的一个;路线选择必须基于当天的制品可达性,而不是历史结论;
  2. 双线拆分让适配与交叉验证共存:ohos 线承载适配、Maven Central 上游线承载验证,commonMain 零依赖保持线无关——"换线不换版"的纪律让 52 项上游测试零改动全绿;同时要提防插件属性装配的隐式依赖(skiko),构建替换时这类坑编译期不报、运行期才炸;
  3. 复刻的可信度来自渲染管线的逐行镜像:手绘库的验收落点是"每一笔折线都来自 Kotlin"——把 toPenPath() 的中点平滑(含闭合接缝)、圆头笔触、填充取首笔、涂鸦裁剪的次序在 ArkTS 侧原样复刻,复刻才不是"画得像"而是"同一份数据的另一种光栅化"。

9.4 适配成果

当前适配已完成:

  • inklet 0.3.0 真实引擎与全部组件源码的双 ABI Kotlin/Native 编译(上游 commonMain 13 个文件零重写、零 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。

参考文档

Logo

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

更多推荐