本文记录 compose-multiplatform 总仓库中的三方组件库 components/AnimatedImage 接入 OpenHarmony 的完整过程,覆盖 CMP 组件库盘点、ohosArm64/ohosX64 目标、Kotlin/Native 动态库、C ABI/N-API 桥接、ArkUI 真机页面、HAP 构建、签名和真机验收。

components/AnimatedImage 是 Compose Multiplatform 的动图组件:上游 commonMain 以 expect class AnimatedImage / loadAnimatedImage(path) / @Composable animate(): ImageBitmap 承诺"一串带时长的帧,每帧重组一次"。本次适配让这条帧序列模型进入 OpenHarmony ARM64 运行时,ArkUI 页面作为真机验收宿主,Compose Multiplatform(CMP)应用可以通过同一个组件语义在鸿蒙侧消费逐帧结果。

项目地址: AtomGit/oh-tpc/compose-multiplatform-AnimatedImage

上游仓库: JetBrains/compose-multiplatform

开发工具: 华为云码道

环境安装: 环境安装指南

一、背景

1.1 为什么做 OpenHarmony 平台 KMP/CMP 适配

components/AnimatedImage 来自 JetBrains 的 Compose Multiplatform 总仓库,制品名 components-animated-image。在 CMP 桌面应用里,AnimatedImage(path) + animate() 两个调用即可装载并逐帧播放动图:桌面 actual 用 Skia Codec 解码,codec.frameCount 给出帧数,缺省帧时长按 DEFAULT_FRAME_DURATION = 100 兜底,rememberInfiniteTransition 驱动帧号无限迭代,每渲染一帧重组一次调用方。鸿蒙应用同样需要这套能力,但组件的 actual 依赖 androidx.compose.ui 与 org.jetbrains.skia,默认构建不会生成 OpenHarmony 变体。

如果只把示例页面改成 ArkTS,页面可以显示动画,却无法证明组件的帧序列模型已经在 OpenHarmony ARM64 设备上完成编译、链接和执行。适配需要同时验证模型层、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 交付链路。

障碍具体问题
目标缺失必须增加 ohosArm64() / ohosX64() 才能生成 OpenHarmony KLIB 与动态库。
总仓库无根构建上游是约 30 个互相独立的 Gradle 工程(Kotlin 2.4.20 / Gradle 9.x / AGP 9.x),鸿蒙插件一个都不认。
工具链不一致鸿蒙侧社区 CMP 是 1.9.2 线(HarmonyOS Kotlin 2.2.21-1.0.0 + skiko-ohosarm64),与本快照 Compose 1.12-alpha 对不齐。
actual 依赖 Compose组件的 Skia/Compose actual 无法在鸿蒙工具链下编译,必须走模型层复用。
语言边界不同ArkTS 不能直接持有 Kotlin 对象,需要 C ABI、C++ N-API 和 JSON。
交付链路复杂.so、CMake、N-API、HAP、签名和 hdc 都要单独验收。

因此,本项目把边界放在帧序列模型、C ABI/N-API 桥接和 ArkUI 验收层。动画语义仍由组件模型负责,ArkTS 只负责调用 Native、解析 JSON 和展示结果。

1.2 库提供的能力

示例提供四个可观察场景(对应 Demo 的四个 Tab):

  • AnimatedImage(核心):kind=animated 按帧号确定性出一帧 PNG,对应上游 animate(): ImageBitmap 每帧返回一图;
  • 概览自检:真实执行总仓库 ui-tooling-preview 的 PreviewParameterProvider 接口,验证 KLIB 层;
  • SplitPane:分栏布局模型,比例/方向改变时与 dividerPos 几何对账;
  • 重组模型:状态变化时只有输入变化的节点"重组",其余跳过,演示 Compose 重组语义。

桥返回的 RenderOk 结果包含:

字段作用
ok / error成功标志与错误信息,桥不把异常抛过 C 边界。
kindinfo、splitpane、animated、recompose 四个稳定标识。
frame / frames / pattern当前帧号、总帧数、帧序列模式(orbit/pulse/wave)。
width / height / bytes出图尺寸与 PNG 字节数,供确定性对账。
pngBase64当前帧 PNG 的 Base64,ArkTS 解码为 PixelMap 显示。
recomposed / total / nodes重组模型的逐节点状态(recomposed/skipped)。

