本文记录 JellyFab 接入 OpenHarmony 的完整过程,覆盖 Compose Multiplatform 动效库盘点、工具链钉版选型、无状态内核提取、ohosArm64 / ohosX64 目标、Kotlin/Native 动态库、C ABI/N-API 桥接、ArkUI Canvas 真机页面、HAP 构建和真机验收。

JellyFab 是基于 Compose Multiplatform 的胶体悬浮菜单库:点一下主 FAB,一排 mini-FAB 会从一个软软鼓起的 blob 里"挤"出来,伴随双弹跳、高斯鼓包形变、阴影联动和 scrim。上游把这套果冻质感全部写在 Compose 里——弧角布局(JellyArcLayout)、36 点 blob 轮廓采样 + 高斯鼓包(JellyBlob)、交错时序(JellyPhysics)。本次适配与真实引擎整库编译路线(KStore 的"原样编译进 KLIB")不同:JellyFab 的可移植价值不在 Compose 组件而在动画数学,因此走的是 cmp_qrose / ComposePhysicsLayout 的"无状态内核"路线——把分散在 Compose 文件里的几何、缓动、时序抽成零 Compose、零协程的纯 Kotlin 内核编进 OpenHarmony KLIB,上游 Compose UI 原样隔离在 src/composeMain 作为 API 对照,ArkUI Canvas 逐帧渲染内核算出的几何作为验收宿主。

全文不出现任何第三方镜像站链接,仓库地址只保留 AtomGit。

项目地址: AtomGit/oh-tpc/JellyFab.git

上游仓库: iprashantpanwar/JellyFab

开发工具: 华为云码道

环境安装: 环境安装指南

一、背景

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

JellyFab 为 Compose Multiplatform 应用提供"果冻悬浮菜单"这一件事的完整答案:一个 JellyFab 可组合函数覆盖主 FAB、主层 mini-FAB 弧形排布、次级链式弹出、blob 果冻形变、阴影与 scrim;JellyFabConfig 暴露交错时长、弹跳系数、阴影参数等全部动效旋钮;JellyFabState 管理展开状态机。上游把这些能力放在 commonMain,由 Android、iOS、桌面、Web、Wasm 共享。OpenHarmony 应用同样需要这套动效能力,但上游构建(Kotlin 2.2.20 / Compose 1.9.1 / AGP)不会生成 OpenHarmony 变体。

如果只把示例页面改成 ArkTS 画几个圆圈,页面可以显示按钮,却无法证明上游的果冻几何、双弹跳时序和交错缓动在 OpenHarmony ARM64 设备上完成了编译、链接和逐帧执行。适配需要同时验证动画数学、缓动引擎、时序状态机、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 交付链路。

障碍具体问题
目标缺失必须增加 ohosArm64() / ohosX64() 才能生成 OpenHarmony KLIB。
工具链不一致上游 Kotlin 2.2.20 / Compose Multiplatform 1.9.1 / AGP 与鸿蒙工具链无法共存于一个 Gradle 构建。
Compose 边界JellyFab 依赖 graphicsLayer、Modifier、ImageVector、createBlurPaint expect/actual——社区 CMP 工具链版本对不齐,强行编译等于再打一套 Compose 发行版。
数学埋在 UI 里果冻几何、缓动、时序分散在 JellyArcLayout / JellyBlob / JellyPhysics 等 Compose 文件中,必须手工抽离。
语言边界不同ArkTS 不能直接持有 Kotlin 对象,需要 C ABI、C++ N-API 和 JSON。
动画语义跨不了桥上游动画由 Animatable 驱动,C ABI 只能同步一来一回。
交付链路复杂.so、CMake、N-API、HAP、签名和 hdc 都要单独验收。

因此,本项目把边界放在:无状态内核的 ohos 编译、Compose UI 的 composeMain 隔离(cmp_qrose 模式)和 C ABI/N-API 桥接层。动画语义由"无状态帧函数"解决——上游 Animatable 白拿的"从哪个状态动画过来"语义,用一对显式布尔让宿主传入。

1.2 库提供的能力

Demo 提供一个真实的逐帧动画会话,对应桥接的全部入口:

  • 版本握手:JellyFabVersion() 返回 fork 版本、引擎标识与轮廓采样数,来自真实 Native 调用而非静态文字;
  • 舞台初始化:JellyFabInit(request) 接收画布物理像素、密度、primary/secondary 数量与全部动效参数覆盖,返回主 FAB 中心、层距、blob 基础半径、两层目标点位与四组时长;
  • 逐帧求值:JellyFabFrame(request) 对任意 tMs 返回完整一帧——blob 的 36 点轮廓、阴影度量、主 FAB 位置、两层 mini-FAB 的中心/缩放/透明度、相位与旋转角;
  • 内核自检:JellyFabSelfTest() 在真机上跑完整不变量清单(弧角归一、极坐标偏移、链式 lerp、轮廓半径、缓动单调性、时序交错),逐项报告通过情况;
  • 无状态设计:内核不持有动画时钟,tMs = 1e9 表示 snap(首帧绘制或 resize 后定格),ArkTS 用自己的帧循环驱动动画——这正是 JVM 测试和真机结果逐位一致的原因。

