Flutter 基础体系 · 第 46/80 篇。示例基于当前稳定 Flutter 与 Dart 3 语言能力;Android、iOS、桌面和 Web 差异会明确说明。

Flutter 显式动画:Controller、Ticker、组合、清理和测试

显式动画(explicit animation)是指开发者直接管理动画的时间轴、播放方向、状态和生命周期。Flutter 中最核心的三个对象是:

  • Ticker:按帧产生时间回调。
  • AnimationController:把时间转换为动画值,并提供启动、暂停、反向、重复等控制方法。
  • Animation<T>:把控制器的数值映射为某种动画数据,例如透明度、位移、颜色或圆角。

显式动画适合以下场景:

  • 动画由手势、网络结果或业务状态主动触发;
  • 多个属性需要共享同一个时间轴;
  • 需要监听动画开始、完成、反向和取消;
  • 需要精确控制动画的暂停、恢复、重复和测试过程。

如果只需要“属性从旧值平滑过渡到新值”,AnimatedContainerAnimatedOpacity 等隐式动画组件通常更简单。显式动画的代价是必须自己处理 TickerAnimationControllerdispose


一、先建立动画模型:时间、值和绘制

一个动画可以抽象为一个随时间变化的函数:

v(t)=f(t)v(t) = f(t)

其中:

  • tt 是从动画开始起经过的时间;
  • ff 是时间到动画值的映射;
  • v(t)v(t) 是当前动画值。

在最常见的情况下,AnimationController 产生一个从 0.01.0 的值:

v(t)={0,t0t/D,0<t<D1,tDv(t) = \begin{cases} 0, & t \leq 0 \\ t / D, & 0 < t < D \\ 1, & t \geq D \end{cases}

D 是动画时长。随后,Curve 可以改变时间进度:

p(t)=C(v(t))p(t) = C(v(t))

例如 Curves.easeOut 会让动画开始较快、结束较慢。最后,Tween<T> 将进度映射到实际属性:

x(t)=lerp(x0,x1,p(t))x(t) = \operatorname{lerp}(x_0, x_1, p(t))

以位移为例:

final position = Tween<double>(
  begin: 0,
  end: 120,
).animate(
  CurvedAnimation(
    parent: controller,
    curve: Curves.easeOut,
  ),
);

此时:

  1. controller.value 提供基础进度;
  2. CurvedAnimation 将线性进度转换为缓动进度;
  3. Tween<double>0.0..1.0 映射为 0..120
  4. 组件在每次动画值变化后重新构建或重绘。

这几个对象的职责不能混淆:Tween 不负责计时,Curve 不负责启动动画,AnimationController 也不知道最终要改变哪个 Widget 属性。


二、Ticker:动画为什么能够逐帧运行

2.1 Ticker 的定义

Ticker 是一个逐帧回调器。它在 Flutter 的帧调度机制下运行,每次收到新的时间戳时调用回调:

Ticker((Duration elapsed) {
  // elapsed 表示从 ticker 启动以来经过的时间
});

Ticker 本身不理解“透明度”或“位移”。它只提供:

  • 开始;
  • 停止;
  • 按帧通知;
  • 从启动点计算经过时间。

可以把它看成动画的“时钟”,而不是动画本身。

在 Flutter 中,Ticker 通常不应直接手动创建。Widget 应通过 TickerProvider 创建它,以便 Flutter 能够知道这个 ticker 属于哪个 State,以及该 State 是否位于被 TickerMode 禁用的子树中。

2.2 TickerProvider 的作用

TickerProvider 的核心方法是:

Ticker createTicker(TickerCallback onTick);

State 常用的两个 mixin 是:

SingleTickerProviderStateMixin
TickerProviderStateMixin

SingleTickerProviderStateMixin 适用于一个 State 只创建一个 ticker 的情况:

class _ExampleState extends State<Example>
    with SingleTickerProviderStateMixin {
  late final AnimationController controller;

  @override
  void initState() {
    super.initState();

    controller = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 300),
    );
  }

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