1.3 实现适配

维度要求
模型复用AnimatedImage 帧序列语义(帧数/帧时长/无限迭代/每帧一图)在 KN 侧确定性复现。
平台目标根构建为 ohos/ui-tooling-preview 与 example/nativeApp 增加 ohosArm64/ohosX64,生成双 ABI 动态库。
桥接稳定使用单个 C ABI(@CName("CmpRender"))和 UTF-8 JSON,不传递 Kotlin 对象地址。
CMP 兼容CMP 应用复用同一组件语义;ArkUI 只负责真机验收。
UI 完整支持帧模式切换、帧数切换、逐帧滑条、播放/暂停和错误展示。
仓库规范项目说明、文章、效果图和源码链接统一使用 AtomGit。

本项目的 ArkUI 页面不是 Compose Multiplatform UI 实现。CMP 工程可以在 Compose 1.9.2 线 ohos 发行版上使用同一组件语义;本仓库跟踪的是 1.12 快照的模型层交付。

二、实现路线图

第 1 阶段:项目初始化     ── 盘点总仓库独立工程、组件源集和 OpenHarmony 交付物
第 2 阶段:目标与依赖打通 ── 新建根构建、加入 ohosArm64/ohosX64、srcDir 引用 KLIB
第 3 阶段:组件模型建模   ── 帧序列/分栏/重组三种模型、JSON 契约和确定性对账
第 4 阶段:原生桥接       ── C ABI、C++ N-API、参数校验和内存释放
第 5 阶段:ArkUI 宿主     ── 四 Tab 页面、播放定时器和 PixelMap 生命周期
第 6 阶段:示例与验证     ── HAP、签名、设备安装、真机运行和效果图

三、逐步实现过程

第 1 阶段:项目初始化

1.1 工程边界
components/AnimatedImage/   上游被适配组件(expect API + Skia actual,零改动)
ohos/ui-tooling-preview/    总仓库唯一零依赖 KMP 库 -> ohos klib(srcDir 引用,不复制)
example/nativeApp/          Kotlin/Native 动态库(CmpRender 桥 + 光栅化 + PNG)
example/harmonyApp/         DevEco Stage、N-API 和 ArkTS 四 Tab 页面
doc/                        适配全流程博客与真机效果图

根构建是新建的 settings.gradle.kts,不把任何上游独立工程 include 进来;Gradle(鸿蒙侧)和 Hvigor(HAP 侧)分开执行,上游工程继续用自己的 Kotlin 2.4.20 工具链独立构建。

1.2 工具链矩阵
项目配置
Kotlin MultiplatformHarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven)
Gradle8.14.1(wrapper 钉死)
JDK21
OpenHarmony 目标ohosArm64 + ohosX64
DevEco compatibleSdkVersion5.1.0(18)
ABIarm64-v8a + x86_64
Bundleorg.terminator.ohos.cmp
export JAVA_HOME=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home
java -version

注意:DevEco 自带 JBR 是 JDK 25,Gradle 8.14.1 不支持,命令行构建要独立指向 JDK 21。

1.3 页面边界
标题区          COMPOSE-MULTIPLATFORM-ANIMATEDIMAGE / CMP 组件模型 Demo
Tab 区          概览 / SplitPane / AnimatedImage(核心)/ 重组模型
控制区          帧模式(orbit/pulse/wave)、帧数(12/24/36)、帧滑条、播放/暂停
图像区          Image(PixelMap) 展示当前帧 PNG
状态区          kind / 尺寸 / png 字节数 / 元数据行 / 错误文本

页面启动时通过 kind=info 自检工具链与 KLIB,切到 AnimatedImage Tab 即出第 0 帧。

第 2 阶段:目标与依赖打通

2.1 新建根构建并加入 ohos 目标
pluginManagement {
    repositories {
        maven("https://maven.eazytec-cloud.com/nexus/repository/maven-public/")
        maven("https://maven.eazytec-cloud.com/nexus/content/groups/harmonyos")
        gradlePluginPortal()
    }
}

include(":ohos:ui-tooling-preview")
include(":example:nativeApp")

ohosArm64() / ohosX64() 只存在于 HarmonyOS Kotlin 插件,插件仓库必须先写 eazytec Maven 再写 gradlePluginPortal(),否则配置期直接 Unresolved reference: ohosArm64。

