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

Flutter 动画体系:Implicit、Controller、Hero、CustomPainter 和性能

Flutter 动画不是一个单独的 API,而是一条从“数值随时间变化”到“组件重新构建、布局、绘制和合成”的完整链路。AnimatedContainerAnimationControllerHeroCustomPainter 分别解决不同层次的问题:

  • Implicit Animation(隐式动画):属性变化后,由组件内部自动补间。
  • AnimationController(动画控制器):由开发者显式管理动画进度、方向、暂停、重复和状态。
  • Hero:在路由切换时,把两个页面中具有相同标识的元素连接成跨页面飞行动画。
  • CustomPainter:直接使用 Canvas 绘制图形,并通过动画驱动重绘。
  • 性能:决定动画每一帧是否能及时完成构建、布局、绘制和栅格化。

理解这些 API 之前,需要先明确 Flutter 动画的基本模型。


一、动画的基本模型:时间如何变成画面

动画的本质是:在一段时间内连续计算状态,并让用户看到这些状态对应的画面。

设动画开始时间为 t0t_0,结束时间为 t1t_1,当前时间为 tt,则归一化时间进度通常为:

u=clamp(tt0t1t0,0,1)u = \operatorname{clamp}\left(\frac{t - t_0}{t_1 - t_0}, 0, 1\right)

其中:

  • uu 是线性进度,范围为 [0,1][0, 1]
  • t1t0t_1 - t_0 是动画时长;
  • clamp 保证动画不会超出边界。

如果起始值为 aa,结束值为 bb,最简单的线性插值是:

x(u)=a+(ba)ux(u) = a + (b-a)u

Flutter 的 Tween<T> 就承担了类似的插值工作。例如:

final tween = Tween<double>(begin: 0, end: 200);

print(tween.transform(0.0)); // 0
print(tween.transform(0.5)); // 100
print(tween.transform(1.0)); // 200

但线性进度通常不够自然。人类感知更接近“先加速、后减速”,因此还需要曲线:

x(u)=Tween(a,b)(C(u))x(u) = \operatorname{Tween}(a,b)(C(u))

其中 CCCurve,例如:

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

动画的典型数据流如下:

flowchart LR
    A[时间与 Ticker] --> B[AnimationController]
    B --> C[Animation<double>]
    C --> D[Curve]
    D --> E[Tween / lerp]
    E --> F[Widget rebuild 或 CustomPainter repaint]
    F --> G[布局 Layout]
    G --> H[绘制 Paint]
    H --> I[栅格 Raster 与合成]
    I --> J[屏幕]

AnimationController 通常不直接代表最终的业务值。它产生的是进度,通常在 0.01.0 之间;TweenColorTweenRectTween 等对象再把进度转换为具体属性。


二、Implicit Animation:让属性变化自动补间

2.1 什么是隐式动画

隐式动画是指:开发者只修改目标属性,组件内部自动从旧值过渡到新值。

例如,下面的代码只改变了容器的宽度、颜色和圆角:

AnimatedContainer(
  duration: const Duration(milliseconds: 300),
  curve: Curves.easeOut,
  width: expanded ? 280 : 120,
  height: 80,
  decoration: BoxDecoration(
    color: expanded ? Colors.blue : Colors.grey,
    borderRadius: BorderRadius.circular(expanded ? 24 : 8),
  ),
  child: const Center(
    child: Text('Implicit'),
  ),
)

expandedfalse 变成 true 时,AnimatedContainer 会比较更新前后的属性,并在 300ms 内对支持动画的属性进行插值。

这里发生了几件事:

  1. 父组件因为状态变化而重建;
  2. AnimatedContainer 收到新的目标属性;
  3. 它保存旧目标值,并创建或更新内部的补间对象;
  4. 内部控制器从当前状态向新目标状态运行;
  5. 每个动画帧计算中间值;
  6. 组件重新构建或更新绘制结果。

隐式动画并不意味着“没有控制器”。恰恰相反,ImplicitlyAnimatedWidget 的实现通常会在内部创建并管理动画控制器;区别在于控制器的生命周期和状态不暴露给调用者。


2.2 常见隐式动画组件

Flutter 提供了许多内置隐式动画组件:

AnimatedContainer
AnimatedOpacity
AnimatedAlign
AnimatedPadding
AnimatedPositioned
AnimatedDefaultTextStyle
AnimatedPhysicalModel
AnimatedSwitcher
AnimatedCrossFade
TweenAnimationBuilder

它们并不是完全等价的。

属性动画

AnimatedOpacity(
  opacity: visible ? 1.0 : 0.0,
  duration: const Duration(milliseconds: 250),
  child: const Icon(Icons.visibility),
)

AnimatedOpacity 会改变绘制透明度,但子树仍然可能参与构建和布局。透明度为 0 并不自动等价于“从树中移除”。

如果元素不可见时不应接收点击,通常还需要配合:

IgnorePointer(
  ignoring: !visible,
  child: AnimatedOpacity(
    opacity: visible ? 1 : 0,
    duration: const Duration(milliseconds: 250),
    child: const SomeButton(),
  ),
)

如果不可见时连布局空间也不应保留,应使用条件构建:

if (visible) const SomeButton()

这两种写法的语义不同:

  • AnimatedOpacity:仍然存在,只是逐渐透明;
  • 条件构建:从 Widget 树中移除,不再参与布局和命中测试。

布局动画