TickerProviderStateMixin 适用于同一个 State 需要创建多个 ticker 的情况,例如多个独立的 AnimationController

class _DashboardState extends State<Dashboard>
    with TickerProviderStateMixin {
  late final AnimationController headerController;
  late final AnimationController chartController;

  @override
  void initState() {
    super.initState();

    headerController = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 250),
    );

    chartController = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 600),
    );
  }

  @override
  void dispose() {
    chartController.dispose();
    headerController.dispose();
    super.dispose();
  }
}

如果明确知道 State 只需要一个控制器,优先使用 SingleTickerProviderStateMixin。如果使用了多个控制器却仍使用单 ticker mixin,通常会在运行时触发断言。

2.3 为什么需要 vsync

vsyncAnimationController 用来获得 Ticker 的对象。它带来两个重要效果:

  1. 动画回调与 Flutter 帧调度同步;
  2. 当对应 Widget 子树不需要动画时,可以暂停 ticker 的回调。

如果不使用 vsync,而是自行用定时器驱动动画,会遇到几个问题:

  • 回调可能与屏幕帧不同步;
  • 页面不可见时仍然执行无意义的工作;
  • 可能出现一帧多次更新或错过绘制时机;
  • 难以与 Flutter 的测试时钟和生命周期整合。

因此,AnimationControllervsync 不是一个可有可无的参数,而是显式动画接入 Flutter 渲染时序的入口。


三、AnimationController:把时间变成可控制的进度

3.1 控制器的状态

AnimationController 同时具有两个角色:

  1. 它是一个 Animation<double>,可以被其他动画对象监听;
  2. 它是一个控制器,能够修改当前动画的运行状态。

常用属性包括:

controller.value
controller.status
controller.isAnimating
controller.duration
controller.reverseDuration
controller.lowerBound
controller.upperBound

默认范围是 0.0..1.0。可以通过 lowerBoundupperBound 改变范围:

final controller = AnimationController(
  vsync: this,
  lowerBound: -1,
  upperBound: 1,
  duration: const Duration(milliseconds: 400),
);

绝大多数 UI 动画使用 0..1 更容易组合。实际属性范围应交给 Tween 处理,而不是滥用控制器的边界。

3.2 播放方法的语义

常用控制方法如下:

controller.forward();
controller.reverse();
controller.forward(from: 0);
controller.reverse(from: 1);
controller.stop();
controller.reset();
controller.repeat();
controller.animateTo(0.7);
controller.animateBack(0.2);

它们的区别如下:

  • forward():向 upperBound 播放;
  • reverse():向 lowerBound 播放;
  • forward(from: 0):先将当前值设为 0,再向前播放;
  • stop():停止当前 ticker,保留当前值;
  • reset():停止并将值设置为 lowerBound
  • repeat():到达边界后重新开始;
  • animateTo(target):在指定时间内移动到目标值;
  • animateBack(target):以反向语义移动到目标值。

forward()reverse() 返回 TickerFuture。如果需要等待动画完成,可以写:

await controller.forward();

如果控制器在等待期间被 dispose,这个 future 默认不会正常完成。需要把异常传播出来时使用:

try {
  await controller.forward().orCancel;
} on TickerCanceled {
  // Widget 已被销毁,动画等待被取消。
}

这在异步业务流程中很重要。例如:

Future<void> showSuccess() async {
  try {
    await controller.forward().orCancel;
    if (!mounted) {
      return;
    }

    setState(() {
      message = '动画完成';
    });
  } on TickerCanceled {
    // 页面离开时属于正常取消,不再更新状态。
  }
}

mounted 检查和 TickerCanceled 处理解决的是两个不同问题:

  • TickerCanceled 表示等待中的动画已取消;
  • mounted 表示异步回调返回时 State 是否仍然存在。

3.3 AnimationStatus 与数值变化

AnimationController 有四种主要状态:

AnimationStatus.dismissed
AnimationStatus.forward
AnimationStatus.reverse
AnimationStatus.completed

状态表示运行方向和边界状态,不等同于“当前值大于还是小于某个阈值”。