2.2 KLIB:srcDir 引用上游源码
kotlin {
    applyDefaultHierarchyTemplate()
    jvm()
    ohosArm64()
    ohosX64()

    sourceSets {
        val commonMain by getting {
            kotlin.srcDir(rootProject.layout.projectDirectory
                .dir("components/ui-tooling-preview/library/src/commonMain/kotlin"))
        }
        val nonAndroidDir = rootProject.layout.projectDirectory
            .dir("components/ui-tooling-preview/library/src/nonAndroidMain/kotlin")
        val jvmMain by getting { kotlin.srcDir(nonAndroidDir) }
        val ohosMain by getting { kotlin.srcDir(nonAndroidDir) }
    }
}

总仓库里唯一 commonMain 零依赖的 KMP 库是 ui-tooling-preview(@Preview / PreviewParameterProvider API)。用 srcDir 引用而不是复制,上游工程一个字节都不用改——这是"适配构建与上游构建物理隔离"的关键一步。

2.3 构建动态库
./gradlew :ohos:ui-tooling-preview:compileKotlinJvm
./gradlew :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64

linkDebugShared* 产出 libohoscmp.so(arm64 ≈7.6MB、x86_64 ≈6.7MB,含 Kotlin/Native 运行时),并由 copyKnSo* 任务把匹配 triple 的 libc++_shared.so 一起拷进:

example/harmonyApp/entry/libs/arm64-v8a/libohoscmp.so
example/harmonyApp/entry/libs/arm64-v8a/libc++_shared.so
example/harmonyApp/entry/libs/x86_64/libohoscmp.so
example/harmonyApp/entry/libs/x86_64/libc++_shared.so

第 3 阶段:组件模型建模

3.1 JSON 契约
Index.ets
        ↓ cmpRender(JSON.stringify(request))
CmpRender(const char*)
        ↓ MiniJson 解析 + 模型计算 + 光栅化
frameN 的确定性 PNG
        ↓ Base64 JSON
N-API cmpRender()
        ↓
ArkUI Image(PixelMap)

页面不复制动画业务判断,每一帧都由 Kotlin/Native 代码生成。

3.2 帧序列模型(核心)

上游 animate() 的三要素——帧数、每帧时长、无限迭代——映射到桥协议:

private fun renderAnimated(json: MiniJson): String {
    val frames = json.int("frames", 24).coerceIn(8, 48)   // 对应 codec.frameCount
    val frame = json.int("frame", 0).coerceIn(0, frames - 1)
    val pattern = json.str("pattern").ifBlank { "orbit" }
    ...
    val t = frame.toFloat() / frames
    when (pattern) {
        "pulse" -> { /* 三环相位扩散 */ }
        "wave" -> { /* 正弦条形 */ }
        else -> { /* orbit:卫星绕环带拖尾 */ }
    }
    canvas.drawSevenSegment(16, size - 34, 3,
        frame.toString().padStart(2, '0'), theme.accent, theme.paneB)
    canvas.fillRect(0, size - 4, (size * frame / (frames - 1)), 4, theme.accent)
    ...
}

同一 (pattern, frame, frames, theme) 输入永远产出同一字节的 PNG——animate() 的"每帧返回一张确定的图"在鸿蒙侧就是对账基线。播放节奏由 ArkTS 定时器驱动(演示档 200ms,对应上游缺省帧时长 100ms 的量级),桥保持无状态。

3.3 对照模型

splitpane 复现分栏几何并返回 dividerPos 像素值;recompose 接收新旧两组状态,逐节点给出 recomposed/skipped;info 真实执行上游接口:

class DemoPreviewParameters : PreviewParameterProvider<String> {
    override val values: Sequence<String> = sequenceOf(
        "Compose", "Multiplatform", "OpenHarmony", "ohosArm64", "ohosX64", "@Preview",
    )
}

previewCount = 6 由上游 nonAndroidMain 的默认实现 values.count() 在设备上算出——KLIB 层不是摆设。

第 4 阶段:原生桥接(技术难点)

4.1 三层桥接
ArkTS
  │ JSON string
  ▼
C++ N-API entry (libcmp_napi.so)
  │ const char*
  ▼
Kotlin/Native C ABI (libohoscmp.so)
  │ CmpRender
  ▼
UTF-8 JSON + DisposeString

