一、先看真实分布:仓库里谁在用什么

entry/src/main/ets/components/ 全量 grep 四个关键词,分布如下(真实结果,非回忆):

驱动

组件

典型用途

animateTo

NavigationAppShell、FloatingTabBarShell、PullDownQuickAccessShell、TransitionDemos

UI 状态过渡:Search 收起/展开(220ms)、TabBar 显隐、转场演示

createAnimator

Coverflow、BookFlip、BookHardFlip、CollectibleBookshelf

手势几何:centerIndex∈ℝ、flipProgress、coverProgress、书架 progress[]

DisplaySync

GrokBot、ThinkingOrbs、BorderBeam

环境动画:表情头像、AI 状态球、边框流光(Canvas 逐帧重绘)

setInterval 16ms

GradientSpin、SlotText、FlowAvatar

历史路径:采样 Date.now() 驱动时间型动画

setInterval ≥400ms

Coverflow(autoplay)

纯调度:何时发起下一次翻页,不碰几何

setTimeout

BookFlip(重绘梯子)

时序补偿:[0,16,32,64,120] 补发重绘

分布不是随机的,四个簇对应四种动画本质。下面逐条拆。

二、通道一:animateTo —— 声明终态,框架管补间

this.getUIContext().animateTo({
  duration: 220,
  curve: Curve.EaseOut
}, () => {
  this.searchVisible = visible;   // 状态一变,依赖它的 height/opacity 自动补间
});

animateTo 的语义是声明终态:你在闭包里改状态,框架对依赖该状态的属性做插值。它是四条通道里唯一「不用自己算每帧」的,适合UI 状态 → UI 属性的过渡——E006 的 Search 收起(height 56→0、opacity 1→0 同步收敛)就是标准用法。

但它有两个结构性短板,都被本项目的真机事故验证过:

短板一:多属性各补各的,几何约束会分叉。 E020 书架 v2 用隐式动画过渡「旋转角 + 邻书位置」,真机上角度转到 60° 时邻书才让开一半——封面插进邻书身体。原因:animateTo 对每个属性分别插值到终值,属性间的几何关系(占位宽度 = 旋转投影之和)在过渡中不成立。凡是「多个属性必须保持几何同步」的动画,隐式补间在数学上就不成立。

短板二:不能被手势接管。 拖拽类交互需要「手指按下时动画立刻停在当前值、随后由手指直接驱动」。隐式动画的中断语义是「重设终态重来」,你拿不到一个干净的、可被手指逐帧改写的当前进度值

所以守则很清楚:animateTo 只做「纯 UI 属性、无几何耦合、无手势接管」的状态过渡。

三、通道二:createAnimator —— 单 progress,派生一切

手势几何类组件(Coverflow / BookFlip / BookHardFlip / Bookshelf)全部用 createAnimator,这是事故换来的共识。标准形态(BookFlip 的 springTo):

private springTo(target: number): void {
  this.stopAnim();                                  // ① 可中断:先停旧动画
  const start: number = this.driver.progress;       // ② 从当前进度接力(手势交回)
  const end: number = target <= 0 ? 0 : 1;
  const dist: number = Math.abs(end - start);
  const duration: number = Math.max(220, Math.floor(300 + dist * 400));  // ③ 时距随距离
  const animator: AnimatorResult = this.getUIContext().createAnimator({
    duration, easing: 'ease-out', fill: 'forwards',
    iterations: 1, begin: 0, end: 1                  // ④ 归一化 0..1
  });
  animator.onFrame = (value: number): void => {
    const t: number = value < 0 ? 0 : (value > 1 ? 1 : value);
    this.driver.progress = start + (end - start) * t;  // ⑤ lerp 回真实区间
    this.driver.active = true;
    this.invalidate();                              // ⑥ 全部几何从这一个 progress 派生
  };
  ...
  animator.play();
}

六个细节各有讲究:

  1. 可中断stopAnim() 先行,拖拽打断动画、程序化翻页互相打断、关书打断翻页,都是同一条路径;

  2. 从当前进度接力:松手瞬间 start = 当前 progress,手指和弹簧无缝交接——这是隐式动画给不了的;

  3. 时距随距离300 + dist × 400,翻一半松手回弹比整页翻转短,物理感来自这里;

  4. 归一化 0..1 再 lerp:源码注释原话「more reliable than begin/end = progress」——不同起点的动画统一在 0..1 域,参数好算、时序稳定;

  5. onFrame 只改一个变量:Bookshelf 的 applyAnimationFrame 同款——角度、透视投影、累计位置、接触阴影全部从同一帧 progress 派生,几何分叉在数学上不可能(E020 v3 修正的直接产物);

  6. easing 只作用于进度,不碰派生逻辑——想换缓动曲线,派生代码零改动。

一句话:凡是有终点、且进度需要被手势接管的动画,Animator 是唯一正解。 没有手势的有限动画(书架选书 650ms、Coverflow 入场)也用它,是因为「单 progress 派生」带来的几何一致性对这类组件同样致命。

四、通道三:DisplaySync —— 无终点的帧时钟

GrokBot、ThinkingOrbs、BorderBeam 这类环境动画(ambient)没有终点:表情永远在眨、状态球永远在转、边框流光永远在跑。它们需要的是帧时钟而不是进度:

const sync: displaySync.DisplaySync = displaySync.create();
const range: ExpectedFrameRateRange = { expected: 60, min: 0, max: 120 };
sync.setExpectedFrameRateRange(range);
sync.on('frame', (info: displaySync.IntervalInfo): void => {
  this.onFrame(info);          // 每帧:墙钟 → 状态推进 → Canvas 重绘
});

