多阶段动效如何摆脱回调地狱:一个基于 ArkUI 的 AnimationStepper 设计

多阶段动效如何摆脱回调地狱:一个基于 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
      }, () => {
        // ...
      })
    })
  })
})

注:文中的 popupScaleform 等变量仅为示例,代表你在真实项目里的本地 UI 状态。

这样写的痛点大家都很熟悉:

  • 可读性差:动画参数和业务逻辑夹在一起,很难快速看出「整个闭环有几个阶段、顺序是什么」;
  • 改动成本高 :想在中间插入/删除一个步骤,要同时改多个 onFinish,非常容易引入 bug;
  • 难以埋点和调试 :每一步开始/结束都想打日志/埋点时,只能在各个 onFinish 里手动加;
  • 难以复用:另一处如果也要做类似的多阶段动效,只能再复制一份回调地狱。

我们希望把「多阶段动画」的时序,变成一张一目了然的「任务列表」,并且有一个统一的驱动器来顺序执行这些任务。


二、核心思想:任务表 + 任务队列 + 统一驱动

思路非常简单:

  • 用一个 任务表(Task Map) 描述「每个任务名对应的一段动画」;
  • 每段动画由两个部分组成:
    • change:要修改的属性(scale、rotation、form 等);
    • animateParam:这次动画的参数(duration、curve、delay、onFinish 等);
  • 用一个 任务队列(Task Queue) 表示「这一次关闭动效要走哪些步骤」;
  • 用一个统一的 AnimationStepper 驱动器:
    • 取出队列头部任务;
    • 调用 uiCtx.animateTo(animateParam, change)
    • 动画结束后,自动推进到下一个任务(除非当前任务自定义了 onFinish)。

用 mermaid 画一个简单的流程示意:

sequenceDiagram participant Popup as PopupContainer participant Engine as CloseAnimationEngine participant Stepper as AnimationStepper participant UI as ArkUI Context Popup->>Engine: onClose() Engine->>Stepper: start([task1, task2, task3...]) loop for each task Stepper->>UI: animateTo(animateParam, change) UI-->>Stepper: onFinish() Stepper->>Stepper: play next task (default) end Stepper-->>Engine: all tasks done Engine-->>Popup: emitCloseFinished()

如果平台不支持 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

例如上文的 switchToIconwobbleIcon

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 里稳健地计算双层容器的尺寸和位置,修正缩放带来的视觉偏移,并生成既自然又「打得准」的抛掷路径。

相关推荐
hh.h.5 小时前
Flutter适配鸿蒙轻量设备的资源节流方案
flutter·华为·harmonyos
2301_796512526 小时前
使用如Redux、MobX或React Context等状态管理库来管理状态,React Native鸿蒙跨平台开发来实战
react native·react.js·harmonyos
萌虎不虎6 小时前
【鸿蒙实现实现低功耗蓝牙(BLE)连接】
华为·harmonyos
FrameNotWork6 小时前
HarmonyOS 教学实战(三):列表分页、下拉刷新与性能优化(让列表真正“丝滑”)
华为·性能优化·harmonyos
柠果7 小时前
HarmonyOS震动反馈开发——提升用户体验的触觉交互
harmonyos
柠果7 小时前
HarmonyOS数据持久化最佳实践——Preferences首选项存储详解
harmonyos
柠果7 小时前
HarmonyOS纯音测听实现——专业听力检测功能开发
harmonyos
柠果7 小时前
HarmonyOS深色模式适配实战——主题切换与WCAG对比度标准
harmonyos
柠果7 小时前
HarmonyOS权限管理实战——麦克风、震动等敏感权限申请
harmonyos