开源鸿蒙平台CMP 三方库「compottie」适配全流程
本文记录
compottie接入 OpenHarmony 的完整过程,覆盖 CMP Lottie 动画库盘点、ohosArm64/ohosX64双目标、Compose 1.12 鸿蒙工具链线、vendored keight 表达式引擎、无头帧渲染入口、C ABI/N-API 桥接、ArkUI 真机页面、HAP 构建、签名和真机验收。
compottie是 Compose Multiplatform 生态最完整的 Lottie 动画库,对外 API 是LottieAnimation/rememberLottiePainter两个@Composable入口,Lottie JSON 解析、图层/关键帧/遮罩/蒙版/渐变/表达式、dotLottie、网络加载、Compose Resources 加载全部收在 7 个 Gradle 模块里。本次适配与已过审的 cmp_qrose / ComposeCharts 的「composeMain 隔离」路线不同:社区 Maven 上已经出现带ohosArm64/ohosX64变体的 Compose 1.12 鸿蒙分支(1.12.0-0.1.0-09,配 Kotlin2.4.20-0.1.0-07),而 compottie 2.3.3 的源码本来就是按 Kotlin 2.4 + Compose 1.12 写的——所以这次是库本体(解析 + 动画引擎 + Skia 渲染 + 表达式)零隔离直接编进 OpenHarmony,仅对三个无 ohos 制品的依赖做小刀替换,再通过LottieFrameRenderer无头渲染入口 +@CName三符号桥暴露给 ArkTS;鸿蒙真机上渲染出的帧与 Android/iOS/Desktop 同源同款。
项目地址: AtomGit/oh-tpc/compottie
上游仓库: alexzhirkevich/compottie
开发工具: 华为云码道
环境安装: 环境安装指南
一、背景
1.1 为什么做 OpenHarmony 平台 KMP/CMP 适配
compottie 为 Compose 应用提供生产级 Lottie 能力:上游发布坐标 io.github.alexzhirkevich:compottie:2.3.3,compottie-core 单模块 187 个 Kotlin 文件(73 个纯 Kotlin + 114 个依赖 Compose),关键帧插值、图层树、表达式求值(纯 Kotlin 的 keight ECMAScript 解释器)、遮罩/蒙版/渐变/混合模式全部在 commonMain,渲染落在 Compose Canvas(Skia)上,支持 Android / iOS / JVM / JS / Wasm 五个目标。OpenHarmony 应用同样需要这套动画能力,但默认构建不会生成 OpenHarmony 的 Kotlin/Native 变体。
如果只在 ArkTS 里重写一个 Lottie 播放器,页面能动,却无法证明公共 Kotlin 动画引擎已经进入 OpenHarmony ARM64 设备完成编译、链接和执行,也无法保证同一份动画 JSON 在两端出同款画面。适配需要同时验证 KMP 动画内核、Compose/Skia 鸿蒙分支、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 交付链路。
| 障碍 | 具体问题 |
|---|---|
| 目标缺失 | 必须增加 ohosArm64() / ohosX64() 才能生成 OpenHarmony KLIB 与动态库。 |
| 工具链断层 | 上游 Kotlin 2.4.0 / Compose 1.12.1 / AGP 9.3.2 / Gradle 9.7.1;社区此前只有 Kotlin 2.2.21 + Compose 1.9.2 线(qrose/ComposeCharts 适配所用),把 2.3.3 源码全面降级工作量不可控。 |
| 三个依赖无 ohos 制品 | keight(表达式引擎)、kotlinx-atomicfu(锁)、androidx.collection(容器映射)在社区 Maven 上都没有可用的 ohos 变体。 |
| 渲染入口是 Composable | 上游正常渲染路径 rememberLottiePainter 需要 composition 和帧时钟,无头 sharedLib 里不可用。 |
| 语言边界不同 | ArkTS 不能直接持有 Kotlin 对象,需要 C ABI、C++ N-API 和 JSON。 |
| 交付链路复杂 | .so、CMake、N-API、HAP、签名和 hdc 都要单独验收。 |
因此,本项目把边界放在 KMP 目标配置、依赖替换和无头渲染桥接层。动画语义(解析、插值、图层、表达式、绘制)仍由公共 Kotlin 实现负责,ArkTS 只负责调用 Native、解码 PNG 和展示结果。
1.2 库提供的能力
真机 Demo 提供两个可观察场景(Tab):
- 播放:11 个样本动画(矩形 / 虚线 / 多边形 / 重复器 / 渐变椭圆 / 弹球 / 遮罩 / 斜切描边 / 亮度蒙版 / 箭头 / 彩带)任选加载,「播放 / 暂停 / 重置」控制,速度 0.25–3x 滑条、渲染尺寸 160–480 滑条、进度滑条,实时 fps 计数(目标 20fps);
- 单帧:任意进度拖动渲染 + 0% / 25% / 50% / 75% / 100% 五档快捷键,用于逐帧对拍 Android/iOS 渲染结果。
Native 侧返回的响应 JSON 包含:
| 字段 | 作用 |
|---|---|
ok / error | 调用结果;Kotlin 异常永不跨语言边界,统一转 JSON 错误。 |
handle | 渲染会话句柄——解析一次、渲染多帧,切样本时先 Close 再 Open。 |
width / height / frameRate / durationFrames / durationMs | 动画元数据(由 compottie 解析模型直出),供 ArkTS 驱动播放时钟。 |
pngBase64 | 指定进度的完整渲染帧 PNG(Skia 光栅化,真渲染管线)。 |
样本动画全部取自上游 example/shared/composeResources/files/,覆盖形状、渐变、遮罩(mask_add)、亮度蒙版(luma_matte)、预合成(view_arrow)、多图层粒子(confetti)等核心特性——同一份 JSON 在 Android 上由上游 compottie 渲染、在鸿蒙上由本适配渲染,画面应当同款。
1.3 实现适配
| 维度 | 要求 |
|---|---|
| 代码复用 | 7 个库模块(core / 主模块 / lite / dot / network / network-core / resources)全部为 ohosArm64/ohosX64 编译;上游 187 文件引擎零隔离、零算法改动。 |
| 平台目标 | 仓库根 Gradle 即鸿蒙构建(沿用 cmp_qrose 目录规则),settings.gradle.kts 只 include 库模块、vendored keight 与 :example:nativeApp;上游 example:desktopApp/webApp/androidApp/shared 与 iosApp/ 留盘不参与构建。 |
| 桥接稳定 | 三个 C ABI 符号 @CName("CompottieOpen"/"CompottieFrame"/"CompottieClose"),原始 Lottie JSON 直传 Open(不包壳,避免嵌套 JSON 解析),会话在 Native 侧以递归 pthread 锁串行化。 |
| CMP 兼容 | 走社区 Compose 1.12 鸿蒙分支(1.12.0-0.1.0-09)直接编译;rendererBackend=skia 保证无窗口 so 里的 CPU 光栅路径。 |
| UI 完整 | 双 Tab 覆盖播放 / 单帧两条链路,启动自动加载默认样本,每个参数至少一个可见控件与状态行回显。 |
| 仓库规范 | 项目说明、文章、效果图和源码链接统一使用 AtomGit。 |
本项目的 ArkUI 页面不是 Compose Multiplatform UI 实现。
LottieAnimationComposable 仍由 Android/iOS/Desktop/Web 消费;鸿蒙 Demo 验证的是同一套动画引擎(含 Skia 渲染)在 OpenHarmony Native 链路里的正确性,ArkTS 通过 PNG 帧驱动画面。
二、实现路线图
第 1 阶段:项目初始化 ── 盘点 7 模块依赖闭包、平台源集与 OpenHarmony 交付物
第 2 阶段:工具链选线 ── -0.1.0 新线(Kotlin 2.4.20 + Compose 1.12 ohos 分支)、eazytec 置顶
第 3 阶段:依赖替换 ── vendored keight、CompottieLock 替 atomicfu、HashMap 替 collection
第 4 阶段:无头渲染入口 ── LottieFrameRenderer + expressionsEngine 公开 API
第 5 阶段:原生桥接 ── 三符号 C ABI、会话管理、C++ N-API、CMake 三件套
第 6 阶段:ArkUI 宿主 ── 双 Tab 播放/单帧工作台、HAP、签名与真机验收
三、逐步实现过程
第 1 阶段:项目初始化
1.1 工程边界
上游仓库根是完整的五平台工程:7 个库模块、example/(desktopApp / webApp / androidApp / shared / iosApp)、.github/ 发布流水线。鸿蒙适配不动其中任何一行:example 各模块与 iosApp/ 留在磁盘但不进 settings.gradle.kts,Gradle(Kotlin 侧)与 Hvigor(DevEco 侧)分开执行,example/harmonyApp 不是根项目的 Kotlin 子模块。
1.2 工具链矩阵
| 项 | 值 |
|---|---|
| Kotlin | HarmonyOS Kotlin 2.4.20-0.1.0-07(eazytec Maven -0.1.0 线;官方 Kotlin 无 ohos 目标) |
| Compose | 1.12.0-0.1.0-09(社区分支,ohos 后端 rendererBackend=skia) |
| skiko | 0.150.1-0.1.0-06(由分支 compose 插件自动接线,不要手动声明依赖) |
| 其余依赖 | serialization 1.9.1-1.1.0-02 / coroutines 1.10.2-1.1.0-02 / okio 3.17.0-0.1.0-07 / ktor 3.3.3-1.1.0-05 / datetime 0.7.1-1.0.0 |
| Gradle | 9.7.1(沿用上游 wrapper) |
| JDK | 21+(本机 DevEco JBR 25) |
| DevEco | compatibleSdkVersion 6.0.1(21)(按本机已装 SDK 钉死)、hvigor modelVersion 5.1.0、abiFilters [arm64-v8a, x86_64] |
settings.gradle.kts 里 eazytec 两个 Maven 仓(maven-public + harmonyos group)必须在 pluginManagement 与 dependencyResolutionManagement 里排最前,否则配置期直接 Unresolved reference: ohosArm64。
1.3 页面边界
页面只做三件事:选样本读 rawfile → 调 compottieOpen/compottieFrame/compottieClose → Base64 解码 PNG 出 PixelMap 展示,并用 durationMs 驱动播放时钟。所有解析、插值、绘制都在 Kotlin 侧,ArkTS 不重写任何动画逻辑。
第 2 阶段:工具链选线(技术难点)
2.1 两条工具链线的抉择
社区 Maven 上并存两条鸿蒙工具链线:
| 线 | Kotlin | Compose | 对 compottie 2.3.3 的代价 |
|---|---|---|---|
-1.x 线(qrose/ComposeCharts 所用) | 2.2.21-1.0.0 | 1.9.2 | 上游按 Kotlin 2.4 + Compose 1.12 编写,全面降级源码工作量不可控 |
-0.1.0 线(本次所用) | 2.4.20-0.1.0-07 | 1.12.0-0.1.0-09 | 与上游同代,零语言/API 降级;依赖闭包里三个缺口(见第 3 阶段) |
-0.1.0 线的 compose 分支发布 foundation / ui / runtime / animation 的 ohosArm64+ohosX64 变体(kotlin-stdlib 元数据 2.3.20,可被 2.4.20 编译器消费),配套 compose-gradle-plugin 与 skiko overlay——这就是 qrose 适配文档里「等社区出现对齐的 compose.ui ohos 制品」等待的那个制品。
2.2 rendererBackend=skia 是硬前提
分支 compose 插件默认 rendererBackend=fusion-renderer:把 Skia 绘制路由到 OHOS fusion 图形服务。该服务面向 ComposeArkUIViewController 宿主场景,无窗口的 sharedLib 里没有对应连接,首次真实 draw 即 SIGSEGV(SEGV_ACCERR)——真机崩溃栈停在 CompottieFrame+156 之下的 Skia 调用。从 ui 制品的 META-INF/compose/ohos-render-backends.properties(supported=fusion-renderer,skia)确认合法取值后,gradle.properties 钉死 rendererBackend=skia:经典全量 Skia natives、CPU 光栅,构建日志确认 rendererBackend=skia, skikoOverlay=false。
2.3 目标裁剪
分支 compose 只发布 ohos/android/ios leaf(无 foundation-desktop),单一 Gradle 版本解析容不下 jvm 目标——库模块只保留 ohosArm64() + ohosX64();上游 android/ios/js/wasm 目标与 AGP、maven-publish 插件全部退出构建。
第 3 阶段:依赖替换(技术难点)
3.1 vendoring keight(表达式引擎)
keight-core(11 文件)+ keight(80 文件)是纯 Kotlin 的 ECMAScript 解释器(Lottie 表达式的底座),上游无 ohos 制品,社区只有过时的 0.0.04-0.1.0(仅 ohosArm64、API 落后两个版本)。做法:从 Maven Central 的 0.0.07 sources jar 原样引入 keight/、keight-core/ 两个子工程,加 jvm()+ohosArm64()+ohosX64();唯一源码改动是剥掉 ScriptEngine.kt 里 JVM 专属的 @Language IDE 注解;依赖按上游 POM 对齐(coroutines 分支版 + datetime 0.7.1-1.0.0 分支版)。Lottie 表达式能力一条不丢。
3.2 atomicfu → CompottieLock
kotlinx-atomicfu 在社区 Maven 上根本没有 ohos 制品。上游 5 个文件用它的 reentrantLock() + withLock(LruMap / MultiOwnerMutex / LottieCompositionResult / LottieStateMachine / DiskLruCache)。替换为 expect/actual 的 CompottieLock:JVM 侧 ReentrantLock,native 侧递归 pthread_mutex_t;withLock 做成 inline 扩展函数以保留上游大量非局部 return 的写法(这是最容易踩的一刀——普通成员函数会报 'return' is prohibited here)。
3.3 androidx.collection → HashMap 与源码集归位
BaseKeyframeAnimation 里唯一的 IntObjectMap 换成 HashMap<Int, Pair<K?, K?>>;jvm/js 目标裁掉后 jvmNativeMain/nonJvmMain 源集失联,其中 5 个平台 actual(IoDispatcher / PlatformImage / ThreadLocal / ZipFileSystem / FileSystem)移入 nativeMain,javaMain/jsMain/webMain/androidMain 留盘不参与编译。
3.4 一个隐蔽的 skiko 重复问题
分支 compose 插件会把 skiko(api + fusion-renderer overlay)自动接进主依赖图。任何模块手动再加全量 skiko 依赖都会撞 KLIB loader: same 'unique_name=org.jetbrains.skiko:skiko';同时每个 KMP 模块(哪怕没有 @Composable 代码)都必须 apply org.jetbrains.compose + kotlin.plugin.compose,否则下游会同时收到 api 与 full 两份 skiko。结论:全模块 apply compose 插件、全仓库不手写 skiko 依赖。
第 4 阶段:无头渲染入口(技术难点)
4.1 为什么必须新增公开 API
上游渲染入口 rememberLottiePainter 是 Composable(需要 composition、帧时钟、rememberTextMeasurer),而内部实现 LottiePainterImpl 是 internal——外部模块无法构造。照 cmp_qrose 加 Qrose.encodeQr 公开入口的先例,在 compottie-core 新增:
public class LottieFrameRenderer(
public val composition: LottieComposition,
/* 上游 rememberLottiePainter 的同名渲染开关 */
expressionEngineFactory: ExpressionsEngineFactory? = null,
enableExpressions: Boolean = expressionEngineFactory != null,
...
) {
public fun render(progress: Float, width: Int, height: Int): ImageBitmap
}
render 逐行镜像 LottiePainterImpl.onDraw 的绘制路径:composition.progressToFrame(progress) → AnimationState.onFrame → CompositionLayer.draw,用公开的 CanvasDrawScope 画进 ImageBitmap;TextMeasurer 通过 skiko 的 createFontFamilyResolver() 无头构造(headlessTextMeasurer expect/actual);表达式开启时在 init 里镜像上游 prepareExpressions 预计算。
4.2 expressionsEngine() 公开工厂
上游把 keight 引擎工厂内联在 Composable 默认参数里。compottie 模块新增 expressionsEngine() 公开函数把它提出来——LottieFrameRenderer(enableExpressions = true, expressionEngineFactory = expressionsEngine()) 即可带表达式渲染,与 rememberLottiePainter 默认引擎完全同款。
第 5 阶段:原生桥接(技术难点)
5.1 三符号会话式桥接
ArkTS compottieOpen(json) / compottieFrame(req) / compottieClose(req)
← libcompottie_napi.so(CMake 现编,ArkTS 唯一入口)
→ C++ JsToUtf8 → CompottieOpen/Frame/Close (const char*)
→ LottieComposition.parse + LottieFrameRenderer + Skia PNG 编码
→ JSON {ok, handle, width, height, frameRate, durationFrames, durationMs, pngBase64}
→ C++ 侧拷贝为 JS string 后立即 libohoscompottie_symbols()->DisposeString(raw)
HarmonyOS Kotlin 的 CAdapter 不会把 @CName 业务符号挂到 ArkTS 模块,所以必须有自己的 CMake NAPI 包装层。协议设计两个要点:原始 Lottie JSON 直传 CompottieOpen(不包壳——桥内 MiniJson 是扁平解析器,嵌套 JSON 字符串会破坏引号状态机);会话式(解析一次、handle 复用、多帧渲染),会话表由全局递归 pthread 锁串行化,AtomicInt 自增句柄。PNG 走 Skia 编码 + Base64(C ABI 以 NUL 结尾,裸二进制不安全)。
5.2 CMake 三件套
OHOS_ARCH(DevEco 注入)选 entry/libs/<abi>/;IMPORTED 引入 KN so + $ORIGIN rpath + POST_BUILD 把 libohoscompottie.so、libc++_shared.so 拷到 libcompottie_napi.so 旁边——每个 ABI 三份 so 缺一不可,少一件装机报 9568347。KN so 不存在时 CMake 直接 FATAL_ERROR 提示先跑 linkDebugShared。
第 6 阶段:ArkUI 宿主与交付
6.1 工程结构
DevEco Stage 工程(example/harmonyApp,打开的是它而不是仓库根):AppScope / entry 双 oh-package、signingConfigs: [] 不带证书、entry 模块 CMake 路径与双 abiFilters、ets/pages/Index.ets 双 Tab 工作台(启动自动加载默认样本——加载按钮必须放在样本列表上方,否则会被长列表挤出首屏)、cpp/types/libcompottie_napi 类型声明包(oh-package.json5 的 name 必须与 so 名一致)。
6.2 构建、签名和安装
# Kotlin 侧双 ABI(copyKnSo* 自动落位 entry/libs/<abi>/)
./gradlew :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64
# HAP(cli-tools hvigor 读 HOS_SDK_HOME;DevEco 内置 hvigor 读 DEVECO_SDK_HOME,两套别混用)
export HOS_SDK_HOME="$HOME/ohos/command-line-tools/sdk"
cd example/harmonyApp && hvigorw assembleHap -p product=default -p buildMode=debug
# 签名:DevEco Studio 打开 example/harmonyApp(不是仓库根目录!)
# File → Project Structure → Signing Configs 自动签名(证书不入库)
hdc install entry/build/default/outputs/default/entry-default-signed.hap
hdc shell aa start -a EntryAbility -b org.terminator.ohos.compottie
四、完整代码对照
4.1 整体架构
compottie-core / compottie / -lite / -dot / -network* / -resources
│ 上游 7 模块零隔离编译(ohosArm64 + ohosX64,引擎算法零改动)
keight-core/ + keight/(vendored 0.0.07)
│ 纯 Kotlin ECMAScript 解释器,仅剥一个 JVM 注解
▼
LottieFrameRenderer.kt + CompottieLock.kt + HeadlessTextMeasurer.kt(新增)
│ 无头渲染公开入口:progress → ImageBitmap(CanvasDrawScope 镜像上游 draw 路径)
▼
Bridge.kt(ohosMain)
│ @CName("CompottieOpen"/"CompottieFrame"/"CompottieClose")(三个 C ABI 符号)
▼
libcompottie_napi.so
│ 字符串转换 + DisposeString
▼
Index.ets
│ Base64 → PixelMap + durationMs 驱动播放时钟
▼
ArkUI 真机双 Tab 播放/单帧工作台
4.2 文件清单
| 文件 | 职责 |
|---|---|
settings.gradle.kts | eazytec 双仓置顶、include 库 + keight + nativeApp、上游 example 留盘 |
gradle.properties | version=2.3.3-0.1.0-01、native 三开关、rendererBackend=skia |
gradle/libs.versions.toml | -0.1.0 线全套(Kotlin/Compose/skiko/serialization/coroutines/okio/ktor/datetime) |
各模块 build.gradle.kts | ohosArm64()+ohosX64()、skiko 组归 native、全模块 apply compose 插件 |
keight-core/ keight/ | vendored 0.0.07(sources jar 原样引入) |
compottie-core/.../CompottieLock.kt ×3 | expect/actual 可重入锁(inline withLock) |
compottie-core/.../LottieFrameRenderer.kt | 无头渲染公开入口 |
compottie/.../ExpressionsEngine.kt | expressionsEngine() 公开工厂 |
nativeApp/.../Bridge.kt | 三符号 C ABI + 会话管理 + Skia PNG 编码 |
harmonyApp/.../napi_init.cpp | N-API 导出、字符串转换与释放 |
harmonyApp/.../CMakeLists.txt | IMPORTED KN so + $ORIGIN rpath + 三件套拷贝 |
harmonyApp/.../Index.ets | ArkUI 双 Tab 工作台(自动加载 + fps 计数) |
README.OpenHarmony.md / _CN.md | 适配说明(中文版 16 节主文档) |
4.3 关键 API 对照
| 层次 | API | 作用 |
|---|---|---|
| KMP | LottieComposition.parse(json) | 上游公开解析入口(真实复用) |
| KMP | LottieComposition.width/height/frameRate/durationFrames | 动画元数据直出 ArkTS 播放时钟 |
| 新增 | LottieFrameRenderer.render(progress) | 无头渲染任意进度帧为 ImageBitmap |
| 新增 | expressionsEngine() | keight 表达式引擎公开工厂 |
| Native | CompottieOpen/Frame/Close | 会话生命周期 + 帧 PNG(JSON 协议) |
| N-API | compottieOpen/compottieFrame/compottieClose | 向 ArkTS 暴露的三个函数 |
| ArkTS | renderFrame / startPlay | 组装请求、解码 PixelMap、定时器驱动进度 |
4.4 ArkTS 与 Kotlin 的边界
const info = JSON.parse(compottieOpen(json));
// → { ok: true, handle: 1, width: 512, height: 512,
// frameRate: 60, durationFrames: 180, durationMs: 3000 }
const frame = JSON.parse(compottieFrame(JSON.stringify({
handle: 1, progress: 0.42, width: 320, height: 320,
})));
// → { ok: true, width: 320, height: 320, pngBase64: '...' }
const bytes = new util.Base64Helper().decodeSync(frame.pngBase64);
const source = image.createImageSource(bytes.buffer);
const pm = await source.createPixelMap(); // Image(pm) 直接展示
// Kotlin 侧:解析、插值、图层、表达式、绘制全部由上游引擎完成
val composition = LottieComposition.parse(json)
val renderer = LottieFrameRenderer(
composition = composition,
enableExpressions = true,
expressionEngineFactory = expressionsEngine(),
)
val frame = renderer.render(progress = 0.42f) // → Skia 光栅化 ImageBitmap
两者之间只传输 JSON 与 PNG,不传输 Kotlin 对象;ArkTS 用 durationMs 步进 progress,其余一概不参与。
五、关键决策说明
决策 1:上 -0.1.0 新工具链线,而不是降级到 1.9.2 线
-1.x 线(Kotlin 2.2.21 + Compose 1.9.2)是 qrose/ComposeCharts 的过审路线,但 compottie 2.3.3 按 Kotlin 2.4 + Compose 1.12 编写,降级等于重写一遍上游。-0.1.0 线与上游同代,适配工作量从「全面源码降级」变为「三个依赖小刀替换」——这是本次适配成立的根本前提。
决策 2:库本体直接编译,而不是 composeMain 隔离
qrose 当年隔离 Compose 是因为社区没有 ohos 的 compose.ui;现在有了。7 个模块 187 个文件零隔离编进鸿蒙,渲染走的是上游真实 draw 管线(CompositionLayer → DrawScope → Skia),而不是软件光栅化近似——「代码复用」从「公式镜像」升级为「整条引擎」。代价是依赖闭包更重(skiko + compose 全家桶进 so),debug 产物 84MB(HAP 打包剥离后单 ABI 约 32MB)。
决策 3:vendoring keight,而不是砍掉表达式
表达式是 compottie 的差异化能力,砍掉等于降级产品。keight 0.0.07 是 80 个纯 Kotlin 文件、零 cinterop,vendoring 成本接近零;社区旧 fork(0.0.04、仅 arm64)API 落后两个版本不能用。vendored 源码与上游逐字节一致(除一个注解),以后社区出统一 fork 时一行 build 配置换回去。
决策 4:新增无头渲染公开入口,而不是 friend-path 黑魔法
LottiePainterImpl 是 internal,理论上可用 -Xfriend-path 越权访问,但那依赖编译器内部行为且随版本漂移。照 qrose 加 Qrose.encodeQr 的先例新增 LottieFrameRenderer + expressionsEngine() 两个公开 API,镜像上游 draw 路径,行为可审计、可在上游文档里对拍。
决策 5:rendererBackend=skia,而不是修 fusion-renderer 崩溃
真机首帧 SIGSEGV 的根因是 fusion-renderer 后端面向 ArkUI 宿主、无窗口 so 里没有图形服务连接。修不动(在 skiko overlay 内部),但插件本身提供 skia 后端(经典全量 Skia、CPU 光栅)——ohos-render-backends.properties 里 supported=fusion-renderer,skia 白纸黑字。一行配置换回一条完全满足无头渲染的路径,真机复测零崩溃、fps 20 稳定。
六、测试与验证
6.1 测试环境
本次验证使用 macOS(arm64)、DevEco JBR 25、HarmonyOS Kotlin 2.4.20-0.1.0-07、Compose 1.12.0-0.1.0-09、Gradle 9.7.1、命令行工具链 HarmonyOS SDK 6.0.1(API 21)、DevEco Studio 5.1.0 Release 与鸿蒙真机 HUAWEI Mate 60 Pro(arm64-v8a,签名安装)。
6.2 编译与链接验证
./gradlew :keight-core:compileKotlinOhosArm64 :keight:compileKotlinOhosArm64 \
:compottie-core:compileKotlinOhosArm64 :compottie:compileKotlinOhosArm64 \
:compottie-lite:compileKotlinOhosArm64 :compottie-dot:compileKotlinOhosArm64 \
:compottie-network-core:compileKotlinOhosArm64 :compottie-network:compileKotlinOhosArm64 \
:compottie-resources:compileKotlinOhosArm64 \
:example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64
7 个库模块 + 2 个 vendored 模块双 ohos 目标全绿;llvm-readelf --dyn-syms 确认 CompottieOpen / CompottieFrame / CompottieClose 均为 FUNC GLOBAL DEFAULT;libcompottie_napi.so 的 NEEDED 只有短名(libace_napi.z.so / libhilog_ndk.z.so / libohoscompottie.so / libc++_shared.so),无本机绝对路径。
6.3 HAP 验证
CompileArkTS: finished
PackageHap: finished(debug,双 ABI)
BUILD SUCCESSFUL
命令行构建产出 entry-default-unsigned.hap ≈ 62MB(debug 未 strip 的 KN so 含 Skia/Compose 全量符号,打包器剥离后 arm64 约 32MB + x86_64 约 29MB),包内双 ABI 三件套齐全;DevEco 自动签名产出 entry-default-signed.hap 真机安装成功。
6.4 功能用例(真机实测)
- 启动即自动加载
rect.json,状态行加载成功 rect.json,信息行尺寸 512x512 · 帧率 60 · 总帧数 180 · 时长 3.00s——元数据由 compottie 解析模型直出; - 「播放」Tab 点播放:fps 计数稳定在 20(实测 91 次帧请求、50ms 间隔精确命中),进度滑条随动画推进,暂停/重置可用;速度 2.3x 时动画明显加速;
- 「单帧」Tab 拖动进度滑条逐帧渲染,0%/25%/50%/75%/100% 快捷键逐档出图;
- 样本切换
mask_add:compottieClose(旧会话)→compottieOpen(2226 字节)→ 首帧,会话生命周期干净;画面为浅蓝三角形 + 深蓝遮罩块的双层合成,遮罩(SaveLayer + BlendMode)路径正确; - 全程 hilog 无
加载失败/渲染失败/ 崩溃信号;切换rendererBackend=skia前的同包首帧渲染稳定复现 SIGSEGV(对照实验确认根因)。
6.5 验证结论
Native 动态库(双 ABI + libc++)、C ABI、N-API、ArkUI 页面和签名 HAP 已完成链路验证。应用在鸿蒙真机上成功安装并进入前台,页面展示了 compottie 真实引擎渲染的 Lottie 动画帧(矩形、遮罩样本目检通过),播放链路 fps 20 稳定,动画元数据与 JSON 内容一致。
七、运行效果
7.1 真机截图