帧响应包含:

字段作用
phasecollapsed / expanding / expanded / collapsing。
blob.points36 点轮廓(扁平 [x0,y0,x1,y1,...]),含高斯鼓包形变。
shadow阴影圆心、半径、透明度与模糊 sigma,随鼓包联动。
primary[] / secondary[]每层 mini-FAB 的中心、缩放、进度与透明度。
rotation / scrim主 FAB 图标旋转角与 scrim 透明度。

内核侧完整能力(弧角几何、极坐标偏移、链式 lerp、双弹跳时序、cubic-bezier 与阻尼弹簧解析解)全部在 commonMain 里随编译进入 .so,JVM 侧保留内核测试交叉验证,证明 ohos .so 里执行的动画逻辑没有漂移。

1.3 实现适配

维度要求
数学复用公式、常数(0.45 半径因子、0.6 高斯 sigma、1.1/0.08/0.1 阴影因子)与符号约定全部保持上游,不"改进"。
平台目标根构建直接声明 ohosArm64 + ohosX64,保留 jvm() 作交叉验证,库模块出 KLIB,example/nativeApp 出双 ABI 动态库。
构建隔离根构建替换为极简 ohos 构建(cmp_qrose 模式);上游 composeApp Demo 留盘不进构建。
Compose 隔离上游 Compose UI 全部迁到 src/composeMain(含平台源码集),不是源集、不参与 ohos 编译,留作 API 对照。
桥接稳定4 个 C ABI 和 UTF-8 JSON,不传递 Kotlin 对象地址;异常永远不跨 FFI。
依赖纪律内核零第三方依赖——零 Compose、零协程、零序列化,JSON 桥用手写 MiniJson。
UI 完整单页覆盖全部桥接入口:Canvas 逐帧渲染、主层/次级点击命中、参数面板、内核自检。
仓库规范项目说明、文章、效果图和源码链接统一使用 AtomGit。

本项目的 ArkUI 页面不是 Compose Multiplatform UI 实现。上游 Compose UI 完整保留在 composeMain,等社区 compose.ui ohos 制品出现后可以接回;当前 Canvas 渲染的是内核算出的同一份几何模型。

二、实现路线图

第 1 阶段:项目初始化     ── 盘点模块与依赖边界、确认数学埋点、确定交付物
第 2 阶段:构建替换       ── eazytec Maven 置前、Kotlin 2.2.21-1.0.0 钉版、Gradle 8.14.1、JDK 21
第 3 阶段:内核提取       ── 几何/缓动/时序/场景四个纯 Kotlin 文件 + 自检,JVM 测试锁定
第 4 阶段:原生桥接       ── @CName × 4 C ABI、MiniJson、C++ N-API、参数校验和内存释放
第 5 阶段:ArkUI 宿主     ── Canvas 逐帧渲染、点击命中、参数面板、内核自检页
第 6 阶段:示例与验证     ── HAP、签名、设备安装、真机运行和效果图

三、逐步实现过程

第 1 阶段:项目初始化

1.1 工程边界
jellyfab/src/commonMain/              新增:无状态内核(几何/缓动/时序/场景/自检,零 Compose 零协程)
jellyfab/src/composeMain/             上游 Compose UI 原样迁入(含平台源码集,ohos 不编)
jellyfab/src/commonTest/              内核 JVM 交叉验证
jellyfab/build.gradle.kts             纯 KMP:jvm() + ohosArm64 + ohosX64
example/nativeApp/                    Kotlin/Native 动态库(Bridge + MiniJson)
example/harmonyApp/                   DevEco Stage、CMake N-API 和 ArkTS Canvas 页面
composeApp/                           上游 Demo,留盘不进 settings
README.OpenHarmony.md / _CN.md        鸿蒙适配说明(中英双份)
README.upstream.md                    上游原文保存

构建拓扑走 cmp_qrose 模式(根构建即 ohos 构建):settings.gradle.kts 只 include :jellyfab 与 :example:nativeApp,rootProject.name = "ohos_jellyfab"。上游根构建的 Compose Multiplatform 插件、AGP、Android 目标、发布段全部随根构建替换剥离——本 fork 不发 Maven Central。