HarmonyOS Kotlin 的 CAdapter 不会把 @CName 函数挂到 ArkTS 模块上:libohoscmp.so 里 nm 得到 CmpRender,但 ArkTS 直接 import 它什么都看不到。所以必须 CMake 再包一层 NAPI。

4.2 Kotlin/Native 导出函数
@CName("CmpRender")
fun cmpRender(request: String): String {
    return try {
        val json = MiniJson.parse(request)
        when (json.str("kind").lowercase()) {
            "info" -> renderInfo(json)
            "splitpane" -> renderSplitPane(json)
            "animated" -> renderAnimated(json)
            "recompose" -> renderRecompose(json)
            else -> renderInfo(json)
        }
    } catch (t: Throwable) {
        """{"ok":false,"error":${jsonString(t.message ?: t.toString())}}"""
    }
}

单入口、JSON 进 JSON 出,失败路径也在 JSON 里返回,Kotlin 异常永远不跨过 C 边界。

4.3 C++ N-API 方法与内存释放
static void DisposeKnString(const char *raw) {
    if (raw == nullptr) {
        return;
    }
    libohoscmp_ExportedSymbols *symbols = libohoscmp_symbols();
    if (symbols != nullptr && symbols->DisposeString != nullptr) {
        symbols->DisposeString(raw);
    }
}

const char *raw = CmpRender(request.c_str());
napi_value out = nullptr;
napi_status st = napi_create_string_utf8(env, raw, NAPI_AUTO_LENGTH, &out);
DisposeKnString(raw);

KN 返回的字符串必须用 libohoscmp_symbols()->DisposeString 释放,不能 free,也不能让 ArkTS 管。N-API 层只做参数类型检查、字符串创建和释放。

4.4 CMake 和类型声明
add_library(ohoscmp SHARED IMPORTED)
set_target_properties(ohoscmp PROPERTIES
  IMPORTED_LOCATION "${KN_SO_DIR}/libohoscmp.so"
  IMPORTED_NO_SONAME TRUE)

add_library(cmp_napi SHARED napi_init.cpp)
target_link_libraries(cmp_napi PUBLIC libace_napi.z.so libhilog_ndk.z.so ohoscmp)
export const cmpRender: (request: string) => string;

abiFilters 同时写 arm64-v8a 和 x86_64;HAP 里每个 ABI 目录三件套(libcmp_napi.so / libohoscmp.so / libc++_shared.so)缺一不可。

第 5 阶段:ArkUI 宿主

5.1 调桥与出图
import { cmpRender } from 'libcmp_napi.so';

const raw: string = cmpRender(JSON.stringify({
  kind: 'animated', frame: this.frame, frames: this.framesCount,
  pattern: this.pattern, theme: this.theme
}));
const parsed: RenderOk = JSON.parse(raw) as RenderOk;
this.decodePng(parsed.pngBase64);
private decodePng(b64: string): void {
  const helper = new util.Base64Helper();
  const bytes: Uint8Array = helper.decodeSync(b64);
  const sliced = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
  const src = image.createImageSource(sliced);
  src.createPixelMap().then((pm: image.PixelMap) => {
    const old = this.pixelMap;
    this.pixelMap = pm;
    old?.release();
  });
}

旧的 PixelMap 换新前 release(),连续播放不涨内存。

5.2 页面交互

AnimatedImage Tab 支持三种帧模式、三档帧数、逐帧滑条和播放/暂停;播放用 setInterval 每 200ms 推进 frame = (frame + 1) % frames,对应上游 rememberInfiniteTransition 的无限迭代。其余三个 Tab 复用同一个 generate() 状态机。

5.3 对比度和错误边界

选中 Tab 与控制按钮使用深色底白字,浅色按钮使用深色文字,状态行使用中灰。生成期间 generating 置位防止重入;桥返回 ok:false 时错误文本显示在状态区,页面不崩。

第 6 阶段:示例与验证

6.1 工程结构
example/
├── nativeApp/src/ohosMain/kotlin/Bridge.kt       CmpRender + 四种模型
├── nativeApp/src/ohosMain/kotlin/Rasterizer.kt   纯 Kotlin 画布 + 七段数码管
├── nativeApp/src/ohosMain/kotlin/PngEncoder.kt   zlib stored PNG
└── harmonyApp/
    ├── entry/src/main/cpp/CMakeLists.txt
    ├── entry/src/main/cpp/napi_init.cpp
    ├── entry/src/main/cpp/types/libcmp_napi/index.d.ts
    └── entry/src/main/ets/pages/Index.ets