AnimatedPadding(
  duration: const Duration(milliseconds: 300),
  padding: EdgeInsets.only(
    top: selected ? 32 : 8,
  ),
  child: const Text('Padding'),
)

因为 padding 会影响布局,动画过程中可能导致父子节点重新布局。布局动画通常比只改变绘制属性更容易扩大影响范围。

子节点切换

AnimatedSwitcher(
  duration: const Duration(milliseconds: 250),
  transitionBuilder: (child, animation) {
    return FadeTransition(
      opacity: animation,
      child: child,
    );
  },
  child: Text(
    '$count',
    key: ValueKey(count),
  ),
)

这里的 Key 很重要。AnimatedSwitcher 需要判断新旧子节点是否是不同对象。若 Text 没有随内容变化的区别性 Key,框架可能把它视为同一个子树,从而不会按预期执行切换动画。


2.3 TweenAnimationBuilder:轻量的值动画

当没有合适的内置 AnimatedXxx 组件时,可以使用 TweenAnimationBuilder

TweenAnimationBuilder<double>(
  tween: Tween<double>(begin: 0, end: progress),
  duration: const Duration(milliseconds: 400),
  curve: Curves.easeOut,
  builder: (context, value, child) {
    return SizedBox(
      width: 240,
      child: LinearProgressIndicator(value: value),
    );
  },
)

TweenAnimationBuilder 适合“目标值改变后自动过渡”的场景,但它仍然是隐式动画:

  • 不能直接调用 forward()reverse()
  • 不能方便地监听 AnimationStatus
  • 不能自由控制暂停、重复和精确跳转;
  • 目标值频繁变化时,当前动画可能被重新定向。

一个常见误区是把 Tween 写成每次构建都固定从 0 开始:

// 容易造成语义混乱
TweenAnimationBuilder<double>(
  tween: Tween(begin: 0, end: progress),
  duration: const Duration(milliseconds: 300),
  builder: ...
)

TweenAnimationBuilder 会根据前一次目标值处理过渡,但“旧值”不应被业务逻辑误认为始终是 0。如果需要明确掌握当前进度,应该使用显式控制器。


2.4 隐式动画的边界

隐式动画适合:

  • 展开与收起;
  • 颜色、尺寸、间距、透明度变化;
  • 页面中局部的状态切换;
  • 不需要外部精确控制的过渡。

它不适合:

  • 需要拖拽手势直接映射到动画进度;
  • 需要暂停、恢复、反向和重复;
  • 需要多个动画共享同一时间轴;
  • 需要监听开始、完成、取消等状态;
  • 需要把同一动画进度传给多个不相邻对象。

例如,下面的需求就更适合 AnimationController

用户拖动卡片时,卡片位置与手指位移同步;松手后根据速度决定展开或回弹。

这不是简单的“旧值到新值”过渡,而是手势、速度、边界和动画状态共同决定的交互过程。


三、AnimationController:显式控制时间和状态

3.1 控制器、Ticker 和 vsync

AnimationController 是显式动画的核心。它产生一个随时间变化的数值,并在每次变化时通知监听者。

控制器需要一个 TickerProvider

class _DemoState extends State<Demo>
    with SingleTickerProviderStateMixin {
  late final AnimationController controller;

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

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

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

Ticker 的作用是:在 Flutter 的帧时钟驱动下,按帧通知动画更新。vsync 让动画与屏幕刷新同步,并在相关树被 TickerMode 禁用时停止无意义的帧通知。

生命周期必须满足:

  1. initState 创建控制器;
  2. dispose 中释放控制器;
  3. 不在 build 中重复创建控制器;
  4. 不在已销毁的 State 上启动动画。

如果动画只需要一个 ticker,使用:

with SingleTickerProviderStateMixin

如果同一个 State 同时管理多个控制器,使用:

with TickerProviderStateMixin

这不是“动画数量越多越快”的配置,而是 provider 能否为多个 ticker 提供 vsync 的生命周期能力。


3.2 控制器的状态和基本操作

AnimationController 的核心值通常位于 [0, 1]

controller.forward();       // 向 1.0 运行
controller.reverse();      // 向 0.0 运行
controller.stop();         // 停止在当前位置
controller.reset();        // 设置回 0.0
controller.repeat();       // 重复运行
controller.forward(from: 0.3);
controller.value = 0.7;

这些操作改变的是控制器的数值和状态,不直接改变 Widget。Widget 必须通过 AnimatedBuilderAnimatedWidget 或监听器读取这个值。

控制器的状态可以通过 AnimationStatus 观察:

controller.addStatusListener((status) {
  switch (status) {
    case AnimationStatus.dismissed:
      debugPrint('在起点');
    case AnimationStatus.forward:
      debugPrint('正向运行');
    case AnimationStatus.reverse:
      debugPrint('反向运行');
    case AnimationStatus.completed:
      debugPrint('到达终点');
  }
});

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

try {
  await controller.forward().orCancel;
  if (!mounted) return;
  debugPrint('动画完成,可以更新页面状态');
} on TickerCanceled {
  // 通常表示控制器在等待期间被 dispose。
}

直接 await controller.forward() 也可以等待,但在控制器被销毁时,使用 .orCancel 能显式处理取消路径。


3.3 Animation, TweenCurvedAnimation 的组合

一个完整的显式动画通常分成三层:

late final AnimationController controller;
late final Animation<double> curved;
late final Animation<double> scale;

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

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

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

  scale = Tween<double>(
    begin: 0.8,
    end: 1.0,
  ).animate(curved);
}