例如:

  • value == 0 通常对应 dismissed
  • 01 播放时是 forward
  • value == 1 通常对应 completed
  • 10 播放时是 reverse

可以分别监听数值和状态:

controller.addListener(() {
  debugPrint('value = ${controller.value}');
});

controller.addStatusListener((status) {
  debugPrint('status = $status');
});

数值监听适合驱动属性;状态监听适合处理“播放完成后执行什么”。


四、一个完整的显式动画组件

下面的组件实现了:

  • 点击后从头播放;
  • 同一个控制器同时驱动透明度、位移和缩放;
  • CurvedAnimation 提供缓动;
  • AnimatedBuilder 触发重建;
  • child 参数避免静态子树重复构建;
  • 销毁时释放控制器。
import 'package:flutter/material.dart';

class ExplicitEntranceCard extends StatefulWidget {
  const ExplicitEntranceCard({
    super.key,
    this.title = '显式动画',
  });

  final String title;

  @override
  State<ExplicitEntranceCard> createState() => _ExplicitEntranceCardState();
}

class _ExplicitEntranceCardState extends State<ExplicitEntranceCard>
    with SingleTickerProviderStateMixin {
  late final AnimationController _controller;
  late final Animation<double> _opacity;
  late final Animation<Offset> _offset;
  late final Animation<double> _scale;

  @override
  void initState() {
    super.initState();

    _controller = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 500),
      debugLabel: 'explicit-entrance-card',
    );

    final curved = CurvedAnimation(
      parent: _controller,
      curve: Curves.easeOutCubic,
      reverseCurve: Curves.easeInCubic,
    );

    _opacity = Tween<double>(
      begin: 0,
      end: 1,
    ).animate(curved);

    _offset = Tween<Offset>(
      begin: const Offset(0, 0.2),
      end: Offset.zero,
    ).animate(curved);

    _scale = Tween<double>(
      begin: 0.92,
      end: 1,
    ).animate(curved);
  }

  void _play() {
    _controller.forward(from: 0);
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      children: [
        AnimatedBuilder(
          animation: _controller,
          child: Card(
            child: Padding(
              padding: const EdgeInsets.all(24),
              child: Text(widget.title),
            ),
          ),
          builder: (context, child) {
            return Opacity(
              opacity: _opacity.value,
              child: FractionalTranslation(
                translation: _offset.value,
                child: Transform.scale(
                  scale: _scale.value,
                  child: child,
                ),
              ),
            );
          },
        ),
        const SizedBox(height: 16),
        ElevatedButton(
          onPressed: _play,
          child: const Text('播放'),
        ),
      ],
    );
  }

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

运行方式:

void main() {
  runApp(
    const MaterialApp(
      home: Scaffold(
        body: Center(
          child: ExplicitEntranceCard(),
        ),
      ),
    ),
  );
}

这段代码中,AnimatedBuilder.animation 接收的是 _controller,而不是必须接收 _opacity_offset_scale。因为这些动画都由同一个父控制器驱动,控制器值变化时它们都会通知监听者。

AnimatedBuilderchild 参数用于承载不依赖动画值的子树。上例中的 CardText 不需要每帧重新创建,只有外层的变换 Widget 根据动画值更新。这是减少无意义构建的结构性方法,但它不会阻止布局或绘制本身随动画发生。


五、组合动画:一个时间轴驱动多个属性

5.1 共享控制器与不同 Tween

同一个控制器可以驱动任意数量的动画:

final curved = CurvedAnimation(
  parent: controller,
  curve: Curves.easeOut,
);

final opacity = Tween<double>(
  begin: 0,
  end: 1,
).animate(curved);

final color = ColorTween(
  begin: Colors.grey,
  end: Colors.blue,
).animate(curved);

final radius = Tween<double>(
  begin: 0,
  end: 24,
).animate(curved);

它们共享:

  • 开始时间;
  • 播放方向;
  • 暂停和停止;
  • 动画时长。

但每个属性拥有自己的值映射。这样可以保证多个视觉变化严格同步,而不是分别启动多个可能产生时序误差的控制器。

