Flutter动画卡成PPT:那些年我们踩过的动画性能坑

Flutter动画卡成PPT:那些年我们踩过的动画性能坑

Flutter 的动画系统功能很强大,但实际做复杂交互动画时,问题往往比想象中多。这篇整理一些真实踩过的动画性能坑,以及对应的优化方案。

场景一:列表页卡片飞入动画卡顿

产品要求列表页加载时卡片有一个从下往上渐入的动画,60 条数据,用 AnimationController + SlideTransition 实现。实测中端机滑动开始卡。

问题分析

class CardItem extends StatefulWidget {
  final int index;
  final Post post;
  
  
  State<CardItem> createState() => _CardItemState();
}

class _CardItemState extends State<CardItem>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<Offset> _offsetAnimation;

  
  void initState() {
    super.initState();
    _controller = AnimationController(
      duration: Duration(milliseconds: 300),
      vsync: this,
    );
    _offsetAnimation = Tween<Offset>(
      begin: Offset(0, 0.3),
      end: Offset.zero,
    ).animate(CurvedAnimation(
      parent: _controller,
      curve: Curves.easeOut,
    ));
    
    // 每个卡片都创建独立的 controller
    Future.delayed(Duration(milliseconds: widget.index * 50), () {
      if (mounted) _controller.forward();
    });
  }
}

问题:60 个卡片 = 60 个 AnimationController。每个 controller 在页面重建时都要重新 build,即使卡片不在可见区域。这对 Flutter 来说是不必要的负担。

解决思路

方案 1:只给可见区域的卡片加动画

ListViewitemBuilder 配合 VisibilityDetector,只有进入可视区的卡片才触发动画,移出后重置:

class AnimatedCardItem extends StatefulWidget {
  final int index;
  final Post post;
  
  
  State<AnimatedCardItem> createState() => _AnimatedCardItemState();
}

class _AnimatedCardItemState extends State<AnimatedCardItem>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  bool _isVisible = false;

  
  void initState() {
    super.initState();
    _controller = AnimationController(
      duration: Duration(milliseconds: 300),
      vsync: this,
    );
  }

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

  
  Widget build(BuildContext context) {
    return VisibilityDetector(
      key: Key('card_${widget.post.id}'),
      onVisibilityChanged: (info) {
        if (info.visibleFraction > 0 && !_isVisible) {
          _isVisible = true;
          _controller.forward();
        }
      },
      child: SlideTransition(
        position: Tween<Offset>(
          begin: Offset(0, 0.3),
          end: Offset.zero,
        ).animate(_controller),
        child: CardContent(post: widget.post),
      ),
    );
  }
}

方案 2:用 AnimatedList 代替手动管理

如果列表数据本身是动态的,用 AnimatedList 配合 GlobalKey 管理动画,比每个 item 独立 controller 更高效:

final GlobalKey<AnimatedListState> _listKey = GlobalKey();
List<Post> _posts = [];

void addPost(Post post) {
  _posts.insert(0, post);
  _listKey.currentState?.insertItem(0, duration: Duration(milliseconds: 300));
}

Widget build(BuildContext context) {
  return AnimatedList(
    key: _listKey,
    initialItemCount: _posts.length,
    itemBuilder: (context, index, animation) {
      return SlideTransition(
        position: animation,
        child: CardContent(post: _posts[index]),
      );
    },
  );
}

场景二:Hero 动画切换时图片闪烁

页面 A 和页面 B 用 Hero 动画过渡,图片在切换过程中会短暂闪烁,然后才显示目标图片。用户体验很差。

问题原因

Hero 动画默认的 flightShuttleBuilder 如果没有正确配置,Flutter 在过渡过程中会复用当前帧作为占位,导致闪烁。

解决方案