它和 Animator 的本质区别:没有 progress、没有 onFinish、驱动的是「时间」而不是「某个值的区间」。每帧回调里用时间戳推状态(相位、粒子位置),然后整帧重绘画布。

两个真机标定出的帧率策略,体现「按成本设定期望帧率」:

组件

帧率

理由

ThinkingOrb

expected 60 / max 120

点阵规模小,吃得起高刷

BorderBeam

expected 30 / max 30 + 32ms 跳帧

源码注释:「Canvas bloom is fill-heavy; 60fps multiplies cost with little visual gain」——填充密集型效果,60fps 纯属浪费

DisplaySync 的完整使用纪律(按需启停、离屏停表、清理、dt clamp、首帧 dt=0、reduceMotion)在姊妹篇《DisplaySync 高刷绘制最佳实践》里有完整五铁律,本文不重复。这里只强调它在选型坐标里的位置:无终点 + 逐帧绘制 = DisplaySync

五、通道四:定时器 —— 只许做调度

setInterval 在选型坐标里被降级到只剩一个合法用途:事件调度——「每 800ms 发起一次翻页」,至于翻页本身怎么动,交给 Animator。Coverflow 的 autoplay 是教科书示范,源码注释直接写明了边界:

/** setInterval id for autoplay scheduling only (not geometry). */
private autoplayTimer: number = -1;

const interval: number = Math.max(400, this.autoplayIntervalMs);
this.autoplayTimer = setInterval((): void => {
  this.handleAutoplayTick();    // tick 里只调 Controller 命令,几何仍走 Animator
}, interval);

handleAutoplayTick 里一串守卫(用户交互中 / 入场动画中 / 拖拽中则跳过本拍)——调度器无状态,安全得很。

但定时器做帧驱动是被历史证明的弯路。 仓库里 GradientSpin、SlotText、FlowAvatar 还留着 16ms setInterval 采样 Date.now() 驱动动画的写法,GradientSpin 的注释记录了当时的选择理由:

// Keep the sampled ArkUI timeline close to the browser's compositor-rate
// opacity interpolation. 32ms made diagonal/snake look stepped and rigid.
this.timer = setInterval(() => { this.frameTime = Date.now(); }, 16);

32ms 会阶梯感,16ms 勉强接近合成器节奏——但这正是在没有帧时钟的地方模拟帧时钟:节拍不与 vsync 对齐、离屏不停、后台不停。《DisplaySync 高刷绘制最佳实践》一文就是这段历史的产物。选型规则由此固化:新的连续 Canvas 动画一律 DisplaySync;存量 16ms 定时器组件按该文五铁律逐步迁移。

顺带一提 setTimeout:它连调度都算不上,是时序补偿。BookFlip 挂载后按 [0,16,32,64,120] 梯子补发重绘,兜「首帧画在 0×0 尺寸」的竞态——和「驱动动画」毫无关系,不要混淆。

六、选型决策树

三问定通道:

Q1: 动画有终点吗?
    ├─ 没有(环境动画:表情/状态球/流光)
    │     → DisplaySync(帧时钟 + Canvas 重绘,按成本设帧率,遵守五铁律)
    └─ 有 → Q2: 进度需要被手势接管,或多个属性要保持几何同步吗?
          ├─ 是(拖拽/翻页/展开/轮播)
          │     → createAnimator(归一化 0..1,单 progress 派生一切,可中断接力)
          └─ 否(纯 UI 状态过渡:显隐/收起/高亮)
                → animateTo(声明终态,框架补间)
                                    Q3(贯穿): 需要周期性「发起」动作吗?
                                    → setInterval 只做调度,几何交给上面三条

对应到真机事故的反向验证:

事故

选错的通道

正解

E020 书架几何分叉

animateTo 补间多属性

Animator 单 progress 派生

E017 松手误判/不跟手

离散步进 + 定时器

onTouch + Animator 接力

GradientSpin 阶梯感

16ms/32ms 定时器采样

DisplaySync(已规范化)

BorderBeam 帧率超支

默认全速重绘

DisplaySync + 30fps 上限 + 跳帧

七、混合是常态

真实的组件几乎都是多通道组合,关键是每条通道只做它那件事

组件

组合

分工

Coverflow

Animator + setInterval(≥400ms)

几何/入场走 Animator;autoplay 只调度

BookFlip

Animator×2 + setTimeout 梯子

页翻与封面开合互斥(先 stopAnim 再起);梯子补首帧时序

BorderBeam

DisplaySync + 32ms 跳帧

帧时钟对齐 vsync,跳帧兑现 30fps 预算

NavigationAppShell

animateTo + 滚动回调

Search 收起是纯 UI 过渡;滚动方向只改状态

BookFlip 的「双 Animator 互斥」值得多说一句:页翻动画和封面开合动画共用一个绘制管线,起任何一个之前先 stopAnim() + stopCoverAnim()——多个驱动写同一份状态时,互斥是纪律,否则两条 onFrame 会互相踩进度。

结语

四条通道不是四个难度档,而是四种动画本质的镜像:终态过渡(animateTo)、有界进度(Animator)、无界帧流(DisplaySync)、离散事件(定时器调度)。ArkUILab 的驱动分布图之所以成簇,是因为每个组件动笔之前都先回答了「这动画的本质是什么」——而这个答案一旦错了,后面所有代码都是在错误的地基上盖楼,且楼越高越难拆。

Logo

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

更多推荐