5.2 Interval:在一个时间轴上分段

Interval 将父动画的 0..1 区间映射到某个子区间。

例如:

final first = CurvedAnimation(
  parent: controller,
  curve: const Interval(0.0, 0.5, curve: Curves.easeOut),
);

final second = CurvedAnimation(
  parent: controller,
  curve: const Interval(0.4, 1.0, curve: Curves.easeIn),
);

first 而言:

  • 父值 0.0..0.5 被映射为子动画 0.0..1.0
  • 父值大于 0.5 后,子动画保持在 1.0

second 而言:

  • 父值小于 0.4 时保持在 0.0
  • 父值 0.4..1.0 被映射为 0.0..1.0

两个区间重叠 0.4..0.5,因此可以产生交错效果。如果希望严格先后执行,应使用不重叠区间,例如 0.0..0.50.5..1.0

完整例子:

class StaggeredPanel extends StatefulWidget {
  const StaggeredPanel({super.key});

  @override
  State<StaggeredPanel> createState() => _StaggeredPanelState();
}

class _StaggeredPanelState extends State<StaggeredPanel>
    with SingleTickerProviderStateMixin {
  late final AnimationController controller;
  late final Animation<double> titleOpacity;
  late final Animation<double> bodyOpacity;
  late final Animation<Offset> bodyOffset;

  @override
  void initState() {
    super.initState();

    controller = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 800),
    );

    titleOpacity = CurvedAnimation(
      parent: controller,
      curve: const Interval(0, 0.45, curve: Curves.easeOut),
    );

    final bodyProgress = CurvedAnimation(
      parent: controller,
      curve: const Interval(0.35, 1, curve: Curves.easeOutCubic),
    );

    bodyOpacity = bodyProgress;
    bodyOffset = Tween<Offset>(
      begin: const Offset(0, 0.15),
      end: Offset.zero,
    ).animate(bodyProgress);
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: controller,
      builder: (context, child) {
        return Column(
          children: [
            Opacity(
              opacity: titleOpacity.value,
              child: const Text('标题'),
            ),
            FractionalTranslation(
              translation: bodyOffset.value,
              child: Opacity(
                opacity: bodyOpacity.value,
                child: const Text('正文'),
              ),
            ),
          ],
        );
      },
    );
  }

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

这里的“交错”不是多个异步任务排队,而是一个父进度经过不同区间函数转换后的结果。因此它的时间关系是确定的,也更容易测试。

5.3 多个控制器:什么时候需要

多个控制器适用于动画生命周期真正独立的情况,例如:

  • 顶部导航栏可以独立展开;
  • 图表加载动画可以独立循环;
  • 用户拖拽过程和提示气泡各自拥有不同的控制逻辑。

如果只是同一交互中的多个属性,不应为了每个属性都创建一个控制器。多个控制器会增加:

  • 生命周期清理数量;
  • 状态组合复杂度;
  • 不同控制器启动时间不一致的可能性;
  • 测试中需要推进和断言的时钟状态。

可以用如下方式组合多个动画监听对象:

AnimatedBuilder(
  animation: Listenable.merge([
    headerController,
    chartController,
  ]),
  builder: (context, child) {
    return buildContent();
  },
);

Listenable.merge 只负责把通知合并,并不会同步控制器,也不会改变任一控制器的播放状态。如果两个控制器必须严格同步,应优先使用同一个控制器和多个 TweenInterval


六、动画驱动 UI 的三种常见方式

6.1 直接监听并 setState

@override
void initState() {
  super.initState();

  controller = AnimationController(
    vsync: this,
    duration: const Duration(milliseconds: 300),
  )..addListener(() {
      setState(() {});
    });
}

然后在 build 中读取 controller.value

这种写法直观,但会让整个 State 对每个动画帧调用 build。当 State 很小、代码需要快速验证时可以使用;复杂页面更适合 AnimatedBuilderAnimatedWidget,把重建范围限制在动画子树。

6.2 AnimatedBuilder

AnimatedBuilder 监听任意 Listenable