数据关系为:

controller.value
    -> CurvedAnimation.value
    -> Tween.transform(...)
    -> scale.value

注意曲线不一定保持在 [0, 1] 内。例如某些带回弹效果的曲线可能在数学上出现超调,因此最终属性可能暂时超过目标值。对于尺寸、透明度等有物理边界的属性,必须确认曲线和插值结果不会造成非法值。

多个属性可以共享同一个进度:

late final Animation<double> opacity;
late final Animation<Offset> offset;

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

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

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

  opacity = Tween<double>(begin: 0, end: 1).animate(curve);
  offset = Tween<Offset>(
    begin: const Offset(0, 0.2),
    end: Offset.zero,
  ).animate(curve);
}

同一时间轴让透明度和位移保持确定的相位关系。若分别使用多个隐式动画,它们的启动时间和中断行为可能不同。


3.4 AnimatedBuilder:只重建需要动画值的子树

AnimatedBuilder(
  animation: controller,
  child: const Icon(Icons.star, size: 48),
  builder: (context, child) {
    return Transform.scale(
      scale: scale.value,
      child: Opacity(
        opacity: opacity.value,
        child: child,
      ),
    );
  },
)

child 参数用于缓存不依赖动画值的子树。上例中,图标本身不随帧变化,因此不会每帧重新创建。

等价地,也可以使用 ListenableBuilder 监听 AnimationController,但 AnimatedBuilder 对动画语义更直接。这里的优化只减少该局部的构建工作,并不保证后续布局和绘制成本也消失:

  • Transform 可能只影响合成;
  • Opacity 可能引入离屏处理;
  • 子树中若存在布局变化,仍可能触发布局;
  • 最终是否高效要通过性能工具验证。

3.5 手势驱动动画

手势驱动动画的关键是:手指位置直接修改控制器的 value,松手后再让控制器完成剩余动画。

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

  @override
  State<SwipeCard> createState() => _SwipeCardState();
}

class _SwipeCardState extends State<SwipeCard>
    with SingleTickerProviderStateMixin {
  late final AnimationController controller;

  @override
  void initState() {
    super.initState();
    controller = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 280),
    );
  }

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

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onHorizontalDragUpdate: (details) {
        final width = context.size?.width ?? 1;
        controller.value = (controller.value + details.delta.dx / width)
            .clamp(0.0, 1.0);
      },
      onHorizontalDragEnd: (details) {
        final velocity = details.velocity.pixelsPerSecond.dx;

        if (velocity > 300) {
          controller.forward();
        } else if (velocity < -300) {
          controller.reverse();
        } else if (controller.value > 0.5) {
          controller.forward();
        } else {
          controller.reverse();
        }
      },
      child: AnimatedBuilder(
        animation: controller,
        builder: (context, child) {
          return Transform.translate(
            offset: Offset(240 * controller.value, 0),
            child: child,
          );
        },
        child: const Card(
          child: SizedBox(
            height: 120,
            child: Center(child: Text('拖动我')),
          ),
        ),
      ),
    );
  }
}

这里的逻辑分为两阶段:

  1. 拖动阶段:controller.value 由手势位移决定;
  2. 松手阶段:根据速度或当前位置调用 forward() / reverse()

若只用 AnimatedContainer,就难以表达“进度由手指实时决定”的关系。


四、Hero:路由之间的共享元素动画

4.1 Hero 解决什么问题

普通页面切换是两个路由整体进出。Hero 则在前后两个路由中寻找具有相同 tag 的元素,并在路由切换期间创建一个飞行中的共享元素。

源页面:

Hero(
  tag: 'book-cover-42',
  child: Image.network(
    imageUrl,
    width: 96,
    height: 96,
    fit: BoxFit.cover,
  ),
)

目标页面:

Hero(
  tag: 'book-cover-42',
  child: Image.network(
    imageUrl,
    width: double.infinity,
    height: 320,
    fit: BoxFit.cover,
  ),
)

跳转:

Navigator.of(context).push(
  MaterialPageRoute<void>(
    builder: (_) => const DetailPage(),
  ),
);

对于一次路由切换,Flutter 会在前后页面中寻找相同标识的 Hero。飞行过程中通常会使用一个位于 Navigator 覆盖层中的临时元素,从源位置移动到目标位置。

重要条件包括:

  • 源页面和目标页面必须位于参与同一次路由切换的 Navigator 中;
  • 两边的 tag 必须匹配;
  • 同一个页面中不能有两个同时存在的相同 tag;
  • 复杂嵌套 Navigator 可能导致两边不在同一 HeroController 管理范围;
  • Hero 不是任意两个 Widget 之间的动画工具,它主要服务于路由转换。

4.2 Hero 的生命周期路径

一次典型的 Hero 动画可以理解为:

sequenceDiagram
    participant S as 源路由
    participant N as Navigator
    participant F as Hero 飞行层
    participant D as 目标路由

    S->>N: push 目标路由
    N->>D: 构建目标路由
    N->>S: 查找源 Hero
    N->>D: 查找目标 Hero
    N->>F: 创建飞行中的共享元素
    F->>F: 根据 RectTween 逐帧改变位置和尺寸
    F->>D: 动画完成后交还目标元素

开发者常见的错误是把 Hero 当成“把源 Widget 移动到新页面”。实际过程是:

  1. 新路由先参与构建;
  2. 框架收集源、目标 Hero 的布局矩形;
  3. 创建飞行中的临时内容;
  4. 源和目标 Hero 在飞行期间可能被隐藏或以占位方式存在;
  5. 飞行完成后,目标页面恢复正常显示。