6.2 构建、签名和安装
export JAVA_HOME=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home
./gradlew :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64

cd example/harmonyApp
~/ohos/command-line-tools/bin/hvigorw assembleHap -p product=default -p buildMode=debug --no-daemon

真机安装需要签名:DevEco Studio 打开 example/harmonyApp,Signing Configs 勾选自动生成(build-profile.json5 已写 "signingConfig": "default")。

hdc list targets
hdc -t <设备序列号> install -r \
  entry/build/default/outputs/default/entry-default-signed.hap
hdc -t <设备序列号> shell aa start \
  -a EntryAbility -b org.terminator.ohos.cmp

四、完整代码对照

4.1 整体架构

components/AnimatedImage(上游 expect API,零改动)
    │ 帧序列语义:frameCount / 帧时长 / 无限迭代 / 每帧一图
    ▼
Bridge.kt(example/nativeApp)
    │ CmpRender:info / splitpane / animated / recompose
    ▼
libcmp_napi.so
    │ cmpRender(request: string): string
    ▼
Index.ets
    │ JSON parse + PixelMap + 定时器
    ▼
ArkUI 真机页面

4.2 文件清单

文件职责
settings.gradle.kts鸿蒙根构建:eazytec 仓库、只 include 两个 ohos 模块
ohos/ui-tooling-preview/build.gradle.ktsjvm + ohosArm64 + ohosX64,srcDir 引用上游源码
example/nativeApp/.../Bridge.kt@CName("CmpRender")、四种模型、MiniJson
example/nativeApp/.../Rasterizer.kt纯 Kotlin 画布、七段数码管、五套色板
example/nativeApp/.../PngEncoder.ktzlib stored PNG,零外部依赖
example/harmonyApp/.../napi_init.cppN-API 导出、参数检查、DisposeString
example/harmonyApp/.../CMakeLists.txt导入 KN so、包装 libcmp_napi.so、双 ABI
example/harmonyApp/.../Index.etsArkUI 四 Tab 页面
doc/img/animated_realdevice.jpeg真机效果图
README.OpenHarmony_CN.md中文主文档(路线、协议、踩坑、验收)

4.3 关键 API 对照

层次API作用
上游AnimatedImage.animate(): ImageBitmap每帧返回一图(鸿蒙侧 = 每次调用返回一帧 PNG)
上游codec.frameCount帧数(= frames)
上游DEFAULT_FRAME_DURATION = 100缺省帧时长(演示档 200ms 定时器)
KLIBPreviewParameterProvider.count/values概览页真实执行的上游接口
NativeCmpRender单入口 JSON 桥
N-APIcmpRender向 ArkTS 暴露唯一函数
ArkTSgenerate() / decodePng()调桥、解码、刷新 PixelMap

4.4 ArkTS 与 Kotlin 的边界

const raw: string = cmpRender(JSON.stringify({
  kind: 'animated', frame: this.frame, frames: this.framesCount, pattern: this.pattern
}));
@CName("CmpRender")
fun cmpRender(request: String): String

两者之间只传输 JSON 字符串,不传输 Kotlin 对象或未校验的动态结构。

五、关键决策说明

决策 1:新建根构建,不并入上游工程

上游约 30 个独立工程钉在 Kotlin 2.4.20 / Gradle 9.x,合并即配置期死锁。根构建只 include 鸿蒙相关模块,上游工程零改动、仍可独立构建。

决策 2:AnimatedImage 走模型层复用,不编 Compose actual

组件 actual 依赖 androidx.compose.ui / org.jetbrains.skia,鸿蒙工具链(CMP 1.9.2 线)对不齐 1.12 快照。帧序列模型(帧数/帧时长/迭代/每帧一图)在 KN 侧确定性复现,ArkTS 只消费 PNG——与社区已过审的 Vico / Reorderable / cmp_qrose 同一条路。

决策 3:KLIB 用 srcDir 引用,源码零复制

ui-tooling-preview 是总仓库唯一零依赖 KMP 库,作为"本快照源码集可被鸿蒙工具链消化"的编译级证明;srcDir 引用让适配构建与上游构建共享源码、互不污染。

决策 4:单入口 JSON 作为跨语言契约