AnimatedBuilder(
  animation: animation,
  builder: (context, child) {
    return Transform.rotate(
      angle: animation.value,
      child: child,
    );
  },
  child: const Icon(Icons.refresh),
);

它不会自动决定如何变换界面,只负责:

  1. 监听 animation
  2. 动画通知时调用 builder
  3. 将静态的 child 原样传回。

6.3 AnimatedWidget

如果某个动画组件具有稳定、可复用的结构,可以继承 AnimatedWidget

class RotatingIcon extends AnimatedWidget {
  const RotatingIcon({
    super.key,
    required Animation<double> animation,
  }) : super(listenable: animation);

  Animation<double> get animation =>
      listenable as Animation<double>;

  @override
  Widget build(BuildContext context) {
    return Transform.rotate(
      angle: animation.value,
      child: const Icon(Icons.refresh),
    );
  }
}

AnimatedWidget 适合将“动画监听 + 固定渲染逻辑”封装成组件;AnimatedBuilder 更适合局部、灵活的组合。


七、生命周期与清理:为什么 dispose 不是可选项

7.1 控制器拥有 ticker

AnimationController 被创建时,它会通过传入的 TickerProvider 创建 ticker。控制器销毁时必须调用:

controller.dispose();

否则可能出现:

  • 页面离开后 ticker 仍在调度;
  • 测试结束时出现 ticker 泄漏错误;
  • 后续帧访问已经失效的 State;
  • 控制器和监听器长期持有对象,导致资源不能回收。

标准顺序是:

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

如果手动添加了外部监听器,还应移除那些由自己注册的监听器:

late final VoidCallback listener;

@override
void initState() {
  super.initState();

  listener = () {
    // ...
  };

  controller.addListener(listener);
}

@override
void dispose() {
  controller.removeListener(listener);
  controller.dispose();
  super.dispose();
}

通常 AnimationController.dispose() 会清理控制器内部对 ticker 的管理,但它不会替开发者清理其他对象上注册的监听器。

7.2 组件更新时的参数变化

如果动画时长来自 Widget 参数,不能只在 initState 读取一次:

class ProgressAnimation extends StatefulWidget {
  const ProgressAnimation({
    super.key,
    required this.duration,
  });

  final Duration duration;

  @override
  State<ProgressAnimation> createState() => _ProgressAnimationState();
}

class _ProgressAnimationState extends State<ProgressAnimation>
    with SingleTickerProviderStateMixin {
  late final AnimationController controller;

  @override
  void initState() {
    super.initState();

    controller = AnimationController(
      vsync: this,
      duration: widget.duration,
    );
  }

  @override
  void didUpdateWidget(covariant ProgressAnimation oldWidget) {
    super.didUpdateWidget(oldWidget);

    if (oldWidget.duration != widget.duration) {
      controller.duration = widget.duration;
    }
  }

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

  @override
  Widget build(BuildContext context) {
    return LinearProgressIndicator(value: controller.value);
  }
}

didUpdateWidget 只在同一个 State 被复用、但 Widget 配置发生变化时调用。这里更新的是控制器配置,不需要重新创建控制器,也不能在每次 build 中重新创建控制器。

7.3 异步回调与已销毁 State

动画常与异步操作组合:

Future<void> runAnimationThenSave() async {
  try {
    await controller.forward().orCancel;

    if (!mounted) {
      return;
    }

    await saveResult();
  } on TickerCanceled {
    // 页面销毁导致动画取消。
  }
}

不要在 dispose 之后继续调用控制器方法。也不要只依赖 mounted 来掩盖所有异步资源问题:网络请求、订阅、定时器和动画 future 仍然应该分别管理。


八、TickerMode、页面不可见和系统减弱动画设置

8.1 TickerMode 的作用

TickerMode 控制子树中的 ticker 是否启用:

TickerMode(
  enabled: false,
  child: SomeAnimatedWidget(),
)

禁用后,相关 ticker 不再产生正常的逐帧回调,因此动画驱动的界面不会继续重建。AnimationController 的时间逻辑在实现上仍可能跨过这段时间;重新启用后,动画通常会按照已经经过的时间继续到相应进度,而不是把暂停区间当成真正的时间暂停。