因此,Hero 子树必须能够在两个页面中分别构建。不要依赖“源页面上的某个局部 State 一直跟随飞行”的假设。


4.3 自定义 Hero 飞行外观

可以通过 flightShuttleBuilder 自定义飞行中的 Widget:

Hero(
  tag: 'avatar',
  flightShuttleBuilder: (
    flightContext,
    animation,
    flightDirection,
    fromContext,
    toContext,
  ) {
    return AnimatedBuilder(
      animation: animation,
      builder: (context, child) {
        return Material(
          color: Colors.transparent,
          child: child,
        );
      },
      child: const CircleAvatar(
        radius: 40,
        child: Icon(Icons.person),
      ),
    );
  },
  child: const CircleAvatar(
    child: Icon(Icons.person),
  ),
)

如果源页面和目标页面使用不同的视觉结构,飞行期间可以提供一个统一的中间表示,而不是强行使用目标页面或源页面的原始结构。

createRectTween 可以改变几何路径:

Hero(
  tag: 'card',
  createRectTween: (begin, end) {
    if (begin == null || end == null) return null;
    return MaterialRectArcTween(begin: begin, end: end);
  },
  child: const Card(
    child: SizedBox(width: 120, height: 80),
  ),
)

矩形插值不仅决定位置,也决定尺寸随时间如何变化。不同的 RectTween 会产生直线移动、弧线移动或其他几何效果。


4.4 Hero 的失败表现和诊断

tag 重复

There are multiple heroes that share the same tag within a subtree.

原因是同一个 Navigator 管理范围内,同时存在多个相同 tag 的 Hero。解决方式是让 tag 对应业务实体的唯一 ID:

Hero(
  tag: 'product-${product.id}',
  child: ...
)

不要使用所有列表项都相同的固定字符串。

Hero 不飞行

常见原因:

  • 两个路由中的 tag 不一致;
  • Hero 被放在不同 Navigator 中;
  • 目标页面尚未以预期方式构建;
  • 组件在切换前后被条件逻辑移除;
  • 使用了不适合当前导航结构的嵌套 Navigator。

嵌套 Navigator 在标签页、Shell 路由和独立导航栈中很常见。此时应检查 Hero 两侧到底属于哪个 Navigator,而不是只看 Widget 树的视觉位置。

图片跳变

源图和目标图虽然 tag 相同,但尺寸、fit、裁剪方式或加载状态不同,飞行中可能出现跳变。生产代码中应尽量保持图像内容和裁剪语义一致;网络图片还要考虑目标页面加载完成前的占位图。

Android、iOS、桌面和 Web 都支持 Flutter 的 Hero 机制,但系统返回手势、浏览器历史导航、桌面窗口大小变化可能使路由过渡的时机不同。不要把 Hero 完成事件当成系统导航一定成功的证明。


五、CustomPainter:直接把动画值画到 Canvas

5.1 CustomPainter 的职责

CustomPainter 用于描述绘制逻辑:

class ProgressPainter extends CustomPainter {
  final double progress;

  ProgressPainter(this.progress);

  @override
  void paint(Canvas canvas, Size size) {
    final center = size.center(Offset.zero);
    final radius = size.shortestSide / 2 - 8;

    final background = Paint()
      ..color = Colors.black12
      ..style = PaintingStyle.stroke
      ..strokeWidth = 8;

    final foreground = Paint()
      ..color = Colors.blue
      ..style = PaintingStyle.stroke
      ..strokeWidth = 8
      ..strokeCap = StrokeCap.round;

    canvas.drawCircle(center, radius, background);

    canvas.drawArc(
      Rect.fromCircle(center: center, radius: radius),
      -math.pi / 2,
      2 * math.pi * progress,
      false,
      foreground,
    );
  }

  @override
  bool shouldRepaint(covariant ProgressPainter oldDelegate) {
    return oldDelegate.progress != progress;
  }
}

使用时:

CustomPaint(
  size: const Size.square(120),
  painter: ProgressPainter(progress),
)

需要导入:

import 'dart:math' as math;

这个绘制器的输入是:

  • Canvas:绘制目标;
  • Size:父布局分配给它的尺寸;
  • progress:业务或动画值。

CustomPainter 本身不负责产生时间,也不负责管理状态。它只是根据当前输入绘制一帧。


5.2 用 repaint 直接驱动重绘

对于高频动画,推荐把 Animation 传给 CustomPainterrepaint 参数:

class AnimatedProgressPainter extends CustomPainter {
  AnimatedProgressPainter({
    required this.animation,
  }) : super(repaint: animation);

  final Animation<double> animation;

  @override
  void paint(Canvas canvas, Size size) {
    final progress = animation.value;
    final center = size.center(Offset.zero);
    final radius = size.shortestSide / 2 - 8;

    final paint = Paint()
      ..color = Colors.blue
      ..style = PaintingStyle.stroke
      ..strokeWidth = 8
      ..strokeCap = StrokeCap.round;

    canvas.drawArc(
      Rect.fromCircle(center: center, radius: radius),
      -math.pi / 2,
      2 * math.pi * progress,
      false,
      paint,
    );
  }

  @override
  bool shouldRepaint(covariant AnimatedProgressPainter oldDelegate) {
    return oldDelegate.animation != animation;
  }
}

使用:

