本文记录 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 Studio5.1+(含 hvigor modelVersion 5.1.0、ohpm)
OpenHarmony SDKcompatibleSdkVersion 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
Bundleorg.terminator.ohos.orbital
签名DevEco 自动签名(signingConfigs 仓库内留空数组,证书不入库)
1.3 源码依赖度盘点

上游库本体只有 9 个 Kotlin 文件、约 700 行核心代码,全部在 orbital/src/commonMain:

上游文件依赖形态鸿蒙侧处置
Orbital.ktLookaheadScope + Layout 封装重写为 Orbital 容器组件(双内容 if/else)
OrbitalScope.ktLookaheadScope 委托重写为 OrbitalScope 类(spec + id 分配 + animateWith)
AnimateMovement.ktonPlaced + intermediateLayout 位移重写为 OrbitalMovement 组件(translate 属性动画)
AnimateTransformation.ktlookahead 尺寸 → 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.ktmovableContentOf 包装近似为 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)
OrbitalScopeOrbitalScope 类(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 / rememberContentWithOrbitalScopeOrbitalMovable 类(稳定 contentId)
FiniteAnimationSpec / spring(dampingRatio, stiffness)OrbitalSpring + orbitalSpring() + OrbitalSpecs 预设
internal DeferredAnimationOrbitalAnimator / 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.mdOpenHarmony 中文首页(状态表 + 快速开始 + Demo 覆盖表)
README.upstream.md上游原文保存
README.OpenHarmony.md / _CN.md鸿蒙适配说明(中英双份,中文主文档 17 节 ≥5000 字)
.gitignore追加鸿蒙条目(.idea / oh_modules / BuildProfile.ets / build)
harmonyApp/orbital/Index.etsHAR 入口(模块根,re-export 13 符号)
harmonyApp/orbital/.../AnimationSpecs.ets弹簧模型 + springCurve 换算 + 四份预设
harmonyApp/orbital/.../OrbitalScope.ets作用域、sharedId 分配、animateWith
harmonyApp/orbital/.../OrbitalAnimator.etsanimateTo 包装 + DeferredAnimation 等价
harmonyApp/orbital/.../Orbital*.ets × 5容器 / 锚点 / 位移 / 尺寸 / 边界五个包装组件
harmonyApp/orbital/.../OrbitalMovable.etsmovable 稳定 contentId
harmonyApp/entry/.../Index.etsArkUI 四 Tab 验收页面(491 行)
harmonyApp/entry/.../EntryAbility.etsUIAbility 标准模板(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 描边)
movablenew 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 功能用例

  1. 启动即进 Movement Tab,aboutToAppear 打出 sharedId=orbital-element-1 与三行 Bounds 数据;
  2. 点 Move block:80×80 色块从 (0,0) 沿弹簧曲线飞到 (200, 250),过冲回落再稳定,无瞬移无闪断;按钮变 Reset position,状态行显示 movement → (200, 250) spring(0.75, 380)——真机实测见第七节效果图;
  3. Transform Tab 点 Grow:色块 96×96 → 260×180 宽高同相弹性放大,Shrink 对称缩小,状态行 transform → 260x180 spring(0.8, 380);
  4. Shared Tab 点海报卡:120×160 → 300×380 详情卡内容连续不闪断(geometryTransition 节点对核心验收点),Back 反向连续收起;
  5. Bounds Tab 点任一行:72 → 200 高度弹性展开露出说明与 orbital-movable-N contentId,点其他行互斥交接;Debug bounds 开关打开出现 1px 蓝色描边且插值中连续移动;
  6. 全程零权限申请、零网络请求,异常路径走状态行与 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 踩坑复盘

  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;
  2. 尾随闭包后不能接链式事件:Comp({...}) { ... }.onClick(...) 报 Declaration or statement expected / Cannot find name 'onClick';改为 content: (): void => { ... } 对象字面量传入后事件链可正常挂;
  3. @BuilderParam 必须配本地默认 @Builder(如 = this.emptyContent),否则父组件未传内容时渲染异常;
  4. @Prop / @State 必须就地初始化默认值,@Prop sharedId: string = '' 一样不能省;
  5. 属性变更必须发生在 animateTo 闭包内:闭包外直接改 isTransformed 等状态,元素瞬变,任何弹簧参数都救不了——这是 ArkUI 属性动画的入口纪律,全 Demo 统一走 scope.animateWith(spec, () => {...});
  6. geometryTransition 同 id 两节点必须 if/else 互斥存在,同时存在系统无法配对,转场退化为闪切;
  7. DevEco 只能打开 example/harmonyApp:仓库根目录是上游 Gradle 工作区,无 hvigor 配置,打开必全红;
  8. 根 .gitignore 的 build/、/.idea 是根锚定/目录名模式,盖不住 DevEco 生成的嵌套产物——example/harmonyApp/.idea、entry/oh_modules、hvigor 生成的 BuildProfile.ets 要逐条追加,否则混进提交;
  9. 全局 animateTo 在 API 12+ 标记 deprecated(建议 getUIContext().animateTo()):库函数拿不到组件上下文,当前保留全局形态(功能完全正常),后续可评估 UIContext 注入式 API;
  10. build-profile.json5 的 signingConfigs 仓库内留空数组 + product 引用 default,勾选 DevEco 自动签名后由 IDE 回填带 material 的完整配置;手写缺 material 会被 schema 拒绝(00303038 must have required property 'material'),证书材料绝不入库;
  11. HAR 以 file:../orbital 引用时路径相对的是 oh-package.json5 所在目录,不是工程根;层级变动要同步改;
  12. 提交前核查暂存区:.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 三条经验

  1. 路线选择按库性质分流,不按惯性:同一审核框架下,算法库(qrose 的编码器、KStore 的存储引擎)走"真编译整库 + C ABI 桥接",纯 UI 机制库只能原生重写——判断标准只有一个:把依赖表打出来,看有多少符号站在 compose.ui 上。Orbital 是 9/9,答案不言自明;
  2. 跨框架的动画手感靠物理模型对齐,不靠肉眼调参:Compose spring 与 ArkUI springCurve 同为阻尼谐振子的两种参数化,一行换算让上游默认参数逐数值平移;Demo 状态行实时显示 spec,等价性可验证;
  3. 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。

参考文档

Logo

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

更多推荐