因此:

  • TickerMode 适合减少不可见子树的逐帧工作;
  • 它不等价于 controller.stop()
  • 如果业务语义要求“暂停后从当前位置继续”,应显式调用 stop(),并根据需要重新启动;
  • 不要把动画帧回调当作精确业务计时器。

页面路由、Tab、可见性组件可能改变 TickerMode。具体页面是否自动禁用 ticker 取决于使用的组件和树结构,不能假设所有不可见页面都自动停止。

8.2 无障碍减弱动画

系统可能启用“减少动画”或“禁用动画”设置。AnimationControlleranimationBehavior,用于描述在 AccessibilityFeatures.disableAnimations 生效时的行为:

controller = AnimationController(
  vsync: this,
  duration: const Duration(milliseconds: 300),
  animationBehavior: AnimationBehavior.normal,
);

常见语义是:

  • AnimationBehavior.normal:在系统要求禁用动画时,控制器可跳到目标边界;
  • AnimationBehavior.preserve:尽量保持动画过程。

具体视觉效果还取决于 Widget 如何消费动画值。无障碍策略不能简单理解为“所有动画都必须删除”:淡入、位移、缩放、循环动画对用户的影响不同。生产代码应避免把无限循环动画作为唯一的信息表达方式,并验证系统无障碍设置下的状态是否仍然可理解。

8.3 平台差异

动画 API 本身在 Android、iOS、桌面和 Web 上保持 Flutter 层的一致性,但运行环境存在差异:

  • Android 和 iOS 通常有成熟的系统减弱动画设置;
  • 桌面平台的窗口遮挡、最小化和刷新率可能影响实际帧调度;
  • Web 页面切到后台后,浏览器可能降低或暂停帧调度;
  • 不同设备刷新率会影响每秒收到的帧数,但控制器的时间进度仍以时间戳为基础;
  • Web 受浏览器标签页生命周期和资源策略影响,不能把 Ticker 当作后台任务调度器。

因此,动画应表达视觉状态变化,而不是承担轮询、超时、重试或后台计时职责。


九、动画值、Curve 和 Tween 的边界

9.1 Tween 不会限制输入范围

很多开发者认为 Tween<double>(begin: 0, end: 1) 会把结果限制在 0..1。实际上,Tween 通常只是按输入进度插值:

x=begin+(endbegin)×tx = begin + (end - begin) \times t

如果 t = 1.2,结果也可能是 1.2。这在 fling、自定义控制器边界或某些组合动画中可能发生。

例如:

final value = Tween<double>(
  begin: 0,
  end: 100,
).evaluate(
  const AlwaysStoppedAnimation<double>(1.2),
);

// value 可能为 120。

如果目标属性要求严格范围,应显式约束:

final opacity = controller.value.clamp(0.0, 1.0);

不过对于标准 AnimationController 的普通 forward(),值通常在控制器的边界内。不要为了理论上的越界而到处加 clamp,应该先明确动画输入是否可能越界。

9.2 Curve 不是物理模拟

Curves.easeOut 等曲线是对进度的函数变换,不代表真实物理。需要速度、阻尼或弹簧效果时,可以使用 CurvedAnimation 配合弹簧相关动画方案,或使用 Flutter 提供的物理模拟 API。

曲线还可能改变单调性。例如某些弹性曲线会产生超过终点或反弹的效果。若目标属性不能接受越界值,就必须验证具体曲线的输出和属性约束。


十、错误路径和常见失败表现

10.1 在 build 中创建控制器

错误示例:

@override
Widget build(BuildContext context) {
  final controller = AnimationController(
    vsync: this,
    duration: const Duration(milliseconds: 300),
  );

  return Container();
}

build 可能被调用很多次。这样会重复创建控制器和 ticker,并且新创建的对象没有稳定的生命周期。正确做法是在 initState 创建,在 dispose 销毁。

10.2 忘记使用正确的 mixin