AnimatedBuilder(
  animation: controller,
  builder: (context, child) {
    return CustomPaint(
      painter: AnimatedProgressPainter(animation: controller),
      child: child,
    );
  },
  child: const SizedBox.square(dimension: 120),
)

上例中需要区分两种通知:

  • repaint: animation:动画变化时,RenderObject 直接请求重绘;
  • AnimatedBuilder:动画变化时,Widget builder 重新运行。

如果已经通过 repaint 让 painter 监听动画,通常不需要再用 AnimatedBuilder 包一层来构建同一个 painter。可以直接写:

CustomPaint(
  size: const Size.square(120),
  painter: AnimatedProgressPainter(animation: controller),
)

完整 State 示例:

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

  @override
  State<ProgressView> createState() => _ProgressViewState();
}

class _ProgressViewState extends State<ProgressView>
    with SingleTickerProviderStateMixin {
  late final AnimationController controller;

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

    controller = AnimationController(
      vsync: this,
      duration: const Duration(seconds: 2),
    )..repeat();
  }

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

  @override
  Widget build(BuildContext context) {
    return CustomPaint(
      size: const Size.square(120),
      painter: AnimatedProgressPainter(animation: controller),
    );
  }
}

这里动画帧的路径是:

Ticker
  -> AnimationController.value 变化
  -> AnimatedProgressPainter 收到 repaint 通知
  -> RenderCustomPaint 重绘

如果父 Widget 没有其他变化,这种方式可以避免因为动画值改变而重建更大的 Widget 子树。


5.3 shouldRepaint 不是动画驱动器

shouldRepaint 只回答一个问题:

新旧 painter 配置不同,是否需要重新绘制?

它不负责:

  • 启动动画;
  • 监听时间;
  • 触发下一帧;
  • 释放控制器。

错误示例:

class BadPainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    // 在 paint 中启动 Timer 或 AnimationController
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}

绘制阶段必须保持无副作用。将控制器、Timer 或订阅放入 painter 会造成重复创建、无法正确释放以及不可预测的重绘。


5.4 Canvas 的坐标、状态和边界

Canvas 的原点通常位于绘制区域左上角,x 向右、y 向下。绘制时应尊重传入的 Size

@override
void paint(Canvas canvas, Size size) {
  final center = Offset(size.width / 2, size.height / 2);
  final radius = math.min(size.width, size.height) / 2;

  canvas.drawCircle(center, radius, Paint()..color = Colors.red);
}

不要假设 CustomPaint 一定有固定尺寸。若没有 childsize 或父约束提供尺寸,可能得到零尺寸,绘制内容也就不可见。

Canvas 状态必须成对保存和恢复:

canvas.save();
canvas.translate(20, 20);
canvas.rotate(0.2);
canvas.drawRect(
  const Rect.fromLTWH(0, 0, 80, 40),
  Paint()..color = Colors.blue,
);
canvas.restore();

如果忘记 restore(),后续绘制会继承错误的变换、裁剪或透明度。


5.5 命中测试和语义

CustomPainter 绘制出来的图形不自动拥有按钮语义,也不自动处理点击。需要交互时,可以:

GestureDetector(
  onTap: onTap,
  child: CustomPaint(
    painter: painter,
    child: const SizedBox.expand(),
  ),
)

这只能提供整个区域的命中测试。如果要判断圆形、路径或多个图形的精确命中,需要在 CustomPainter.hitTest 或外层手势逻辑中实现几何判断。

无障碍语义也需要单独提供,例如:

Semantics(
  label: '下载进度 70%',
  child: CustomPaint(
    painter: ProgressPainter(0.7),
    child: const SizedBox.square(dimension: 120),
  ),
)

视觉绘制和辅助技术树是两条不同的路径。Canvas 上画出文字或图标,不等于系统可以读取它们。


六、四种机制如何选择

可以按“谁控制时间”和“动画作用在哪一层”判断:

需求 适合机制
容器尺寸或颜色变化 AnimatedContainer
透明度、间距、位置自动变化 AnimatedOpacityAnimatedPaddingAnimatedPositioned
两个 Widget 状态切换 AnimatedSwitcher
一个数值自动从旧值到新值 TweenAnimationBuilder
播放、暂停、反向、重复 AnimationController
手势直接映射动画进度 AnimationController
多个属性共享时间轴 AnimationController + 多个 Tween
路由间共享元素 Hero
圆环、波形、路径、粒子等自定义图形 CustomPainter
自定义图形还需要时间控制 AnimationController + CustomPainter

HeroAnimationController 并不是互斥关系。Hero 自己管理路由飞行过程,但可以使用 flightShuttleBuilder 等 API 定制外观;如果需要一个独立于路由过渡的复杂页面动画,通常应由页面自己的控制器管理。


七、动画与 Flutter 渲染树的关系

Flutter 的 Widget 只是配置。动画最终影响的是 Element、RenderObject 和图层。

一次帧处理通常可以抽象为:

事件或 Ticker
  -> 状态变化
  -> Widget 重建(可能发生)
  -> RenderObject 属性更新
  -> 布局(可能发生)
  -> 绘制(可能发生)
  -> 图层合成
  -> 栅格化
  -> 屏幕显示

7.1 重建、布局和绘制不是同一件事

  • 重建(build):重新执行 Widget 的配置逻辑;
  • 布局(layout):根据约束计算尺寸和位置;
  • 绘制(paint):把 RenderObject 画到 Canvas;
  • 栅格化(rasterization):将绘制指令转换成 GPU 或软件可显示的像素;
  • 合成(compositing):将不同图层组合起来。