1.2 工具链矩阵
项目配置
Kotlin MultiplatformHarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven)
JDK21(org.gradle.java.home 写进 gradle.properties)
OpenHarmony 目标ohosArm64 + ohosX64(jvm() 保留作交叉验证)
Gradle8.14.1(2.2.21 旧线必须降版,不能沿用上游 wrapper)
DevEco product5.1.0(18) 兼容
DevEco Studio5.1.0 Release
ABIarm64-v8a + x86_64
Bundleorg.terminator.ohos.jellyfab
版本2.0.0-ohos.1(上游 2.0.0)
export JAVA_HOME="/path/to/jdk-21"
java -version
1.3 源码依赖度盘点
模块文件依赖形态处置
动画数学弧角/极坐标/链式 lerp/blob 采样/时序/缓动纯 Kotlin 语义,埋在 Compose 文件里手工抽离成 5 个内核文件(唯一新增源码)
Compose UIJellyFab / JellyBlob / JellyFabState / JellyFabItem / JellyArcLayout / 图标androidx.compose.ui + expect/actual原样迁入 composeMain,一个字节不改
平台源码集androidMain / iosMain / webMain / jvmMain 等Compose 依赖迁入 composeMain/platform/ 隔离

与 KStore"整库真编译"路线的本质区别在这里:JellyFab 的数学不能直接编——它和 Compose 类型(Offset、Animatable、graphicsLayer)绑在同一批文件里,拆的时候要手洗。抽离纪律是把公式、常数和符号约定原样搬走,Offset 换成自带的 JellyPoint,其余一个字符都不"改进"。

1.4 页面边界
标题区          JellyFab · OHOS / 版本握手行(2.0.0-ohos.1 · jelly-kernel-pure-kotlin)
Tab 切换        Demo / About
Canvas 区       逐帧渲染:scrim → 次级层 → 主层 → 阴影 → blob 轮廓 → 主 FAB 图标
状态行          最近一次 snap/动画结果(如"settled in 2466ms · phase expanded")
参数面板        Primary / Secondary 数量加减(0..8)+ Apply 重建舞台 + Self-test
点击命中        次级 mini-FAB → 切换次级层;主 FAB → 展开/收起
关于区          版本握手、库简介、适配路线、仓库与社区链接、自检结果

页面启动时先调 JellyFabVersion() 做握手,Canvas onReady 后用物理像素宽高与密度调 JellyFabInit 建舞台。

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

2.1 工具链钉版(为什么是 2.2.21-1.0.0 + Gradle 8.14.1)

ohosArm64() / ohosX64() 只存在于 HarmonyOS Kotlin 插件,社区此前批准的钉法是 2.2.21-1.0.0。本仓内核零第三方依赖,不存在 KStore 那种"制品线 stdlib 元数据反推工具链版本"的问题,直接沿用已审钉法。代价是 Gradle 必须降到 8.14.1——只有 2.4.20 新线才支持 9.x wrapper,两条工具链线的经验不能互相套用。

settings.gradle.kts 把 eazytec 仓库写在最前(显式命名,避免与全局 init 脚本的自动命名镜像条目冲突):

pluginManagement {
    repositories {
        maven("https://maven.eazytec-cloud.com/nexus/repository/maven-public/") { name = "eazytecPublic" }
        maven("https://maven.eazytec-cloud.com/nexus/content/groups/harmonyos") { name = "eazytecHarmonyos" }
        gradlePluginPortal()
        google()
        mavenCentral()
    }
}

rootProject.name = "ohos_jellyfab"

include(":jellyfab")
include(":example:nativeApp")
2.2 编译真实内核

jellyfab/build.gradle.kts 收敛为纯 KMP 模块:

kotlin {
    applyDefaultHierarchyTemplate()
    jvmToolchain(21)

    jvm()            // 交叉验证用
    ohosArm64()
    ohosX64()

    sourceSets {
        commonMain.dependencies {}          // 零依赖
        commonTest.dependencies {
            implementation(kotlin("test"))
        }
    }
}

注意 composeMain 不是 Kotlin 源集,天然不参与编译——隔离靠目录约定而不是构建脚本排除。实测:

./gradlew :jellyfab:jvmTest                                      # 内核测试全绿
./gradlew :jellyfab:compileKotlinOhosArm64 :jellyfab:compileKotlinOhosX64   # BUILD SUCCESSFUL
2.3 双 ABI 动态库

example/nativeApp 出 sharedLib(库模块不开 binaries,避免每模块吐一份带运行时的 so):

ohosArm64 { binaries.sharedLib { baseName = "ohosjellyfab" } }
ohosX64  { binaries.sharedLib { baseName = "ohosjellyfab" } }