一个 @CName、一个 cmpRender、扁平 JSON 协议。C 签名 const char* -> const char* 永不变,ArkTS 侧 cmpRender(string): string 永不变,扩展字段只加 JSON 键。

决策 5:ArkUI 作为验收宿主,CMP 复用组件语义

当前页面验证 OpenHarmony Native 链路;等 1.12 线 ohos 制品落地,同一模型语义可直接接回 Skia 真解码路径。

六、测试与验证

6.1 测试环境

本次验证使用 macOS arm64、JDK 21(homebrew openjdk@21 21.0.12.1)、DevEco Studio 5.1.0 Release 与配套 command-line-tools(hvigorw/ohpm/hdc)、HarmonyOS Kotlin 2.2.21-1.0.0,真机为 HUAWEI HAD-W24,设备序列号 3QC0124C20001268,系统 HarmonyOS 7.0.0.107(arm64-v8a)。

6.2 编译与链接验证

./gradlew :ohos:ui-tooling-preview:compileKotlinJvm
./gradlew :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64
nm -gU example/harmonyApp/entry/libs/arm64-v8a/libohoscmp.so   # CmpRender / libohoscmp_symbols

JVM 与 ohos 双目标编译同一份上游源码;dividerPos = margin + avail × f 与 同输入同字节 PNG 构成两条确定性对账线。

6.3 HAP 验证

CompileArkTS: finished
BuildNativeWithCmake / BuildNativeWithNinja: finished
PackageHap: finished
SignHap: finished (DevEco 自动签名)
BUILD SUCCESSFUL
install bundle successfully
start ability successfully

unsigned debug HAP ≈6.56MB,内含双 ABI 三件套 so 与 ets/modules.abc;真机走自动签名后的 entry-default-signed.hap。

6.4 功能用例

  1. 启动后自动执行 kind=info 自检,六行元数据即时显示(previewCount=6);
  2. AnimatedImage Tab 切换 orbit / pulse / wave 三种帧模式,画面与状态行同步刷新;
  3. 切换 12 / 24 / 36 帧后帧号归零、播放停止,逐帧滑条与底部帧号读数一致;
  4. 播放/暂停正常,连续播放期间状态行 png= 字节数稳定在 262488B(256×256 帧);
  5. SplitPane 拖动比例、切方向/主题,dividerPos 与公式对账;
  6. 重组模型只对输入变化的节点标强调框,状态行给出 重组 n/5 与逐节点串;
  7. 非法输入(如缺字段)由桥回 ok:false,页面状态区显示错误文本,应用不崩。

6.5 验证结论

KLIB、Native 动态库、C ABI、N-API、ArkUI 页面和签名 HAP 已完成链路验证。应用在 HUAWEI HAD-W24 上成功安装并进入前台,AnimatedImage 帧序列在真机逐帧播放,状态行展示了真实尺寸、字节数与元数据。

七、运行效果

7.1 真机截图

在这里插入图片描述

截图中可以看到:

  • 页面标题为 “compose-multiplatform · OpenHarmony”,副标题标注 “CMP 组件模型 Demo · Kotlin/Native ohosArm64/ohosX64”;
  • 顶部 Tab 选中 AnimatedImage;
  • 帧模式选中 pulse(三环相位扩散),帧数选中 36 帧,帧滑条位于 30/35;
  • 画面中央为当前帧渲染结果,左下角七段帧号读数,底部进度条随帧推进;
  • 状态行显示 生成成功 animated 256x256 png=262488B——每帧字节数与确定性对账一致;
  • 播放按钮可随时启停连续播放。

7.2 命令速查

git clone https://atomgit.com/oh-tpc/compose-multiplatform-AnimatedImage.git
cd compose-multiplatform-AnimatedImage
export JAVA_HOME=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home
./gradlew :ohos:ui-tooling-preview:compileKotlinJvm
./gradlew :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64
cd example/harmonyApp
~/ohos/command-line-tools/bin/hvigorw assembleHap -p product=default -p buildMode=debug --no-daemon
hdc install entry/build/default/outputs/default/entry-default-signed.hap
hdc shell aa start -a EntryAbility -b org.terminator.ohos.cmp

八、遗留问题与改进方向