7.2 命令速查
git clone https://atomgit.com/oh-tpc/compottie.git
cd compottie
./gradlew :example:nativeApp:linkDebugSharedOhosArm64 \
:example:nativeApp:linkDebugSharedOhosX64
# DevEco Studio 打开 example/harmonyApp,自动签名后:
export HOS_SDK_HOME="$HOME/ohos/command-line-tools/sdk"
cd example/harmonyApp
hvigorw assembleHap -p product=default -p buildMode=debug
hdc install entry/build/default/outputs/default/entry-default-signed.hap
hdc shell aa start -a EntryAbility -b org.terminator.ohos.compottie
八、遗留问题与改进方向
8.1 踩坑复盘
- fusion-renderer 首帧 SIGSEGV:无窗口 sharedLib 里默认渲染后端会走到图形服务的坏连接,
gradle.properties必须钉rendererBackend=skia——这是全链路唯一一个崩溃级坑,且只在真机首次真实 draw 时暴露(编译、链接、parse 全都不报错); - skiko unique_name 重复:分支 compose 插件自动接线 skiko 后,手动加全量 skiko 依赖、或某模块漏 apply compose 插件,都会让下游 KLIB 冲突;
- 无 desktop leaf:分支 compose 不发布
foundation-desktop,库模块带 jvm 目标必炸——JVM 交叉验证只能在上游仓库做; - atomicfu 的
withLock是 inline:替换锁实现时必须保持 inline 扩展函数形态,否则上游非局部return全部编译失败; - explicitApi 严格模式对 internal 声明也要求显式类型;
- hvigor 双环境变量:cli-tools 版读
HOS_SDK_HOME、DevEco 内置版读DEVECO_SDK_HOME,混用报SDK component missing;compatibleSdkVersion必须与本机已装 SDK 精确匹配(本机6.0.1(21)); - API 21 ArkTS 严格模式:
Base64Helper.decodeSync是实例方法、JSON.parse需as Record<string, Object>显式转换、throw不接受任意类型; - Demo UX:加载按钮放在样本列表下方会被挤出首屏,用户「看不到入口」会误判为渲染失败——改为启动自动加载 + 按钮上移。
8.2 已知问题
- 无 jvm 目标:分支 compose 无 desktop leaf,同一 Gradle 解析容不下 stock JVM compose;JVM 侧对拍请在上游仓库跑同一样本;
- Demo 是 PNG 帧驱动(约 20fps):验证的是真渲染管线,不是 60fps 产品形态;
- 文字图层经无头
TextMeasurer+ 系统字体 resolver,未做逐字验收(样本刻意不含文字); - 动画内嵌图片/字体资产未接(
LottieAssetsManager未传入 Demo);compottie-dot/compottie-network编译通过但未上机; - debug so 含 Skia/Compose 全量符号体积大(84MB),HAP 打包剥离后单 ABI 约 32MB,生产建议
linkReleaseShared+ strip。
8.3 未来优化方向
- 接入
ComposeArkUIViewController宿主方案,让LottieAnimation在 ArkUI 页面直接跑,PNG 桥降级为 fallback——工具链已就绪; - 透出
LottieAssetsManager(内嵌图片/字体)与 dotLottie 演示; - 文字图层逐字验收 + 字体 resolver 压力测试;
- vendored keight 换社区统一 fork(待其升到 0.0.07 API);
- 在 CI 中加入 Native 链接、ELF 符号检查、HAP 构建与
rendererBackend配置守卫; - release so strip + PNG 帧率自适应(按
durationMs与设备性能)。
九、总结
9.1 核心难点回顾
compottie 2.3.3(7 模块,Kotlin 2.4 + Compose 1.12 源码)
→ -0.1.0 工具链线(Kotlin 2.4.20-0.1.0-07 + Compose 1.12.0-0.1.0-09 ohos 分支)
→ 三依赖小刀替换(vendored keight / CompottieLock / HashMap)
→ LottieFrameRenderer 无头渲染入口(镜像上游 draw 路径)
→ C ABI(CompottieOpen/Frame/Close 会话式三符号)
→ C++ N-API(libcompottie_napi.so)
→ ArkTS Base64 → PixelMap + durationMs 播放时钟
→ ArkUI 真机双 Tab 工作台(fps 20 稳定)
9.2 封装层次
- KMP 层:上游 7 模块零隔离编译,解析/插值/图层/表达式/绘制算法零改动;
- 新增层:
LottieFrameRenderer+expressionsEngine()把 Composable 渲染能力翻译成无头 API; - Native 层:双 ABI 动态库以三个 C ABI 符号输出会话与 PNG 帧;
- N-API 层:完成参数检查、字符串转换和 Native 内存释放;
- ArkTS 层:管理双 Tab 状态、播放时钟、PixelMap 生命周期(旧帧 release);
- DevEco 层:完成 CMake、HAP、签名、安装和真机运行。
9.3 三条经验
- 工具链线选对,适配工作量降一个数量级:qrose 时代的「隔离 Compose + 软件光栅化」是 1.9.2 线下的无奈之举;
-0.1.0线出现后,「库本体直接编译」成为可能——选线之前先扫一遍社区 Maven 的-0.1.0后缀制品,别急着动源码; - 崩溃要追到后端配置层:SIGSEGV 指向 Skia 调用栈时,先查渲染后端(fusion-renderer vs skia)而不是怀疑自己的绘制代码;制品内嵌的
ohos-render-backends.properties是权威配置来源; - 会话式桥接配元数据直出:
Open一次解析多帧渲染 +durationMs元数据驱动 ArkTS 时钟,让「动画语义」全部留在 Kotlin 侧,ArkTS 只做宿主——PNG 帧序列足以验收渲染正确性,接入方式以后可以换,C 签名永不变。
9.4 适配成果
当前适配已完成:
- 根构建改造的 OpenHarmony Gradle 工程(
-0.1.0工具链线、上游 example 留盘不进构建); - 7 个库模块 + 2 个 vendored keight 模块的双 ABI Kotlin/Native 编译(
libohoscompottie.soarm64/x64 + 匹配 libc++); - 无头渲染公开 API(
LottieFrameRenderer/expressionsEngine())与三符号会话式 C ABI + C++ N-API 包装(libcompottie_napi.so); rendererBackend=skia关键修复(真机 SIGSEGV 根因消除,有切换前后对照实验);- DevEco Stage ArkUI 双 Tab 验收页面(11 个上游样本、自动加载、fps 计数);
- HAP 构建(unsigned ≈ 62MB debug 双 ABI)、签名、Mate 60 Pro 真机安装与运行验证(fps 20、遮罩样本目检通过);
- AtomGit 项目文档(
README.OpenHarmony.md/README.OpenHarmony_CN.md)与真机效果图。
适配过程 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)