UI 与交互篇 (3/6):动画体系:隐式动画到自定义动画

动画体系:隐式动画到自定义动画

系列:UI 与交互篇 · 第 3/6 篇

Flutter 动画 ImplicitlyAnimatedWidget AnimationController 性能


1. 问题背景:业务场景 + 现象

  • 场景:房间状态角标、排行数字跳动、底部 Tab 切换、弱提示条滑入滑出、列表项展开收起、游戏 HUD 分数滚动等,都需要「顺眼」的过渡,而不是硬切。
  • 现象
    • setState 里改数值,外包一层 AnimatedContainer一多就乱,时长曲线各写各的。
    • 需要串行动画(先缩再放)时,把 Future.delayedsetState 堆在一起,取消导航或 dispose 后仍回调,偶发报错。
    • 列表里每个 cell 都挂 AnimationController,滑动时 CPU 飙高、掉帧
    • 设计要「品牌曲线」,发现 Curves.ease 全家不够用,不敢碰 CustomPainter / TweenSequence

目标:用一套从隐式到显式、再到完全自控 的升级路径,让动画可组合、可复用、可收尾


2. 原因分析:核心原理 + 排查过程

2.1 Flutter 动画在框架里大致怎么走

  • 隐式动画 Widget (如 AnimatedOpacityTweenAnimationBuilder):内部替你管 AnimationControllerAnimationduration + curve + 目标值变化即触发重建插值。
  • 显式动画 :你自己 TickerProvider + AnimationController,把 Animation<double> 交给子组件或 AnimatedBuilder适合多段、手势驱动、可暂停恢复
  • 自定义绘制动画CustomPainterrepaint 监听 Listenable(常常是 controller),在 paint 里按进度算路径/矩阵------适合无法用布局表达的形变

2.2 常见卡顿与错乱从哪里来

类型 典型原因
掉帧 每帧 build 里做重计算;列表内过多独立 AnimationController;大图未缓存仍参与过渡
内存/泄漏 Controller 未在 dispose 释放;路由 pop 后 addStatusListener 仍触发
视觉「假」 时长与 curve 与交互节奏不一致;多属性不同步(透明度结束了位移还在跑)

2.3 排查时可问自己的三个问题

  1. 这是单一属性 随数据变,还是编排一段表演 ?前者 → 隐式 / TweenAnimationBuilder;后者 → Controller + Interval
  2. 动画是否绑定在列表 item 生命周期?若是,能否抽成「可见时才驱动」或使用隐式减少 ticker 数量?
  3. 退出页面时,谁负责 stop() / dispose() ?是否在 Ticker 已停用的 context 里再 setState

3. 解决方案:方案对比 + 最终选择

3.1 分层选型(建议团队统一口径)

需求 优先方案 说明
单一数值/样式随状态变 Animated*TweenAnimationBuilder 代码少,自带 controller 生命周期
多段、循环、手势跟手 AnimationController + Tween / Curve 可控 forward/reverse/repeat
多条动画不同时间段 一条 Controller + 多个 Interval(或 TweenSequence 避免多个 controller 抢同一套状态
形变/粒子/路径 CustomPainter + repaint: animation 少触发布局,GPU 友好

3.2 最终选择(落地原则)

  • 默认隐式样 :能用 TweenAnimationBuilder 就不要手写 controller。
  • 编排显式样 :一个页面一个「导演」controller(或 AnimationController + staggered),子组件只接收 Animation<double> 或具体 Tween
  • 列表里慎挂 ticker :优先数据驱动的隐式短动画 ,长表演用 Hero / 独立层 / 可见性策略。
  • 退出必清理dispose controller;异步结束回调先判断 mounted(或统一用可取消的 token)。

4. 关键代码:最小必要代码片段

4.1 隐式:透明度 + 位移(无手写 Controller)

dart 复制代码
TweenAnimationBuilder<double>(
  tween: Tween(begin: 0, end: visible ? 1 : 0),
  duration: const Duration(milliseconds: 220),
  curve: Curves.easeOutCubic,
  builder: (context, t, child) {
    return Opacity(
      opacity: t,
      child: Transform.translate(
        offset: Offset(0, (1 - t) * 8),
        child: child,
      ),
    );
  },
  child: bannerChild,
);

要点:child 传给 builder 外面,避免子树随 tween 每帧重建

4.2 显式:单 Controller 串行两段(缩放 → 淡出)

dart 复制代码
class _PulseState extends State<Pulse> with SingleTickerProviderStateMixin {
  late final AnimationController _c = AnimationController(
    vsync: this,
    duration: const Duration(milliseconds: 600),
  );

  late final Animation<double> scale = Tween(begin: 1.0, end: 1.08).animate(
    CurvedAnimation(parent: _c, curve: const Interval(0.0, 0.55, curve: Curves.easeOut)),
  );

  late final Animation<double> fade = Tween(begin: 1.0, end: 0.0).animate(
    CurvedAnimation(parent: _c, curve: const Interval(0.45, 1.0, curve: Curves.easeIn)),
  );

  @override
  void dispose() {
    _c.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _c,
      builder: (context, child) {
        return Opacity(
          opacity: fade.value,
          child: Transform.scale(scale: scale.value, child: child),
        );
      },
      child: widget.child,
    );
  }
}

