Flutter 动画控制器的生命周期管理:避免内存泄漏的工程实践
一、那个持续渲染的动画——即使页面已经 Pop 了
Flutter 页面动画调试时遇到了一个奇怪的现象:从商品列表进入详情再返回,反复操作 20 次后,iPhone 11 的 Profiler 显示内存从 120MB 涨到 380MB,并且 GPU Usage 持续 15%。所有页面都已Navigator.pop()了,为什么还有动画在渲染?
问题出在AnimationController。我在initState中创建了三个控制器,dispose中也调用了controller.dispose()。但漏掉了一个场景:TickerProvider的vsync绑定。当State对象被dispose后,Ticker若未停止,动画循环仍在合成器线程运行,而持有的引用(Listener callbacks)阻止了 GC 回收。
二、AnimationController 的生命周期与 Ticker 机制
Ticker是 Flutter 动画体系中最底层的时间驱动机制,等价于前端的requestAnimationFrame。每次屏幕刷新时,Ticker回调被执行,AnimationController根据elapsed时间计算动画值,然后通过notifyListeners触发重建。
关键陷阱:AnimationController的dispose()调用Ticker.stop()是异步的。如果在dispose()之后立即重建同一个 Widget(比如快速切换 Tab),新创建的 Controller 可能还注册在同一个TickerProvider上,导致旧 Ticker 和新 Ticker 同时运行。
三、安全的 AnimationController 管理模式
// lib/animation/animation_controller_manager.dart // AnimationController 生命周期管理 Mixin // 解决两个核心问题: // 1. 确保 dispose 时所有 Controller 正确释放 // 2. 防止重复创建和异步 stop 竞态 import 'package:flutter/material.dart'; /// 动画控制器安全管理者 /// /// 使用方式:在 State 中混入此 Mixin, /// 通过 [createController] 创建而非直接 new AnimationController mixin AnimationControllerMixin<T extends StatefulWidget> on State<T> { /// 统一管理所有通过此 Mixin 创建的控制器 final List<AnimationController> _controllers = []; /// 是否已 dispose——防止 dispose 后误操作 bool _isDisposed = false; /// 安全创建 AnimationController /// /// 相比直接 new AnimationController(vsync: this),这里做了三件事: /// 1. 自动注册到 _controllers 列表,dispose 时统一清理 /// 2. 断言检查:防止在 dispose 后创建 /// 3. 包装 dispose 确保幂等性(可以多次调用) AnimationController createController({ required Duration duration, double lowerBound = 0.0, double upperBound = 1.0, TickerProvider? vsync, String? debugLabel, }) { assert( !_isDisposed, 'AnimationController 不能在 dispose 后创建。' '请检查 Widget 生命周期。debugLabel: $debugLabel' ); final controller = AnimationController( duration: duration, lowerBound: lowerBound, upperBound: upperBound, vsync: vsync ?? this as TickerProvider, debugLabel: debugLabel, ); // 包装原始 dispose,确保从列表中移除 final originalDispose = controller.dispose; controller.dispose = () { originalDispose.call(); _controllers.remove(controller); }; _controllers.add(controller); return controller; } /// 暂停所有动画(用于页面不可见时省电) /// /// 使用场景:Navigator.push 新页面覆盖当前页面时, /// 当前页面的动画应该暂停而非继续消耗 GPU 资源 void pauseAllAnimations() { for (final controller in _controllers) { if (controller.isAnimating) { controller.stop(); // stop 不重置值,保持当前状态 } } } /// 恢复所有暂停的动画(用于页面重新可见时) void resumeAllAnimations() { // 仅恢复那些被 stop 暂停但"应该"在运行的动画 // 需要额外记录"被暂停前是 forward 还是 reverse 状态" // 此处简化处理,实际工程中应维护 animState 标记 } /// 统一释放所有控制器 /// /// 必须在 State.dispose() 的第一行调用 /// 注意:复制列表后再遍历,因为 controller.dispose() 会修改 _controllers void disposeAllControllers() { _isDisposed = true; final copy = List<AnimationController>.from(_controllers); for (final controller in copy) { controller.dispose(); } _controllers.clear(); } @override void dispose() { disposeAllControllers(); super.dispose(); } }// lib/animation/animation_status_listener.dart // 带安全检查的动画状态监听器 // // 问题场景:AnimationController 执行 forward() 后, // 如果在动画完成前页面 Pop,StatusListener 回调中的 setState // 会尝试更新已 dispose 的 State——抛异常。 import 'package:flutter/material.dart'; /// 安全的动画状态监听包装 /// /// 确保监听器回调执行时 State 仍然 alive class SafeAnimationListener { final VoidCallback? _onDispose; bool _mounted = true; SafeAnimationListener({VoidCallback? onDispose}) : _onDispose = onDispose; bool get isMounted => _mounted; /// 仅在组件仍然挂载时执行回调 void safeCall(VoidCallback callback) { if (_mounted) { callback(); } } void dispose() { _mounted = false; _onDispose?.call(); } } /// 具体使用示例:带动画的评分组件 class RatingWidget extends StatefulWidget { final double rating; // 0.0 ~ 5.0 const RatingWidget({required this.rating}); @override State<RatingWidget> createState() => _RatingWidgetState(); } class _RatingWidgetState extends State<RatingWidget> with AnimationControllerMixin<RatingWidget> { late final AnimationController _scaleController; late final Animation<double> _scaleAnimation; late final SafeAnimationListener _safeListener; @override void initState() { super.initState(); // 使用 Mixin 的安全创建方法 _scaleController = createController( duration: const Duration(milliseconds: 300), debugLabel: 'rating-scale', ); _scaleAnimation = Tween<double>(begin: 0.8, end: 1.0).animate( CurvedAnimation( parent: _scaleController, curve: Curves.elasticOut, ), ); // 安全监听器:确保 setState 时 State 未被 dispose _safeListener = SafeAnimationListener(); _scaleController.addStatusListener((status) { _safeListener.safeCall(() { if (status == AnimationStatus.completed) { // 动画完成后可在此做后续操作 } }); }); // 动画启动 _scaleController.forward(); } @override void dispose() { _safeListener.dispose(); // disposeAllControllers 由 Mixin 处理 super.dispose(); } @override Widget build(BuildContext context) { return AnimatedBuilder( animation: _scaleAnimation, builder: (context, child) { return Transform.scale( scale: _scaleAnimation.value, child: child, ); }, child: Row( children: List.generate(5, (i) { return Icon( i < widget.rating ? Icons.star : Icons.star_border, color: Colors.amber, ); }), ), ); } }四、性能边界的实践清单
不要复用已 dispose 的 Controller。controller.dispose()后其内部的_ticker被置 null,任何后续操作(forward()、addListener())都会抛异常。如果需要重复播放动画(比如点赞按钮),用controller.reset()+controller.forward(),而不是 dispose 后重建。
Hero 动画的 Controller 生命周期。Hero 动画的createRectTween和flightShuttleBuilder共享同一个Animation<double>实例。Hero 过渡期间的 Controller 由 Flutter 框架内部管理,不需要手动 dispose。如果在flightShuttleBuilder中又创建了额外的 Controller,需要在过渡完成时手动清理。
TickerProviderStateMixinvsSingleTickerProviderStateMixin。如果只有一个 AnimationController,用SingleTickerProviderStateMixin——它在 dispose 时自动 stop 唯一的 Ticker。多个 Controller 必须用TickerProviderStateMixin。
五、总结
Flutter AnimationController 的生命周期管理可以浓缩为三条铁律:
- 创建即注册——每个 Controller 创建后立即加入统一管理列表,dispose 时统一清理
- 回调即挂载检查——所有 Listener/StatusListener 回调执行前必须检查
mounted,防止操作已销毁的 State - 停止比释放更优先——
controller.stop()比controller.dispose()更轻量,适用于页面不可见场景
这些规则不复杂,但漏掉任何一条,泄漏就会在用户反复导航 50 次后浮现。