一个动画值变化可能只需要绘制,也可能扩大到布局。

例如:

Transform.translate(
  offset: Offset(x, 0),
  child: expensiveChild,
)

通常可以通过变换图层移动已有内容,但如果动画改变的是:

SizedBox(
  width: animatedWidth,
  child: expensiveChild,
)

那么尺寸变化会影响约束和布局,父子关系可能需要重新计算。

这不是绝对规则。具体行为还取决于 RenderObject、图层边界、是否发生裁剪或离屏合成,必须结合性能分析确认。


八、帧预算与性能判断

如果设备刷新频率为 ff,每帧可用时间近似为:

B=1000f msB = \frac{1000}{f}\ \text{ms}

例如:

  • 60Hz:约 16.67ms16.67\text{ms}
  • 90Hz:约 11.11ms11.11\text{ms}
  • 120Hz:约 8.33ms8.33\text{ms}

这个预算包括多个阶段,不只是 Dart 代码:

UI 线程:事件、Dart、build、layout、部分 paint
Raster 线程:图层栅格化、纹理上传、部分图形处理
GPU/合成:平台相关的最终合成

如果 UI 线程一帧耗时过长,表现为构建或布局抖动;如果 Raster 线程耗时过长,表现为绘制复杂、阴影、裁剪、离屏层或纹理处理造成的卡顿。两者都可能导致丢帧。

不能根据动画时长推断性能。例如 300ms 动画不是只执行一次,而是会在整个时间段内执行几十帧;每帧都必须在当前设备的预算内完成。


九、动画性能中的关键取舍

9.1 尽量缩小动画影响范围

错误结构:

AnimatedBuilder(
  animation: controller,
  builder: (context, _) {
    return HugePage(
      progress: controller.value,
    );
  },
)

这会让整个 HugePage 每帧进入 builder。更细粒度的结构是:

Column(
  children: [
    const StaticHeader(),
    AnimatedBuilder(
      animation: controller,
      builder: (context, child) {
        return Transform.translate(
          offset: Offset(controller.value * 100, 0),
          child: child,
        );
      },
      child: const AnimatedPanelContent(),
    ),
    const StaticFooter(),
  ],
)

静态部分不依赖动画值,就不应被放进每帧执行的 builder。

9.2 RepaintBoundary 的收益和成本

RepaintBoundary 会把子树划分为独立的绘制边界:

RepaintBoundary(
  child: AnimatedChart(),
)

当边界外发生重绘时,边界内部可能复用已有图层;当边界内部动画时,也可能避免整棵父树重新绘制。

但它不是越多越好。边界会增加图层和缓存管理成本,过多边界可能带来:

  • 更多图层;
  • 更高的内存占用;
  • 缓存失效后的重新绘制成本;
  • 合成复杂度上升。

应根据实际重绘范围和 DevTools 结果使用,而不是机械添加。

9.3 Opacity、裁剪和 saveLayer

某些效果可能触发离屏绘制或额外图层,例如:

  • 大范围 Opacity
  • 带复杂裁剪的内容;
  • BackdropFilter
  • 某些阴影和滤镜;
  • Canvas 中显式调用 saveLayer()

saveLayer() 的语义是先把后续内容画到离屏缓冲,再应用混合或效果。它适合解决特定绘制问题,但大面积或高频使用会增加内存带宽和栅格成本。

不要仅因为“使用了 Canvas”就认为一定高效,也不要仅因为“使用了 Widget”就认为一定低效。真正决定成本的是每帧需要处理的内容、面积、图层和像素数量。


十、CustomPainter 的性能边界

10.1 在 paint 中避免重复分配和复杂计算

下面的代码每次绘制都创建渐变:

@override
void paint(Canvas canvas, Size size) {
  final paint = Paint()
    ..shader = const LinearGradient(
      colors: [Colors.blue, Colors.purple],
    ).createShader(Offset.zero & size);

  canvas.drawRect(Offset.zero & size, paint);
}

如果渐变确实依赖 size,创建 Shader 不可避免;但不应在每帧重复执行与动画无关的大量数据处理,例如重新解析大型 JSON、构建完整路径数据或加载图片。

可以在 painter 外预先准备不可变数据:

class ChartPainter extends CustomPainter {
  ChartPainter({
    required this.points,
    required this.progress,
  });

  final List<Offset> points;
  final double progress;

  @override
  void paint(Canvas canvas, Size size) {
    // 只根据 progress 绘制当前帧
  }

  @override
  bool shouldRepaint(covariant ChartPainter oldDelegate) {
    return oldDelegate.progress != progress ||
        !identical(oldDelegate.points, points);
  }
}

如果 points 是可变列表,identical 可能无法发现列表内容被原地修改。更可靠的做法是使用不可变数据,并在数据变更时创建新对象。

10.2 shouldRepaint 过于保守或过于激进

总是返回 true

@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;

会让任何相关配置更新都重绘,可能增加成本。

总是返回 false

@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;

则可能导致数据已经变化但画面不更新。

正确判断应覆盖所有影响绘制结果的输入:

@override
bool shouldRepaint(covariant ProgressPainter oldDelegate) {
  return oldDelegate.progress != progress ||
      oldDelegate.color != color;
}

如果 painter 通过 repaint 监听一个持续变化的 Animation,动画通知本身会触发重绘;shouldRepaint 仍然负责配置对象替换时的判断。