4.3 自定义:Painter 跟一条 Animation

dart 复制代码
class RibbonPainter extends CustomPainter {
  RibbonPainter(this.progress) : super(repaint: progress);
  final Animation<double> progress;

  @override
  void paint(Canvas canvas, Size size) {
    final t = progress.value;
    // 用 t 插值路径控制点、渐变起止等
  }

  @override
  bool shouldRepaint(covariant RibbonPainter oldDelegate) =>
      oldDelegate.progress != progress;
}

5. 效果验证:数据/截图/日志

  • DevTools Performance :打开 Performance overlay 或记录一段 Timeline,对比优化前后 UI thread jankbuild 次数。
  • 直觉验收 :用 0.75× / 1.25× 系统动画速度走一遍关键路径,慢放仍能感到节奏一致
  • 压测 :在列表里快速滚动同时触发动画,观察是否出现 Concurrent modification / Ticker disposed 类异常(若有,检查异步与 dispose 顺序)。

可记录前后:平均帧耗时、单次交互内 build 调用次数、列表 scroll 时 CPU%(定性即可,适合写进复盘)。


6. 可复用结论:通用经验 + 避坑清单

通用经验

  1. 先问是不是编排:是 → 一条时间轴管起来;否 → 隐式收尾最快。
  2. TweenAnimationBuilderchild 复用 是免费性能点,和 AnimatedBuilder 同理。
  3. 把「进度」往下传,不要把「Controller」泄漏到无关子组件。
  4. 曲线即产品语言 :团队定 2~3 套 Duration + Curve 组合,比每人手写更像同一款 App。

避坑清单

  • 列表项里人手一个长生命周期 AnimationController,滑动仍 repeat
  • AnimationControllerinitStateforward(),但忘记在 dispose 里释放。

    \] 用 `async/await` 串联动画,**页面退出后仍改 state**。

  • heavy 的 decode/布局计算放在 paint 每帧做(应缓存或下沉到静态资源)。

下一篇预告:深色模式、主题系统与设计令牌ThemeExtension、语义色、组件侧零魔法数)。

相关推荐
cyforkk1 小时前
前端架构实战:当服务器关闭时,如何优雅提示 502 错误?
服务器·前端·架构
高桥凉介发量惊人2 小时前
UI 与交互篇(1/6):组件化思路:从页面复制到可复用组件
前端
kyriewen2 小时前
Generator 函数:那个能“暂停”的函数,到底有什么用?
前端·javascript·面试
打酱油的D2 小时前
01. Node.js 运行时
前端·后端
是大强2 小时前
Electron 打包用 junction 代替 symlink
前端·javascript·electron
哈罗哈皮2 小时前
trea也很强,我撸一个给你看(附教程)
前端·人工智能·微信小程序
就是个名称2 小时前
echart绘制天顶图
linux·前端·javascript
arvin_xiaoting3 小时前
OpenClaw学习总结_II_频道系统_5:Signal集成详解
java·前端·学习·signal·ai agent·openclaw·signal-cli
哆啦A梦15883 小时前
统一返回包装类 Result和异常处理
java·前端·后端·springboot