HarmonyOS 鸿蒙 动效驱动选型 —— animateTo、Animator、DisplaySync 与定时器的四条通道
一、先看真实分布:仓库里谁在用什么
对 entry/src/main/ets/components/ 全量 grep 四个关键词,分布如下(真实结果,非回忆):
|
驱动 |
组件 |
典型用途 |
|---|---|---|
|
|
NavigationAppShell、FloatingTabBarShell、PullDownQuickAccessShell、TransitionDemos |
UI 状态过渡:Search 收起/展开(220ms)、TabBar 显隐、转场演示 |
|
|
Coverflow、BookFlip、BookHardFlip、CollectibleBookshelf |
手势几何:centerIndex∈ℝ、flipProgress、coverProgress、书架 progress[] |
|
|
GrokBot、ThinkingOrbs、BorderBeam |
环境动画:表情头像、AI 状态球、边框流光(Canvas 逐帧重绘) |
|
|
GradientSpin、SlotText、FlowAvatar |
历史路径:采样 |
|
|
Coverflow(autoplay) |
纯调度:何时发起下一次翻页,不碰几何 |
|
|
BookFlip(重绘梯子) |
时序补偿: |
分布不是随机的,四个簇对应四种动画本质。下面逐条拆。
二、通道一: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();
}
六个细节各有讲究:
-
可中断:
stopAnim()先行,拖拽打断动画、程序化翻页互相打断、关书打断翻页,都是同一条路径; -
从当前进度接力:松手瞬间
start = 当前 progress,手指和弹簧无缝交接——这是隐式动画给不了的; -
时距随距离:
300 + dist × 400,翻一半松手回弹比整页翻转短,物理感来自这里; -
归一化 0..1 再 lerp:源码注释原话「more reliable than begin/end = progress」——不同起点的动画统一在 0..1 域,参数好算、时序稳定;
-
onFrame 只改一个变量:Bookshelf 的
applyAnimationFrame同款——角度、透视投影、累计位置、接触阴影全部从同一帧 progress 派生,几何分叉在数学上不可能(E020 v3 修正的直接产物); -
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 的驱动分布图之所以成簇,是因为每个组件动笔之前都先回答了「这动画的本质是什么」——而这个答案一旦错了,后面所有代码都是在错误的地基上盖楼,且楼越高越难拆。
更多推荐




所有评论(0)