多阶段动效如何摆脱回调地狱:一个基于 ArkUI 的 AnimationStepper 设计
关键词:ArkUI、ETS、多阶段动画、回调地狱、动画编排
TL;DR:先把每一步动画定义成数据,再用一个统一驱动器顺序执行,你就再也不用在 onFinish 里手动接力。
在复杂弹窗关闭动效里,我们经常会遇到这样的需求:
- 先轻微放大提示「动效开始」;
- 然后缩小到一个较小的尺寸;
- 再切换成图标形态,做一段来回晃动;
- 最后沿一条曲线抛掷到某个目标入口。
这些阶段之间有严格的先后顺序,而且每个阶段用的动画参数(时长、曲线、onFinish)都不同。
如果不加抽象,代码很容易变成一串嵌套的 animateTo + onFinish,维护成本极高。
本文分享一套在 ArkUI/ETS 下的通用做法:AnimationStepper(动画步骤机),它把多阶段动画抽象成「任务表 + 任务队列」,从源头上消灭回调地狱。
一、典型问题:多阶段动画的回调地狱
先看一个简化的反例(伪代码):
ts
// 伪代码示意:不要这样写
uiCtx.animateTo(step1Param, () => {
// 步骤1:放大
popupScale = 1.1
}, () => {
// onFinish1 -> 步骤2
uiCtx.animateTo(step2Param, () => {
// 步骤2:缩小
popupScale = 0.8
}, () => {
// onFinish2 -> 步骤3
uiCtx.animateTo(step3Param, () => {
// 步骤3:切 icon
form = 'icon'
}, () => {
// onFinish3 -> 步骤4
uiCtx.animateTo(step4Param, () => {
// 步骤4:旋转
rotation = 0
}, () => {
// ...
})
})
})
})
注:文中的
popupScale、form等变量仅为示例,代表你在真实项目里的本地 UI 状态。
这样写的痛点大家都很熟悉:
- 可读性差:动画参数和业务逻辑夹在一起,很难快速看出「整个闭环有几个阶段、顺序是什么」;
- 改动成本高 :想在中间插入/删除一个步骤,要同时改多个
onFinish,非常容易引入 bug; - 难以埋点和调试 :每一步开始/结束都想打日志/埋点时,只能在各个
onFinish里手动加; - 难以复用:另一处如果也要做类似的多阶段动效,只能再复制一份回调地狱。
我们希望把「多阶段动画」的时序,变成一张一目了然的「任务列表」,并且有一个统一的驱动器来顺序执行这些任务。
二、核心思想:任务表 + 任务队列 + 统一驱动
思路非常简单:
- 用一个 任务表(Task Map) 描述「每个任务名对应的一段动画」;
- 每段动画由两个部分组成:
change:要修改的属性(scale、rotation、form 等);animateParam:这次动画的参数(duration、curve、delay、onFinish 等);
- 用一个 任务队列(Task Queue) 表示「这一次关闭动效要走哪些步骤」;
- 用一个统一的 AnimationStepper 驱动器:
- 取出队列头部任务;
- 调用
uiCtx.animateTo(animateParam, change); - 动画结束后,自动推进到下一个任务(除非当前任务自定义了
onFinish)。
用 mermaid 画一个简单的流程示意:
如果平台不支持 mermaid,可复制代码块到 mermaid.live 查看。
三、AnimationStepper 的通用接口设计
在 ArkUI/ETS 下,我们可以把 AnimationStepper 设计成一个与业务完全解耦的类:
ts
// 动画步骤的描述
export interface StepConfig {
change: () => void // 这一步要修改的属性
animateParam?: AnimateParam // ArkUI animateTo 的参数(可选)
}
export class AnimationStepper {
private stepMap: Record<string, StepConfig>
private queue: string[] = []
constructor(stepMap: Record<string, StepConfig>) {
this.stepMap = stepMap
}
start(taskList: string[]): void {
if (!taskList || taskList.length === 0) {
return
}
this.queue = [...taskList] // 拷贝一份,避免外部修改
this.play()
}
private play(): void {
const taskName = this.queue.shift()
if (!taskName) {
return
}
const step = this.stepMap[taskName]
if (!step) {
console.warn(`Unknown animation step: ${taskName}`)
this.play() // 跳过非法任务,继续下一个
return
}
const p = step.animateParam
const animParam: AnimateParam = {
duration: p?.duration,
curve: p?.curve,
delay: p?.delay,
iterations: p?.iterations,
playMode: p?.playMode,
// 如果没有自定义 onFinish,就自动播放下一个任务
onFinish: p?.onFinish ?? (() => this.play()),
}
// 注意:这里用的是全局的或注入的 uiContext
uiContext.animateTo(animParam, step.change)
}
}
要点:
- 任务表 stepMap 通过构造函数注入,AnimationStepper 本身不知道「业务是什么」,只负责调度;
start()只接收任务名数组,外部可以通过不同数组来组合不同链路;- 默认
onFinish会自动推进play(),只有在你明确写了onFinish时,才会覆盖这个行为。
四、把关闭动效拆成任务:两个链路的任务表设计
以一个通用的关闭动效为例,我们可以设计以下几个任务(名字起得语义化一点,便于阅读):
centerScaleUp:居中轻微放大,比如 1.0 → 1.1;centerScaleDownToIcon:从原始尺寸缩小到接近图标大小;switchToIcon:切换到 icon 形态;wobbleIcon:使用弹簧曲线做来回晃动;shrinkAndThrow:缩小并沿贝塞尔曲线抛掷到终点。
对于「无图标模式」,任务链可以是:
ts
const tasksWithoutIcon = ['centerScaleUp'] // onFinish 里直接接 shrinkAndThrow
对于「有图标模式」,任务链可以是:
ts
const tasksWithIcon = ['centerScaleDownToIcon', 'switchToIcon'] // switchToIcon 的 onFinish 再起 wobble
对应的任务表示例(伪代码,popupState/engine 均指代你持有的本地 UI 状态与动效引擎实例):
ts
const stepMap: Record<string, StepConfig> = {
centerScaleUp: {
change: () => {
popupState.scale = 1.1
},
animateParam: {
duration: 400,
curve: Curve.EaseOut,
onFinish: () => {
// 无 icon 模式下,直接进入抛掷
engine.startThrowAnimation()
},
},
},
centerScaleDownToIcon: {
change: () => {
popupState.scale = engine.iconScale
},
animateParam: { duration: 300 },
},
switchToIcon: {
change: () => {
popupState.form = 'icon'
},
animateParam: {
duration: 100,
onFinish: () => {
// 切 icon 完成后,单独开一条 wobble 链路
stepper.start(['wobbleIcon'])
},
},
},
wobbleIcon: {
change: () => {
popupState.rotation = 0
},
animateParam: {
curve: curves.springMotion(0.125, 0.25),
onFinish: () => {
engine.startThrowAnimation()
},
},
},
}
这样,整个动效的时间轴就变成了两部分:
- 「中心缩放 + 切 icon + wobble」由 AnimationStepper 管理;
- 「缩小 + 抛掷 + 光圈」由 CloseAnimationEngine(动效引擎)实现,被当成一个更粗粒度的步骤。
五、和业务逻辑的分层:Stepper 只关心「怎么动」,不关心「为什么动」
实际项目中,很多人会不自觉地把业务逻辑写进动画步骤里,比如:
- 在
onFinish里直接做网络请求; - 在
change里顺带改业务状态; - 在动画数组里混入一堆埋点逻辑。
为了保持 AnimationStepper 的可复用性,我们建议:
- 业务逻辑放在上层 Engine 里 :
比如 CloseAnimationEngine 作为动效引擎,决定什么时候播放复杂关闭动画、什么时候直接关闭、什么时候只播放部分链路; - Stepper 只接收「要修改的状态」和「动画参数」 :
它只负责「在多长时间内,把 state 从 A 调到 B」; - 埋点/日志通过 Hook 的方式注入 :
可以在onFinish里调用上层注入的回调,而不是直接写具体埋点 API。
一个典型的分层写法是:
ts
class CloseAnimationEngine {
private stepper: AnimationStepper
constructor() {
this.stepper = new AnimationStepper(this.buildStepMap())
}
startCloseAnimation(options: { hasIcon: boolean }) {
const tasks = options.hasIcon
? ['centerScaleDownToIcon', 'switchToIcon']
: ['centerScaleUp']
this.stepper.start(tasks)
}
private buildStepMap(): Record<string, StepConfig> {
// 这里组装 stepMap,内部可以访问 popupState 等
// 但不要直接写具体业务埋点
return { /* ... */ }
}
}
这样,AnimationStepper 就可以在多个动效场景(不止关闭动效)中被复用。
六、实战中的几个经验和坑
在实际项目中,我们在使用 AnimationStepper 时踩过一些坑,也有一些实践经验可以分享:
1. 用语义化任务名,避免「step1/step2/step3」
与其写:
ts
['step1', 'step2', 'step3']
不如写成:
ts
['centerScaleUp', 'switchToIcon', 'wobbleIcon']
这样当你在日志/埋点里记录任务名时,也能一眼看懂现在运行到了哪一步。
2. 默认递归推进 + 自定义 onFinish
默认的策略是:如果没有定义 onFinish,Stepper 就自动去跑下一个任务。
这已经能覆盖大部分「线性多阶段」场景。
只有当你需要打断流程(比如在某一步重启一条新链路,或在某一步结束整个动效)时,才去自定义 onFinish。
例如上文的 switchToIcon 和 wobbleIcon。
3. 注意异步逻辑与多次 start
如果某个步骤里还包含异步逻辑(例如等待某个资源 load 完成),要注意防止「重复 start」导致队列被覆盖或交错。
建议:
- 每次
start前内部清空队列; - 对于涉及异步的步骤,额外在上层 Engine 做状态防抖(例如「动效运行中」标记)。
4. 给每一步埋点或打日志
AnimationStepper 的一个巨大优势是:
它为每一步动画天然提供了一个「唯一任务名」,可以用来做埋点或日志。
比如在 play() 里加一行:
ts
logger.info(`[AnimationStepper] start task: ${taskName}`)
再在 onFinish 里加一个结束时间戳,就可以很容易地统计出:
- 每一步的平均耗时;
- 哪一步最容易被用户中断;
- 不同动画参数对用户行为的影响。
5. HarmonyOS 关键帧动画与 motionPath 的坑
- 关键帧接口下的 motionPath 失效 :实测鸿蒙自带的多步骤/关键帧动画接口中,
motionPath会被忽略,路径段直接不生效;目前只能用animateTo串联(或用 AnimationStepper 这类步骤机)来分段控制。 - 修改 motionPath 需伴随布局属性刷新 :仅更新
motionPath字符串不会立刻生效,通常需要同时变更一次位置/布局属性(如position/offset)来触发重绘,路径动画才会启动。 - 当前版本实测的限制/可能是平台缺陷,欢迎有解法的读者补充
七、小结
这一篇我们围绕一个核心问题展开:如何优雅地编排多阶段动效,而不是掉进回调地狱。
通过 AnimationStepper 这个轻量抽象,我们把多阶段动画拆成三部分:
- 任务表:定义每一步要改什么、怎么动;
- 任务队列:定义这次要按什么顺序走哪些步骤;
- 统一驱动器:负责按顺序执行任务,默认帮助衔接下一步。
这套模式不仅适用于弹窗关闭动效,任何「多步骤 UI 动画」场景都可以参考:
比如卡片展开/收起、引导流程、Tab 切换过渡等。
在下一篇中,我们会把视角转向「空间」:
如何在 ArkUI 里稳健地计算双层容器的尺寸和位置,修正缩放带来的视觉偏移,并生成既自然又「打得准」的抛掷路径。