十一、使用 DevTools 诊断动画卡顿

11.1 使用 Profile 模式

不要只在 Debug 模式判断动画性能。Debug 模式包含额外检查,不能代表发布性能。常用命令:

flutter run --profile

前置条件:

  • 连接 Android 或 iOS 真机,或者使用目标平台的 Profile 支持;
  • 项目可以正常构建;
  • 使用 Flutter DevTools 连接运行中的应用。

Web、桌面和移动端的 Profile 能力与渲染后端不同,测试结果不能直接横向等价。

11.2 Performance 面板观察什么

在 DevTools 的 Performance 页面中,应关注:

  • 哪些帧超过当前刷新率预算;
  • UI 线程是否耗时过高;
  • Raster 线程是否耗时过高;
  • 某个动画开始时是否出现大量布局或绘制;
  • 是否存在异常的离屏层、图片上传或 shader 编译。

判断路径可以是:

卡顿
  -> UI 线程高
      -> 检查 build、状态传播、layout
  -> Raster 线程高
      -> 检查 CustomPainter、阴影、滤镜、图片、saveLayer
  -> 两者都不高但仍卡
      -> 检查平台合成、输入阻塞、纹理上传和设备环境

11.3 性能覆盖层和重绘调试

调试阶段可以开启性能覆盖层:

MaterialApp(
  showPerformanceOverlay: true,
  home: const HomePage(),
)

也可以使用调试标志观察重绘区域,但这些变量属于调试能力,不应在生产环境开启:

import 'package:flutter/rendering.dart';

void main() {
  debugRepaintRainbowEnabled = true;
  runApp(const MyApp());
}

彩色闪烁区域可以帮助确认“是否整页都在重绘”,但它不能直接告诉你实际耗时。最终仍需结合 DevTools 时间线和真机验证。


十二、不同平台的动画差异

Flutter 的 Widget 动画 API 跨 Android、iOS、桌面和 Web 基本保持一致,但运行环境会改变帧时钟、渲染后端和系统行为。

Android 与 iOS

  • 设备可能运行在 60Hz、90Hz、120Hz 等刷新率;
  • 高刷新率设备的单帧预算更小;
  • 系统返回手势可能影响路由过渡;
  • 图片解码、平台视图和纹理上传可能成为 Raster 或平台侧瓶颈。

桌面端

  • 窗口尺寸变化可能频繁触发布局;
  • 鼠标悬停、滚轮和窗口动画会产生不同输入节奏;
  • 窗口缩放、多个显示器和不同 DPI 会改变绘制尺寸;
  • 系统动画风格不应被假设为移动端行为。

Web

  • 浏览器通常按页面刷新和可见性调度帧;
  • 页面进入后台标签页后,浏览器可能降低或暂停定时器和绘制;
  • Web 的渲染后端、浏览器版本和 Canvas/WebGL 环境会影响栅格性能;
  • URL 历史导航与移动端 Navigator 操作的时序可能不同。

因此,动画逻辑应依赖 Flutter 的 TickerAnimationController,不要用 Timer.periodic 模拟逐帧动画。Timer 不与屏幕刷新同步,也无法表达 Ticker 的生命周期语义。


十三、可访问性和“减少动画”

动画不仅是视觉效果,也可能影响可访问性。对于前庭敏感、认知负担较高或偏好减少动效的用户,应考虑提供较少动态的表现。

应用可以读取平台的辅助功能状态,例如:

final reduceMotion =
    MediaQuery.maybeOf(context)?.disableAnimations ?? false;

具体字段和行为应以当前稳定 Flutter API 为准,并在目标平台验证。使用时不要简单地把所有动画强制设为零,因为某些动画还承担状态可见性功能。更合理的处理可能是:

final duration = reduceMotion
    ? Duration.zero
    : const Duration(milliseconds: 300);

或者保留淡入淡出但取消复杂位移和缩放。Hero 也应考虑减少动效时的替代表现,否则页面导航仍可能产生明显的共享元素飞行。


十四、常见误解与失败路径

误解一:动画一定会导致整棵 Widget 树重建

不一定。使用 CustomPainter(repaint: animation) 或合适的 AnimatedBuilder,可以把更新范围限制在局部。但如果动画修改了布局尺寸、父级约束或大范围状态,仍可能扩大影响。

误解二:RepaintBoundary 可以解决所有动画卡顿

不能。它主要影响绘制边界和重绘传播。如果真正的问题是每帧大量 build、layout、图片解码或 Raster 线程过载,单独添加边界不会解决根因。

误解三:把动画放到 CustomPainter 就一定比 Widget 高效

CustomPainter 减少了通用 Widget 布局表达的开销,但它仍然可能:

  • 绘制大量路径;
  • 触发离屏层;
  • 处理大面积像素;
  • 每帧执行复杂数学;
  • 造成严重 Raster 压力。

它适合自定义绘制,不是自动性能优化器。

误解四:AnimatedOpacity(opacity: 0) 等于删除组件

透明组件通常仍然存在于布局、语义或命中测试路径中,具体效果取决于父子结构。需要删除空间或禁用交互时,应使用条件构建、IgnorePointer 或语义控制。

误解五:所有动画都应该用 AnimationController

如果只是把一个颜色从旧值过渡到新值,AnimatedContainer 更直接。显式控制器会带来 State、Ticker、dispose 和状态管理成本。控制能力和维护成本应一起评估。


