做 Flutter 开发有一段时间的朋友,多半会碰到一个坎儿:动画。数据渲染、状态管理都趟过来了,一到动画这块儿总觉得差点意思。要么卡顿,要么不跟手,要么代码越写越乱。其实 Flutter 的动画体系在跨端框架里算是最完整的一档,只是它给人的第一印象往往是概念多、抽象层厚,真正用起来需要先建立一个全局认知。这篇东西是我反复折腾 Flutter 动画之后的一次系统梳理,把 Animation、AnimationController、Tween、Curve 这些核心概念串起来,再把隐式动画、显式动画、Hero 页面转场、动画库选型、性能优化这些实战要点一个个拆开讲,也会顺带整理一些我在真实项目里踩过的坑。适合正在学 Flutter 的初中级开发者,也适合想优化既有项目动画体验的工程同学参考。
1. 先把 Flutter 动画的底层逻辑看明白:别急着写代码
1.1 动画的本质是一串随时间变化的数值
很多人一上来就查 AnimatedContainer 的用法,或者抄一段 AnimationController 的代码,结果换一个场景就不会写了。核心原因是没理解 Flutter 动画的底层模型。不管多花哨的动画,本质都是一件事:把某个值在一段时间内从起点映射到终点。位置动画是位移值在变,透明度动画是 opacity 值在变,旋转动画是角度值在变,缩放动画是 scale 值在变。值到了,画面就到了,仅此而已。
Flutter 把这个模型做得非常工程化:Animation<double>是一个抽象类,它只负责"当前值是多少"和"值变了通知监听者";AnimationController是具体的驱动器,它在每一帧根据时间推进计算出当前值;Tween负责把 0 到 1 的归一化进度映射到你需要的区间,比如 0.0 到 300.0 的位移;Curve则对时间曲线做变换,让动画出现缓入、缓出、回弹这些节奏感。
这四者协作关系有点像倒水:AnimationController是水龙头,均匀地出水;Tween是水管和漏斗,决定水流进哪个桶;Curve是阀门形状,让水流一会儿快一会儿慢;最后渲染层接到水,把数值画到屏幕上。理解了这条链路,你就能明白为什么 Flutter 动画代码总是那几个类在组合——因为它把"驱动、映射、节奏、渲染"四件事彻底解耦了。
1.2 隐式动画与显式动画的分工:谁负责简单,谁负责自由
Flutter 动画还有一个很容易让人混淆的分层:隐式动画和显式动画。我见过不少同学把这两类混在一起用,结果代码既啰嗦又难以维护。区分它们其实很简单:隐式动画是声明式的,你只需要告诉它目标值,它自己补全中间帧;显式动画是命令式的,你要亲自控制每一帧的推进节奏。
隐式动画的代表是AnimatedContainer、AnimatedOpacity、AnimatedPadding这些。你直接把目标值改掉,比如把容器的宽度从 100 改成 200,组件会自动从旧值过渡到新值,时长和曲线通过参数指定。这类组件适合业务里的常规状态切换,比如按钮按下变色、卡片展开收起、网络加载时透明度变化,不需要精确控制动画的启停和方向。
显式动画的核心是AnimationController,它需要你手动forward()、reverse()、repeat(),并且通常搭配AnimatedBuilder来重建需要变化的 Widget。它的优点是控制粒度极细,可以实现循环、反向、打断、组合、联动这些复杂效果。缺点是代码量更大、状态管理要自己负责。我的建议是:能用隐式动画解决的需求,绝对不碰显式动画;只有隐式动画确实表达不了时,比如需要连续循环的加载动效、拖拽跟手、复杂转场,才上AnimationController。
这两个体系往后会并行出现,下文我把它们各自的 API 和典型场景单独拆开讲清楚。
2. 手动控制动画的核心三件套:AnimationController、Tween 与 Curve 实操
2.1 AnimationController 的创建与销毁:vsync 是干什么的
很多新手第一次写AnimationController就被vsync: this卡住了。vsync本质是一个 TickerProvider,负责给动画提供帧信号。它存在的意义是让动画和屏幕刷新率同步,并且当页面不可见时自动暂停,不浪费系统资源。State 类里要混入SingleTickerProviderStateMixin或TickerProviderStateMixin,前者用于单个 controller,后者用于多个 controller。
我见过有人在 State 里直接写late AnimationController _controller;然后在 initState 里创建,但忘了在 dispose 里销毁,结果导致 Ticker 泄漏,页面反复进出后出现内存上涨甚至崩溃。正确的生命周期写法是这样:
class _MyPageState extends State<MyPage> with SingleTickerProviderStateMixin { late AnimationController _controller; @override void initState() { super.initState(); _controller = AnimationController( vsync: this, duration: const Duration(milliseconds: 600), )..forward(); } @override void dispose() { _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return AnimatedBuilder( animation: _controller, builder: (context, child) { return Opacity( opacity: _controller.value, child: child, ); }, child: const Text('淡入内容'), ); } }这里有个使用AnimatedBuilder的细节值得讲:child参数的作用是缓存不变的部分。上面例子中Text('淡入内容')本身不随动画变化,放进child后,动画每一帧重建时这个子树不会被重建,只是通过Opacity改变透明度。如果直接写Opacity(opacity: _controller.value, child: const Text('淡入内容')),虽然也能跑,但每次帧回调都会重新 buildText这棵子树,性能在复杂页面上差距会很明显。这是官方文档里没特别强调、但实战影响很大的优化点。
2.2 Tween 和 Curve 的配合:节奏感是调出来的
Tween的作用是把AnimationController的 0 到 1 的进度值映射到目标区间。它的底层实现很简单:begin + (end - begin) * progress,但实际使用时有几个进阶玩法。
第一是链式 Tween。比如你要让一个组件先向下移动 50 像素,再向上回弹 20 像素,可以用TweenSequence把多段 Tween 串起来。或者叠加一个CurveTween来改变单段 Tween 的节奏:
final curvedAnimation = CurvedAnimation( parent: _controller, curve: Curves.easeOutBack, ); final tween = Tween(begin: 0.0, end: 300.0).animate(curvedAnimation);Curves.easeOutBack会带来轻微的回弹过冲,让动画看起来有弹性,适合弹窗弹出、卡片翻转这类场景。而Curves.easeInOutCubic会让动画两头慢中间快,适合大范围位移和页面转场。
第二是自定义 Tween。内置的 Tween 支持 double 类型,但你想让 Color、Rect、Alignment 也参与动画,Flutter 内置了对应的ColorTween、RectTween、AlignmentTween。自己也能继承Tween<T>重写lerp方法,比如把两个自定义对象按进度插值。这是很多花哨效果的基础,也是面试官喜欢深挖的一个点。
关于 Curve 的选择,我给一套相对稳妥的参考:普通 UI 元素的进入和退出用Curves.easeInOut或者自带标准曲线的Curves.linearToEaseOut;弹窗和浮层用Curves.easeOutBack;无限循环的强调动画用Curves.easeInOutCubic;物理感强的拖拽模拟用Curves.elasticOut但要克制,用多了会显得廉价。这里说的参数计算,本质上是根据位移长度和预期感受反推 duration:一个 300 像素的位移动画,600 毫秒比较合适;一个透明度动画,150 到 200 毫秒足够;一个页面转场,300 毫秒左右是行业共识。超过了这个范围,用户就会觉得"界面反应慢"。
2.3 一个完整的平移动画示例:代码直接抄
把上面的知识点串起来,做一个"按钮从底部滑入并伴随透明度变化"的效果,这是列表加载、弹窗出现、底部面板展开最常见的动效之一。完整代码如下:
class SlideInButton extends StatefulWidget { const SlideInButton({super.key}); @override State<SlideInButton> createState() => _SlideInButtonState(); } class _SlideInButtonState extends State<SlideInButton> with SingleTickerProviderStateMixin { late final AnimationController _controller; late final Animation<double> _offset; late final Animation<double> _opacity; @override void initState() { super.initState(); _controller = AnimationController( vsync: this, duration: const Duration(milliseconds: 600), ); final curved = CurvedAnimation( parent: _controller, curve: Curves.easeOutCubic, ); _offset = Tween<double>(begin: 80.0, end: 0.0).animate(curved); _opacity = Tween<double>(begin: 0.0, end: 1.0).animate(curved); _controller.forward(); } @override void dispose() { _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return AnimatedBuilder( animation: _controller, builder: (context, child) { return Opacity( opacity: _opacity.value, child: Transform.translate( offset: Offset(0, _offset.value), child: child, ), ); }, child: ElevatedButton( onPressed: () {}, child: const Text('确认'), ), ); } }注意这里Transform.translate只是改变绘制位置,不改变布局占位,性能开销比Padding或Positioned小。如果你想在动画结束后做事情,可以监听状态:
_controller.addStatusListener((status) { if (status == AnimationStatus.completed) { // 动画结束后的回调,比如发起网络请求 } });AnimationStatus有四个值:forward、completed、reverse、dismissed,分别对应播放中、正向播完、反向播放中、反向归零。搞清楚这四态,你就能自由控制动画的未来走向。
3. 用对内置动画组件,少写 80% 的手动代码
3.1 隐式动画组件的典型用法
Flutter 内置的隐式动画组件数量不少,很多场景其实用它们就够了,不需要上 Controller。我把最常用、最不容易出错的几个挑出来说。
AnimatedContainer是最全能的那个,它可以让容器属性变化时自动过渡,包括宽高、背景色、边框、圆角、阴影、内边距。比如做一个展开卡片,只要把高度参数改掉,就自动有动画效果:
AnimatedContainer( duration: const Duration(milliseconds: 300), curve: Curves.easeInOut, width: _expanded ? 200 : 100, height: _expanded ? 200 : 100, decoration: BoxDecoration( color: _expanded ? Colors.blue : Colors.grey, borderRadius: BorderRadius.circular(_expanded ? 16 : 8), ), )AnimatedOpacity适合控制显隐但不触发布局变化的场景,比如图片加载完成前的占位占位淡出、网络错误提示的淡入淡出。AnimatedSwitcher则是"切换子组件时的过渡容器",当你一个位置要交替显示不同 Widget 时,它非常省事,常见用法是图片轮播、Tab 内容切换、数字的翻转效果。配合Key变化,它能识别新旧子 Widget 并执行淡入淡出、缩放或位移的过渡。
AnimatedPositioned只在Stack内部有效,它的特点是不改变组件类型只挪位置,适合做悬浮按钮移动、工具提示定位这些效果。AnimatedAlign则是在Align内部改变对齐方式,适合表单输入时让标题从居中变到顶部这类小细节。
3.2 Hero 页面转场动画实战
页面级转场动画里面,Hero是我使用频率最高、也最容易出效果的一个。它的原理是:当两个页面都存在相同tag的Hero组件时,路由切换过程中 Flutter 会自动把这两个组件视作同一个元素,然后做位置、大小、形状的飞行动画。典型场景是商品列表页的缩略图飞到详情页的大图。
使用上要特别注意 tag 的世界唯一性。我踩过的一个坑是:在同一个页面里用图片 ID 当作 tag,比如tag: product.id,结果页面里同时出现了重复 ID 的多个商品,动画直接乱飞甚至闪烁。正确做法是确保 tag 在整棵 Widget 树里唯一,建议用"页面名 + 业务 ID"拼接,比如tag: 'product_${product.id}'。
大小和形状差异大的组件做 Hero 动画时要小心。如果起点组件和终点组件的圆角、阴影不一致,过渡过程中 Flutter 会尽力插值,但效果可能生硬。我一般会限制 Hero 只对图片或者整体圆角矩形做动画,不要把带复杂文本的卡片直接套 Hero,否则容易"拉丝"。
3.3 内置组件选择速查表
根据我自己的项目经验,整理了一个可以贴在工位上的速查表:
| 组件 | 触发方式 | 典型场景 | 注意点 |
|---|---|---|---|
| AnimatedContainer | 属性变化 | 卡片展开、颜色切换、尺寸变化 | 避免内部文本过多,重建成本大 |
| AnimatedOpacity | 透明度变化 | 加载占位淡出、错误提示 | 不影响布局,适合叠层内容 |
| AnimatedSwitcher | 子树切换 | Tab 内容过渡、图片切换 | 用 Key 区分新旧子树 |
| AnimatedPositioned | Stack 内位置变化 | 悬浮按钮移动、提示浮层 | 必须放在 Stack 中 |
| AnimatedAlign | 对齐方式变化 | 表单标题位移、头像位置调整 | 触发时可能引起兄弟节点布局变化 |
| AnimatedPadding | 内边距变化 | 列表底部间距收起、键盘弹出适配 | 会触发重新布局,注意性能 |
| Hero | 路由切换 | 列表页到详情页的图片飞入 | tag 必须全局唯一 |
| AnimatedCrossFade | 两个组件交叉淡化 | 加载中与加载成功状态切换 | 子组件最好是轻量级 |
| TweenAnimationBuilder | 任意 Tween | 数字滚动、进度条、倒计时 | 不依赖 Controller,自带生命感知 |
这个表可以在写代码前先对一遍需求类型,往往能省不少重新设计的时间。
4. 实战封装一个可复用的加载动画组件:从需求拆解到动效落地
4.1 Loading 动画的整体设计
热搜里的 "loading动画" 几乎是每个 App 都躲不开的需求。我直接以"弹性的三点式加载动画"为例,拆一遍完整的实战流程。这类动画常见于内容加载中、下拉刷新时,特点是循环播放、节奏稳定、不打扰用户。
设计上先确定三个关键参数:
- 时长:一组动画循环周期建议 1200 到 1600 毫秒,太快显得焦虑,太慢显得卡顿
- 曲线:每个小圆点需要"放大-缩小"的脉冲效果,用
Curves.easeInOut让缩放节奏圆润 - 错峰:三个点要有相位差,不能同时放大缩小,否则没有层次感
这三个参数定下来后,动画的基本骨架就出来了,剩下的就是代码实现。
4.2 核心实现代码与参数说明
为了让组件具备复用性,我把三个点的延迟时间做成了入参,同时对外暴露size(点的大小)和spacing(点间距),这样不同业务线可以自己微调视觉密度而不该动核心逻辑。
class ThreeDotLoading extends StatefulWidget { const ThreeDotLoading({ super.key, this.size = 12.0, this.spacing = 8.0, this.color = Colors.blue, }); final double size; final double spacing; final Color color; @override State<ThreeDotLoading> createState() => _ThreeDotLoadingState(); } class _ThreeDotLoadingState extends State<ThreeDotLoading> with SingleTickerProviderStateMixin { late final AnimationController _controller; @override void initState() { super.initState(); _controller = AnimationController( vsync: this, duration: const Duration(milliseconds: 1200), )..repeat(); } @override void dispose() { _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return AnimatedBuilder( animation: _controller, builder: (context, child) { return Row( mainAxisSize: MainAxisSize.min, children: List.generate(3, (index) { return _Dot( controller: _controller, delay: index * 0.15, size: widget.size, spacing: widget.spacing, color: widget.color, ); }), ); }, ); } } class _Dot extends StatelessWidget { const _Dot({ required this.controller, required this.delay, required this.size, required this.spacing, required this.color, }); final AnimationController controller; final double delay; final double size; final double spacing; final Color color; @override Widget build(BuildContext context) { double phase = (controller.value - delay) % 1.0; if (phase < 0) phase += 1.0; final scale = 0.5 + 0.5 * Curves.easeInOut.transform(phase); return Container( width: size + spacing, alignment: Alignment.center, child: Transform.scale( scale: scale, child: Container( width: size, height: size, decoration: BoxDecoration(color: color, shape: BoxShape.circle), ), ), ); } }这里的计算逻辑是:controller.value在 0 到 1 之间循环,每个圆点根据自己的delay偏移取值,再对 1 取模保证相位始终落在 0 到 1 区间。Curves.easeInOut.transform(phase)把本来线性的进度变成缓入缓出,0.5 + 0.5 *则让缩放范围落在 0.5 到 1.0 之间,不至于缩到完全没有。
4.3 动画工作流:从设计稿到真机验收
一个动画从设计稿到线上,中间一定不是"拿到就用"。我自己在项目里总结了一套轻量流程,分享出来供工程团队参考。
第一步是设计评审。动画设计师给到的是概念稿或原型,开发要做的第一件事是把动效拆解成参数:时长、曲线、相位差、位移距离。这一步最容易漏掉的是"异常态",比如动画被反复触发、组件被销毁、页面切后台,这些情况开发要在代码层面把它闭合掉。我见过不少加载动画在组件 dispose 后还repeat()空转,白白消耗性能。
第二步是原型落地。先用 Flutter 原生组件搭出 80% 效果,比如用AnimatedContainer或AnimationController。这个阶段不建议直接上 Lottie 或 Rive,因为内置动画的调试效率更高,而且后续维护成本更低。只有确认原生代码复杂度太高或者效果差异明显,才切换到序列帧方案。
第三步是真机调优。模拟器里看到的流畅度和真机差别很大,低端安卓机尤其明显。至少要覆盖三档机型:高端 iOS、中端 Android、低端 Android。重点观察帧耗时是否稳定,动画期间有没有掉到 60FPS 以下。低端机如果出现明显掉帧,优先检查是否因为动画触发了大面积的setState重建,或者是否存在不必要的透明度和阴影计算。
第四步是可访问性检查。这个容易被忽视但很重要:如果用户系统开启了"移除动画"的辅助功能,动画应当能被禁用或降级。Flutter 目前通过MediaQuery.disableAnimations可以读取该设置,合理的降级方案是把循环动画替换成静态提示,把位移动画直接跳到终点状态。这一步做不做,往往就是专业团队和 Demo 团队的分水岭。
5. 动画跑起来之后:性能优化与常见坑位排查实录
5.1 让动画流畅的关键:减少重建与隔离重绘
动画性能问题排在首位的永远是"重建范围过大"。Flutter 的动画本质上是帧回调,每帧都会触发 build。如果你在动画代码里把整个页面都塞进AnimatedBuilder,那相当于每帧重建整页所有 Widget。页面简单还好,一旦有列表、有地图就有明显的卡顿。
最实用的优化手段有三个。第一个是上文提到的AnimatedBuilder.child缓存不变子树,保证只有变化的那个节点参与重建。第二个是给动画节点包RepaintBoundary,把动画重绘区域隔离出来,避免影响它周围的兄弟节点和父节点。像列表中的多个动画小部件,给每个动画区域都套一层RepaintBoundary,能显著降低复合线程的压力。
第三个是避免在动画过程中触发saveLayer。Flutter 中Opacity、阴影、ClipRRect配合 Canvas 的saveLayer会开启离屏渲染,代价很高。能不用完整Opacity就用AnimatedOpacity,因为它内部只在动画结束时合并图层;阴影和多层裁剪尽量放在静态层,不要在动画层上反复计算。这里我踩过坑:一个 circular progress 转圈动画,外面套了阴影容器加圆角裁切,低端机直接能把帧率掉到 40 以下。去掉阴影改用手绘圆弧后,帧率稳定回到 60。
5.2 常见问题速查表
下面这些问题是 Flutter 动画使用中高频出现的,我在多个项目里都遇到过,整理成表格方便查阅:
| 现象 | 直接原因 | 解决方案 |
|---|---|---|
| 动画不执行,只闪一下 | 没调用 forward/repeat,或调用时机在 build 阶段 | 确认在 initState 或点击回调里启动动画,不要在 build 里触发 |
| 页面退出后动画还跑 | controller 没在 dispose 中销毁 | 必须在 dispose 里调用controller.dispose() |
| 动画执行时掉帧 | 重建范围过大/动画层阴影过重 | 用 AnimatedBuilder.child 缓存子树,套 RepaintBoundary |
| 动画结束后状态没归位 | 没有监听 AnimationStatus 或 Tween 终点设错 | 检查 Tween.begin/end 与监听逻辑 |
| 动画显示不全,边缘截断 | 组件边界超出父容器后被裁剪 | 检查父容器 clipBehavior,必要时改成 Clip.none |
| 动画曲线不生效 | CurvedAnimation 没有正确传入 Tween | 确认Tween.animate(curved)是链式调用 |
| Hero 动画闪烁 | tag 重复导致误配 | 使用全局唯一 tag,格式建议"页面名+业务ID" |
| 隐式动画突然跳到终点 | 系统开启"移除动画"辅助设置 | 通过MediaQuery.disableAnimations做降级 |
| 多个 controller 报 Ticker 挂起 | 错误使用 SingleTickerProviderStateMixin | 多 controller 用 TickerProviderStateMixin |
这里有个排查技巧可以分享:动画问题别一上来就改代码,先在 Flutter 的 debug 模式里打开性能叠加图(debugPaintLayerBordersEnabled或 DevTools 的 Layer 面板),看哪些区域在动画期间持续重绘。定位到渲染图层范围后,再对照问题速查表,命中率能提高很多。
5.3 面试官爱问的几个动画考点
热搜里有"flutter面试题 2026",动画这块也是面试高频区。我不建议背题,但如果把这些底层逻辑理解透了,面试自然能聊出东西。
最常见的问题包括:隐式动画和显式动画的区别及适用场景;AnimationController的vsync作用;Tween和Curve分别解决了什么问题;AnimatedBuilder和AnimatedWidget的区别;如何实现一个循环动画;如何监听动画结束并触发业务逻辑。还有一个进阶问题我经常拿来问候选人:如果动画过程中用户快速退出页面,如何处理?这个能考察到生命周期管理、Ticker 泄漏和 controller 释放,是区分"会写动画"和"写得好动画"的关键。
回答这类问题时,核心是表达出"Flutter 动画是一套值驱动、帧同步、可组合、可销毁的资源体系"这个整体认知,再递进出具体 API。死记 API 名通常撑不过三轮追问。
6. 第三方动画库怎么选:拿算力换效率的时机判断
6.1 热门动画库横向对比
Flutter 生态里的动画库很丰富,但选型需要谨慎,因为动画库一旦进入项目,往往难以替换。我按使用场景把主流的几类做了对比。
第一类是链式动画库,代表是flutter_animate。它最大的优势是让动画代码变得极其简洁,几乎所有内置动画都可以像写 CSS 那样链式拼接:Text('hello').animate().fadeIn().scale().slideX()。它底层还是封装了AnimationController和各类隐式组件。适合快速原型和 UI 细节丰富的业务页面,但要知道它的 DSL 不一定能覆盖所有自定义效果,遇到需求发散时仍需回到原生动画体系。
第二类是官方维护的animations包,里面包含OpenContainer、FadeThroughTransition、SharedAxisTransition这些 Material Motion 规范里定义的交互动画。如果你在设计上遵循 Material 设计语言,这套库是性价比最高的选择,组件设计精致、行为规范、性能经过验证,而且视觉和 Android 系统原生交互一致。
第三类是设计师协同向的方案:Lottie和Rive。这两者本质是"把 AE 导出的矢量动画数据在 Flutter 中渲染",不是编写动画逻辑而是回放数据。Lottie的生态最成熟,设计工具导出流程完善;Rive的优势是支持实时交互、状态机和可变属性,能做出游戏级别的响应式动画,但学习成本和调试成本也更高。
第四类是序列帧动画方案,通常用spritewidget或自定义的帧图播放器。它适用于手绘像素风、帧依赖型动画,比如角色行走、攻击动作、NPC 表情,这个方向一般出现在游戏和互动内容里,普通业务页面很少涉及。
6.2 真实项目里的选型建议
选型不是一味的"上最热门的库",要根据团队结构和技术负债综合判断。我的实际操作原则是三条。
第一,动画量少、交互单一的场景,只依赖 Flutter 原生动画即可。比如加载提示、按钮反馈、页面转场,原生组件完全够用,引入第三方库反而增加体积和维护成本。
第二,动画种类多、但偏 UI 视觉效果的场景,优先选用flutter_animate这类链式封装库,它能大幅降低代码量和开发时间,而且动画参数可以集中配置,设计评审时可以直接读代码参数,沟通效率高。
第三,设计团队深度参与、动画复杂程度接近短片特效的场景,用Rive或Lottie。这种场景下代码手写动画的成本极高,动效的微调周期也会拖垮开发节奏。让设计直接在 Rive 里调素材,开发负责数据加载和状态机控制,是合理分工。
包体积也要提前评估。一个复杂 Lottie JSON 可能几百 KB,如果塞进启动页,会直接影响首包下载体积。我一般会在资源加载策略上做优化:按需加载、远端下发、格式压缩,而不是把所有动画资源一股脑打进 assets 里。
另外,热搜里的 "flutter 内嵌数据库"、"flutter 微信登录" 这类话题跟动画没有直接关系,不展开。但有一点可以提醒:动画组件在项目里也会遇到类似的"版本不一致导致资源无法加载"的问题,尤其是第三方动画库和 Flutter SDK 版本不兼容时表现很明显。所以选动画库之前,先确认它支持当前 Flutter 版本,最简单的方法是直接看库的 pub.dev 页面上的 compatibility 标签。
最后再分享一个我个人的小习惯:写完一段动画代码,不要只在模拟器和高端真机上跑,一定要找一台低端 Android 机做压力测试。很多动画问题在模拟器上根本看不出来,一上低端机就原形毕露。我一般会用强制 60FPS 条件下的 Profile 模式跑一段加载流程,观察帧时间曲线有没有明显尖峰,如果有,对照上文的问题表逐项排查。动画做得流畅、跟手,给用户的体验提升是立竿见影的。这也正是花费心思去啃 Flutter 动画体系的回报所在。