如果 State 没有实现 TickerProvider,下面代码无法成立:

AnimationController(
  vsync: this,
);

应使用:

class _State extends State<MyWidget>
    with SingleTickerProviderStateMixin {
  // ...
}

或:

class _State extends State<MyWidget>
    with TickerProviderStateMixin {
  // ...
}

10.3 控制器已销毁后仍然播放

常见表现包括断言失败、TickerFuture 被取消、异步回调访问失效 State。诊断时检查:

  1. dispose 是否调用了控制器的 dispose
  2. 是否保存并继续使用了已销毁 State 的回调;
  3. 是否在异步 await 后缺少 mounted 检查;
  4. 是否把控制器放在了比 Widget 更长的对象中,却没有建立清晰的所有权关系。

10.4 对无限动画使用 pumpAndSettle

测试中的 pumpAndSettle() 会持续推进时间,直到没有待处理帧。如果被测组件使用:

controller.repeat();

它可能永远有新帧,测试就会超时。

对于循环动画,应固定推进时间:

await tester.pump(const Duration(milliseconds: 200));

并断言某个时刻的状态,而不是等待“稳定”。

10.5 误解 reverseCurve

final animation = CurvedAnimation(
  parent: controller,
  curve: Curves.easeOut,
  reverseCurve: Curves.easeIn,
);

reverseCurve 只在动画反向运行时使用。它不会改变正向播放,也不会自动让 Tween 反转;反转由父控制器的播放方向决定。


十一、可测试的显式动画

显式动画的优势之一是时间轴可以被测试控制。Flutter Widget 测试中的 WidgetTester.pump 会推进测试时钟并触发必要的帧。

为了测试内部控制器,可以通过 GlobalKey 暴露 State。示例组件:

import 'package:flutter/material.dart';

class TestableFadeBox extends StatefulWidget {
  const TestableFadeBox({
    super.key,
    required this.onFinished,
  });

  final VoidCallback onFinished;

  @override
  TestableFadeBoxState createState() => TestableFadeBoxState();
}

class TestableFadeBoxState extends State<TestableFadeBox>
    with SingleTickerProviderStateMixin {
  late final AnimationController controller;
  late final Animation<double> opacity;

  TestableFadeBoxState();

  @override
  void initState() {
    super.initState();

    controller = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 400),
    );

    opacity = CurvedAnimation(
      parent: controller,
      curve: Curves.linear,
    );

    controller.addStatusListener((status) {
      if (status == AnimationStatus.completed) {
        widget.onFinished();
      }
    });
  }

  void start() {
    controller.forward(from: 0);
  }

  @override
  Widget build(BuildContext context) {
    return FadeTransition(
      opacity: opacity,
      child: const SizedBox(
        key: ValueKey('fade-box'),
        width: 100,
        height: 100,
      ),
    );
  }

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

对应测试:

import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/material.dart';

void main() {
  testWidgets('动画在指定时间内从 0 运行到 1', (tester) async {
    var finished = 0;
    final key = GlobalKey<TestableFadeBoxState>();

    await tester.pumpWidget(
      MaterialApp(
        home: Scaffold(
          body: TestableFadeBox(
            key: key,
            onFinished: () {
              finished++;
            },
          ),
        ),
      ),
    );

    expect(key.currentState!.controller.value, 0);

    key.currentState!.start();

    // 只推进一半时长,动画尚未完成。
    await tester.pump(const Duration(milliseconds: 200));

    final middleValue = key.currentState!.controller.value;
    expect(middleValue, greaterThan(0));
    expect(middleValue, lessThan(1));
    expect(finished, 0);

    // 再推进剩余时间。
    await tester.pump(const Duration(milliseconds: 200));

    expect(key.currentState!.controller.value, 1);
    expect(finished, 1);
  });
}

测试过程的因果关系是:

  1. 初始构建完成,控制器值为 0
  2. start() 调用 forward(from: 0)
  3. pump(200ms) 让测试时钟前进 200 毫秒;
  4. 总时长为 400 毫秒,因此控制器处于中间进度;
  5. 再推进 200 毫秒到达终点;
  6. 控制器发出 completed 状态,完成回调执行一次。

