本文记录 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 的每一个文件都 import androidx.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 / maxcurve / track 的采样数组与极值(画布归一化映射用)。
valuekeyframe 的单点取值。
colorscolortrack 的 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 运行时,@Composable API 形态不在鸿蒙侧;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 MultiplatformHarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven)
JDK21(org.gradle.java.home 指向 Homebrew openjdk@21)
OpenHarmony 目标ohosArm64 + ohosX64(:easing 保留 jvm() 作交叉验证)
Gradle8.14.1(wrapper 钉死;2.2.21 旧线要求 8.x)
依赖内核零第三方依赖(纯 kotlin.math);:easing oracle 用 androidx.compose 1.6.6 JVM jar
DevEco product5.1.0(18) 兼容
DevEco Studio5.1.0 Release
ABIarm64-v8a + x86_64
Bundleorg.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 文件依赖形态处置
easing10纯数学,但每个 object 都实现 androidx.compose.animation.core.Easingjvm() oracle(源码零改动编进 jvmTest)
value-animator9100% @Composable + AnimationSpec + Compose 编译器插件留盘不进构建,纯逻辑内核复刻进 nativeApp
app20+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 查表驱动)

复刻的四个要点:

  1. 30 条曲线逐字复制,每个族标注上游文件出处(// Sine.kt 等)。上游对象实现的 androidx.compose.animation.core.Easing 换成同签名的本地 fun interface Easing——接口不是数值语义的一部分,换壳不换数。
  2. MultipleValuesAnimator 逐行对齐:zipWithNext 分组、frame <= 0 钳首值、frame >= N-1 钳末值、(integerPart, decimalPart) 段内插值。
  3. ValueAtFraction 三支:Float 线性、Int 截断、Color 保留上游怪癖(r/g/b 两端 pow(2.2)、插值后直接按 sRGB 构造,无逆伽马——红→蓝中点是 #7F007F,比标准 lerp 偏暗)。
  4. 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 方法覆盖内核
AnimEvalanimEval(req)30 曲线 + 4 内置采样、关键帧取值、轨迹、颜色轨迹
AnimVersionanimVersion()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.tomlKotlin 2.2.21-1.0.0 + androidx JVM 坐标 + junit
build.gradle.kts(根)版本 1.0.2-ohos.1、全局禁用 Ohos*Test* 任务
easing/build.gradle.ktsjvm-only KMP:上游源码零改动,oracle 身份
example/nativeApp/build.gradle.ktsjvm() 交叉验证 + 双 ABI sharedLib、cxxShared 定位 libc++、copyKnSo* 回填
nativeApp/.../AnimEngine.kt内核 1:1 复刻(带上游文件出处注释)
nativeApp/.../AnimApi.kt五 op 协议、MiniJson、错误通道
nativeApp/.../Bridge.kt2 个 C ABI(@CName)
nativeApp/.../jvmTest/交叉验证 + 内核钉值 + 协议(21 项)
harmonyApp/.../cpp/napi_init.cppN-API 导出、参数检查、DisposeString
harmonyApp/.../cpp/CMakeLists.txtIMPORTED 引入 KN so、$ORIGIN rpath、POST_BUILD 三件套
harmonyApp/.../ets/pages/Index.etsArkUI 两 Tab 验收页面
README.OpenHarmony_CN.md鸿蒙适配主文档(24 节)

4.3 关键 API 对照

层次API作用
上游 easingEaseInXxx : Easing(30 个 object)transform(fraction) 单方法曲线
复刻AnimEngine.EaseInXxx同公式逐字复刻,jvmTest 容差 0
复刻AnimEngine.easing(name)名称注册表(大小写不敏感 + 别名)
上游 value-animatorMultipleValuesAnimator.animate(frame)frame→段内插值,边界钳制
复刻AnimEngine.valueAt(easing, values, t)frame = easing(t)×(N-1) 后走同一映射
上游 value-animatorValueAtFraction.Float / Int / Color线性 / 截断 / pow(2.2) 无逆伽马
androidx 参考FastOutSlowInEasing 等 3 条二分求解,字节码级移植
NativeAnimEval / AnimVersionC ABI JSON 导出
N-APIanimEval(req) / animVersion()向 ArkTS 暴露能力
ArkTSfetchCurve() / 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 项用例全部通过:

测试类用例数钉住什么
CrossValidationTest430 条曲线对上游 oracle 逐位一致(101 点 × 容差 0);EaseInQuint 偏差原样继承;3 条 androidx Bezier 对参考实现一致;注册表 30 + 4
AnimatorMathTest7段内插值与边界钳制、单关键帧常量、Int 截断、Color pow(2.2) 怪癖(#7F007F)、过冲极值(-0.118 / 1.119)、hex 往返
AnimApiTest10五 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. 启动后状态栏下出现 桥 1.0.2-ohos.1 · 上游 1.0.2 · Kotlin 2.2.21-1.0.0(来自真实 Native 调用,非静态文字);
  2. 默认 EaseInOutElastic:灰曲线明显过冲(越出红框内侧)、黑球 2.5 秒一圈跳回起点(Restart);
  3. 依次切十族 × In/Out/InOut:Quint 族起势最陡、Expo 前半贴 0 后半贴 1、Back 单侧回拉、Elastic 多段振荡衰减、Bounce 末段弹跳平台;
  4. 切 Compose 族:FastOutSlowIn / LinearOutSlowIn / FastOutLinearIn / Linear 四条并列,形态与各自名称语义一致;
  5. 曲线 Tab 状态行实时读数与球位置吻合(如 EaseInOutElastic(0.37) = 0.83);
  6. 旋转三角:2500ms 内先绕 X 翻 180°、停顿、再绕 Y 翻 180°,读数分别走过 0→180→180→0 与 0→0→180→180→0;
  7. 颜色三角:红→绿→蓝→绿→红 循环 5 秒,中段过渡色偏暗(上游 pow(2.2) 怪癖,非 bug);
  8. 弹跳圆点:三颗依次下弹 12vp 回位,周期 600ms、错峰 70ms,波形先急后缓;
  9. 加载按钮:点按后 1500ms 内小球 ±35vp 穿行,随后恢复 “Login”;
  10. 传入不存在的缓动名,状态行显示 失败: 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 踩坑复盘

  1. 只改 ArkTS 页面不算 CMP 适配,必须让动画内核真正编译成 ohosArm64 / ohosX64 动态库并由真机执行;
  2. settings.gradle.kts 里 eazytec 双仓必须排在 pluginManagement 与 dependencyResolutionManagement 最前,否则配置期直接 Unresolved reference: ohosArm64;
  3. Kotlin 注释里写路径字面量 …/easing/* 时,/* 会在嵌套块注释里开启新注释,把整个文件吞掉、报"Unclosed comment"且所有符号 unresolved——注释里避免 /* 字面量;
  4. Kotlin 对象属性按声明序初始化:注册表 allEasings 引用后面声明的 Bezier val 会报 Variable must be initialized——声明上移;
  5. String.format("%06X".format)是 JVM-only,Kotlin/Native 没有——跨平台代码手写 hex 格式化;
  6. androidx CubicBezierEasing 的实现是纯二分 + 0.001 容差,不是教科书牛顿法;自己写牛顿法会与参考值差 ~1e-4——反编译字节码按其真实算法与运算次序移植,连近似行为都对齐;
  7. ArkUI 的 rotate 通用属性重复设置是覆盖不是叠加——绕 X / Y 双轴旋转要外层容器与内层节点嵌套组合;
  8. ArkTS 侧 import KN so 没有 AnimEval:CAdapter 不导出业务函数,必须 CMake NAPI 包装,三个名字(nm_modname / import 语句 / index.d.ts)必须一致;
  9. HAP 缺当前 ABI 的 so 会报安装失败 9568347,abiFilters 必须双 ABI,每个 ABI 三件套缺一不可;
  10. KN 返回字符串必须 DisposeString 归还运行时,N-API 层不能 free();
  11. 根构建的 gitignore 是根锚定模式,盖不住嵌套目录——DevEco 打开工程后生成的 example/harmonyApp/.idea/、entry/build/、entry/libs/ 要单独追加,否则 so 混进提交;
  12. 命令行 hvigor 需要 export DEVECO_SDK_HOME=/Applications/DevEco-Studio.app/Contents/sdk 与 DevEco 自带 Node / ohpm / JBR,系统 JAVA_HOME 指向别的 JDK 会换一种不相干的报错。

8.2 已知问题

  • HarmonyOS 上没有 Compose 运行时:@Composable API 形态不在鸿蒙侧,桥提供的是同一套数值语义,帧调度由 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 三条经验

  1. 源码依赖度决定适配路线:KStore 零 UI 真编译整库、qrose 纯算法进 commonMain + Painter 隔离、material-motion 100% Compose 复刻内核——compose-animations 的特殊之处是"纯数学 + 单接口依赖",于是 easing 当 oracle、value-animator 复刻内核,一个仓里两条处置并存;
  2. oracle 交叉验证比钉值强一档:手算钉值证明"和昨天抄的一样",上游真源码断言证明"和上游一样"——上游的偏差(EaseInQuint、Color 怪癖)被原样继承并显式钉住,这才是 CMP 数值语义对齐的可信证明;
  3. 动画库的验收在数值语义不在渲染面:"设备上看起来流畅"不构成验收,jvmTest 逐位一致 + Demo 时序对齐上游(2500/600/175ms、stagger 70ms、Reverse 三角波)才构成——曲线形态、过冲幅度、关键帧节奏三层都对上,迁移才不用重推参数。

9.4 适配成果

当前适配已完成:

  • compose-animations 1.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。

参考文档

Logo

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

更多推荐