Hero(
  tag: 'product_image_${product.id}',
  child: Image.network(
    product.imageUrl,
    fit: BoxFit.cover,
    // 添加语义化标签,便于 Hero 识别
    frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
      if (wasSynchronouslyLoaded) return child;
      return AnimatedOpacity(
        opacity: frame == null ? 0 : 1,
        duration: Duration(milliseconds: 200),
        curve: Curves.easeOut,
        child: child,
      );
    },
  ),
)

如果 Hero 包裹的是 ClipRRect 或其他装饰容器,要确保 Hero 直接包裹内容层:

// 错误:Hero 包裹的是装饰容器,不是图片本身
Hero(
  tag: 'image',
  child: ClipRRect(
    borderRadius: BorderRadius.circular(12),
    child: Image.network(url),
  ),
)

// 正确:两层 Hero,外层管位置,内层管内容
Hero(
  tag: 'image_${product.id}',
  child: Material(
    type: MaterialType.transparency,
    child: ClipRRect(
      borderRadius: BorderRadius.circular(12),
      child: Image.network(url),
    ),
  ),
)

场景三:骨架屏加载动画导致页面卡顿

骨架屏(Skeleton Screen)是主流的加载反馈方案,但如果动画实现不当,会和真实内容渲染抢 CPU。

问题代码

class SkeletonBox extends StatefulWidget {
  
  State<SkeletonBox> createState() => _SkeletonBoxState();
}

class _SkeletonBoxState extends State<SkeletonBox>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;

  
  void initState() {
    super.initState();
    _controller = AnimationController(
      vsync: this,
      duration: Duration(milliseconds: 1500),
    )..repeat();
  }

  
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _controller,
      builder: (context, child) {
        return Container(
          decoration: BoxDecoration(
            gradient: LinearGradient(
              begin: Alignment(-1 + 2 * _controller.value, 0),
              end: Alignment(_controller.value * 2, 0),
              colors: [Colors.grey[300]!, Colors.grey[100]!, Colors.grey[300]!],
            ),
          ),
        );
      },
    );
  }
}

每个 SkeletonBox 都有自己的 AnimationController,当页面有 10+ 个骨架单元时,动画控制器数量会很可观。

优化方案

方案 1:共享 AnimationController

class SkeletonAnimation extends InheritedWidget {
  static SkeletonAnimation of(BuildContext context) {
    return context.dependOnInheritedWidgetOfExactType<SkeletonAnimation>()!;
  }

  final AnimationController controller;

  SkeletonAnimation({super.child, required this.controller});

  
  bool updateShouldNotify(SkeletonAnimation old) => controller != old.controller;
}

class SkeletonBox extends StatelessWidget {
  
  Widget build(BuildContext context) {
    final animation = SkeletonAnimation.of(context);
    return AnimatedBuilder(
      animation: animation.controller,
      builder: (context, child) {
        return Container(
          decoration: BoxDecoration(
            gradient: LinearGradient(
              begin: Alignment(-1 + 2 * animation.controller.value, 0),
              end: Alignment(animation.controller.value * 2, 0),
              colors: [Colors.grey[300]!, Colors.grey[100]!, Colors.grey[300]!],
            ),
          ),
        );
      },
    );
  }
}

页面层创建一个 controller,所有骨架单元共享:

class ProductDetailPage extends StatefulWidget {
  
  State<ProductDetailPage> createState() => _ProductDetailPageState();
}

class _ProductDetailPageState extends State<ProductDetailPage>
    with SingleTickerProviderStateMixin {
  late AnimationController _skeletonController;

  
  void initState() {
    super.initState();
    _skeletonController = AnimationController(
      vsync: this,
      duration: Duration(milliseconds: 1500),
    )..repeat();
  }

  
  void dispose() {
    _skeletonController.dispose();
    super.dispose();
  }

  
  Widget build(BuildContext context) {
    return SkeletonAnimation(
      controller: _skeletonController,
      child: // ... 骨架屏内容
    );
  }
}

方案 2:用 RepaintBoundary 隔离

如果共享 controller 改动太大,可以直接加 RepaintBoundary 隔离骨架屏和内容渲染:

