隐式动画与显式动画
Site Owner
Published on 2026-05-22
系统讲解Flutter AnimatedContainer/AnimatedOpacity隐式动画、AnimationController显式控制及Tween组合

隐式动画与显式动画 (Implicit & Explicit Animations)
模块:六 - 动画系统
预计阅读时间:25 分钟
前言
Flutter 的动画系统是其最令人着迷的特性之一。与其他框架不同,Flutter 从底层设计了一套完整的动画架构,将"动画是什么"(描述层)与"动画如何运行"(执行层)完全分离。
理解 Flutter 动画的第一步,是搞清楚两大阵营的边界:
- 隐式动画(Implicit Animations):你只需要改变目标值,Flutter 自动处理过渡
- 显式动画(Explicit Animations):你完全掌控动画的播放、暂停、反转和每一帧的值
本文从底层机制出发,彻底讲透这两套系统的设计哲学、实现原理与最佳实践。
一、动画的底层基础:Ticker 与 vsync
在讲隐式/显式动画之前,必须先理解 Flutter 动画的驱动力。
1.1 Ticker:帧回调的原始驱动
Ticker 是 Flutter 动画的最底层机制。每一帧屏幕刷新时,Ticker 会触发一次回调,传入自上次开始以来的 Duration:
class MyWidget extends StatefulWidget { ... }
class _MyWidgetState extends State<MyWidget> {
late Ticker _ticker;
Duration _elapsed = Duration.zero;
@override
void initState() {
super.initState();
// 直接使用 Ticker(通常你不需要这样做)
_ticker = Ticker((elapsed) {
setState(() {
_elapsed = elapsed;
});
});
_ticker.start();
}
@override
void dispose() {
_ticker.dispose(); // 必须释放!
super.dispose();
}
}
关键理解:Ticker 只是一个计时器,它本身不知道什么是"动画值"。AnimationController 正是在 Ticker 之上构建的,它将 elapsed 时间映射到 [0.0, 1.0] 的进度值。
1.2 vsync:防止后台动画浪费资源
vsync 参数接收一个 TickerProvider,它的核心作用是:当 Widget 不可见时,自动暂停 Ticker,避免后台消耗 CPU/GPU 资源。
// 最常用的两种 TickerProvider mixin:
// 1. 只有一个 AnimationController 时
class _MyState extends State<MyWidget> with SingleTickerProviderStateMixin {
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this, // this 就是 TickerProvider
duration: const Duration(milliseconds: 300),
);
}
}
// 2. 有多个 AnimationController 时
class _MyState extends State<MyWidget> with TickerProviderStateMixin {
late AnimationController _controller1;
late AnimationController _controller2;
}
⚠️ 常见错误:在
initState中忘记vsync: this,或在dispose中忘记_controller.dispose(),都会导致内存泄漏和断言错误。
二、隐式动画:声明式的优雅
2.1 什么是隐式动画
隐式动画的设计哲学是:你只需声明"我想要什么状态",框架负责如何从当前状态过渡到目标状态。
Flutter 内置了大量 AnimatedXxx Widget,当它们的属性发生变化时,会自动产生动画:
class _CardState extends State<Card> {
bool _expanded = false;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => setState(() => _expanded = !_expanded),
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
width: _expanded ? 300 : 150,
height: _expanded ? 200 : 100,
decoration: BoxDecoration(
color: _expanded ? Colors.blue : Colors.grey,
borderRadius: BorderRadius.circular(_expanded ? 20 : 8),
),
child: const Center(child: Text('点击展开')),
),
);
}
}
这段代码涉及了同时对 宽度、高度、颜色、圆角 四个属性的动画——而你完全没有写任何 AnimationController。
2.2 常用隐式动画 Widget 大全
| Widget | 动画属性 | 适用场景 |
|---|---|---|
AnimatedContainer | 任意 Container 属性 | 布局变化、样式切换 |
AnimatedOpacity | opacity | 淡入淡出 |
AnimatedPadding | padding | 间距变化 |
AnimatedAlign | alignment | 对齐变化 |
AnimatedPositioned | 位置(需在 Stack 内) | 元素滑动定位 |
AnimatedDefaultTextStyle | 文字样式 | 文字大小/颜色变化 |
AnimatedScale | 缩放 | 放大/缩小效果 |
AnimatedRotation | 旋转角度 | 旋转效果 |
AnimatedSlide | 偏移 | 滑入滑出 |
AnimatedSwitcher | 子 Widget 切换 | 内容替换动画 |
AnimatedCrossFade | 两个子 Widget 交叉淡化 | 状态切换 |
2.3 AnimatedSwitcher:内容切换的利器
class _CounterState extends State<Counter> {
int _count = 0;
@override
Widget build(BuildContext context) {
return Column(
children: [
AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
// 自定义过渡效果
transitionBuilder: (child, animation) {
return ScaleTransition(scale: animation, child: child);
},
child: Text(
'$_count',
// ⚠️ key 是必须的!AnimatedSwitcher 通过 key 判断是否是新 Widget
key: ValueKey<int>(_count),
style: const TextStyle(fontSize: 40),
),
),
ElevatedButton(
onPressed: () => setState(() => _count++),
child: const Text('+1'),
),
],
);
}
}
为什么需要 key? AnimatedSwitcher 通过比较新旧 child 的 key 来判断是否发生了变化。如果没有 key,两个相同类型的 Text Widget 会被认为是"同一个 Widget 更新了",而不是"旧 Widget 离开、新 Widget 进入"。
2.4 自定义隐式动画:ImplicitlyAnimatedWidget
当内置 Widget 无法满足需求时,可以继承 ImplicitlyAnimatedWidget 来创建自己的隐式动画:
// 自定义:动画文字颜色
class AnimatedTextColor extends ImplicitlyAnimatedWidget {
const AnimatedTextColor({
super.key,
required this.color,
required this.child,
super.duration = const Duration(milliseconds: 200),
super.curve = Curves.linear,
});
final Color color;
final Widget child;
@override
AnimatedWidgetBaseState<AnimatedTextColor> createState() =>
_AnimatedTextColorState();
}
class _AnimatedTextColorState
extends AnimatedWidgetBaseState<AnimatedTextColor> {
ColorTween? _colorTween;
@override
void forEachTween(TweenVisitor<dynamic> visitor) {
// 核心:注册需要动画的 Tween
_colorTween = visitor(
_colorTween, // 当前 Tween(首次为 null)
widget.color, // 目标值
(value) => ColorTween(begin: value as Color), // 创建 Tween 的工厂
) as ColorTween?;
}
@override
Widget build(BuildContext context) {
return DefaultTextStyle.merge(
style: TextStyle(color: _colorTween?.evaluate(animation)),
child: widget.child,
);
}
}
三、显式动画:精确控制的力量
3.1 显式动画的四要素
显式动画需要你手动管理四个核心组件:
AnimationController ──→ Animation<T> ──→ Widget
↑ ↑
Ticker驱动 Tween + Curve
(0.0 ~ 1.0) (值变换 + 缓动)
要素一:AnimationController
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 500),
// lowerBound 和 upperBound 默认为 0.0 和 1.0
// 也可以自定义范围,但通常保持默认
);
}
// AnimationController 的常用方法:
_controller.forward(); // 正向播放 0→1
_controller.reverse(); // 反向播放 1→0
_controller.repeat(); // 循环播放
_controller.repeat(reverse: true); // 往返循环
_controller.stop(); // 停止
_controller.reset(); // 重置到起始值
_controller.animateTo(0.5); // 动画到指定值
// 监听状态变化:
_controller.addStatusListener((status) {
if (status == AnimationStatus.completed) {
// 动画播放完成
} else if (status == AnimationStatus.dismissed) {
// 动画回到起始位置
}
});
要素二:Tween(值的插值器)
Tween 定义了从 begin 到 end 的值变化范围:
// 基础 Tween
final tween = Tween<double>(begin: 0.0, end: 1.0);
// 常用内置 Tween:
ColorTween(begin: Colors.red, end: Colors.blue);
SizeTween(begin: Size(100, 100), end: Size(200, 200));
RectTween(begin: Rect.zero, end: Rect.fromLTWH(0, 0, 100, 100));
IntTween(begin: 0, end: 100);
BorderRadiusTween(
begin: BorderRadius.circular(0),
end: BorderRadius.circular(20),
);
// Tween 链(先应用 curve,再变换值):
final animation = Tween<double>(begin: 0.0, end: 300.0)
.chain(CurveTween(curve: Curves.elasticOut))
.animate(_controller);
要素三:Curve(缓动曲线)
// 将 controller 的线性进度映射为非线性的动画进度
final curvedAnimation = CurvedAnimation(
parent: _controller,
curve: Curves.easeInOut, // 正向曲线
reverseCurve: Curves.easeIn, // 反向曲线(可选)
);
// 常用内置曲线:
Curves.linear // 线性
Curves.easeIn // 先慢后快
Curves.easeOut // 先快后慢
Curves.easeInOut // 两端慢中间快
Curves.bounceOut // 弹跳回弹
Curves.elasticIn // 弹簧拉伸
Curves.elasticOut // 弹簧回弹
要素四:AnimatedBuilder(Widget 层的监听)
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller, // 监听的 Animation
builder: (context, child) {
return Transform.rotate(
angle: _controller.value * 2 * math.pi, // 每次都重建
child: child, // child 不随动画变化,所以传入 child 参数避免重建
);
},
child: const Icon(Icons.refresh, size: 48), // 不参与动画的子树
);
}
child 参数的优化意义:builder 在每一帧都会调用。如果 child 是一个复杂的 Widget 树,每帧重建代价很高。通过将不变的部分传入 child 参数,框架会缓存它,builder 直接接收已建好的 Widget 实例。
3.2 完整示例:自定义加载指示器
class PulsingLoader extends StatefulWidget {
const PulsingLoader({super.key});
@override
State<PulsingLoader> createState() => _PulsingLoaderState();
}
class _PulsingLoaderState extends State<PulsingLoader>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _scaleAnimation;
late Animation<double> _opacityAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 800),
)..repeat(reverse: true); // 创建并立即开始往返循环
// 缩放:0.5 → 1.0,使用弹性曲线
_scaleAnimation = Tween<double>(begin: 0.5, end: 1.0).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
);
// 透明度:0.3 → 1.0,与缩放同步
_opacityAnimation = Tween<double>(begin: 0.3, end: 1.0).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
);
}
@override
void dispose() {
_controller.dispose(); // 必须!否则内存泄漏
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
child: Container(
width: 60,
height: 60,
decoration: const BoxDecoration(
color: Colors.blue,
shape: BoxShape.circle,
),
),
builder: (context, child) {
return Opacity(
opacity: _opacityAnimation.value,
child: Transform.scale(
scale: _scaleAnimation.value,
child: child,
),
);
},
);
}
}
3.3 使用 AnimationController.value 监听器
除了 AnimatedBuilder,还可以使用 addListener 手动触发 setState:
@override
void initState() {
super.initState();
_controller = AnimationController(vsync: this, duration: ...)
..addListener(() {
setState(() {}); // 触发 rebuild
});
}
对比两种方式:
| 方式 | 重建范围 | 推荐度 |
|---|---|---|
addListener + setState | 整个 build 方法 | 仅适合简单场景 |
AnimatedBuilder | 仅 builder 函数内 | ✅ 推荐,粒度更小 |
AnimationController + 直接读值 | 不触发重建(手动控制) | 进阶用法 |
四、隐式 vs 显式:如何选择?
决策树
需要动画吗?
│
▼
是否需要:
- 精确控制播放时机(暂停/恢复)?
- 循环/往返播放?
- 与手势交互联动(如拖拽进度)?
- 多个动画协调(交错动画)?
- 监听动画完成事件?
│
YES → 显式动画(AnimationController)
│
NO → 隐式动画(AnimatedXxx)
实践原则
- 优先考虑隐式动画:代码量少,几乎不会有内存泄漏风险
- 当
AnimatedXxx满足需求时,永远不要手写AnimationController - 显式动画必须成对处理生命周期:
initState创建 →dispose释放
五、TweenAnimationBuilder:隐式与显式的桥梁
TweenAnimationBuilder 是一个特殊的 Widget,它让你无需 State 就能使用 Tween,是隐式动画的高级形式:
class ColorAnimationDemo extends StatefulWidget {
const ColorAnimationDemo({super.key});
@override
State<ColorAnimationDemo> createState() => _ColorAnimationDemoState();
}
class _ColorAnimationDemoState extends State<ColorAnimationDemo> {
Color _targetColor = Colors.blue;
@override
Widget build(BuildContext context) {
return TweenAnimationBuilder<Color?>(
tween: ColorTween(begin: Colors.grey, end: _targetColor),
duration: const Duration(milliseconds: 400),
curve: Curves.easeInOut,
builder: (context, color, child) {
return Container(
width: 200,
height: 200,
color: color,
child: child,
);
},
child: TextButton(
onPressed: () => setState(() {
_targetColor = _targetColor == Colors.blue
? Colors.orange
: Colors.blue;
}),
child: const Text('切换颜色'),
),
);
}
}
TweenAnimationBuilder 的工作原理:
- 当
tween的end值变化时,自动从当前值动画到新的目标值 - 不需要
AnimationController和mixin - 适合单次触发的动画(进入动画、状态切换)
六、性能最佳实践
6.1 使用 const 构造函数减少重建
// ❌ 每帧都重建 Icon
AnimatedBuilder(
animation: _controller,
builder: (context, _) => Row(
children: [
Transform.rotate(
angle: _controller.value * 2 * pi,
child: Icon(Icons.refresh), // 每帧重建
),
],
),
);
// ✅ Icon 只构建一次
AnimatedBuilder(
animation: _controller,
child: const Icon(Icons.refresh), // 只构建一次
builder: (context, child) => Row(
children: [
Transform.rotate(
angle: _controller.value * 2 * pi,
child: child, // 复用已构建的 Widget
),
],
),
);
6.2 Transform vs AnimatedContainer 的性能差异
// ❌ AnimatedContainer 改变 size 会触发 layout pass
AnimatedContainer(
duration: const Duration(milliseconds: 300),
width: _expanded ? 200 : 100, // 触发重新布局
height: _expanded ? 200 : 100,
);
// ✅ Transform.scale 只在合成阶段处理,不触发 layout
AnimatedBuilder(
animation: _scaleAnimation,
child: Container(width: 200, height: 200, color: Colors.blue),
builder: (context, child) => Transform.scale(
scale: _scaleAnimation.value,
child: child, // 跳过 layout,直接在 GPU 合成
),
);
原则:优先使用 Transform、Opacity 等只影响合成阶段的属性,避免动画中触发 layout。
七、实战案例:卡片展开效果
综合运用隐式动画 + 显式动画实现一个带层次感的卡片展开:
class ExpandableCard extends StatefulWidget {
const ExpandableCard({super.key, required this.title, required this.content});
final String title;
final String content;
@override
State<ExpandableCard> createState() => _ExpandableCardState();
}
class _ExpandableCardState extends State<ExpandableCard>
with SingleTickerProviderStateMixin {
bool _expanded = false;
late AnimationController _controller;
late Animation<double> _rotateAnimation; // 箭头旋转
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 250),
);
_rotateAnimation = Tween<double>(begin: 0.0, end: 0.5).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _toggle() {
setState(() {
_expanded = !_expanded;
_expanded ? _controller.forward() : _controller.reverse();
});
}
@override
Widget build(BuildContext context) {
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
// 隐式动画:高度和圆角
height: _expanded ? 200 : 60,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(_expanded ? 16 : 8),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(_expanded ? 0.15 : 0.05),
blurRadius: _expanded ? 20 : 4,
offset: const Offset(0, 4),
),
],
),
child: Column(
children: [
InkWell(
onTap: _toggle,
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Expanded(
child: Text(
widget.title,
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
// 显式动画:箭头旋转
RotationTransition(
turns: _rotateAnimation,
child: const Icon(Icons.keyboard_arrow_down),
),
],
),
),
),
// AnimatedOpacity:内容淡入淡出
AnimatedOpacity(
duration: const Duration(milliseconds: 200),
opacity: _expanded ? 1.0 : 0.0,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(widget.content),
),
),
],
),
);
}
}
八、过关自测
问题一:为什么 AnimationController 必须在 dispose 中调用 .dispose()?
AnimationController内部持有一个Ticker,Ticker持有对TickerProvider(即 State)的引用。如果不显式 dispose,即使 Widget 已经从树中移除,State 对象也不会被 GC,Ticker会继续占用调度资源,最终导致内存泄漏和"A RenderObject was disposed with an active Ticker" 断言。
问题二:vsync 参数的作用是什么?
vsync接收TickerProvider,当 Widget 对应的 Route 不在前台(如被遮挡或切到后台)时,TickerProvider会自动停止Ticker,从而让AnimationController暂停,防止后台 UI 继续消耗 CPU/GPU 资源。
问题三:AnimatedSwitcher 切换时子 Widget 没有动画,通常是什么原因?
最常见原因是缺少 key。
AnimatedSwitcher通过对比新旧 child 的runtimeType和key来判断是否需要切换动画。若两个 child 是相同类型且没有 key,框架会认为是同一个 Widget 属性更新,不会触发切换动画。
总结
| 特性 | 隐式动画 | 显式动画 |
|---|---|---|
| 代码量 | 少 | 多 |
| 控制粒度 | 低(自动) | 高(完全手动) |
| 生命周期管理 | 框架负责 | 开发者负责 |
| 适合场景 | 状态变化触发的过渡 | 循环动画、手势联动、交错动画 |
| 内存泄漏风险 | 低 | 需手动 dispose |
| 典型 API | AnimatedContainer、AnimatedOpacity | AnimationController、AnimatedBuilder |
Flutter 动画的最高境界是混合使用:在合适的地方用隐式动画降低复杂度,在需要精确控制的地方用显式动画发挥全部威力。下一篇我们将在这个基础上,探讨更复杂的交错动画与物理动画。