8.1 踩坑复盘

  1. 上游总仓库不能直接加 ohosArm64()——Kotlin 2.4.20 / Gradle 9 与鸿蒙插件不兼容,必须新建根构建物理隔离;
  2. DevEco 自带 JBR(JDK 25)跑不动 Gradle 8.14.1,命令行构建要独立 JDK 21;
  3. pluginManagement 仓库顺序错误会在配置期报 Unresolved reference: ohosArm64,eazytec Maven 必须在 gradlePluginPortal() 之前;
  4. KN so 必须与同 triple 的 libc++_shared.so 一起拷入 HAP libs,只拷主 so 会 dlopen 失败;
  5. ArkTS 只能 import CMake 包装的 libcmp_napi.so,CAdapter 不会把 @CName 业务符号挂给 ArkTS;
  6. KN 返回字符串必须经 DisposeString 释放,不能 free;
  7. Kotlin 源文件漏 import(本次实际踩到 kotlin.math.PI)在 ohos 编译期立刻暴露,编译期修掉即可。

8.2 已知问题

  • 帧内容为生成式演示帧(orbit/pulse/wave),不是真实动图文件解码;接真解码要等 1.12 线 ohos 制品;
  • PNG 使用 zlib stored 未压缩块,256×256 单帧 ≈262KB,连续播放每帧过 NAPI 的 Base64 约 350KB;
  • 组件的 Compose/Skia actual 不在此 fork 编译,ohos 上的 CMP 运行时由社区 1.9.2 线承担;
  • ui-tooling-preview API 在上游已标 @Deprecated(迁往 androidx 线),它是本快照唯一零依赖 KMP 库,作为编译级证明保留;
  • 演示帧时长 200ms 为定值,未透出每帧独立时长字段。

8.3 未来优化方向

  • 桥协议增加 delays 字段,逐帧时长对齐 framesInfo[i].duration 语义;
  • 在 KN 侧实现纯 Kotlin 的 GIF 解码(LZW + 帧处置),替换生成式演示帧;
  • 待社区 1.12 线 ohos 制品落地,把 animate() 接回 Skia 真解码与 Compose 场景;
  • 在 CI 中加入 Native 链接、ELF 符号检查和 HAP 构建;
  • 补充模拟器(arm64/x86_64)与不同 API 版本的验收矩阵。

九、总结

9.1 核心难点回顾

compose-multiplatform 总仓库(无根构建,Kotlin 2.4.20)
    → 取出 components/AnimatedImage 的帧序列模型
    → 新建根构建 + ohosArm64/ohosX64
    → C ABI(CmpRender)
    → C++ N-API(libcmp_napi.so)
    → ArkTS JSON 解析 + PixelMap
    → ArkUI 真机逐帧播放

9.2 封装层次

  • 模型层:AnimatedImage 帧序列语义(帧数/帧时长/迭代/每帧一图)确定性复现;
  • KLIB 层:总仓库唯一零依赖库 ui-tooling-preview 编译为 ohos klib;
  • Native 层:生成双 ABI 动态库并通过单 C ABI 输出 JSON;
  • N-API 层:完成参数检查、字符串转换和 Native 内存释放;
  • ArkTS 层:管理 Tab 状态、播放定时器、PixelMap 生命周期和错误文本;
  • DevEco 层:完成 CMake、HAP、签名、安装和真机运行。

9.3 三条经验

  1. 适配总仓库里的组件,先新建独立的鸿蒙根构建,再用 srcDir/模型引用复用上游,绝不污染上游工程;
  2. 用单入口 JSON 和少量 C ABI 代替跨语言对象传递,并明确字符串释放责任;
  3. 分别记录编译、链接、符号、HAP 构建和真机运行,用"同输入同字节 PNG"这类确定性对账避免把编译成功误认为设备链路可用。

9.4 适配成果

当前适配已完成:

  • components/AnimatedImage 帧序列模型的 OpenHarmony 交付(双 ABI libohoscmp.so + libcmp_napi.so);
  • 总仓库唯一零依赖 KMP 库 ui-tooling-preview 的 ohosArm64 / ohosX64 KLIB;
  • 单入口 C ABI 与 C++ N-API 桥接;
  • DevEco Stage ArkUI 四 Tab 验收页面(AnimatedImage 为核心);
  • HAP 构建、自动签名、设备安装和 HAD-W24 真机验证;
  • AtomGit 项目文档、OpenHarmony README、本博客与本地效果图(doc/img/)。

适配过程 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开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