RepaintBoundary(
  child: SkeletonBox(),
)

骨架屏动画变化时,不会触发外层页面重绘。

场景四:页面转场动画掉帧

页面从列表页进入详情页,用 PageRouteBuilder 自定义了一个缩放 + 渐变的转场动画。高端机没问题,中端机转场有明显卡顿。

问题分析

自定义转场动画的性能问题通常出在两个地方:

  1. 过度绘制(Overdraw):动画过程中有多层半透明元素叠加
  2. 动画在 UI 线程执行:Flutter 的动画默认在 UI 线程,如果 build 逻辑复杂,会抢占 CPU

解决方案

PageRouteBuilder(
  pageBuilder: (context, animation, secondaryAnimation) => DetailPage(),
  transitionsBuilder: (context, animation, secondaryAnimation, child) {
    return FadeTransition(
      opacity: animation,
      child: ScaleTransition(
        scale: Tween<double>(begin: 0.95, end: 1.0).animate(
          CurvedAnimation(
            parent: animation,
            curve: Curves.easeOut,
          ),
        ),
        child: child,
      ),
    );
  },
  transitionDuration: Duration(milliseconds: 300),
  // 关闭反向转场动画(手势返回时不需要这个效果)
  reverseTransitionDuration: Duration(milliseconds: 250),
)

更根本的优化是减少动画过程中的绘制复杂度:

// 方案:在转场时简化内容
transitionsBuilder: (context, animation, secondaryAnimation, child) {
  return AnimatedBuilder(
    animation: animation,
    builder: (context, _) {
      // 转场过程中降低内容复杂度
      final opacity = animation.value;
      return Opacity(
        opacity: opacity < 0.5 ? 2 * opacity : 2 * (1 - opacity),
        child: child,
      );
    },
  );
}

场景五:动画内存泄漏

动画 controller 如果没有正确 dispose,会导致内存泄漏。尤其是页面被 pop 后 controller 还在运行。

// 错误:页面销毁时没有取消动画
class _AnimatedWidgetState extends State<AnimatedWidget> {
  late AnimationController _controller;

  
  void initState() {
    super.initState();
    _controller = AnimationController(
      vsync: this,
      duration: Duration(seconds: 2),
    )..repeat();
  }

  // 忘记 dispose!
}
// 正确
class _AnimatedWidgetState extends State<AnimatedWidget>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;

  
  void initState() {
    super.initState();
    _controller = AnimationController(
      vsync: this,
      duration: Duration(seconds: 2),
    )..repeat();
  }

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

如果你的 widget 是 StatelessWidget,不能直接用 with SingleTickerProviderStateMixin,必须改成 StatefulWidget。不要为了"省事"在 StatelessWidget 里塞 controller。

性能自检清单

动画性能问题排查可以用这个清单:

  1. DevTools Performance Overlay:查看 UI 和 Raster 线程耗时,UI 线程长说明 build 逻辑重,Raster 线程长说明渲染重
  2. RepaintRainbow:给页面加 RepaintBoundary 并用不同颜色标识,频繁变红的区域就是重绘区
  3. 查看 Flutter 层 FPS:Flutter DevTools 的 Timeline 可以看到每帧耗时
  4. 减少动画中的 widget 重建:动画回调里尽量只改 TransformOpacityDecoration 这些不触发子 widget 重建的属性

总结

Flutter 动画性能问题的根因通常就几个:

  1. 动画 controller 数量过多:共享 controller 或用 AnimatedList 集中管理
  2. 过度绘制:半透明叠加、多层容器,用 RepaintBoundary 隔离
  3. 动画触发重建:动画过程中 setState,动画只改渲染属性不改状态
  4. 资源没有释放:controller dispose 是基本要求

性能优化的优先级建议:先确保动画能跑,再追求流畅,最后才是酷炫。脱离性能的交互动画反而会伤害体验。

最后更新 4/20/2026, 4:48:48 AM