copyKnSo* 任务把链接产物与匹配 triple 的 libc++_shared.so 一起拷进 example/harmonyApp/entry/libs/<abi>/——KN so 的 DT_NEEDED 包含 libc++,缺一件运行期 dlopen 就失败。

第 3 阶段:内核提取(技术难点)

3.1 无状态帧函数

上游 JellyFab() 是四块 Compose 的组合:scrim Box、主层弧形、次级链、JellyBlob + 图标。它们的布局、动画状态与 z 序全部可以从 (spec, state, fromState, elapsedMs) 恢复:

主 FAB 中心     = 底端角落,padding(24.dp) 翻转
主层中心[i]     = 主中心 + radialOffset(angle[i], layerDistance, progress[i])
次级[i]         = 从主层[0]的*当前*中心链式 lerp
blob 轮廓       = 36 采样 + 高斯鼓包
阴影            = 鼓包影响度驱动的 spread/alpha/lift

fromState 是让无状态帧函数仍然正确的关键:"从展开态收起"和"从未展开"最终都停在 progress 0,但只有前者会从 1 动画下来。上游靠 Animatable 白拿这个语义,内核用一对显式布尔(fromExpanded / fromSecondary)让宿主传入。

3.2 五个内核文件
文件职责
JellyGeometry.kt弧角归一、极坐标偏移(主层 cos/sin,次级 -sin——两套符号约定都是上游原样)、链式 lerp、36 点轮廓采样 + 高斯鼓包、阴影度量
JellyEasing.ktcubic-bezier tween(FastOutSlowIn / LinearOutSlowIn)+ 阻尼弹簧解析解
JellyTimeline.kt展开/收起/次级四条交错时序与双弹跳
JellyScene.ktframe(spec, state, elapsedMs, fromState) → 整帧(JellyFrame)
JellySelfTest.kt不变量自检清单,可在真机执行

提取纪律:Offset 换 JellyPoint,其余照抄——常数 0.45(blob 半径因子)、0.6(高斯 sigma)、1.1/0.08/0.1(阴影三因子)、36(采样数)逐字保留。符号约定也是:主层 270° 是正上方,次级 90° 是正上方,两者共存是上游原状,不是 bug。

3.3 JVM 测试锁定

内核语义用 commonTest 锁定:弧角序列(count=3 → [270°,225°,180°])、极坐标偏移的 progress 截断、链式 lerp 的依赖次序、轮廓采样数与半径正性、缓动中段超线性、展开帧位于两布局之间、收起帧从展开布局出发、settled 语义、spec 换算(dp × density)。这份测试在 JVM 上跑,同时约束 ohos .so——同一份 commonMain 代码,测试通过即"内核没漂"。

第 4 阶段:原生桥接

4.1 三层桥接
ArkTS
  │ JSON string
  ▼
C++ N-API entry(libjellyfab_napi.so)
  │ const char*
  ▼
Kotlin/Native C ABI(libohosjellyfab.so)
  │ JellyFabVersion / JellyFabInit / JellyFabFrame / JellyFabSelfTest
  ▼
UTF-8 JSON + DisposeString
4.2 Kotlin/Native 导出函数
@file:OptIn(kotlin.experimental.ExperimentalNativeApi::class)

@CName("JellyFabVersion")  fun jellyFabVersion(): String
@CName("JellyFabInit")     fun jellyFabInit(request: String): String
@CName("JellyFabFrame")    fun jellyFabFrame(request: String): String
@CName("JellyFabSelfTest") fun jellyFabSelfTest(): String
C ABI 符号ArkTS 方法覆盖内核
JellyFabVersionjellyFabVersion()fork 版本握手(真机版本行数据来源)
JellyFabInitjellyFabInit(req)JellySpec + JellyLayout 构造、两层目标点位与时长
JellyFabFramejellyFabFrame(req)JellyScene.frame 整帧求值
JellyFabSelfTestjellyFabSelfTest()JellySelfTest.run 不变量清单

四个入口全部 String → String,整体 try/catch,Kotlin 异常永远不跨 FFI;非法 JSON、缺字段、未初始化都走同一个 {"ok":false,"error":"..."} 通道。

4.3 MiniJson 为什么不上 kotlinx.serialization

桥的出入参是扁平 JSON。为这点东西拉序列化依赖会多出一个 KN 依赖,还要和 HarmonyOS Kotlin 插件的版本对齐。手写 MiniJson 只做解析/序列化 + 索引访问,字符串里的转义走完整状态机(含 \uXXXX),所以 About 文案里的任意字符都能安全回读。C ABI 保持 const char* -> const char*,ArkTS 侧 jellyFabFrame(string): string 永远不用改。