这里使用线性曲线,便于验证中间值。如果测试的是 easeOut,不应断言中间值等于 0.5,因为曲线已经改变了进度映射;可以断言它位于合法范围,或针对曲线本身写更精确的测试。

11.1 测试 UI 结果而不是实现细节

如果只关心用户可见结果,可以寻找语义或结构变化:

testWidgets('动画完成后显示成功状态', (tester) async {
  await tester.pumpWidget(const MaterialApp(
    home: SomePage(),
  ));

  await tester.tap(find.text('播放'));
  await tester.pump(const Duration(milliseconds: 300));

  expect(find.text('完成'), findsOneWidget);
});

如果组件内部控制器只是实现细节,直接暴露 State 会增加测试与实现的耦合。更稳妥的方式是给最终结果添加:

  • Key
  • Semantics
  • 可观察文本;
  • 业务回调。

但在测试动画时间、方向和边界时,读取控制器值是合理的,因为这些正是显式动画的核心行为。

11.2 测试销毁路径

至少应验证组件移除后没有持续动画或异常:

testWidgets('移除组件后可以安全清理动画', (tester) async {
  final key = GlobalKey<TestableFadeBoxState>();
  var show = true;

  await tester.pumpWidget(
    StatefulBuilder(
      builder: (context, setState) {
        return MaterialApp(
          home: show
              ? TestableFadeBox(
                  key: key,
                  onFinished: () {},
                )
              : const SizedBox(),
        );
      },
    ),
  );

  key.currentState!.start();
  await tester.pump(const Duration(milliseconds: 50));

  show = false;
  await tester.pumpWidget(
    StatefulBuilder(
      builder: (context, setState) {
        return MaterialApp(
          home: show
              ? TestableFadeBox(
                  key: key,
                  onFinished: () {},
                )
              : const SizedBox(),
        );
      },
    ),
  );

  await tester.pump();
});

测试框架在测试结束阶段通常会帮助发现未清理的 ticker。若测试报 ticker 泄漏,应优先检查所有创建控制器的 State 是否实现了 dispose,而不是简单增加等待时间。


十二、显式动画的选择边界

可以用以下因果关系选择方案:

  • 只有属性变化,没有主动控制需求:优先隐式动画;
  • 需要点击后播放、反向、暂停或重复:使用 AnimationController
  • 多个属性必须共享时间轴:一个控制器配多个 Tween
  • 多个阶段需要交错:一个控制器配多个 Interval
  • 多个动画生命周期互相独立:多个控制器,并使用 TickerProviderStateMixin
  • 动画只影响局部 UI:使用 AnimatedBuilderAnimatedWidget
  • 动画需要与异步流程串联:使用 TickerFuture,并处理 TickerCanceledmounted
  • 动画无法停止或测试超时:检查是否使用了 repeat()TickerMode 或错误的 pumpAndSettle()

显式动画的核心并不是“手动调用某个播放函数”,而是建立一条完整的数据流:

Ticker
  ↓ 每帧时间通知
AnimationController
  ↓ 产生 0..1 进度和状态
Curve / Interval
  ↓ 改变时间分布
Tween<T>
  ↓ 映射到实际属性
AnimatedBuilder / Transition / 自定义绘制
  ↓
Widget 树、RenderObject 和最终帧

生命周期则是另一条必须闭合的路径:

initState 创建控制器
        ↓
用户操作或业务事件启动动画
        ↓
Ticker 按帧驱动控制器
        ↓
动画监听者更新界面
        ↓
dispose 停止并释放控制器

只要其中任一环缺失,就会出现对应故障:没有 vsync 无法接入帧调度,没有监听者界面不会变化,没有正确组合会产生错误时序,没有 dispose 会造成 ticker 泄漏,没有测试时钟控制则难以稳定验证动画行为。


系列导航与关联阅读

官方资料

本文依据 Flutter 与 Dart 官方文档重新梳理;正文与示例由 WR BLOG 编写。