开源鸿蒙平台 CMP 三方库「compose-animations」适配全流程
本文记录
compose-animations接入 OpenHarmony 的完整过程,覆盖动画库依赖度盘点、100% Compose 依赖的处置决策、动画内核 1:1 复刻、上游源码 JVM oracle 交叉验证、ohosArm64/ohosX64目标、Kotlin/Native 动态库、C ABI/N-API 桥接、ArkUI 真机页面、HAP 构建和真机验收。
compose-animations是 Touchlab 的 Compose Multiplatform 动画工具库:easing模块提供 30 条缓动曲线(Sine / Quad / Cubic / Quart / Quint / Expo / Circ / Back / Elastic / Bounce 十族 × In / Out / InOut),value-animator模块提供多关键帧值动画(animateValuesAsState一口气在 N 个关键帧间推进、InfiniteTransition无限循环版、九种类型的ValueAtFraction插值)。本次适配与社区早期路线有本质区别:KStore 是零 UI 纯 Kotlin 引擎可以真编译整库,qrose 是编码器纯 Kotlin、Painter 隔离到composeMain;而compose-animations的每一个文件都 importandroidx.compose——连纯数学的缓动对象都实现着androidx.compose.animation.core.Easing接口,value-animator更是@Composable端到端。因此本次走 material-motion-compose 先例的「内核 1:1 复刻 + jvmTest 钉值」路线,并再加一层更强的验证:上游:easing源码原样编进 JVM 作 oracle,30 条曲线 × 101 个采样点容差 0 逐位一致——复刻没抄错是机器证明的,不是"看起来像"。
项目地址: AtomGit/oh-tpc/compose-animations.git
上游仓库: touchlab/compose-animations
开发工具: 华为云码道
环境安装: 环境安装指南
一、背景
1.1 为什么做 OpenHarmony 平台 KMP/CMP 适配
compose-animations 为 CMP 应用提供两类动画能力。easing:30 条缓动曲线,每条是一个 object : Easing,实现 transform(fraction: Float): Float,公式抄自 easings.net;上游已声明这些能力官方 Compose 动画 API 基本覆盖、库处于维护模式。value-animator:animateFloatValuesAsState(0f, 12f, 0f) 把 N 个关键帧交给一条 AnimationSpec 推进——MultipleValuesAnimator 内部把 frame∈[0, N-1] 映射到第 k 段插值,easing 作用在整条关键帧路径上而不是每段各 ease 一次;ValueAtFraction<T> 为 Float / Int / Dp / Size / Offset / Rect / IntOffset / IntSize / Color 定义插值。KMP 项目把这些能力放在 commonMain,由 Android、iOS、桌面、wasm 共享。
鸿蒙适配的价值不在"再造一个动画库"——ArkUI 自带动画——而在于数值语义对齐:业务在其它平台用 EaseInOutElastic / animateFloatValuesAsState(0f, 12f, 0f, startDelay = 70L) 写的动画,落到 HarmonyOS 时由同一份 Kotlin 内核算出同一串数值,曲线形态、过冲幅度、关键帧时序、错峰节奏全部一致,迁移不用重推参数。
| 障碍 | 具体问题 |
|---|---|
| 目标缺失 | 必须增加 ohosArm64() / ohosX64() 才能生成 OpenHarmony 产物。 |
| 工具链不一致 | 上游 Kotlin 2.0.0 / Gradle 8.4 / AGP 8.2.0 / Compose 1.6.10 与鸿蒙工具链无法共存于一个 Gradle 构建。 |
| Compose 依赖度 100% | easing 与 value-animator 没有一个文件不 import androidx.compose;社区线上没有与 HarmonyOS Kotlin 2.2.21-1.0.0 配套的 compose 编译器插件与 ohos klib。 |
| 语言边界不同 | ArkTS 不能直接持有 Kotlin 对象,需要 C ABI、C++ N-API 和 JSON。 |
| 动画语义跨不了桥 | @Composable、AnimationSpec、InfiniteTransition、Compose 帧时钟是 Kotlin 侧概念,C ABI 只能同步一来一回。 |
| 交付链路复杂 | .so、CMake、N-API、HAP、签名和 hdc 都要单独验收。 |
因此,本项目把边界放在:动画内核(30 条曲线公式 + 多关键帧推进 + 逐类型插值)的纯 Kotlin 复刻与交叉验证、根构建替换(cmp_qrose 模式)和 C ABI/N-API 桥接层。数值语义完全由复刻内核负责且以逐位一致为验收线,ArkTS 只负责取轨迹、按相位查表和渲染。
1.2 库提供的能力
Demo 提供两个 Tab,对应桥接的全部入口:
- 曲线列表(list):34 个可用名称(30 条库曲线 + 4 条 Compose 内置参考);
- 全曲线(curve):任一缓动的 101 点采样与
min/max(过冲族越出 [0,1],如 EaseInOutElastic 到 -0.117 / 1.117); - 单点取值(keyframe):多关键帧链在进度 t 的取值;
- 整段轨迹(track):
values:"0,180,180,0,0"这类关键帧链的整段采样; - 颜色轨迹(colortrack):
#RRGGBB关键帧链的颜色采样(保留上游 pow(2.2) 插值怪癖); - 版本握手(version):
animVersion()返回 fork 版本号,来自真实 Native 调用而非静态文字。
响应包含:
| 字段 | 作用 |
|---|---|
ok | 操作是否成功,失败时 error 带原文。 |
values / min / max | curve / track 的采样数组与极值(画布归一化映射用)。 |
value | keyframe 的单点取值。 |
colors | colortrack 的 hex 颜色数组。 |
name / count | 回显曲线名与采样数。 |
动画语义完整能力(30 条曲线、MultipleValuesAnimator 段内插值与边界钳制、ValueAtFraction 三支插值、androidx CubicBezierEasing 二分求解)全部在 AnimEngine 里随编译进入 .so,JVM 侧保留 21 项 jvmTest 交叉验证用例,其中 30 条曲线对上游 oracle 逐位一致,证明 ohos .so 里执行的动画数学没有漂移。
1.3 实现适配
| 维度 | 要求 |
|---|---|
| 代码复用 | 上游 easing / value-animator 源码零改动;数值内核 1:1 复刻进 example/nativeApp/commonMain(每个族标注上游文件出处),不手抄进 ArkTS。 |
| 平台目标 | 根构建声明 ohosArm64 + ohosX64,:easing 保留 jvm() 作交叉验证 oracle,example/nativeApp 出双 ABI 动态库。 |
| 构建隔离 | 根构建替换为极简 ohos 构建(cmp_qrose 模式);:value-animator、:app、iosApp/、kotlin-js-store/ 留盘不进构建。 |
| 桥接稳定 | 使用 2 个 C ABI 和 UTF-8 JSON,不传递 Kotlin 对象地址;异常永远不跨 FFI。 |
| 验证纪律 | 上游源码编进 jvmTest 作 oracle,30 条曲线 × 101 点容差 0;内核钉值 + 协议全路径共 21 项。 |
| UI 完整 | 两 Tab 覆盖全部桥接入口:曲线族/三态切换、实时取值、四组多关键帧 Demo、版本握手。 |
| 仓库规范 | 项目说明、文章、效果图和源码链接统一使用 AtomGit。 |
本项目的 ArkUI 页面不是 Compose Multiplatform UI 实现。HarmonyOS 上没有 Compose 运行时,
@ComposableAPI 形态不在鸿蒙侧;ArkTS Demo 拿到的是与上游逐位一致的数值语义,帧调度由 ArkTS 定时器承担。
二、实现路线图
第 1 阶段:项目初始化 ── 盘点模块与依赖边界、确认 Compose 依赖度 100%、确定交付物
第 2 阶段:目标与依赖打通 ── 根构建替换、eazytec Maven 置前、Kotlin 2.2.21-1.0.0 钉版、:easing 降为 jvm oracle
第 3 阶段:内核复刻 ── AnimEngine 1:1 复刻 30 曲线 + 多关键帧内核 + androidx Bezier,桥协议建模
第 4 阶段:原生桥接 ── @CName C ABI、C++ N-API、参数校验和内存释放
第 5 阶段:ArkUI 宿主 ── 轨迹预取、40ms 查表驱动、两 Tab 验收页面
第 6 阶段:示例与验证 ── HAP、签名、设备安装、真机运行和效果图
三、逐步实现过程
第 1 阶段:项目初始化
1.1 工程边界
easing/ 上游 30 条缓动(源码零改动;jvm() 交叉验证 oracle,进构建)
value-animator/ 上游多关键帧 API(源码零改动;留盘不进构建,内核已复刻)
example/nativeApp/ Kotlin/Native 动态库(AnimEngine + AnimApi + Bridge + jvmTest)
example/harmonyApp/ DevEco Stage、CMake N-API 和 ArkTS 页面
README.OpenHarmony.md / _CN.md 鸿蒙适配说明(中英双份)
README.upstream.md 上游原文保存
app/ iosApp/ kotlin-js-store/ 上游 Demo 遗留,留盘不进构建
构建拓扑走 cmp_qrose 模式(根构建即 ohos 构建):settings.gradle.kts 只 include :easing 与 :example:nativeApp,rootProject.name = "ohos_composeanimations"。上游的 AGP / jetbrainsCompose / composeCompiler / vanniktech 发布插件全部随根构建替换剥离——本 fork 不发 Maven Central。
1.2 工具链矩阵
| 项目 | 配置 |
|---|---|
| Kotlin Multiplatform | HarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven) |
| JDK | 21(org.gradle.java.home 指向 Homebrew openjdk@21) |
| OpenHarmony 目标 | ohosArm64 + ohosX64(:easing 保留 jvm() 作交叉验证) |
| Gradle | 8.14.1(wrapper 钉死;2.2.21 旧线要求 8.x) |
| 依赖 | 内核零第三方依赖(纯 kotlin.math);:easing oracle 用 androidx.compose 1.6.6 JVM jar |
| DevEco product | 5.1.0(18) 兼容 |
| DevEco Studio | 5.1.0 Release |
| ABI | arm64-v8a + x86_64 |
| Bundle | org.terminator.ohos.composeanimations |
export JAVA_HOME="/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home"
java -version
这里与 KStore 不同:KStore 因 kotlinx 制品线(coroutines 1.11.0-0.1.0-07 基于 stdlib 2.4.20)被迫升到 Kotlin 2.4.20-0.1.0-07、沿用 Gradle 9.x;compose-animations 的复刻内核零第三方依赖,没有元数据反推压力,直接用社区最早批准的经典钉法 2.2.21-1.0.0 + Gradle 8.14.1(qrose / haze 同款)——两条工具链线的选择标准是"版本跟着依赖走",本仓没有依赖,就用社区默认线。
1.3 源码依赖度盘点
| 模块 | commonMain 文件 | 依赖形态 | 处置 |
|---|---|---|---|
easing | 10 | 纯数学,但每个 object 都实现 androidx.compose.animation.core.Easing | jvm() oracle(源码零改动编进 jvmTest) |
value-animator | 9 | 100% @Composable + AnimationSpec + Compose 编译器插件 | 留盘不进构建,纯逻辑内核复刻进 nativeApp |
app | 20+ | Voyager + material3 的 CMP Demo | 留盘不进构建 |
这是本次适配的核心判断:easing 的 10 个文件里,Compose 只贡献了一个单方法接口 fun interface Easing { fun transform(fraction: Float): Float }——数值语义 100% 纯 Kotlin;value-animator 的 MultipleValuesAnimator(frame→段内插值)和 ValueAtFraction(逐类型 lerp)也是纯逻辑,但 animate*AsState / InfiniteTransition 包装离不开 Compose 运行时。前者值得连源码一起编(作 oracle),后者只能复刻内核。
1.4 页面边界
标题区 COMPOSE-ANIMATIONS · OPENHARMONY / 版本握手行(桥 1.0.2-ohos.1 · 上游 1.0.2 · Kotlin 2.2.21-1.0.0)
Tab 切换 缓动曲线 / 多值动画
曲线 Tab 十族 + Compose 共 11 个族选择、In/Out/InOut 三态、Compose 四条内置选择
曲线画布 320×220:蓝竖线/红横线边界(对齐上游 EasingsScreen 画法)、灰曲线、黑球 2500ms Restart 循环
实时读数 EaseInOutElastic(0.37) = 0.83 形式的状态行
动画 Tab 旋转三角 / 颜色三角 / 弹跳圆点 / 加载按钮 四组 Demo + 各自实时读数
状态行 版本握手、曲线采样信息、错误文本
页面启动时先调 animVersion() 做握手,再预取曲线与四组轨迹。
第 2 阶段:目标与依赖打通
2.1 根构建替换
settings.gradle.kts 把 eazytec 仓库写在最前(否则官方 Kotlin 插件在配置期直接 Unresolved reference: ohosArm64):
pluginManagement {
repositories {
maven("https://maven.eazytec-cloud.com/nexus/repository/maven-public/")
maven("https://maven.eazytec-cloud.com/nexus/content/groups/harmonyos")
gradlePluginPortal()
google()
mavenCentral()
}
}
rootProject.name = "ohos_composeanimations"
include(":easing")
include(":example:nativeApp")
gradle.properties 钉版本与 KN 发行版下载开关:
org.gradle.java.home=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home
version=1.0.2-ohos.1
kotlin.native.ignoreDisabledTargets=true
kotlin.native.distribution.downloadFromMaven=true
根 build.gradle.kts 保留两件事:版本 1.0.2-ohos.1,以及全局禁用 ohos 测试任务(鸿蒙线上没有 ohos 设备测试执行器,测试统一 JVM 跑)。
2.2 :easing 降为 jvm-only oracle
上游 easing/build.gradle.kts 是 androidTarget + ios + wasmJs + Compose 插件的满配 KMP。鸿蒙 fork 里收敛为:
plugins {
alias(libs.plugins.kotlin.multiplatform)
}
kotlin {
jvm() // 只编 JVM:源码全部实现 androidx Easing,无 ohos klib 可解析
sourceSets {
commonMain.dependencies {
implementation(libs.compose.animation.core) // androidx.compose.animation:animation-core:1.6.6
implementation(libs.compose.runtime)
}
}
}
直连 androidx 的 JVM 坐标(上游 compose.animation 1.6.10 在 JVM 上就是 androidx.compose 1.6.x),源码一行不动。实测:
./gradlew :easing:compileKotlinJvm # BUILD SUCCESSFUL
这个模块在鸿蒙构建里的唯一身份是交叉验证 oracle:它的类路径供 :example:nativeApp:jvmTest 引用,30 条曲线拿上游真源码对复刻内核逐点断言。
2.3 :value-animator 的处置
@Composable 函数需要 org.jetbrains.kotlin.plugin.compose 编译器插件;HarmonyOS Kotlin 2.2.21-1.0.0 线上没有配套版本,硬编等于自己再发一套 Compose 工具链。处置是把模块移出 settings.gradle.kts(源文件留盘),其纯逻辑内核——MultipleValuesAnimator 的段内插值与边界钳制、ValueAtFraction 的 Float 线性 / Int 截断 / Color pow(2.2) 三支——逐行复刻进 AnimEngine,由 jvmTest 钉值。上游 API 形态(State<T>、InfiniteTransition)留给未来社区 CMP 的 ohos 制品对齐后再接回。
第 3 阶段:内核复刻与桥协议建模
3.1 内核复刻(AnimEngine.kt)
co.touchlab.composeanimations.easing/**(上游真源码,jvm() 编译)
↓ jvmTest 逐位断言(容差 0)
example.nativeapp.AnimEngine(纯 Kotlin 复刻,jvm + 双 ohos 编译)
↓ AnimApi 扁平 JSON
Bridge.kt(@CName × 2)
↓ UTF-8 JSON
N-API animEval() / animVersion()
↓
ArkUI Index.ets(两 Tab 查表驱动)
复刻的四个要点:
- 30 条曲线逐字复制,每个族标注上游文件出处(
// Sine.kt等)。上游对象实现的androidx.compose.animation.core.Easing换成同签名的本地fun interface Easing——接口不是数值语义的一部分,换壳不换数。 MultipleValuesAnimator逐行对齐:zipWithNext分组、frame <= 0钳首值、frame >= N-1钳末值、(integerPart, decimalPart)段内插值。ValueAtFraction三支:Float 线性、Int 截断、Color 保留上游怪癖(r/g/b 两端 pow(2.2)、插值后直接按 sRGB 构造,无逆伽马——红→蓝中点是#7F007F,比标准 lerp 偏暗)。- androidx Bezier 按字节码移植:
FastOutSlowInEasing等三条内置曲线反编译 androidx 1.6.6 的CubicBezierEasing,发现它用的是纯二分求 t、x 容差 0.001(不是牛顿法)——按其真实算法与运算次序移植,连近似行为都对齐。
3.2 JSON 契约
请求是扁平 JSON(MiniJson 只切顶层,数组入参走引号内逗号串),响应是标准 JSON(真数组,ArkTS JSON.parse 直取):
请求 : {"op":"curve","name":"EaseInOutElastic","samples":101}
{"op":"keyframe","name":"Linear","values":"0,120,240","t":0.5}
{"op":"track","name":"FastOutSlowIn","values":"0,12,0","samples":101}
{"op":"colortrack","colors":"#FF0000,#00FF00,#0000FF","samples":101}
{"op":"list"}
响应 : {"ok":true,"name":"...","count":101,"values":[...],"min":-0.117,"max":1.117}
{"ok":false,"error":"..."}
一个不对称设计:入参数组是字符串、出参数组是真 JSON 数组。MiniJson(继承自 cmp_qrose 审核模式)不解析嵌套,values:"0,120,240" 靠引号状态机跳过逗号正确切分;响应由桥手工拼装,可以是真数组,ArkTS 侧零额外处理。
3.3 错误通道
每个导出整体 try/catch,Kotlin 异常永远不跨 FFI:
@CName("AnimEval")
fun animEval(request: String): String = try {
AnimApi.eval(request)
} catch (t: Throwable) {
"""{"ok":false,"error":${jsonString(t.message ?: t.toString())}}"""
}
非法缓动名、缺 values / t、不支持的 op、数字解析失败,全部走同一个 ok:false 通道,ArkTS 侧统一渲染。
第 4 阶段:原生桥接(技术难点)
4.1 三层桥接
ArkTS
│ JSON string
▼
C++ N-API entry(libanim_napi.so)
│ const char*
▼
Kotlin/Native C ABI(libohosanim.so)
│ AnimEval / AnimVersion
▼
UTF-8 JSON + DisposeString
HarmonyOS Kotlin 的 CAdapter 不会把 @CName 业务函数挂到 ArkTS 模块上——libohosanim.so 里能 nm 到 AnimEval,但 ArkTS import 'libohosanim.so' 看不到它,所以 CMake 必须再包一层 NAPI。
4.2 Kotlin/Native 导出函数
@CName("AnimEval")
fun animEval(request: String): String // curve / keyframe / track / colortrack / list 一口进
@CName("AnimVersion")
fun animVersion(): String // 版本握手
| C ABI 符号 | ArkTS 方法 | 覆盖内核 |
|---|---|---|
AnimEval | animEval(req) | 30 曲线 + 4 内置采样、关键帧取值、轨迹、颜色轨迹 |
AnimVersion | animVersion() | fork 版本握手(真机版本行的数据来源) |
4.3 C++ N-API 方法分发
napi_property_descriptor desc[] = {
{"animEval", nullptr, NapiAnimEval, nullptr, nullptr, nullptr,
static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr},
{"animVersion", nullptr, NapiAnimVersion, nullptr, nullptr, nullptr,
static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr},
};
每个回调统一三步:napi_typeof 校验必须是 string、OH_LOG_INFO 打字节数、调 KN 函数并包装返回。KN 返回的字符串必须经 libohosanim_symbols()->DisposeString 归还 Kotlin 运行时,绝不 free()。libohosanim_api.h 按社区已审仓库同款手写(只声明 ExportedSymbols 结构与两个业务符号),不拖整份生成头。API 26 的 NAPI 头把 napi_get_value_string_utf8 缓冲区标成非 const——先量长度、std::vector<char> 拷贝再构造 std::string。
4.4 CMake 和类型声明
add_library(ohosanim SHARED IMPORTED)
set_target_properties(ohosanim PROPERTIES
IMPORTED_LOCATION ${KN_SO}
IMPORTED_NO_SONAME TRUE)
add_library(anim_napi SHARED napi_init.cpp)
target_link_libraries(anim_napi PUBLIC
libace_napi.z.so
libhilog_ndk.z.so
ohosanim)
OHOS_ARCH 为空或 libohosanim.so 缺失直接 FATAL_ERROR(提示先跑 link 任务);$ORIGIN rpath 加 POST_BUILD 拷贝保证 NAPI so、KN so、libc++ 三件套同目录,dlopen 时互相可见。
export const animEval: (request: string) => string;
export const animVersion: () => string;
第 5 阶段:ArkUI 宿主
5.1 轨迹预取和查表驱动
动画库的桥调用量级与存储库不同——每帧过 NAPI→JSON→解析在低端机上是浪费。设计成一次取整条轨迹、每帧只查表:
aboutToAppear(): void {
this.handshakeVersions(); // animVersion() 版本握手
this.fetchCurve(); // op=curve 取当前曲线 101 点
this.fetchAnimatorTracks(); // 四组 Demo 的轨迹各取 101 点
this.timerId = setInterval(() => this.tick(), 40); // 40ms 查表定时器
}
private tick(): void {
const elapsed = Date.now() - this.startedAt;
const p = (elapsed % 2500) / 2500; // 上游 infiniteRepeatable(tween(2500)) Restart
this.ballY = this.yFor(this.sample(this.curveValues, p)); // 数组索引,零桥调用
}
四组 Demo 的时序全部对齐上游:曲线 2500ms Restart(Linear 走时间轴、所选缓动走数值轴)、旋转三角 2500ms Linear(rotationX [0,180,180,0,0]、rotationY [0,0,180,180,0])、颜色三角 5000ms(2500ms Linear + Reverse 三角波)、弹跳圆点 600ms + (1+i)×70ms 错峰、加载按钮球 175ms Reverse + 1500ms 复位。
5.2 页面交互
曲线 Tab 自上而下:族选择(十族 + Compose)、三态选择(Compose 族换成四条内置选择)、320×220 曲线画布(蓝竖线 / 红横线边界、灰曲线、黑球)、实时取值行。动画 Tab:旋转三角(双层容器分别绕 X / Y 轴,下方 rotationX / rotationY 读数)、颜色三角(hex 读数)、三颗弹跳圆点、加载按钮(点按后小球 ±35vp 穿行 1500ms)。
5.3 过冲映射和错误边界
Elastic / Back 的输出越出 [0,1](InOutElastic 到 ±0.117),画布按 min/max 归一化映射,红蓝边界仍是 [0,1] 的视觉参照、过冲留在框内可见;球与曲线用同一映射。ok:false 时错误原文进状态行;Native 调用本身抛错(理论不该发生)同样被 try/catch 收进状态行。
第 6 阶段:示例与验证
6.1 工程结构
example/
├── nativeApp/
│ ├── build.gradle.kts jvm() + 双 ABI sharedLib(baseName=ohosanim)+ cxxShared + copyKnSo*
│ └── src/
│ ├── commonMain/kotlin/
│ │ ├── AnimEngine.kt 30 曲线 + 多关键帧内核 + androidx Bezier(1:1 复刻)
│ │ └── AnimApi.kt 五 op 协议 + MiniJson(扁平 JSON,继承自 cmp_qrose 审核模式)
│ ├── ohosMain/kotlin/Bridge.kt @CName × 2
│ └── jvmTest/kotlin/ CrossValidationTest / AnimatorMathTest / AnimApiTest(21 项)
└── harmonyApp/
├── build-profile.json5 signingConfigs 留空(签名由 DevEco 自动生成回填)
├── AppScope/app.json5 bundleName org.terminator.ohos.composeanimations、versionName 1.0.2-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/libohosanim_api.h
├── src/main/cpp/types/libanim_napi/{index.d.ts, oh-package.json5}
└── src/main/ets/pages/Index.ets ArkUI 两 Tab 验收页面
6.2 构建、签名和安装
./gradlew :easing:compileKotlinJvm # 上游 oracle
./gradlew :example:nativeApp:jvmTest # 21 项交叉验证
./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 中勾选自动生成,证书写入本机 ~/.ohos/config,不进入仓库(仓库内 build-profile.json5 的 signingConfigs 保持空数组,勾选自动签名后由 IDE 回填带 material 的完整配置)。
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.composeanimations -m entry
四、完整代码对照
4.1 整体架构
co.touchlab.composeanimations.easing/**(上游真源码,jvm() 编译)
│ 30 个 object : Easing,jvmTest 逐位断言
example.nativeapp.AnimEngine(纯 Kotlin 复刻)
│ 30 曲线 / MultipleValuesAnimator / ValueAtFraction / CubicBezierEasing
example.nativeapp.AnimApi + MiniJson
│ AnimEval / AnimVersion
libanim_napi.so
│ animEval / animVersion
Index.ets
│ 轨迹预取 + 40ms 查表 + @State 刷新
▼
ArkUI 真机页面(两 Tab)
4.2 文件清单
| 文件 | 职责 |
|---|---|
settings.gradle.kts | 极简 ohos 构建、eazytec Maven 置前、ohos_composeanimations、value-animator / app 移出 |
gradle/libs.versions.toml | Kotlin 2.2.21-1.0.0 + androidx JVM 坐标 + junit |
build.gradle.kts(根) | 版本 1.0.2-ohos.1、全局禁用 Ohos*Test* 任务 |
easing/build.gradle.kts | jvm-only KMP:上游源码零改动,oracle 身份 |
example/nativeApp/build.gradle.kts | jvm() 交叉验证 + 双 ABI sharedLib、cxxShared 定位 libc++、copyKnSo* 回填 |
nativeApp/.../AnimEngine.kt | 内核 1:1 复刻(带上游文件出处注释) |
nativeApp/.../AnimApi.kt | 五 op 协议、MiniJson、错误通道 |
nativeApp/.../Bridge.kt | 2 个 C ABI(@CName) |
nativeApp/.../jvmTest/ | 交叉验证 + 内核钉值 + 协议(21 项) |
harmonyApp/.../cpp/napi_init.cpp | N-API 导出、参数检查、DisposeString |
harmonyApp/.../cpp/CMakeLists.txt | IMPORTED 引入 KN so、$ORIGIN rpath、POST_BUILD 三件套 |
harmonyApp/.../ets/pages/Index.ets | ArkUI 两 Tab 验收页面 |
README.OpenHarmony_CN.md | 鸿蒙适配主文档(24 节) |
4.3 关键 API 对照
| 层次 | API | 作用 |
|---|---|---|
| 上游 easing | EaseInXxx : Easing(30 个 object) | transform(fraction) 单方法曲线 |
| 复刻 | AnimEngine.EaseInXxx | 同公式逐字复刻,jvmTest 容差 0 |
| 复刻 | AnimEngine.easing(name) | 名称注册表(大小写不敏感 + 别名) |
| 上游 value-animator | MultipleValuesAnimator.animate(frame) | frame→段内插值,边界钳制 |
| 复刻 | AnimEngine.valueAt(easing, values, t) | frame = easing(t)×(N-1) 后走同一映射 |
| 上游 value-animator | ValueAtFraction.Float / Int / Color | 线性 / 截断 / pow(2.2) 无逆伽马 |
| androidx 参考 | FastOutSlowInEasing 等 3 条 | 二分求解,字节码级移植 |
| Native | AnimEval / AnimVersion | C ABI JSON 导出 |
| N-API | animEval(req) / animVersion() | 向 ArkTS 暴露能力 |
| ArkTS | fetchCurve() / tick() | 轨迹预取 + 查表驱动 |
4.4 ArkTS 与 Kotlin 的边界
// ArkTS:一次取轨迹,之后每帧只查表
const res = JSON.parse(animEval(JSON.stringify({
op: 'track', name: 'FastOutSlowIn', values: '0,12,0', samples: 101
}))) as TrackResult;
const idx = Math.round(p * (res.values!.length - 1));
const translateY = res.values![idx]; // 喂给 .translate({ y: translateY })
// Kotlin:easing 作用在整条关键帧路径上,不是每段各 ease 一次
fun valueAt(easing: Easing, values: FloatArray, progress: Float): Float {
val animator = MultipleValuesAnimator(values.toList(), ::lerpFloat)
return animator.animate(
easing.transform(progress.coerceIn(0f, 1f)) * animator.targetFrameValue
)
}
两者之间只传输扁平 JSON 字符串,不传输 Kotlin 对象或未校验的动态结构。
五、关键决策说明
决策 1:内核 1:1 复刻,而不是硬接 Compose 或 ArkTS 手抄
三条路里硬接 Compose 等于再发一套工具链(上游 1.6.10 与社区线无交集);ArkTS 手抄公式一旦漂移,鸿蒙上的曲线和 Android/iOS 对不上,"数值语义对齐"这个适配价值就归零;复刻内核放在 example/nativeApp/commonMain(material-motion 先例)——不进库模块 public API,将来社区 CMP 的 ohos 制品对齐时好拆好退。
决策 2:工具线钉 2.2.21-1.0.0 + Gradle 8.14.1
与 KStore 相反的选择,原因同样是"版本跟着依赖走":KStore 的 kotlinx 制品线把 stdlib 推到 2.4.20;本仓复刻内核零第三方依赖、:easing oracle 只用 androidx JVM jar(元数据 1.9,2.2.21 可读),没有升线压力,就用社区最早批准的经典钉法。
决策 3:根构建替换(cmp_qrose 模式),:easing 进构建、:value-animator 留盘
上游根构建(AGP / Compose 插件 / vanniktech 发布)对鸿蒙没有保留价值,整体替换。:easing 留在构建里身份是 jvm oracle;:value-animator 100% 依赖 Compose 编译器插件,jvm 都编不了,留盘不进 settings.gradle.kts——它的存在方式只有这一种。
决策 4:上游源码 oracle 交叉验证,比纯钉值强一档
material-motion 的复刻用"手算钉值"验证;本仓把上游 :easing 原样编进 jvmTest 类路径,30 条曲线 × 101 点直接拿上游真源码断言相等(容差 0)。钉值只能证明"和昨天抄的一样",oracle 证明"和上游一样"——EaseInQuint 上游偏差(写成 1-(1-x)^5,与 EaseOutQuint 同式)被原样继承并有测试钉住,若有人"好心修正"立刻红灯。
决策 5:轨迹预取 + 查表驱动,每帧零桥调用
桥设计成"一次算整条轨迹"而非"每帧求值":ArkTS 40ms 定时器只做数组索引,NAPI 过桥只在参数变化时发生。要更平滑就调大 samples(上限 1001)或在 ArkTS 侧线性内插——这是展示层关切,不动内核。
六、测试与验证
6.1 测试环境
本次验证使用 macOS(arm64)、JDK 21(Homebrew openjdk@21)、DevEco Studio 5.1.0 Release、OpenHarmony 命令行工具(hvigor / hdc)与鸿蒙真机(arm64)。
6.2 JVM 交叉验证
./gradlew :example:nativeApp:jvmTest
21 项用例全部通过:
| 测试类 | 用例数 | 钉住什么 |
|---|---|---|
CrossValidationTest | 4 | 30 条曲线对上游 oracle 逐位一致(101 点 × 容差 0);EaseInQuint 偏差原样继承;3 条 androidx Bezier 对参考实现一致;注册表 30 + 4 |
AnimatorMathTest | 7 | 段内插值与边界钳制、单关键帧常量、Int 截断、Color pow(2.2) 怪癖(#7F007F)、过冲极值(-0.118 / 1.119)、hex 往返 |
AnimApiTest | 10 | 五 op 协议、引号内逗号串解析、别名解析、错误通道、默认 op |
6.3 HAP 验证
:easing:compileKotlinJvm: BUILD SUCCESSFUL
:example:nativeApp:jvmTest: 21/21 PASSED
linkDebugSharedOhosArm64 / linkDebugSharedOhosX64: BUILD SUCCESSFUL
llvm-nm -D: AnimEval / AnimVersion / libohosanim_symbols 均为全局 T 符号
BuildNativeWithCmake: finished(双 ABI 编出 libanim_napi.so)
CompileArkTS: finished
PackageHap: finished
BUILD SUCCESSFUL
install bundle successfully
start ability successfully
entry-default-unsigned.hap 约 7.8 MB,内含双 ABI 各三件套(libanim_napi.so / libohosanim.so / libc++_shared.so);HAP 内 strip 后 KN so arm64 约 2.7MB、x86_64 约 2.2MB(debugShared 未 strip 时分别约 8.5MB / 7.7MB,含 Kotlin/Native 运行时)。
6.4 功能用例
- 启动后状态栏下出现
桥 1.0.2-ohos.1 · 上游 1.0.2 · Kotlin 2.2.21-1.0.0(来自真实 Native 调用,非静态文字); - 默认 EaseInOutElastic:灰曲线明显过冲(越出红框内侧)、黑球 2.5 秒一圈跳回起点(Restart);
- 依次切十族 × In/Out/InOut:Quint 族起势最陡、Expo 前半贴 0 后半贴 1、Back 单侧回拉、Elastic 多段振荡衰减、Bounce 末段弹跳平台;
- 切 Compose 族:FastOutSlowIn / LinearOutSlowIn / FastOutLinearIn / Linear 四条并列,形态与各自名称语义一致;
- 曲线 Tab 状态行实时读数与球位置吻合(如
EaseInOutElastic(0.37) = 0.83); - 旋转三角:2500ms 内先绕 X 翻 180°、停顿、再绕 Y 翻 180°,读数分别走过 0→180→180→0 与 0→0→180→180→0;
- 颜色三角:红→绿→蓝→绿→红 循环 5 秒,中段过渡色偏暗(上游 pow(2.2) 怪癖,非 bug);
- 弹跳圆点:三颗依次下弹 12vp 回位,周期 600ms、错峰 70ms,波形先急后缓;
- 加载按钮:点按后 1500ms 内小球 ±35vp 穿行,随后恢复 “Login”;
- 传入不存在的缓动名,状态行显示
失败: unknown easing: ...,应用不崩。
6.5 验证结论
动画内核、Kotlin/Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 已完成链路验证。应用在鸿蒙真机上成功安装并进入前台,页面展示了真实的动画会话:版本握手、30 条曲线族切换、四组多关键帧 Demo 的时序与数值语义(见下节效果图)。
七、运行效果
7.1 真机截图

7.2 命令速查
git clone https://atomgit.com/oh-tpc/compose-animations.git
cd compose-animations
export JAVA_HOME="/path/to/jdk-21"
./gradlew :easing:compileKotlinJvm # 上游 oracle
./gradlew :example:nativeApp:jvmTest # 21 项交叉验证
./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.composeanimations -m entry
hdc shell hilog | grep AnimNapi
八、遗留问题与改进方向
8.1 踩坑复盘
- 只改 ArkTS 页面不算 CMP 适配,必须让动画内核真正编译成
ohosArm64/ohosX64动态库并由真机执行; settings.gradle.kts里 eazytec 双仓必须排在pluginManagement与dependencyResolutionManagement最前,否则配置期直接Unresolved reference: ohosArm64;- Kotlin 注释里写路径字面量
…/easing/*时,/*会在嵌套块注释里开启新注释,把整个文件吞掉、报"Unclosed comment"且所有符号 unresolved——注释里避免/*字面量; - Kotlin 对象属性按声明序初始化:注册表
allEasings引用后面声明的 Bezier val 会报Variable must be initialized——声明上移; String.format("%06X".format)是 JVM-only,Kotlin/Native 没有——跨平台代码手写 hex 格式化;- androidx
CubicBezierEasing的实现是纯二分 + 0.001 容差,不是教科书牛顿法;自己写牛顿法会与参考值差 ~1e-4——反编译字节码按其真实算法与运算次序移植,连近似行为都对齐; - ArkUI 的
rotate通用属性重复设置是覆盖不是叠加——绕 X / Y 双轴旋转要外层容器与内层节点嵌套组合; - ArkTS 侧
importKN so 没有AnimEval:CAdapter 不导出业务函数,必须 CMake NAPI 包装,三个名字(nm_modname/ import 语句 /index.d.ts)必须一致; - HAP 缺当前 ABI 的 so 会报安装失败
9568347,abiFilters必须双 ABI,每个 ABI 三件套缺一不可; - KN 返回字符串必须
DisposeString归还运行时,N-API 层不能free(); - 根构建的 gitignore 是根锚定模式,盖不住嵌套目录——DevEco 打开工程后生成的
example/harmonyApp/.idea/、entry/build/、entry/libs/要单独追加,否则 so 混进提交; - 命令行 hvigor 需要
export DEVECO_SDK_HOME=/Applications/DevEco-Studio.app/Contents/sdk与 DevEco 自带 Node / ohpm / JBR,系统 JAVA_HOME 指向别的 JDK 会换一种不相干的报错。
8.2 已知问题
- HarmonyOS 上没有 Compose 运行时:
@ComposableAPI 形态不在鸿蒙侧,桥提供的是同一套数值语义,帧调度由 ArkTS 定时器承担; - 上游已声明维护模式、每个 easing 都标
@Deprecated(指向官方EaseInXxx):jvmTest 引用时的废弃告警是预期现象,fork 以与上游 1.0.2 逐位一致为验收线,不做"顺手修复"; EaseInQuint上游实现与 easings.net 不一致(等于 EaseOutQuint):原样保留并测试钉住,上游修正则跟随;ValueAtFraction.Color在 pow(2.2) 空间插值且不逆变换,颜色中点偏暗:复刻保留,业务要标准过渡在 ArkTS 侧自行 lerp;- Demo 每 40ms 从 101 点轨迹取最近采样(查表非逐帧求值),极端平滑场景需调大
samples或 ArkTS 侧内插; - CAdapter 生成胶水的 clang 警告与 shape
fill的 SDK 26 版本标注告警(实为 d.ts 回归,fill自 API 7 可用)不影响产物; - HAP 安装必须使用与 bundle name
org.terminator.ohos.composeanimations匹配的签名。
8.3 未来优化方向
- 评估把
track/curve的采样上限放宽或支持 ArkTS 侧线性内插参数; - 等社区 CMP 的 ohos 制品与上游版本线对齐后,
:easing/:value-animator直接加ohosArm64()编 klib,AnimEngine 降级为对照实现; - 评估
.har形态发布,让 ArkTS 工程直接依赖而不是走 so 三件套; - 在 CI 中加入 ohos 编译、ELF 符号检查和 HAP 构建;
- 补充模拟器(x86_64)与其他设备 ABI 的验收矩阵和自动化截图脚本(uitest dumpLayout + snapshot_display)。
九、总结
9.1 核心难点回顾
上游 easing + value-animator(100% import androidx.compose)
→ 源码依赖度盘点:easing 纯数学可作 oracle、value-animator 内核可复刻
→ 工具线钉 2.2.21-1.0.0 + Gradle 8.14.1(零第三方依赖,社区经典线)
→ 根构建替换(cmp_qrose 模式)
→ AnimEngine 1:1 复刻(30 曲线 + 多关键帧内核 + androidx Bezier 字节码级)
→ :easing jvm() oracle 进 jvmTest(30 曲线 × 101 点 × 容差 0)
→ C ABI(@CName × 2)+ 扁平 JSON 协议
→ C++ N-API(DisposeString 纪律)
→ ArkTS 轨迹预取 + 40ms 查表驱动
→ ArkUI 真机两 Tab 验收(曲线族 / 多关键帧 Demo)
9.2 封装层次
- 内核层:
AnimEngine——30 条缓动公式(上游出处注释)、MultipleValuesAnimator、ValueAtFraction三支、CubicBezierEasing二分求解; - 协议层:
AnimApi+ MiniJson——五 op 扁平 JSON、别名解析、错误通道; - Native 层:生成双 ABI 动态库并通过 2 个 C ABI 输出 JSON;
- N-API 层:完成参数检查、字符串转换和 Native 内存释放;
- ArkTS 层:管理页面状态、轨迹预取、相位查表、实时读数与错误文本;
- DevEco 层:完成 CMake、HAP、签名、安装和真机运行。
9.3 三条经验
- 源码依赖度决定适配路线:KStore 零 UI 真编译整库、qrose 纯算法进 commonMain + Painter 隔离、material-motion 100% Compose 复刻内核——
compose-animations的特殊之处是"纯数学 + 单接口依赖",于是 easing 当 oracle、value-animator 复刻内核,一个仓里两条处置并存; - oracle 交叉验证比钉值强一档:手算钉值证明"和昨天抄的一样",上游真源码断言证明"和上游一样"——上游的偏差(EaseInQuint、Color 怪癖)被原样继承并显式钉住,这才是 CMP 数值语义对齐的可信证明;
- 动画库的验收在数值语义不在渲染面:"设备上看起来流畅"不构成验收,jvmTest 逐位一致 + Demo 时序对齐上游(2500/600/175ms、stagger 70ms、Reverse 三角波)才构成——曲线形态、过冲幅度、关键帧节奏三层都对上,迁移才不用重推参数。
9.4 适配成果
当前适配已完成:
compose-animations1.0.2动画内核的双 ABI Kotlin/Native 编译(30 条曲线 + 多关键帧语义 + androidx Bezier,1:1 复刻带出处注释);- 上游
:easing源码零改动编进 jvmTest 作 oracle,30 条曲线 × 101 点容差 0 逐位一致(21 项 jvmTest 全绿); - 2 个 C ABI 符号和 C++ N-API 包装层(
llvm-nm验证全局 T 符号); - DevEco Stage ArkUI 两 Tab 验收页面(曲线族切换、实时取值、旋转三角、颜色三角、弹跳圆点、加载按钮、版本握手);
- HAP 构建(双 ABI 六件套 so,约 7.8MB)、签名、设备安装和真机验证;
- AtomGit 项目文档、OpenHarmony README(中英双份)和本地效果图。
适配过程 FAQ
以下问题来自
ohosArm64目标接入、NAPI 桥接、HAP 构建与真机验收的实际流程,按出现频率排序。
Q1:DevEco Studio Sync 失败或 ohpm 依赖下载超时,怎么处理?
- 确认 DevEco Studio 版本与工程
build-profile.json5的compatibleSdkVersion一致; - 在
File > Settings > Build, Execution, Deployment > Ohpm配置可用的 ohpm 仓库镜像或代理; - 删除
oh_modules、oh-package-lock.json5与.hvigor缓存后重新 Sync; - 公司网络需要代理时,同时给 ohpm 与 hvigor 配置
http.proxy。
Q2:CMake 报 Missing .../libxxx.so 或找不到 Kotlin/Native 动态库?
- 先在
example目录执行./gradlew :nativeApp:linkDebugSharedOhosArm64(模拟器用linkDebugSharedOhosX64); - 确认
libxxx.so与libc++_shared.so已复制到ohos/entry/libs/<abi>/; - 检查
entry/build-profile.json5的abiFilters与设备 ABI 是否一致(真机arm64-v8a,模拟器x86_64); - 清理
entry/.cxx与entry/build后重试。
Q3:ArkTS 报 Cannot find module 'libxxx_napi.so' 或调用返回 undefined?
- 确认 CMake 的
add_library(<target> SHARED ...)名称与import ... from 'libxxx_napi.so'完全一致; - 解包 HAP,确认
libs/<abi>/libxxx_napi.so已打包; - 删除
entry/build、entry/.cxx与oh_modules后重新构建; - 检查
module.json5的srcEntry与页面路径是否正确。
Q4:真机安装报 signature verification failed 或 install failed?
- 在 DevEco Studio 的
Signing Configs配置证书、Profile 与密码; bundleName必须与签名 profile 中的 bundle name 一致;- 设备需加入 profile 的
deviceIds,调试证书有效期需覆盖当前时间; - 用
hdc list targets确认设备在线,再执行hdc install -r xxx-signed.hap。
Q5:应用启动白屏、闪退或 NAPI 调用崩溃?
- 用
hdc hilog | grep -i "napi\|kotlin"查看原生日志; - 确认
libc++_shared.so与 Kotlin/Native 工具链版本匹配,且 ABI 与设备一致; - 检查 C ABI 返回的字符串是否由 Native 侧分配、ArkTS 侧复制后是否调用
XxxFree释放; - 在 DevEco 的 Native 调试配置中附加
lldb,定位空指针与越界访问。
Q6:Gradle 报 ohosArm64 目标不存在或 Kotlin/Native 编译失败?
- 使用鸿蒙发行版 Kotlin(如
2.2.21-1.0.0),并在settings.gradle.kts配置 eazytec 仓库; - Gradle 建议 8.x、JDK 21,版本不匹配时先统一工具链;
- 确认
kotlin { ohosArm64() }已应用,且local.properties的 SDK 路径有效; - 清理
~/.gradle/caches中对应版本的 Kotlin/Native 缓存后重试。
Q7:HAP 构建报 SDK 版本不匹配或 compatibleSdkVersion 错误?
- 对齐 DevEco Studio、HarmonyOS SDK 与
build-profile.json5中的compatibleSdkVersion; - 升级 DevEco Studio 后同步升级 hvigor 与 SDK,避免
modelVersion与 IDE 不匹配; - 命令行构建时设置
DEVECO_SDK_HOME指向 DevEco 的sdk目录。
Q8:命令行 hvigor 构建失败,但 DevEco 里能构建?
- 使用 DevEco 自带的 node 与 hvigor:
"$DEVECO/Contents/tools/node/bin/node" "$DEVECO/Contents/tools/hvigor/bin/hvigorw.js" ...; - 设置
JAVA_HOME、NODE_HOME、DEVECO_SDK_HOME; - 加上
--no-daemon排除守护进程缓存影响,并清理entry/build与.hvigor。
参考文档
更多推荐


所有评论(0)