4.4 C++ N-API 与 CMake

每个 N-API 回调统一三步:napi_typeof 校验必须是 string、OH_LOG_INFO 打字节数、调 KN 函数并包装返回。KN 返回的字符串必须经 libohosjellyfab_symbols()->DisposeString 归还 Kotlin 运行时,绝不 free()。

add_library(ohosjellyfab SHARED IMPORTED)
set_target_properties(ohosjellyfab PROPERTIES
  IMPORTED_LOCATION ${KN_SO}
  IMPORTED_NO_SONAME TRUE)

add_library(jellyfab_napi SHARED napi_init.cpp)
target_link_libraries(jellyfab_napi PUBLIC
  libace_napi.z.so
  libhilog_ndk.z.so
  ohosjellyfab)

OHOS_ARCH 为空或 libohosjellyfab.so 缺失直接 FATAL_ERROR(提示先跑 link 任务);$ORIGIN rpath 加 POST_BUILD 拷贝保证 NAPI so、KN so、libc++ 三件套同目录。

第 5 阶段:ArkUI 宿主

5.1 初始化与逐帧驱动
aboutToAppear(): void {
  const info = JSON.parse(jellyFabVersion()) as VersionInfo;
  this.versionInfo = `${info.name} ${info.version} · ${info.engine}`;
}

private reinit(): void {
  const raw = jellyFabInit(JSON.stringify({
    widthPx: this.canvasW, heightPx: this.canvasH, density: this.density,
    primaryCount: this.primaryCount, secondaryCount: this.secondaryCount
  }));
  // @State 刷新状态行 + snapFrame 定格首帧
}

private stepFrame(): void {
  const tMs = Date.now() - this.animStartMs;
  const frame = JSON.parse(jellyFabFrame(JSON.stringify({
    tMs, expanded: this.expanded, secondaryExpanded: this.secondaryExpanded,
    fromExpanded: this.fromExpanded, fromSecondary: this.fromSecondary
  }))) as FrameInfo;
  this.redraw(frame);                       // Canvas 重绘
  if (frame.settled) { /* 停帧,状态行报耗时 */ }
}

ArkTS 用 setInterval(16ms) 驱动帧循环,settled === true 时停帧——内核无状态,帧循环的节奏完全由宿主控制。

5.2 Canvas 渲染次序

每帧按 z 序画六层:scrim 半透明遮罩 → 次级 mini-FAB(透明度驱动)→ 主层 mini-FAB → 阴影圆(alpha/半径来自内核)→ blob 36 点轮廓(lineTo 闭合填充 + 白描边)→ 主 FAB 十字图标(按 rotation 旋转,收起态 45° 呈 ×,展开态 0° 呈 +)。几何全部来自内核,ArkTS 不重算任何弧度或鼓包。

5.3 点击命中与自检

触摸命中测试次序与渲染 z 序一致:先次级(alpha > 0.5 才可点)再主层最后主 FAB,半径加 8px 容差。About Tab 的 Self-test 按钮调 jellyFabSelfTest(),把每条不变量的名称与明细逐项显示——这是内核在真机上自证清白的入口。

第 6 阶段:示例与验证

6.1 工程结构
example/
├── nativeApp/
│   ├── build.gradle.kts                      双 ABI sharedLib(baseName=ohosjellyfab)+ cxxShared + copyKnSo*
│   └── src/ohosMain/kotlin/
│       ├── Bridge.kt                         @CName × 4 + 错误通道
│       └── MiniJson.kt                       零依赖 JSON(继承自 cmp_qrose 审核模式)
└── harmonyApp/
    ├── build-profile.json5                   signingConfigs 留空(签名由 DevEco 自动生成回填)
    ├── AppScope/app.json5                    bundleName org.terminator.ohos.jellyfab、versionName 2.0.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/libohosjellyfab_api.h
        ├── src/main/cpp/types/libjellyfab_napi/{index.d.ts, oh-package.json5}
        └── src/main/ets/pages/Index.ets      ArkUI Canvas 验收页面
6.2 构建、签名和安装
./gradlew :jellyfab:jvmTest                                      # 内核交叉验证
./gradlew :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64

cd example/harmonyApp
hvigorw assembleHap -p product=default -p buildMode=debug --no-daemon

签名在 DevEco Studio 的 File → Project Structure → Signing Configs 中勾选自动生成,证书写入本机,不进入仓库(仓库内 build-profile.json5 的 signingConfigs 保持空数组,勾选自动签名后由 IDE 回填完整配置)。

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.jellyfab -m entry

四、完整代码对照

4.1 整体架构