十五、一个组合示例:隐式动画、显式动画和 CustomPainter

下面的页面同时展示:

  • AnimatedContainer 处理展开状态;
  • AnimationController 控制旋转;
  • CustomPainter 根据控制器绘制圆环;
  • AnimatedBuilder 只更新局部内容。
import 'dart:math' as math;

import 'package:flutter/material.dart';

void main() {
  runApp(const AnimationApp());
}

class AnimationApp extends StatelessWidget {
  const AnimationApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
        useMaterial3: true,
      ),
      home: const AnimationDemoPage(),
    );
  }
}

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

  @override
  State<AnimationDemoPage> createState() => _AnimationDemoPageState();
}

class _AnimationDemoPageState extends State<AnimationDemoPage>
    with SingleTickerProviderStateMixin {
  late final AnimationController controller;
  bool expanded = false;

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

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

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

  void toggle() {
    setState(() {
      expanded = !expanded;
    });

    if (expanded) {
      controller.forward();
    } else {
      controller.reverse();
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Flutter Animation')),
      body: Center(
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            AnimatedContainer(
              duration: const Duration(milliseconds: 350),
              curve: Curves.easeOut,
              width: expanded ? 280 : 160,
              height: expanded ? 180 : 100,
              padding: const EdgeInsets.all(16),
              decoration: BoxDecoration(
                color: expanded ? Colors.blue : Colors.grey,
                borderRadius: BorderRadius.circular(
                  expanded ? 28 : 12,
                ),
              ),
              child: AnimatedBuilder(
                animation: controller,
                builder: (context, child) {
                  return CustomPaint(
                    painter: RingPainter(
                      progress: controller.value,
                      angle: controller.value * math.pi,
                    ),
                    child: child,
                  );
                },
                child: const Center(
                  child: Text(
                    '动画',
                    style: TextStyle(
                      color: Colors.white,
                      fontSize: 24,
                    ),
                  ),
                ),
              ),
            ),
            const SizedBox(height: 24),
            FilledButton(
              onPressed: toggle,
              child: Text(expanded ? '收起' : '展开'),
            ),
          ],
        ),
      ),
    );
  }
}

class RingPainter extends CustomPainter {
  const RingPainter({
    required this.progress,
    required this.angle,
  });

  final double progress;
  final double angle;

  @override
  void paint(Canvas canvas, Size size) {
    final center = size.center(Offset.zero);
    final radius = size.shortestSide / 2 - 10;

    final trackPaint = Paint()
      ..color = Colors.white24
      ..style = PaintingStyle.stroke
      ..strokeWidth = 6;

    final progressPaint = Paint()
      ..color = Colors.white
      ..style = PaintingStyle.stroke
      ..strokeWidth = 6
      ..strokeCap = StrokeCap.round;

    canvas.drawCircle(center, radius, trackPaint);

    canvas.save();
    canvas.translate(center.dx, center.dy);
    canvas.rotate(angle);
    canvas.translate(-center.dx, -center.dy);

    canvas.drawArc(
      Rect.fromCircle(center: center, radius: radius),
      -math.pi / 2,
      progress * 2 * math.pi,
      false,
      progressPaint,
    );

    canvas.restore();
  }

  @override
  bool shouldRepaint(covariant RingPainter oldDelegate) {
    return oldDelegate.progress != progress ||
        oldDelegate.angle != angle;
  }
}

运行结果是:

  1. 点击“展开”;
  2. AnimatedContainer 自动改变尺寸、颜色和圆角;
  3. AnimationController0 运行到 1
  4. AnimatedBuilder 根据控制器值重建 CustomPaint
  5. RingPainter 重新绘制进度圆环;
  6. 再次点击时,容器隐式反向过渡,控制器显式反向运行。

这个示例故意让两种机制共存:容器属性适合隐式动画,圆环角度和绘制进度适合显式控制。实际工程中,如果圆环完全由控制器驱动,可以进一步使用 CustomPainter(repaint: controller),减少中间 Widget 重建。


十六、最终判断框架

遇到动画需求时,可以按以下顺序判断:

  1. 目标是否只是属性从旧值变成新值?
    是:优先考虑隐式动画。

  2. 是否需要暂停、反向、重复、拖拽或多个属性共享进度?
    是:使用 AnimationController

  3. 动画是否发生在两个路由之间,并且对象需要保持视觉连续?
    是:使用 Hero,并检查 Navigator、tag 和嵌套导航结构。

  4. 是否需要绘制普通 Widget 难以表达的图形?
    是:使用 CustomPainter,把绘制输入设计成清晰、可比较的数据。

  5. 动画是否卡顿?
    先区分 UI 线程和 Raster 线程,再决定检查 build、layout、paint、图层、图片还是平台合成。

  6. 是否已经正确处理生命周期?
    控制器必须绑定合适的 vsync,并在 dispose 中释放;异步等待动画完成时还要处理取消和 mounted 状态。

Flutter 动画的核心不是记住多少个 AnimatedXxx 组件,而是理解这条因果链:

时间进度
  -> 曲线与插值
  -> Widget / RenderObject 输入变化
  -> 重建、布局或重绘
  -> 图层与像素处理
  -> 当前帧是否按时显示

隐式动画隐藏了时间轴管理,显式控制器暴露了时间轴,Hero 管理跨路由的几何连续性,CustomPainter 管理像素级绘制,而性能分析负责确认这四者组合后是否仍能在目标设备上完成每一帧。


系列导航与关联阅读

官方资料

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