帧动画(Frame Animation)是一种通过快速连续播放一系列静态图像来产生运动错觉的动画技术,在移动应用中极为常见。本文将介绍如何在 Flutter 中实现帧动画。

首先,我们需要准备好帧动画所需的图片资源。以下是一个简单的示例:

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

  @override
  State<FrameAnimPage> createState() => _FrameAnimPageState();
}

class _FrameAnimPageState extends State<FrameAnimPage> with SingleTickerProviderStateMixin {
  static final _frames = [
    Assets.icons.icTabSettingAnim00,
    Assets.icons.icTabSettingAnim01,
    Assets.icons.icTabSettingAnim02,
    ...
  ];

  late final AnimationController _controller;
  late final Animation<int> _frameAnimation;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      vsync: this,
      duration: Duration(milliseconds: 20 * _frames.length),
    );
    _frameAnimation = IntTween(begin: 0, end: _frames.length - 1).animate(_controller);
  }

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            AnimatedBuilder(
              animation: _frameAnimation,
              builder: (_, __) => Image(
                image: _frames[_frameAnimation.value].provider(),
                width: 100,
                height: 100,
              ),
            ),
            const SizedBox(height: 32),
            FilledButton.icon(
              onPressed: () {
                if (_controller.isAnimating) {
                  _controller.stop();
                }
                _controller.reset();
                _controller.forward();
              },
              icon: const Icon(Icons.play_arrow),
              label: const Text('播放'),
            ),
          ],
        ),
      ),
    );
  }
}

Flutter 的动画系统基于 VSync 信号驱动。每帧(通常为 60fps,约 16.67ms/帧)Ticker 会触发回调,AnimationController 据此更新动画值并通知监听者重建 UI。代码中通过 duration 参数指定总时长,这里设定为每帧 20ms,总时长 = 20ms × 帧数。

AnimatedBuilder 负责监听动画值变化,获取当前帧索引并转换为 ImageProvider 提供给 Image 组件。而播放、暂停等控制逻辑则由 AnimationController 统一管理。

如果你满心欢喜地运行上述代码,会发现帧动画虽然能播放,但首次播放时会出现明显的闪烁。

帧动画首次播放闪烁

这是由于首次播放时,Image 组件每帧切换到一个新的 ImageProvider,如果该帧的图片尚未解码缓存,Flutter 需要解码图片,期间画面空白造成闪烁。

有两种解决方案。

第一种方法是在页面渲染后立即预缓存所有帧图片,播放时直接从内存缓存读取,消除解码延迟。

class _FrameAnimPageState extends State<FrameAnimPage> with SingleTickerProviderStateMixin {
  ...
  static final _frames = [
    Assets.icons.icTabSettingAnim00,
    Assets.icons.icTabSettingAnim01,
    Assets.icons.icTabSettingAnim02,
    ...
  ];
  bool _precached = false;

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    if (!_precached) {
      _precached = true;
      for (final frame in _frames) {
        precacheImage(frame.provider(), context);
      }
    }
  }
}

第二种方法比较剑走偏锋,在新帧未加载完时继续显示旧帧,这样就不会产生画面空白,也就没有了闪烁问题。

class _FrameAnimPageState extends State<FrameAnimPage> with SingleTickerProviderStateMixin {
  ...
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            AnimatedBuilder(
              animation: _frameAnimation,
              builder: (_, __) => Image(
                image: _frames[_frameAnimation.value].provider(),
                width: 100,
                height: 100,
                gaplessPlayback: true,
              ),
            ),
            ...
          ],
        ),
      ),
    );
  }
}

注意:该方式下,首次播放每帧仍需现场解码,若解码耗时较长,旧帧停留时间可能过长,导致视觉上的“掉帧”感。

我们可以将两者结合使用,并封装成一个新的组件:

class FrameAnimImage extends StatefulWidget {
  final List<String> assetPaths; // 图片路径列表
  final AnimationController? controller; // 动画控制器
  final int durationPerFrame; // 每帧时长(毫秒)
  final bool isLoop; // 是否循环播放
  final double? width; // 内容的宽度
  final double? height; // 内容的高度

  const FrameAnimImage({
    Key? key,
    required this.assetPaths,
    this.controller,
    this.durationPerFrame = 20,
    this.isLoop = true,
    this.width,
    this.height,
  }) : super(key: key);

  @override
  State<FrameAnimImage> createState() => _FrameAnimImageState();
}

class _FrameAnimImageState extends State<FrameAnimImage> with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<int> _animation;

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

  void _initAnimation() {
    final totalDuration = Duration(milliseconds: widget.assetPaths.length * widget.durationPerFrame);
    _controller = widget.controller ?? AnimationController(vsync: this, duration: totalDuration);
    _controller.duration = totalDuration;
    _animation = IntTween(begin: 0, end: widget.assetPaths.length - 1).animate(_controller);
    if (widget.isLoop) {
      _controller.repeat();
    } else {
      _controller.forward();
    }
  }

  @override
  void didUpdateWidget(FrameAnimImage oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (oldWidget.assetPaths != widget.assetPaths ||
        oldWidget.durationPerFrame != widget.durationPerFrame ||
        oldWidget.isLoop != widget.isLoop) {
      _controller.stop();
      _controller.reset();
      _controller.duration = Duration(
        milliseconds: widget.assetPaths.length * widget.durationPerFrame,
      );
      _animation = IntTween(begin: 0, end: widget.assetPaths.length - 1).animate(_controller);
      if (widget.isLoop) {
        _controller.repeat();
      } else {
        _controller.forward();
      }
    }
  }

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    for (var path in widget.assetPaths) {
      precacheImage(AssetImage(path), context);
    }
  }

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

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _animation,
      builder: (context, child) {
        return Image.asset(
          widget.assetPaths[_animation.value],
          gaplessPlayback: true,
          width: widget.width,
          height: widget.height,
        );
      },
    );
  }
}

AnimatedBuilder 替换为 FrameAnimImage 组件即可,无需再手动处理首次播放闪烁的问题。