jellyfab/src/composeMain/**(上游 Compose UI,ohos 不编)
    │ JellyFab / JellyBlob / JellyFabState / JellyArcLayout(API 对照)
jellyfab/src/commonMain/**(无状态内核)
    │ JellyGeometry / JellyEasing / JellyTimeline / JellyScene / JellySelfTest
Bridge.kt + MiniJson
    │ JellyFabVersion / JellyFabInit / JellyFabFrame / JellyFabSelfTest
libjellyfab_napi.so
    │ jellyFabVersion / jellyFabInit / jellyFabFrame / jellyFabSelfTest
Index.ets
    │ JSON parse + setInterval 帧循环 + Canvas 分层绘制
    ▼
ArkUI 真机页面(Canvas 逐帧果冻动画)

4.2 关键 API 对照

层次API作用
内核JellyGeometry.blobOutline(...)36 点轮廓 + 高斯鼓包采样
内核JellyTimeline.expandProgress / expandBulge交错展开进度与鼓包(双弹跳)
内核JellyScene.frame(spec, state, t, from)无状态整帧求值
内核JellySelfTest.run()不变量自检清单
NativeJellyFabVersion / Init / Frame / SelfTestC ABI JSON 导出
N-APIjellyFabVersion / Init / Frame / SelfTest向 ArkTS 暴露能力
ArkTSstepFrame() / redraw()帧循环驱动 + Canvas 分层绘制

4.3 ArkTS 与 Kotlin 的边界

const frame = JSON.parse(jellyFabFrame(JSON.stringify({
  tMs: 120, expanded: true, secondaryExpanded: false,
  fromExpanded: false, fromSecondary: false
})));
// frame.blob.points → 36 点轮廓;frame.primary[] → 主层点位
val frame = JellyScene.frame(
    spec = spec,
    state = JellyState(expanded = true),
    elapsedMs = 120.0,
    fromState = JellyState(expanded = false),
)

两者之间只传输扁平 JSON 字符串,不传输 Kotlin 对象或未校验的动态结构;几何语义只在 Kotlin 侧存在一份。

五、关键决策说明

决策 1:无状态内核而不是整库编译

JellyFab 的数学和 Compose 类型绑在同一批文件里,整库编译需要一套 ohos Compose 发行版,不现实;抽内核是唯一能把上游动画语义搬进 .so 的路。纪律是把公式、常数、符号约定原样搬走,测试锁死——而不是"重写一个果冻动画"。

决策 2:工具链沿用 2.2.21-1.0.0 + Gradle 8.14.1

内核零第三方依赖,不存在"制品线反推工具链"的问题,直接沿用社区已审钉法。代价是 Gradle 必须降 8.14.1——2.2.21 旧线不支持 9.x wrapper。这条与 KStore 的 2.4.20 线经验相反,两套钉法不能互相套用。

决策 3:根构建替换(cmp_qrose 模式),composeApp 留盘

上游根构建(Compose Multiplatform 插件、AGP、Android/iOS/js/wasm 目标、发布段)对鸿蒙没有保留价值,整个替换为极简 ohos 构建。composeApp Demo 需要 AGP + 完整 CMP 工具链,和 HarmonyOS Kotlin 不能共存于一个 Gradle 构建,留盘不进 settings.gradle.kts——它的存在方式只有这一种。

决策 4:fromState 显式传参代替 Animatable

上游动画状态由 Animatable 持有,"从哪个状态动画过来"是隐式的。C ABI 只能同步一来一回,内核改为无状态:(state, fromState, elapsedMs) 三元组完整决定一帧。这让 JVM 测试和真机逐位一致成为可能,也让 ArkTS 帧循环完全自主。

决策 5:自检进桥

JellyFabSelfTest 把内核不变量清单做成 C ABI 导出,真机上逐项执行并回传明细。几何库没有"能读就算过"的外部判据(KStore 有落盘闭环),自检就是它的验收闸门——JVM 测试与真机自检双保险,才敢说"内核没漂"。

六、测试与验证

6.1 测试环境

本次验证使用 Windows(x64)、JDK 21(Eclipse Temurin)、DevEco Studio 5.1.0 Release、OpenHarmony 命令行工具(hvigor / hdc)与鸿蒙真机(arm64)。

6.2 JVM 交叉验证

./gradlew :jellyfab:jvmTest

内核测试全部通过,钉住:

断言组钉住什么
弧角序列evenArcAngles(count=3) → [270°,225°,180°](上/左上/左)
极坐标与链式 lerpprogress 截断、链式依赖次序(次级从主层[0]当前中心生长)
轮廓采样36 点、半径正性、鼓包只沿鼓包角方向推
缓动FastOutSlowIn 中段超线性、LinearOutSlowIn 滞后、阻尼弹簧过冲
时序交错推进、双弹跳峰值、settled 判定
场景展开帧位于两布局之间、收起帧从展开布局出发、scrim/rotation 边界
spec 换算dp × density 全链路(fabSize / layerDistance / secondaryRadius / blobBaseRadius)

同一份 commonMain 代码编进 ohos .so——测试通过即真机执行的动画逻辑没有漂移。

6.3 HAP 验证

:jellyfab:jvmTest: BUILD SUCCESSFUL
:jellyfab:compileKotlinOhosArm64 / OhosX64: BUILD SUCCESSFUL
linkDebugSharedOhosArm64 / linkDebugSharedOhosX64: BUILD SUCCESSFUL
copyKnSoArm64 / copyKnSoX64: 三件套回填 entry/libs/
BuildNativeWithCmake: finished(双 ABI 编出 libjellyfab_napi.so)
CompileArkTS: finished
PackageHap: finished
BUILD SUCCESSFUL
install bundle successfully
start ability successfully

libohosjellyfab.so arm64 约 4.0MB、x86_64 约 3.7MB(debugShared,含 Kotlin/Native 运行时),内含双 ABI 三件套;JellyFabVersion / JellyFabInit / JellyFabFrame / JellyFabSelfTest 均为全局 T 符号(llvm-nm -D 验证)。

6.4 功能用例

  1. 启动后版本行出现 JellyFab 2.0.0-ohos.1 · jelly-kernel-pure-kotlin(来自真实 Native 调用,非静态文字);
  2. Canvas onReady 后自动初始化舞台,状态行显示画布物理像素、密度与两层数量;
  3. 点主 FAB,主层 mini-FAB 交错弹出、blob 高斯鼓包形变、阴影联动、图标旋转 45°→0°,状态行报 settled in ...ms · phase expanded;
  4. 展开态点次级 mini-FAB,次级链从主层[0]当前中心链式生长,透明度渐入;
  5. 参数面板把 Primary 调到 8、Secondary 调到 5,点 Apply 重建舞台,再次展开验证满配弧形排布(见效果图);
  6. About Tab 点 Self-test,内核不变量逐项报告通过;
  7. 收起后 blob 平复、scrim 消退、图标回转 45°,帧循环自动停。

6.5 验证结论

动画内核、缓动引擎、时序状态机、Native 动态库、C ABI、N-API、ArkUI Canvas 页面和 HAP 已完成链路验证。应用在鸿蒙真机上成功安装并进入前台,页面展示了真实的逐帧果冻动画:版本握手、交错展开、次级链、鼓包形变与内核自检(见下节效果图)。

七、运行效果

7.1 真机截图

在这里插入图片描述

7.2 命令速查

git clone https://atomgit.com/oh-tpc/JellyFab.git
cd JellyFab
export JAVA_HOME="/path/to/jdk-21"
./gradlew :jellyfab:jvmTest                                                      # 内核交叉验证
./gradlew :jellyfab:compileKotlinOhosArm64 :jellyfab:compileKotlinOhosX64        # klib
./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.jellyfab -m entry
hdc shell hilog | grep JellyFab

八、遗留问题与改进方向

8.1 踩坑复盘

  1. 只改 ArkTS 页面不算 KMP/CMP 适配,必须让动画数学真正编进 ohosArm64 / ohosX64 动态库;
  2. settings.gradle.kts 里 eazytec 双仓必须排在 pluginManagement 与 dependencyResolutionManagement 最前且显式命名,否则与全局 init 脚本的自动命名镜像条目冲突,配置期直接 Unresolved reference: ohosArm64;
  3. @CName 需要 @OptIn(ExperimentalNativeApi::class),文件头 @file:OptIn 一次声明最省事;
  4. Kotlin 的 Pair<String, Double> 不会自动装进 vararg Pair<String, MiniJson>——桥的响应构造处必须显式包 MiniJson.Num(...),编译期就会拦下;
  5. composeMain 隔离靠目录约定而非构建脚本:它不是源集,天然不编译;平台源码集(androidMain / iosMain 等)要一并挪进 composeMain/platform/,否则 Compose 依赖从旧路径漏进配置期;
  6. ArkTS 接口必须与桥的 JSON 字段逐一对齐:SelfTestInfo 漏声明 error 字段会让 CompileArkTS 直接失败——失败通道(ok:false + error)是四个入口共用的契约;
  7. 全局 vp2px 在新 SDK 上已弃用,改用 this.getUIContext().vp2px(...),语义不变;
  8. HAP 缺当前 ABI 的 so 会报安装失败 9568347,abiFilters 必须双 ABI,每个 ABI 三件套(NAPI so / KN so / libc++)缺一不可;
  9. KN 返回字符串必须 DisposeString 归还运行时,N-API 层不能 free();
  10. 签名材料由 DevEco 自动写入 build-profile.json5,提交前保护该文件,证书绝不进入 AtomGit。

8.2 已知问题

  • HarmonyOS 上没有 Compose UI:JellyFab 可组合函数仍在 composeMain,Canvas 渲染的是内核几何而不是 Skia 矢量;
  • 模糊是近似:上游 createBlurPaint expect/actual 的高斯模糊在 Canvas shadow 上做简化,不逐像素复刻;
  • 图标走 Canvas 绘制:CloseIcon / MoreHorizontal 是 Compose ImageVector,鸿蒙侧用十字线代替;
  • 拖拽重排与手势冲突处理未桥接:上游 JellyFabState 的拖拽语义留在 Kotlin 侧;
  • android / ios / js / wasmJs 目标剥离(源文件留在树里),发布段一并剥离,本 fork 不发 Maven Central;
  • HAP 安装必须使用与 bundle name org.terminator.ohos.jellyfab 匹配的签名。

8.3 未来优化方向

  • 把上游 CloseIcon / MoreHorizontal 换成 Canvas Path 精确复刻,替代十字线近似;
  • 评估 Canvas shadowBlur 之外的高斯模糊实现(离屏 + 多次模糊),逼近上游 createBlurPaint 视觉;
  • 把 JellyFabConfig 的更多动效旋钮(bounceFactor / shadowOpacity / stagger)补进 JellyFabInit 参数面板实时调节;
  • 在 CI 中加入 ohos 编译、ELF 符号检查和 HAP 构建;
  • 补充真机(x86_64)与其他设备 ABI 的验收矩阵和自动化截图脚本。

九、总结

9.1 核心难点回顾

上游 JellyFab(动画数学埋在 Compose 文件里)
    → 手工抽离无状态内核(公式/常数/符号约定逐字保留)
    → 工具链对齐 Kotlin 2.2.21-1.0.0(Gradle 8.14.1)
    → 根构建替换(cmp_qrose 模式,Compose UI 隔离 composeMain)
    → ohosArm64 / ohosX64 KLIB + JVM 测试锁定
    → C ABI(@CName × 4)+ MiniJson 扁平 JSON
    → C++ N-API(DisposeString 纪律)
    → ArkTS setInterval 帧循环 + Canvas 分层绘制
    → ArkUI 真机验收(交错展开 / 次级链 / 鼓包形变 / 内核自检)

9.2 封装层次

  • 内核层:JellyGeometry / JellyEasing / JellyTimeline / JellyScene 纯 Kotlin,零 Compose 零协程零依赖;
  • 隔离层:上游 Compose UI 原样保留在 composeMain,作为 API 对照与未来接回的基础;
  • Native 层:生成双 ABI 动态库并通过 4 个 C ABI 输出 JSON;
  • N-API 层:完成参数检查、字符串转换和 Native 内存释放;
  • ArkTS 层:管理舞台状态、帧循环、Canvas 分层渲染、点击命中与自检展示;
  • DevEco 层:完成 CMake、HAP、签名、安装和真机运行。

9.3 三条经验

  1. 动效类 CMP 库的适配路线由"数学和 UI 的耦合度"决定:数学独立(KStore)走整库编译,数学埋在 Compose 文件里(JellyFab)走无状态内核抽离——路线选择必须基于耦合度盘点,而不是历史结论;
  2. 无状态化是跨 FFI 动画的唯一出路:把 Animatable 的隐式动画起点变成显式 fromState 传参,帧函数才能既无状态又正确,JVM 与真机才能逐位一致;
  3. 几何库没有外部判据,自检就是验收闸门:把内核不变量做成 C ABI 导出在真机执行,配合 JVM 测试双保险,"内核没漂"这句话才成立。

9.4 适配成果

当前适配已完成:

  • JellyFab 2.0.0(2.0.0-ohos.1)动画内核的双 ABI Kotlin/Native 编译(公式、常数、符号约定与上游逐字一致,仅载体从 Compose 类型换成 JellyPoint);
  • 无状态内核五件套(几何 / 缓动 / 时序 / 场景 / 自检)+ JVM 交叉验证全绿;
  • 4 个 C ABI 符号和 C++ N-API 包装层(llvm-nm 验证全局 T 符号);
  • DevEco Stage ArkUI Canvas 验收页面(逐帧渲染、两层点击命中、参数面板、内核自检、版本握手);
  • HAP 构建(双 ABI 三件套 so)、签名、设备安装和真机验证;
  • 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开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