动画体系:隐式动画到自定义动画
系列:UI 与交互篇 · 第 3/6 篇
Flutter 动画 ImplicitlyAnimatedWidget AnimationController 性能
1. 问题背景:业务场景 + 现象
- 场景:房间状态角标、排行数字跳动、底部 Tab 切换、弱提示条滑入滑出、列表项展开收起、游戏 HUD 分数滚动等,都需要「顺眼」的过渡,而不是硬切。
- 现象:
- 用
setState里改数值,外包一层AnimatedContainer,一多就乱,时长曲线各写各的。 - 需要串行动画(先缩再放)时,把
Future.delayed和setState堆在一起,取消导航或 dispose 后仍回调,偶发报错。 - 列表里每个 cell 都挂
AnimationController,滑动时 CPU 飙高、掉帧。 - 设计要「品牌曲线」,发现
Curves.ease全家不够用,不敢碰CustomPainter/TweenSequence。
- 用
目标:用一套从隐式到显式、再到完全自控的升级路径,让动画可组合、可复用、可收尾。
2. 原因分析:核心原理 + 排查过程
2.1 Flutter 动画在框架里大致怎么走
- 隐式动画 Widget(如
AnimatedOpacity、TweenAnimationBuilder):内部替你管AnimationController和Animation,duration+curve+ 目标值变化即触发重建插值。 - 显式动画:你自己
TickerProvider+AnimationController,把Animation<double>交给子组件或AnimatedBuilder,适合多段、手势驱动、可暂停恢复。 - 自定义绘制动画:
CustomPainter的repaint监听Listenable(常常是 controller),在paint里按进度算路径/矩阵——适合无法用布局表达的形变。
2.2 常见卡顿与错乱从哪里来
| 类型 | 典型原因 |
|---|---|
| 掉帧 | 每帧 build 里做重计算;列表内过多独立 AnimationController;大图未缓存仍参与过渡 |
| 内存/泄漏 | Controller 未在 dispose 释放;路由 pop 后 addStatusListener 仍触发 |
| 视觉「假」 | 时长与 curve 与交互节奏不一致;多属性不同步(透明度结束了位移还在跑) |
2.3 排查时可问自己的三个问题
- 这是单一属性随数据变,还是编排一段表演?前者 → 隐式 /
TweenAnimationBuilder;后者 →Controller+Interval。 - 动画是否绑定在列表 item 生命周期?若是,能否抽成「可见时才驱动」或使用隐式减少 ticker 数量?
- 退出页面时,谁负责
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/ 独立层 / 可见性策略。 - 退出必清理:
disposecontroller;异步结束回调先判断mounted(或统一用可取消的 token)。
4. 关键代码:最小必要代码片段
4.1 隐式:透明度 + 位移(无手写 Controller)
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 串行两段(缩放 → 淡出)
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
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 jank 与
build次数。 - 直觉验收:用 0.75× / 1.25× 系统动画速度走一遍关键路径,慢放仍能感到节奏一致。
- 压测:在列表里快速滚动同时触发动画,观察是否出现 Concurrent modification / Ticker disposed 类异常(若有,检查异步与 dispose 顺序)。
可记录前后:平均帧耗时、单次交互内 build 调用次数、列表 scroll 时 CPU%(定性即可,适合写进复盘)。
6. 可复用结论:通用经验 + 避坑清单
通用经验
- 先问是不是编排:是 → 一条时间轴管起来;否 → 隐式收尾最快。
TweenAnimationBuilder的child复用是免费性能点,和AnimatedBuilder同理。- 把「进度」往下传,不要把「Controller」泄漏到无关子组件。
- 曲线即产品语言:团队定 2~3 套
Duration + Curve组合,比每人手写更像同一款 App。
避坑清单
- 列表项里人手一个长生命周期
AnimationController,滑动仍repeat。 -
AnimationController在initState里forward(),但忘记在dispose里释放。
[ ] 用async/await串联动画,页面退出后仍改 state。 - 在
build里controller.forward()(应放在事件回调或didUpdateWidget等明确时机)。 - heavy 的
decode/布局计算放在paint每帧做(应缓存或下沉到静态资源)。
下一篇预告:深色模式、主题系统与设计令牌(ThemeExtension、语义色、组件侧零魔法数)。