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:只给可见区域的卡片加动画
用 ListView 的 itemBuilder 配合 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 自定义了一个缩放 + 渐变的转场动画。高端机没问题,中端机转场有明显卡顿。
问题分析
自定义转场动画的性能问题通常出在两个地方:
- 过度绘制(Overdraw):动画过程中有多层半透明元素叠加
- 动画在 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。
性能自检清单
动画性能问题排查可以用这个清单:
- DevTools Performance Overlay:查看 UI 和 Raster 线程耗时,UI 线程长说明 build 逻辑重,Raster 线程长说明渲染重
- RepaintRainbow:给页面加
RepaintBoundary并用不同颜色标识,频繁变红的区域就是重绘区 - 查看 Flutter 层 FPS:Flutter DevTools 的 Timeline 可以看到每帧耗时
- 减少动画中的 widget 重建:动画回调里尽量只改
Transform、Opacity、Decoration这些不触发子 widget 重建的属性
总结
Flutter 动画性能问题的根因通常就几个:
- 动画 controller 数量过多:共享 controller 或用 AnimatedList 集中管理
- 过度绘制:半透明叠加、多层容器,用 RepaintBoundary 隔离
- 动画触发重建:动画过程中 setState,动画只改渲染属性不改状态
- 资源没有释放:controller dispose 是基本要求
性能优化的优先级建议:先确保动画能跑,再追求流畅,最后才是酷炫。脱离性能的交互动画反而会伤害体验。
