开源鸿蒙平台 CMP 三方库「Orbital」适配全流程
本文记录
Orbital接入 OpenHarmony 的完整过程,覆盖共享元素转场库盘点、路线选型(为什么是 ArkTS 重写而不是 Kotlin/Native 桥接)、上游 API ↔ ArkUI 机制对照、弹簧参数数学换算、HAR 库实现、ArkUI 四 Tab 真机页面、HAP 构建和真机验收。
Orbital是 skydoves 出品的 Jetpack Compose / Compose Multiplatform 共享元素转场动画库:基于 Compose UI 的 Lookahead 双遍布局(LookaheadScope+intermediateLayout)与movableContent运行时机制,把"布局即动画"做成四类开箱能力——Modifier.animateMovement(位置移动)、Modifier.animateTransformation(尺寸变换)、Modifier.animateSharedElementTransition(共享元素转场)与Modifier.animateBounds(边界动画),并以rememberMovableContentOf让同一内容在组合树中迁移时保持连续。本次适配与社区已批准的算法库路线(qrose / KStore 的"真编译整库 + C ABI/N-API 桥接")有本质区别:Orbital 的全部能力构建在compose.ui运行时机制之上,而compose.ui没有 ohos 制品,Lookahead 与movableContent也不是可以编进.so的纯算法——因此本 fork 采用 ArkTS HAR 等价重写:上游 8 个公共 API 逐条映射到 ArkUI 的geometryTransition+animateTo+curves.springCurve,弹簧参数做纯数学换算,一个完整的共享元素转场能力库以 HAR 形态运行在真机上,ArkUI 四 Tab 页面作为验收宿主。
项目地址: AtomGit/oh-tpc/Orbital.git
上游仓库: skydoves/Orbital
开发工具: 华为云码道
环境安装: 环境安装指南
一、背景
1.1 为什么做 OpenHarmony 平台 KMP/CMP 适配
Orbital 为 Compose 应用提供"共享元素转场"这一件事的完整答案:Orbital 容器内部用 LookaheadScope + Layout 先做一遍 lookahead 测量拿到所有子项的目标几何,再在 intermediateLayout 里对当前几何做动画插值——上游把这套两遍布局封装成 OrbitalScope 与四个 Modifier.animate* 扩展,业务一行修饰就能得到海报点开成详情页、列表项展开成详情卡这类连续转场;DeferredAnimation 内部封装 Animatable,目标值变化时惰性启动或续接弹簧动画;rememberMovableContentOf / rememberContentWithOrbitalScope 则让带动画身份的内容在组合树中迁移不闪断。KMP 项目把这些能力放在 commonMain,由 Android、iOS、桌面共享。OpenHarmony 应用同样需要这类转场能力,但上游构建(Compose Multiplatform 1.6.11 / Kotlin 2.0.0)不会生成 OpenHarmony 变体。
关键分歧在库的性质:KStore 这类存储库的引擎是纯 Kotlin 算法,可以原样编成 Kotlin/Native 动态库再经 C ABI / N-API 桥接;而 Orbital 的每一个公开 API 的返回值都是 Modifier / Composable,每一行动画逻辑都站在 androidx.compose.ui 的测量、布局与绘制管线上。compose.ui 目前没有 ohos 制品,Kotlin/Native 编译路线在这类纯 UI 机制库上不可行。
| 障碍 | 具体问题 |
|---|---|
| 机制不可编译 | LookaheadScope / intermediateLayout / onPlaced / localLookaheadPositionOf 是 compose.ui 运行时机制,不是纯算法,编不进 .so。 |
| 制品缺失 | compose.ui / compose.animation 没有 ohos 变体,commonMain 依赖坐标整体不可解析。 |
| movableContent 无对应物 | 组合树实例迁移是 Compose runtime 概念,ArkTS 没有等价 API,需要设计近似表达。 |
| 动画模型不同 | Animatable + FiniteAnimationSpec 驱动的是布局管线内的逐帧插值;ArkUI 的 animateTo 作用于属性变更,语义对齐需要换算。 |
| 语言边界不同 | Kotlin 的 Modifier 链式 DSL 与 ArkTS 的组件/属性体系形态完全不同,API 面需要重新设计而非直接桥接。 |
| 交付链路独立 | HAR 模块、hvigor 工程、签名和 hdc 都要单独验收,且不能破坏上游 Gradle 工程。 |
因此,本项目把边界放在:ArkTS HAR 库的等价重写(API 语义按上游对照表逐条对齐)、弹簧动画的数学换算(Compose spring ↔ ArkUI springCurve 同为阻尼谐振子模型)和 DevEco 工程交付(example/harmonyApp 双模块:orbital HAR + entry HAP Demo)。转场语义由 ArkUI 原生能力负责,上游 Kotlin 源码一行不动、原样留盘作为 Android 侧对照基线。
1.2 库提供的能力
Demo 提供一个真实的转场会话,对应 HAR 库的全部入口:
- 位置移动(Movement):对应上游
Modifier.animateMovement。80×80 色块在 300×340 轨迹区内从 (0,0) 弹簧飞到 (200, 250),过冲回落再稳定; - 尺寸变换(Transformation):对应上游
Modifier.animateTransformation。色块 96×96 ↔ 260×180 弹性放大缩小,宽高同相; - 共享元素转场(Shared):对应上游
Modifier.animateSharedElementTransition。120×160 海报卡点开成 300×380 详情卡,内容全程连续不闪断,返回反向连续; - 边界动画 + movable(Bounds):对应上游
Modifier.animateBounds+rememberMovableContentOf。三条目点击互斥展开(72 ↔ 200 高度),每行带稳定 contentId,debug 开关显示动画边界描边; - 状态行验收:每次交互底部状态行记录目标值与弹簧参数(如
movement → (200, 250) spring(0.75, 380)),数值读自 spec 对象本身而非硬编码。
HAR 库的完整能力(弹簧工厂、scope 上下文、四个包装组件、movable 稳定 id、数值动画器)全部在 example/harmonyApp/orbital 模块里实现并导出,上游 orbital/api/orbital.api 的 8 个公共 API 快照与鸿蒙侧导出面一一对照(见 4.3 节)。
1.3 实现适配
| 维度 | 要求 |
|---|---|
| 代码复用 | 上游 Kotlin 源码零改动留盘(orbital/ 模块原样保留);鸿蒙侧以 ArkTS 等价重写,API 语义逐条对齐上游 8 个公共 API。 |
| 平台目标 | DevEco Stage 模型,HarmonyOS 5.1.0(18)(API 18)及以上;HAR 模块 phone / tablet 双设备类型。 |
| 构建隔离 | 鸿蒙工程独立于上游 Gradle 工程,位于 example/harmonyApp;上游 app/ benchmark*/ 模块不参与鸿蒙构建。 |
| 动画底座 | ArkUI geometryTransition(节点对转场)+ animateTo + curves.springCurve(物理弹簧),参数与 Compose spring 数学换算。 |
| 依赖纪律 | HAR 零第三方依赖、Demo 零权限申请、无网络图;file:../orbital 相对路径引用,未来切 ohpm 发布。 |
| UI 完整 | 单页覆盖库全部入口:四个 Tab 对应四类能力,状态行记录 spec,异常走 UI 不崩。 |
| 仓库规范 | 项目说明、文章、效果图和源码链接统一使用 AtomGit。 |
本项目的 ArkUI 页面不是 Compose Multiplatform UI 实现,HAR 库也不是 Kotlin 代码的转译——这是一次语义对齐的原生重写:等价的部分(弹簧物理、转场配对)精确换算,近似的部分(Lookahead 逐帧中间布局、movableContent 树迁移)诚实标注,见第 8.2 节。
二、实现路线图
第 1 阶段:项目初始化 ── 盘点上游 9 个源文件的依赖形态、确认 compose.ui 无 ohos 制品、确定 ArkTS 重写路线
第 2 阶段:机制对照建模 ── LookaheadScope ↔ geometryTransition、movableContent ↔ 稳定 id、spring ↔ springCurve 参数换算
第 3 阶段:HAR 库实现 ── AnimationSpecs / OrbitalScope / 五个包装组件 / OrbitalMovable / OrbitalAnimator
第 4 阶段:ArkUI 宿主 ── 单页四 Tab、状态行验收、弹簧闭包纪律
第 5 阶段:构建与修复 ── hvigor assembleHar / assembleHap、HAR 入口位置与尾随闭包两处编译修复
第 6 阶段:示例与验证 ── HAP、签名、设备安装、真机运行和效果图
三、逐步实现过程
第 1 阶段:项目初始化
1.1 工程边界
orbital/ 上游 Kotlin 库(9 个源文件全在 commonMain,零改动留盘,不参与鸿蒙构建)
app/ benchmark/ benchmark-app/ 上游 Android Demo 与基准(零改动留盘)
buildSrc/ scripts/ spotless/ 上游 Gradle 基础设施(零改动留盘)
example/harmonyApp/ 鸿蒙工程(DevEco 只打开这里)
AppScope/ bundleName org.terminator.ohos.orbital、versionName 0.4.0-ohos.1
orbital/ ArkTS HAR 库(本 fork 的适配产物,10 个源文件)
entry/ 四 Tab HAP Demo
README.OpenHarmony.md / _CN.md 鸿蒙适配说明(中英双份)
README.upstream.md 上游原文保存
doc/ 博客文章与效果图
构建拓扑与 qrose / KStore 的"根构建替换"模式不同:上游 Gradle 工程一行不动,鸿蒙侧是独立的 hvigor 工程,两套构建互不感知。settings.gradle.kts 保持上游原样(:app :orbital :benchmark-app :benchmark),DevEco Studio 永远只打开 example/harmonyApp。
1.2 工具链矩阵
| 项目 | 配置 |
|---|---|
| DevEco Studio | 5.1+(含 hvigor modelVersion 5.1.0、ohpm) |
| OpenHarmony SDK | compatibleSdkVersion 5.1.0(18)(API 18) |
| 工程形态 | Stage 模型,entry(HAP)+ orbital(HAR)双模块 |
| 库交付 | HAR,main: Index.ets(模块根),零第三方依赖 |
| Demo 依赖 | "orbital": "file:../orbital",devDependencies 仅 @ohos/hypium 1.0.21 |
| 动画底座 | geometryTransition + animateTo + curves.springCurve |
| Bundle | org.terminator.ohos.orbital |
| 签名 | DevEco 自动签名(signingConfigs 仓库内留空数组,证书不入库) |
1.3 源码依赖度盘点
上游库本体只有 9 个 Kotlin 文件、约 700 行核心代码,全部在 orbital/src/commonMain:
| 上游文件 | 依赖形态 | 鸿蒙侧处置 |
|---|---|---|
Orbital.kt | LookaheadScope + Layout 封装 | 重写为 Orbital 容器组件(双内容 if/else) |
OrbitalScope.kt | LookaheadScope 委托 | 重写为 OrbitalScope 类(spec + id 分配 + animateWith) |
AnimateMovement.kt | onPlaced + intermediateLayout 位移 | 重写为 OrbitalMovement 组件(translate 属性动画) |
AnimateTransformation.kt | lookahead 尺寸 → Constraints.fixed | 重写为 OrbitalTransformation 组件(width/height 动画) |
AnimateSharedElementTransition.kt | 位移 + 尺寸双层组合 | 重写为 OrbitalElement 组件(geometryTransition 锚点) |
AnimateBounds.kt | 双层 offset+size + debug 绘制 | 重写为 OrbitalBounds 组件(translate + size + debug 描边) |
DeferredAnimation.kt(internal) | Animatable 惰性启动封装 | 重写为 OrbitalAnimator / orbitalAnimateTo |
RememberableContent.kt / RememberMovables.kt | movableContentOf 包装 | 近似为 OrbitalMovable 稳定 contentId |
盘点结论决定路线:9 个文件里没有一个能离开 compose.ui 编译——Modifier.composed {}、Layout、onPlaced、drawWithContent 全部是 UI 运行时符号,commonMain 依赖 compose.ui / compose.animation / compose.runtime 三件套在 ohos 制品线全部缺失。这不是 KStore 那种"补一个 dispatcher actual"能解决的适配面,而是整库换底座。
1.4 页面边界
标题区 Orbital · OpenHarmony / 副标题(shared element transitions · 0.4.0-ohos.1)
Tab 切换 Movement / Transform / Shared / Bounds 四胶囊按钮(选中紫色高亮)
Movement 面板 300×340 轨迹区 + 80×80 渐变色块 + Move/Reset 按钮 + 目标读数行
Transform 面板 OrbitalTransformation 包装的渐变卡(96×96 ↔ 260×180)+ Grow/Shrink 按钮
Shared 面板 Orbital 双内容容器 + 同 sharedId 的 OrbitalElement 配对(120×160 ↔ 300×380)
Bounds 面板 三条 OrbitalBounds 行(72 ↔ 200 互斥展开)+ Debug bounds 描边开关
状态行 最近一次操作的目标值与弹簧参数(如 movement → (200, 250) spring(0.75, 380))
页面 aboutToAppear 里先 hilog.info 打点、向 scope 取一次 nextSharedElementId() 存入 @State,两个转场分支共用同一 id。
第 2 阶段:机制对照建模(技术选型的核心)
2.1 Lookahead 双遍布局 ↔ geometryTransition 节点对
上游的转场本质:lookahead 先算出"动画结束后子项在哪、多大",intermediateLayout 再把"当前值 → 目标值"的插值过程渲染出来。ArkUI 没有两遍布局 API,但提供了 geometryTransition(id):绑定同一 id 的两个节点在不同时存在的分支(if/else)间切换时,系统自动对位置和尺寸做几何接力动画。两者机制不同、范式重合——上游的"共享元素转场"类目恰好落在重合区,这正是 OrbitalElement 组件的底座。
Compose: if (a) { Box(Modifier.animateSharedElementTransition(scope)) } else { Box(...) }
→ lookahead 拿目标几何 → intermediateLayout 逐帧插值
ArkUI: if (a) { Column().geometryTransition(id) } else { Column().geometryTransition(id) }
→ 节点一消一生时系统做几何接力(配 animateTo 闭包提供弹簧曲线)
movableContent 的近似表达:ArkTS 没有组合树实例迁移,用 OrbitalMovable 给内容发一个稳定 contentId,在两处放置点都挂这个 id 的 geometryTransition,视觉上内容"迁移"过去——语义近似但覆盖上游 demo 的全部场景,边界在第 8.2 节诚实标注。
2.2 弹簧参数换算:两个框架的同源物理模型
上游所有动画用 spring(dampingRatio, stiffness) 描述(Compose FiniteAnimationSpec),ArkUI 对应 curves.springCurve(velocity, mass, stiffness, damping)。两者是同一个阻尼谐振子模型的两种参数化,运动方程都是 x'' = -(stiffness/mass)·x - (damping/mass)·x':
- Compose 用归一化阻尼比 ζ(dampingRatio)描述衰减:ζ=1 临界阻尼不弹,ζ=0.5 中等回弹;
- ArkUI 用物理阻尼系数 c(damping)直接进方程。
换算只需一行:damping = dampingRatio × 2 × √(mass × stiffness),HAR 内部取 mass = 1。上游 demo 默认 spring(dampingRatio = 0.75f, stiffness = 380f) 换算后 damping = 0.75 × 2 × √380 ≈ 29.24,鸿蒙侧弹几下、多久停与 Android 端一致——弹簧手感是可证明的等价,不是"调得像"。
// HAR 内部实现(AnimationSpecs.ets)
export function toSpringCurve(spec: OrbitalSpring): ICurve {
const damping: number = spec.dampingRatio * 2 * Math.sqrt(spec.stiffness);
return curves.springCurve(0, 1, spec.stiffness, damping);
}
2.3 API 契约
| 上游 Compose API | 鸿蒙 ArkTS 等价物 |
|---|---|
Orbital(isTransformed, onStartContent, onTransformedContent) | Orbital 组件(@Prop isTransformed + @BuilderParam 双内容 + scope) |
OrbitalScope | OrbitalScope 类(movement/transform spec + nextSharedElementId() + animateWith()) |
Modifier.animateMovement(scope, spec) | OrbitalMovement 组件(targetX/targetY + spec) |
Modifier.animateTransformation(scope, spec) | OrbitalTransformation 组件(targetWidth/targetHeight + spec) |
Modifier.animateSharedElementTransition(scope, ...) | OrbitalElement 组件(sharedId,内部 geometryTransition) |
Modifier.animateBounds(...) | OrbitalBounds 组件(x/y/w/h + 双 spec + debug 描边) |
rememberMovableContentOf / rememberContentWithOrbitalScope | OrbitalMovable 类(稳定 contentId) |
FiniteAnimationSpec / spring(dampingRatio, stiffness) | OrbitalSpring + orbitalSpring() + OrbitalSpecs 预设 |
internal DeferredAnimation | OrbitalAnimator / orbitalAnimateTo() |
命名全部保留上游词汇(Orbital / animateMovement / animateBounds / movable),业务方从 Compose 迁移时对照表即可平移。
第 3 阶段:HAR 库实现
3.1 模块结构
example/harmonyApp/orbital/
├── Index.ets HAR 入口(必须在模块根,与 oh-package.json5 同级)
├── build-profile.json5 stageMode HAR 模块
├── hvigorfile.ts harTasks
├── oh-package.json5 name orbital、version 0.4.0-ohos.1、main Index.ets
└── src/main/
├── module.json5 type har、deviceTypes [phone, tablet]
├── resources/base/element/string.json
└── ets/Orbital/
├── AnimationSpecs.ets OrbitalSpring / orbitalSpring / OrbitalSpecs / toSpringCurve
├── OrbitalScope.ets 作用域 + id 分配 + animateWith
├── OrbitalAnimator.ets orbitalAnimateTo / OrbitalAnimator(DeferredAnimation 等价)
├── Orbital.ets 双内容容器组件
├── OrbitalElement.ets 共享元素锚点(geometryTransition)
├── OrbitalMovement.ets 位移包装组件(translate)
├── OrbitalTransformation.ets 尺寸包装组件(width/height)
├── OrbitalBounds.ets 边界动画组件(位移+尺寸+debug 描边)
└── OrbitalMovable.ets movable 稳定 id
3.2 核心实现
弹簧与作用域:
export class OrbitalSpring {
readonly dampingRatio: number;
readonly stiffness: number;
constructor(dampingRatio: number = 0.75, stiffness: number = 380) { ... }
}
export class OrbitalSpecs {
static readonly movement: OrbitalSpring = new OrbitalSpring(0.75, 380); // 上游 demo 位移默认
static readonly transform: OrbitalSpring = new OrbitalSpring(0.8, 380); // 尺寸默认
static readonly stiff: OrbitalSpring = new OrbitalSpring(0.9, 900); // 快收
static readonly gentle: OrbitalSpring = new OrbitalSpring(0.6, 200); // 慢弹
}
export function orbitalAnimateTo(spec: OrbitalSpring, apply: () => void, onFinish?: () => void): void {
const param: AnimateParam = {
duration: 600, curve: toSpringCurve(spec), iterations: 1, playMode: PlayMode.Normal
};
if (onFinish !== undefined) { param.onFinish = onFinish; }
animateTo(param, apply); // 状态变更必须发生在 apply 闭包内,弹簧才生效
}
export class OrbitalScope {
movementSpec: OrbitalSpring;
transformSpec: OrbitalSpring;
nextSharedElementId(): string { this.idSeed += 1; return 'orbital-element-' + this.idSeed.toString(); }
animateWith(spec: OrbitalSpring, apply: () => void): void { orbitalAnimateTo(spec, apply); }
}
容器与锚点(对应上游 Orbital composable 与 animateSharedElementTransition):
@Component
export struct Orbital {
@Prop isTransformed: boolean = false;
scope: OrbitalScope = new OrbitalScope();
@BuilderParam startContent: (scope: OrbitalScope) => void = this.emptyContent;
@BuilderParam transformedContent: (scope: OrbitalScope) => void = this.emptyContent;
@Builder emptyContent(_scope?: OrbitalScope) { Column() {}.visibility(Visibility.None) }
build() {
Column() {
if (!this.isTransformed) { this.startContent(this.scope) }
else { this.transformedContent(this.scope) }
}
}
}
@Component
export struct OrbitalElement {
sharedId: string = '';
@BuilderParam content: () => void = this.emptyContent;
build() {
Column() { this.content() }.geometryTransition(this.sharedId)
}
}
边界动画(对应上游 Modifier.animateBounds,含 debug 描边):
@Component
export struct OrbitalBounds {
@Prop targetX: number = 0;
@Prop targetY: number = 0;
@Prop targetWidth: number = 160;
@Prop targetHeight: number = 80;
movementSpec: OrbitalSpring = OrbitalSpecs.movement;
transformSpec: OrbitalSpring = OrbitalSpecs.transform;
debug: boolean = false;
@BuilderParam content: () => void = this.emptyContent;
build() {
Column() {
Column() { this.content() }
.width(this.targetWidth).height(this.targetHeight)
.justifyContent(FlexAlign.Center)
.border(this.debug ? { width: 1, color: '#FF0288D1' } : { width: 0 })
}
.translate({ x: this.targetX, y: this.targetY })
}
}
movable 的近似表达:
export class OrbitalMovable {
readonly key: string;
constructor(key: number | string) { this.key = key.toString(); }
contentId(): string { return 'orbital-movable-' + this.key; }
}
第 4 阶段:ArkUI 宿主
4.1 状态与弹簧闭包纪律
Demo 单页 491 行,四 Tab 的每次状态切换都收进 animateWith 闭包——这是动画生效的唯一途径:
private orbitalScope: OrbitalScope = new OrbitalScope(OrbitalSpecs.movement, OrbitalSpecs.transform);
private toggleMovement(): void {
this.orbitalScope.animateWith(OrbitalSpecs.movement, () => {
this.moved = !this.moved;
this.moveX = this.moved ? MOVE_TARGET_X : 0;
this.moveY = this.moved ? MOVE_TARGET_Y : 0;
this.status = `movement → (${this.moveX}, ${this.moveY}) ${this.specLabel(OrbitalSpecs.movement)}`;
});
}
4.2 共享元素配对(if/else 互斥)
Orbital({
isTransformed: this.isTransformed,
scope: this.orbitalScope,
startContent: (scope: OrbitalScope): void => { this.startPoster(scope) },
transformedContent: (scope: OrbitalScope): void => { this.transformedPoster(scope) }
})
@Builder
startPoster(scope: OrbitalScope) {
OrbitalElement({ sharedId: this.sharedId }) { this.posterFront() } // 120×160 海报
.onClick(() => { this.expandPoster(); })
}
// transformed 分支内同一个 this.sharedId 包 300×380 详情卡
sharedId 在 aboutToAppear 里取一次,两个分支共用;展开/收起都在 animateWith(OrbitalSpecs.transform, ...) 闭包内翻转 isTransformed,geometryTransition 的几何接力自动吃进弹簧曲线。
4.3 Bounds 面板与 debug 描边
三条目用 OrbitalMovable(1..3) 生成稳定 contentId,OrbitalBounds 的 targetHeight 在 72 ↔ 200 间互斥切换;debug 开关打开后每行动画边界出现 1px 蓝色描边,插值过程中描边连续移动,可肉眼核对轨迹——对应上游 animateBounds(debug = true) 的绘制能力。
第 5 阶段:构建与修复
5.1 构建命令(真实执行记录)
cd example/harmonyApp
# 库模块:29 tasks
hvigorw --mode module -p module=orbital@default -p product=default assembleHar --no-daemon
# BUILD SUCCESSFUL in 8s 439ms
# Demo 模块:41 tasks
hvigorw --mode module -p module=entry@default -p product=default assembleHap --no-daemon
# BUILD SUCCESSFUL in 3s 437ms → entry-default-unsigned.hap(约 169 KB)
纯 ArkTS 交付链路的优势在这组数字里很直观:没有 .so、没有 CMake、没有 ABI 矩阵,169 KB 的 HAP 就装下了库 + Demo 全部逻辑。
5.2 两处真实编译修复
修复 1:HAR 入口位置(hvigor 00304003 Invalid main file 'Index.ets')。 最初把 re-export 入口放在 src/main/ets/Index.ets,hvigor 按 oh-package.json5 所在目录解析 main,报错找不到文件。OpenHarmony HAR 的规范位置是模块根(与 oh-package.json5 同级):orbital/Index.ets re-export 全部 13 个符号,源码仍在 src/main/ets/Orbital/ 下。
修复 2:尾随闭包后接链式事件调用(Declaration or statement expected / Cannot find name 'onClick')。 OrbitalBounds({...}) { ... }.onClick(...) 这个"尾随闭包 + 链式事件"组合 ArkTS 编译器不支持。修复为 content 走对象字面量箭头传入(content: (): void => { this.boundsRowCard(row) }),事件链直接跟在组件调用之后。尾随闭包单独使用(后无链式)与对象字面量后接链式都合法,只有这个组合踩雷。
修复后全量源码(库 10 文件 + Demo 页)零错误编译,仅 1 个无害警告:全局 animateTo 在 API 12+ 标记 deprecated(建议 UIContext 形态,当前完全可用)。
5.3 工程配置要点
// 工程级 build-profile.json5
{
"app": {
"signingConfigs": [], // 仓库内留空数组,自动签名后由 IDE 回填带 material 的完整配置
"products": [{
"name": "default",
"signingConfig": "default",
"compatibleSdkVersion": "5.1.0(18)",
"runtimeOS": "HarmonyOS"
}],
"buildModeSet": [{ "name": "debug" }, { "name": "release" }]
},
"modules": [
{ "name": "entry", "srcPath": "./entry", "targets": [{ "name": "default", "applyToProducts": ["default"] }] },
{ "name": "orbital", "srcPath": "./orbital", "targets": [{ "name": "default", "applyToProducts": ["default"] }] }
]
}
// entry/oh-package.json5 —— HAR 以 file: 相对路径挂载(相对 oh-package.json5 所在目录)
"dependencies": { "orbital": "file:../orbital" }
第 6 阶段:示例与验证
签名在 DevEco Studio 的 File → Project Structure → Signing Configs 中勾选自动生成,证书写入本机,不进入仓库。
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.orbital -m entry
四、完整代码对照
4.1 整体架构
com.skydoves.orbital/**(上游 Kotlin 库,零改动留盘)
│ LookaheadScope / intermediateLayout / movableContent / DeferredAnimation
API 契约(上游 8 公共 API ↔ 鸿蒙 13 导出符号)
│ 语义映射 + 弹簧数学换算
orbital HAR(ArkTS 重写)
│ Orbital / OrbitalScope / OrbitalElement / OrbitalMovement / OrbitalTransformation / OrbitalBounds / OrbitalMovable / OrbitalSpring
geometryTransition + animateTo + curves.springCurve
│ 节点对几何接力 + 物理弹簧插值
Index.ets
│ @State 驱动 + animateWith 闭包纪律
ArkUI 真机页面(单页四 Tab)
4.2 文件清单
| 文件 | 职责 |
|---|---|
README.md | OpenHarmony 中文首页(状态表 + 快速开始 + Demo 覆盖表) |
README.upstream.md | 上游原文保存 |
README.OpenHarmony.md / _CN.md | 鸿蒙适配说明(中英双份,中文主文档 17 节 ≥5000 字) |
.gitignore | 追加鸿蒙条目(.idea / oh_modules / BuildProfile.ets / build) |
harmonyApp/orbital/Index.ets | HAR 入口(模块根,re-export 13 符号) |
harmonyApp/orbital/.../AnimationSpecs.ets | 弹簧模型 + springCurve 换算 + 四份预设 |
harmonyApp/orbital/.../OrbitalScope.ets | 作用域、sharedId 分配、animateWith |
harmonyApp/orbital/.../OrbitalAnimator.ets | animateTo 包装 + DeferredAnimation 等价 |
harmonyApp/orbital/.../Orbital*.ets × 5 | 容器 / 锚点 / 位移 / 尺寸 / 边界五个包装组件 |
harmonyApp/orbital/.../OrbitalMovable.ets | movable 稳定 contentId |
harmonyApp/entry/.../Index.ets | ArkUI 四 Tab 验收页面(491 行) |
harmonyApp/entry/.../EntryAbility.ets | UIAbility 标准模板(hilog TAG OrbitalDemo) |
doc/img/orbital-demo-device.png | 真机效果图 |
4.3 关键 API 对照
| 层次 | API | 作用 |
|---|---|---|
| 弹簧 | orbitalSpring(0.75, 380) / OrbitalSpecs.movement | 构造弹簧规格(与上游 spring() 同参) |
| 弹簧 | toSpringCurve(spec) | 换算 springCurve(0, 1, k, ζ·2√k),纯数学等价 |
| 作用域 | new OrbitalScope(movementSpec, transformSpec) | 承载两份 spec |
| 作用域 | scope.nextSharedElementId() | 生成不冲突的转场锚点 id |
| 作用域 | scope.animateWith(spec, apply) | 状态变更收进 animateTo 闭包 |
| 容器 | Orbital({ isTransformed, startContent, transformedContent }) | 双内容互斥切换 |
| 锚点 | OrbitalElement({ sharedId }) | geometryTransition 节点对 |
| 动画 | OrbitalMovement / OrbitalTransformation / OrbitalBounds | 位移 / 尺寸 / 边界(+debug 描边) |
| movable | new OrbitalMovable(key).contentId() | 稳定迁移 id |
| 数值 | OrbitalAnimator.update(target, spec, apply) | 延迟启动的数值弹簧(上游 internal 类公开化) |
4.4 ArkTS 与上游 Compose 的边界
// 上游(Android/iOS/Desktop):一行修饰,Lookahead 管线内插值
Orbital(isTransformed = expanded, scope = orbitalScope,
onStartContent = { Box(Modifier.animateSharedElementTransition(orbitalScope)) { Poster() } },
onTransformedContent = { Box(Modifier.animateBounds(orbitalScope)) { Details() } })
// 鸿蒙:同一语义的 ArkTS 表达
Orbital({
isTransformed: this.expanded, scope: this.orbitalScope,
startContent: (scope: OrbitalScope): void => {
OrbitalElement({ sharedId: this.sharedId }) { this.Poster() }
},
transformedContent: (scope: OrbitalScope): void => {
OrbitalElement({ sharedId: this.sharedId }) { this.Details() }
}
})
// 切换必须收进弹簧闭包:scope.animateWith(OrbitalSpecs.transform, () => { this.expanded = !this.expanded })
两者之间隔着的是整套布局管线,不是字符串协议——这正是 UI 机制库与算法库适配形态的根本区别。
五、关键决策说明
决策 1:ArkTS 等价重写,而不是等 compose.ui ohos 制品
社区制品线(eazytec Maven)补齐的是 kotlinx 系与 Kotlin 编译器,compose.ui 的 ohos 变体尚不可用;就算可用,上游 Compose Multiplatform 1.6.11 与社区 CMP 版本矩阵也不对齐。等待意味着适配无限期搁置,而 Orbital 的能力面(四类转场 + 弹簧)在 ArkUI 有完整的原生对应物——重写是当下唯一能交付真机可用性的路线。
决策 2:geometryTransition 做转场底座,而不是逐帧手写插值
理论上可以自绘两遍布局(先量目标几何,再用 Animator 逐帧插值 placement)更接近 Lookahead 语义,但那是重写一个布局引擎的工作量。geometryTransition 是 ArkUI 官方的共享元素转场机制,节点对几何接力恰好覆盖上游四类范式(一消一生的切换场景),配 animateTo 闭包即可吃进弹簧曲线。近似边界(非切换场景的连续重排)在文档明确标注,不冒充完整复刻。
决策 3:弹簧参数做数学换算,而不是凭手感调参
spring(dampingRatio, stiffness) 与 springCurve(velocity, mass, stiffness, damping) 同为阻尼谐振子,damping = ζ × 2√(mass·stiffness)、mass = 1 一行换算。上游 demo 的默认手感(0.75/380、0.8/380)在鸿蒙侧逐数值平移,Demo 状态行直接显示当前 spec——观感一致性是可验证的,而不是"调得差不多"。
决策 4:HAR 形态 + file: 相对路径,而不是源码拷贝或 so 三件套
算法库走 KN 桥接要维护 .so + CMake + N-API 三件套;纯 ArkTS 库天然打成 HAR,业务方一行 "orbital": "file:../orbital" 引用,未来 ohpm 发布后切 ohpm install orbital 零成本迁移。HAR 保持零第三方依赖,审核与复用成本最小。
决策 5:上游 Gradle 工程零改动,双工程并存
qrose / KStore 用"根构建替换"是因为库要进 Gradle 编译链;Orbital 的鸿蒙侧与 Gradle 完全无关,上游工程保持原样反而是最优解——Android 侧对照基线(demo、benchmark、API 快照)全部存活,鸿蒙工程独立演进,两套构建互不污染。
六、测试与验证
6.1 测试环境
本次验证使用 Windows(x64)、DevEco Studio 5.1、DevEco 自带 hvigor / ohpm / JBR、命令行 hdc 与鸿蒙真机(arm64)。
6.2 编译层验证
:orbital:default@HarCompileArkTS: finished(29 tasks,assembleHar)
:orbital:default@PackageHar: finished
:entry:default@CompileArkTS: finished(41 tasks,assembleHap)
:entry:default@PackageHap: finished
BUILD SUCCESSFUL
库与 Demo 全部 ArkTS 源码零错误;唯一警告为全局 animateTo deprecation(见 8.1 第 9 条)。HAP 产物 entry-default-unsigned.hap 约 169 KB——纯 ArkTS 交付无原生载荷,体积仅为 KN 桥接路线(KStore 约 12.4 MB)的零头。
6.3 HAP 验证
DevEco 自动签名: PASSED(证书本机生成,未入仓库)
install bundle successfully
start ability successfully
hilog OrbitalDemo: Index appeared, sharedId=orbital-element-1, rows=3
6.4 功能用例
- 启动即进 Movement Tab,
aboutToAppear打出sharedId=orbital-element-1与三行 Bounds 数据; - 点 Move block:80×80 色块从 (0,0) 沿弹簧曲线飞到 (200, 250),过冲回落再稳定,无瞬移无闪断;按钮变 Reset position,状态行显示
movement → (200, 250) spring(0.75, 380)——真机实测见第七节效果图; - Transform Tab 点 Grow:色块 96×96 → 260×180 宽高同相弹性放大,Shrink 对称缩小,状态行
transform → 260x180 spring(0.8, 380); - Shared Tab 点海报卡:120×160 → 300×380 详情卡内容连续不闪断(geometryTransition 节点对核心验收点),Back 反向连续收起;
- Bounds Tab 点任一行:72 → 200 高度弹性展开露出说明与
orbital-movable-NcontentId,点其他行互斥交接;Debug bounds 开关打开出现 1px 蓝色描边且插值中连续移动; - 全程零权限申请、零网络请求,异常路径走状态行与 hilog 不崩。
6.5 验证结论
ArkTS 库、弹簧换算、geometryTransition 转场、animateTo 闭包、HAR 打包、HAP 构建、签名和设备安装已完成链路验证。应用在鸿蒙真机上成功安装并进入前台,Movement 弹簧动画实测到位(状态行数值与截图互证),四 Tab 验收清单随真机操作逐项过点(见下节效果图与 README.OpenHarmony_CN.md 第 11 节)。
七、运行效果
7.1 真机截图

7.2 命令速查
git clone https://atomgit.com/oh-tpc/Orbital.git
cd Orbital/example/harmonyApp # DevEco Studio 只打开这个目录
hvigorw --mode module -p module=orbital@default -p product=default assembleHar --no-daemon
hvigorw assembleHap --mode module -p module=entry@default -p product=default --no-daemon
hdc install -r entry/build/default/outputs/default/entry-default-signed.hap
hdc shell aa start -a EntryAbility -b org.terminator.ohos.orbital -m entry
hdc shell hilog | grep -i orbital
八、遗留问题与改进方向
8.1 踩坑复盘
- HAR 的
Index.ets必须放模块根:hvigor 按oh-package.json5所在目录解析main,入口放在src/main/ets/下会报00304003 Invalid main file 'Index.ets'——入口(re-export)与源码(src/main/ets/Orbital/)分开放,main 保持Index.ets; - 尾随闭包后不能接链式事件:
Comp({...}) { ... }.onClick(...)报Declaration or statement expected/Cannot find name 'onClick';改为content: (): void => { ... }对象字面量传入后事件链可正常挂; @BuilderParam必须配本地默认@Builder(如= this.emptyContent),否则父组件未传内容时渲染异常;@Prop/@State必须就地初始化默认值,@Prop sharedId: string = ''一样不能省;- 属性变更必须发生在
animateTo闭包内:闭包外直接改isTransformed等状态,元素瞬变,任何弹簧参数都救不了——这是 ArkUI 属性动画的入口纪律,全 Demo 统一走scope.animateWith(spec, () => {...}); geometryTransition同 id 两节点必须 if/else 互斥存在,同时存在系统无法配对,转场退化为闪切;- DevEco 只能打开
example/harmonyApp:仓库根目录是上游 Gradle 工作区,无 hvigor 配置,打开必全红; - 根
.gitignore的build/、/.idea是根锚定/目录名模式,盖不住 DevEco 生成的嵌套产物——example/harmonyApp/.idea、entry/oh_modules、hvigor 生成的BuildProfile.ets要逐条追加,否则混进提交; - 全局
animateTo在 API 12+ 标记 deprecated(建议getUIContext().animateTo()):库函数拿不到组件上下文,当前保留全局形态(功能完全正常),后续可评估 UIContext 注入式 API; build-profile.json5的signingConfigs仓库内留空数组 + product 引用default,勾选 DevEco 自动签名后由 IDE 回填带material的完整配置;手写缺material会被 schema 拒绝(00303038 must have required property 'material'),证书材料绝不入库;- HAR 以
file:../orbital引用时路径相对的是 oh-package.json5 所在目录,不是工程根;层级变动要同步改; - 提交前核查暂存区:
.idea/、oh_modules/、BuildProfile.ets、entry/build/一旦混入,审核仓库观感立刻劣化——git diff --cached --name-only | grep -iE "\.idea|oh_modules|BuildProfile"一行兜底。
8.2 已知问题
geometryTransition是节点对转场,在"节点一消一生"的切换帧触发;Compose Lookahead 能对任意布局变化(滚动中的列表、连续重排)逐帧插值——本 fork 覆盖上游四类标准范式,不承诺泛化能力;- movable 语义近似:
OrbitalMovable是稳定 contentId + 节点对几何接力,不是组合树实例迁移;跨页面(非同一子树)迁移未承诺,@State需业务侧上提到共同父组件; OrbitalTransformation作用于 width/height,不支持任意变换矩阵(旋转、斜切、透视);- HAR 未发布 ohpm 中央仓,业务方以
file:相对路径或拷贝模块引用; - 上游 Gradle 工程(
orbital/、app/、benchmark*/)原样留盘不参与鸿蒙构建,Android 侧能力以对照基线形式存在——等 compose.ui ohos 制品与 CMP 版本矩阵对齐后可评估 KMP 形态回归; - Demo 零权限、无网络图(海报用渐变色块模拟),上游 GlideImage 示例未平移,属 Demo 取材选择而非库能力边界。
8.3 未来优化方向
- 发布 ohpm 中央仓(
ohpm install orbital),业务侧脱离 file: 引用; - 探索
attributeModifier形态 API,提供更接近上游Modifier.animate*的链式语法; - 补充第二个转场 id 的多元素场景(上游
OrbitalMultipleSharedElementTransitionExample)与导航级页面迁移示例; - 在 CI 中加入 hvigor assembleHar / assembleHap 与 HAP 产物校验;
- 补充模拟器(x86_64)验收矩阵、自动化截图脚本与其余三 Tab 的真机动图。
九、总结
9.1 核心难点回顾
上游 orbital(9 个 Kotlin 文件,全 commonMain,全站 compose.ui)
→ 确认 compose.ui 无 ohos 制品 → 路线分流:ArkTS 等价重写(qrose/KStore 是 KN 桥接,本库不是)
→ 机制对照建模(Lookahead ↔ geometryTransition、movableContent ↔ 稳定 id)
→ 弹簧参数数学换算(damping = ζ × 2√(mass·stiffness),mass = 1)
→ HAR 库 10 源文件(13 个导出符号对齐上游 8 公共 API)
→ ArkUI 单页四 Tab 宿主(animateWith 闭包纪律 + 状态行验收)
→ hvigor 实跑修复(Index.ets 位置、尾随闭包链式)
→ HAP 构建(169 KB)、签名、真机安装与 Movement 实测
9.2 封装层次
- 契约层:上游 8 个公共 API ↔ 鸿蒙 13 个导出符号的语义映射表;
- 弹簧层:
OrbitalSpring模型 +toSpringCurve纯数学换算 + 四份预设; - 作用域层:
OrbitalScope管理双 spec、分配转场锚点 id、收口animateWith; - 组件层:
Orbital容器 +OrbitalElement/OrbitalMovement/OrbitalTransformation/OrbitalBounds四个包装组件 +OrbitalMovable稳定 id; - ArkTS 层:管理页面状态、弹簧闭包、状态行验收与错误兜底;
- DevEco 层:完成 HAR 打包、HAP 构建、签名、安装和真机运行。
9.3 三条经验
- 路线选择按库性质分流,不按惯性:同一审核框架下,算法库(qrose 的编码器、KStore 的存储引擎)走"真编译整库 + C ABI 桥接",纯 UI 机制库只能原生重写——判断标准只有一个:把依赖表打出来,看有多少符号站在 compose.ui 上。Orbital 是 9/9,答案不言自明;
- 跨框架的动画手感靠物理模型对齐,不靠肉眼调参:Compose spring 与 ArkUI springCurve 同为阻尼谐振子的两种参数化,一行换算让上游默认参数逐数值平移;Demo 状态行实时显示 spec,等价性可验证;
- UI 库的验收重心在"内容连续性":算法库验收看输出对不对,UI 动画库验收看闪不闪断——四 Tab 每个都定义了"无瞬移、无闪断、不先消后现"的判据,状态行把每次动画的目标值与弹簧参数落成可核对的文字。
9.4 适配成果
当前适配已完成:
- Orbital
0.4.0(上游 Apache-2.0)四类转场能力的 ArkTS HAR 等价重写(13 个导出符号,零第三方依赖); - 弹簧参数与 Compose
spring的数学换算及四份上游对齐预设; - DevEco Stage ArkUI 单页四 Tab 验收页面(Movement / Transformation / Shared / Bounds + debug 描边 + 状态行);
- hvigor 双模块构建(assembleHar 29 tasks + assembleHap 41 tasks,BUILD SUCCESSFUL,HAP 约 169 KB)、自动签名、真机安装与 Movement 弹簧动画实测;
- 上游 Gradle 工程零改动共存、
.gitignore嵌套产物治理; - AtomGit 项目文档、OpenHarmony README(中英双份,中文主文档 17 节 ≥5000 字)、本博客与本地效果图。
适配过程 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)