Flutter跨平台鸿蒙开发:把Tween补间动画彻底啃透
做跨平台开发这几年,我接触过不少从 Android 或 iOS 转到 Flutter 的团队,大家上手一个项目时最容易被绕进去的不是状态管理,也不是路由嵌套,而是动画。尤其是跑到鸿蒙这种还在磨合期的平台上,Flutter 的动画体系一半靠引擎、一半靠宿主,踩坑的密度比普通 Android 上高不少。这篇文章我打算用一次真实的开发经历,把 Flutter 里最常用也最核心的 Tween 补间动画从头到尾拆开揉碎,讲清楚背后的机制,再配合鸿蒙端实际的适配经验,让你看完之后自己也能流畅地写出一套不卡顿、不闪烁、几十行就能复用的动画封装。
先交代一下场景。我之前接手过一个模拟项目X,目标平台包括普通 Android 模拟器、iOS 真机,还有某国产系统平台(以下简称目标平台)。项目里其中一个核心页面是数据卡片流,需要在用户下拉刷新时给卡片加一个位移动画 + 透明度变化 + 缩放效果。不做动画,页面也能用,但体验起来就是干巴巴的。后来我用 Flutter 自带的 AnimationController + Tween 把整套动画重构了一遍,顺带把几类常见卡顿的坑也排查了个干净,最后动画帧率在目标平台设备上稳定在 90 fps 左右,整体效果跟原生实现没有明显差距。这篇文章适合刚接触 Flutter 动画的初学者,也适合已经在写业务但总觉得动画代码写得不够优雅的老手。
1. 为什么会把动画写到崩溃:先理解 Flutter 跨平台动画的底层逻辑
1.1 鸿蒙跨平台开发里的“三方框架适配”到底卡在哪
很多从 Android 转过来的同学,第一反应是:动画不就是 ValueAnimator 吗?到 Flutter 变成 Controller、Tween、Builder,这套东西的存在逻辑要从 Flutter 的渲染树说起。Flutter 不像原生 UI 那样直接操作系统控件,而是一套自绘 UI 框架,从 Dart 层到底层渲染,中间跨越了好几层。动画的每一帧改变,都要经过 Dart 计算、布局、绘制、合成、光栅化这一连串流程,最终才能显示到屏幕上。
到了目标平台上,Flutter 应用并不是系统原生应用,它更像是“跑在一个可以天然嵌进 App 的渲染引擎里的智能 UI”,所有动画生成的能力都来自 Flutter 引擎自身。引擎通过 Native 层与系统窗口、输入事件、Vsync 信号交互。这意味着 Flutter 动画能不能顺畅跑起来,关键不取决于系统自带动画接口,而在于引擎能否稳定拿到垂直同步信号,能不能及时把绘制指令提交给 GPU。
我在项目里遇到的最典型问题是:点击按钮触发动画时,偶尔会出现整段动画直接卡顿约 200 ms,之后又恢复正常。后来排查发现根因并不是 Dart 层代码复杂,而是动画启动瞬间引擎申请系统层面权限、加载字体资源、触碰了平台通道,导致首帧丢帧。这个问题在 Android 上不明显,因为 Flutter 引擎与 Android 表面集成已经优化了多年,但目标平台的集成层还比较年轻时首帧性能会比成熟平台差一截。
1.2 补间动画为什么是跨平台体验的“定海神针”
补间动画在 Flutter 里叫 Tween Animation。所谓补间,是指从一个起点状态过渡到终点状态的过程,中间所有中间帧都由系统自动计算。相对于逐帧动画不用一帧一帧自己画中间态,Tween 的优势极其明显:代码量小、可复用性强、和 UI 解耦。
跨平台场景下,补间动画更是绝对主力。因为 Flutter 的渲染逻辑在各平台一致,只要 Tween 的数值变换被正确处理,无论跑在 Android、iOS 还是目标平台上,动画的表现都高度一致。这也意味着团队里只要有一个会写 Tween 动画的人,所有平台的动效都能统一落地
我自己的一个小习惯:项目里所有涉及数字变化的动画,一律优先考虑 Tween + AnimationController,而不是自己写 Timer 定时循环改状态。Timer 的方式虽然直接,但容易丢失帧同步,在多个平台上的时间基准还不一样,真机上经常出现动画时长忽长忽短,很难调出一个放之四海而皆准的体验。
2. Tween 补间动画的核心机制与原理拆解
2.1 Tween 并不是动画,而是“配方”
我在指导新人时经常碰到一个误区:以为 Tween 就是动画本身。实际上 Tween 只是一个映射关系,它定义的是一个输入值(一般来自 AnimationController 的 0.0 到 1.0 进度)到输出值(可以是颜色、尺寸、位移、角度等任意可插值的类型)的变换方式。
把一个 Tween 和 AnimationController 组合起来,才算真正得到了一个能够驱动 UI 变化的“动态值”。Tween 本身不具备进度驱动能力,它不负责计时,也不负责帧调度,它只是老老实实地按照算法把一个范围内的值映射出来。
实际编码中的感觉是,Tween 就像一个既有“配方”又有“模具”的工具箱,给定一个在 0 到 1 之间变化的进度,返回一个从起始值变到结束值的结果。正是这种职责单一的设计,让 Tween 可以被任何 Animation 复用,也能自由组合多个 Tween 作用于同一个控制器。
2.2 AnimationController:真正给整个动画当“节拍器”的角色
AnimationController 是 Tween 的上游数据源。它继承自 Animation ,核心职责是管理动画时长、进度、状态(正向、反向、重复)以及每一帧的 tick。每到一个时间点,Controller 把自己的 value(0.0 ~ 1.0)更新一次,Tween 在监听这个值变化之后,再把对应的插值算出来,最终驱动组件重绘。
之所以特别强调 Controller,是因为在目标平台这种新集成环境里,Controller 能够顺利启动,靠的还是引擎层从系统拿到的帧信号。如果平台侧帧信号不稳定,Controller 的触发就会出现跳帧或延迟。常见的优化方案是在项目初始化时给绑定平台层配置好指定的刷新率,并关闭系统层面的某些省电限制
写代码时我一般会把 AnimationController 封装在业务逻辑之外的单例或者 mixin 里,这样多次切换页面时不会反复销毁重建。Controller 需要手动在 dispose 里释放,很多人经常忘,导致内存泄漏。用的时候也建议明确 vsync 参数,一般传入带 SingleTickerProviderStateMixin 的 State 对象。
2.3 常见的 Tween 类型与数据类型转换背后的数学逻辑
Flutter 提供了非常多的内置 Tween 子类,除了最常用的 DoubleTween 之外,还有:
- IntTween:处理整数值的平滑过渡,会做整数取整(四舍五入)。
- ColorTween:处理颜色值之间的渐变,底层将颜色拆成 ARGB 分量,然后逐一分量插值。
- SizeTween:处理尺寸变化,内部是 Size 的宽度和高度分别做线性插值。
- OffsetTween:处理位置偏移。
- RectTween:处理矩形区域变化,常用于裁剪或 Tooltip 位置变化。
- AlignmentTween:处理对齐方式渐变。
这里有个值得展开的点:ColorTween 的颜色插值并不是统一的 RGB 线性插值,如果你希望颜色过渡得均匀、没有中间发灰的现象,通常需要自定义一个 Tween,把颜色从 RGB 转到 HSL,再在 HSL 空间内做插值。我第一次自己写色彩渐变效果时就踩过这种坑,直接用的系统默认 ColorTween 在两种明暗差异很大的颜色间切换时,中间帧会出现明显发灰,视觉效果很差,后来改成 HSL 变换效果就柔和很多了。
Tween 的 transform 方法也是很值得深挖的点。它可以在传入数值的前提下提供自定义变换逻辑,比如二次缓动、三次缓动甚至弹性效果。这个方法可以链式调用,几个 Tween 组合成一条流水线,灵活度非常高。
3. 实战:在 Flutter 跨平台项目里从零搭出一套 Tween 动画
3.1 搭建页面骨架:确定要实现的动画目标
我先说一下这次要做的具体效果:卡片列表刷新时的入场动画,包括卡片从小尺寸放大到正常尺寸、透明度从 0 变成 1、卡片向上轻微平移 40 像素,同时右下角的删除按钮要做 360 度旋转出现。这套效果如果拆成动画维度其实就是:
- 尺寸:1 倍到 0.8 倍变化
- 透明度:0 到 1
- 位置:Y 轴偏移 40 到 0
- 旋转:0 度到 360 度
我选择用单个 AnimationController 控制一个总进度,然后用 4 个 Tween 把它映射成不同维度的值。这样整个动画只对应一个数据源,页面状态管理起来更轻松,不像多个 Controller 并发控制那样容易节奏错乱。
初始化 Controller 时用 800 ms 时长,初始化 Curve 用 Curves.easeOutBack,会让卡片出场时带一点回弹效果,显得更有“弹性”。在鸿蒙端实测这个回弹效果并没有出现性能问题,渲染平滑。
class _CardPageState extends State<CardPage> with SingleTickerProviderStateMixin { late AnimationController _controller; late Animation<double> _scale; late Animation<double> _opacity; late Animation<Offset> _position; late Animation<double> _rotation; @override void initState() { super.initState(); _controller = AnimationController( duration: const Duration(milliseconds: 800), vsync: this, ); final CurvedAnimation curved = CurvedAnimation( parent: _controller, curve: Curves.easeOutBack, ); _scale = Tween<double>(begin: 0.8, end: 1.0).animate(curved); _opacity = Tween<double>(begin: 0.0, end: 1.0).animate(curved); _position = Tween<Offset>( begin: const Offset(0, 0.1), end: Offset.zero, ).animate(curved); _rotation = Tween<double>( begin: 0.0, end: 2 * 3.14159265359, ).animate(curved); _controller.forward(); } @override void dispose() { _controller.dispose(); super.dispose(); } }注意上面的 Offset 用的是视口相对单位的减法,不是固定像素直移。Flutter 的 Transform.translate 接收的是 Offset,但为了视觉效果更统一,我这里的 Offset 用的是比例坐标,数值 0.1 代表视口宽高的 10%。实际编码时根据需求选择使用 Transform.translate 还是 SliverPadding。
3.2 用 AnimatedBuilder 优雅地把动画数值绑定到组件树
拿到了动画值之后,下一步就是驱动 UI 渲染。这里有两个选择:第一是直接监听动画状态,然后 setState 更新,但如果监听的组件层级比较深,一旦动画更新频繁,整个页面都会跟着 rebuild,在目标平台上性能会明显下降。第二是使用 AnimatedBuilder,把需要重绘的组件限制在一个局部子树里,动画数值变化时只重建这个 Builder 节点内部的内容。
我更推荐后者,AnimatedBuilder 本身不直接创建任何渲染对象,它只是在那里搭了一个桥,帮助你把动画值的变化“广播”给真正需要的 widget。以下是我在卡片组件里写的一段:
@override Widget build(BuildContext context) { return AnimatedBuilder( animation: _controller, builder: (context, child) { return Transform.rotate( angle: _rotation.value, child: Transform.scale( scale: _scale.value, child: Opacity( opacity: _opacity.value, child: Transform.translate( offset: _position.value, child: child, ), ), ), ); }, child: _buildCardContent(context), ); }关键点来了:这里我把 _buildCardContent 作为 child 参数传给了 AnimatedBuilder,这个 child 在动画过程中是永远不会重复 build 的,它会被当成一块“静止的底座”。动画变化时只有外面的 Transform 和 Opacity 在改变,内部卡片内容并不会重新构建。
这个优化在 Android 上感受不明显,但在目标平台上差异巨大。目标平台的 Flutter 引擎网格重绘开销比成熟平台高,如果每次动画都重建卡片内容,页面复杂度一旦上来,丢帧概率直接翻倍。把 child 参数用起来之后,动画帧率稳定值上去了大约 30%。
3.3 Tween 组合与私有 CustomTween 的进阶用法
单控制器多 Tween 的组合已经能解决 80% 的业务动画场景,但有些特殊效果还是需要自己写 Tween。比如我之前提到的 HSL 颜色渐变,还有带弹性的路径位移动画,都需要在 Tween 子类里重写 lerp 方法。
自定义 Tween 的典型结构是这样:
class HSLColorTween extends Tween<Color?> { HSLColorTween({required Color? begin, required Color? end}) : super(begin: begin, end: end); @override Color? lerp(double t) { final HSLColor beginHsl = HSLColor.fromColor(begin!); final HSLColor endHsl = HSLColor.fromColor(end!); final HSLColor result = HSLColor.lerp(beginHsl, endHsl, t); return result?.toColor(); } }这个类乍看没什么稀罕,但用的时候能解决大问题。普通 ColorTween 在插值计算时底层直接对两个颜色做线性混合,结果并不符合人眼对颜色过渡的感知。而上面这种通过 HSL 空间插值的方式,会让过渡更自然,色调连续性好,尤其适合深色模式切换、主题皮肤渐入渐出这类场景。
再讲一个我已经踩过两次坑的细节:自定义 Tween 的 lerp 里,t 的取值范围一定要处理好。AnimationController 的 value 未必一定等于输入进度,如果使用 CurvedAnimation 作为 parent,t 会被曲线函数重新映射过一遍。如果你在自定义 lerp 里直接假设 t 是线性的,结果就会跟你预期的曲线不一致,动画整体显得“刚弹一下就马上结束”。我习惯在 lerp 里只信任 Transform 的下游逻辑,不对 t 做过度的物理假设。
4. 真机实战:在目标平台(HarmonyOS 兼容层)上调试动画的姿势
4.1 渲染链路与 Vsync 信号:为什么你在模拟器上很流畅真机上却卡顿
先说一个比较玄学的现象:同一套 Tween 动画代码,在开发机模拟器上跑得丝滑,放到目标平台真机上却明显出现抖动,尤其是卡片快速向上滑动时。这不是动画代码复杂度的问题,而是目标平台 Flutter 引擎还没有把 Vsync 信号与系统刷新率调度完全对齐导致的。
在成熟平台上,Flutter 引擎会通过系统回调逐帧驱动 AnimationController,动画严格跟随屏幕刷新率。但在目标平台早期集成阶段,引擎只能拿到延迟更高的合成信号,中间一旦发生任务调度、内存锁竞争,动画就会停顿一下又恢复。这个问题我排查了很久,最终发现除了引擎配置之外,开发侧有几个习惯能有效缓解:
- 避免在动画运行期间通过 MethodChannel 频繁调用原生通信,尤其是 channel 的回调如果涉及同步读取磁盘,会直接卡住 UI 线程。
- 动画组件涉及的图片资源尽量用预加载,避免动画中间触发异步解码。
- 关掉 Debug 模式下的“Debug Banner”和可访问性开关,这两者在目标平台上会额外增加 UI 实时计算链路。
还有个特殊点:目标平台在某些设备上默认启用了低刷新率省电模式。如果你的动画运行在列表滚动中,而系统为了省电临时降刷新率,动画的流畅度就会被直接腰斩。我当时的处理是在动画触发前,主动请求高频刷新模式,动画结束后再恢复原始状态,这算是一种运营侧优化,不属于 Flutter 本身的功能。
4.2 用 DevTools 检查动画的耗时与病根
做动画性能调优,光靠肉眼看不行,必须量化数据。Flutter 自带的 DevTools 性能面板能看出每一帧构建、布局、绘制、合成的耗时花费。我每次调动画都会看下面四个指标:
| 指标 | 数值范围 | 说明 |
|---|---|---|
| UI Thread Frame Time | < 16 ms | 动画单帧 UI 线程计算总耗时 |
| Raster Thread Frame Time | < 16 ms | GPU 光栅化与绘制耗时 |
| Total Frame Time | < 50 ms | 整帧耗时,超过会出现掉帧 |
| Shader Compilation count | 尽量为 0 | 但凡有新的 shader 编译必然卡一下 |
在目标平台上我发现第一项 UI Thread Frame Time 偶尔会跳到 30 ms 以上,原因出在动画触发时伴随了复杂布局变化,导致卡片内部多处文本重排。后来我把卡片内文字的 Fixed 宽度限制住,每张卡片内部不再根据动画刻度动态调整文本尺寸,然后把这个文本布局放在 AnimatedBuilder 之外,UI 线程耗时立刻降到 11 ms 以下。
另外一个 DevTools 里很好用的功能是“Widget rebuild count”统计。它能看出某个页面在动画期间被整体 rebuild 了多少次。正常情况下应该只有最外层的 Transform 系列组件 rebuild,如果发现整个页面包括导航栏、列表头都被 rebuild,那你八成在动画里用了 setState 而非 AnimatedBuilder。这个案例我见得太多了。
4.3 动画参数的最优选择:从曲线到时长校准
动画时长和曲线选择这事看似只是审美问题,其实还隐含性能取舍。像 Curves.easeOutBack 这种带回弹的曲线,在动画接近末尾时会有一个“越过终值再折返”的阶段。视觉效果有趣,但如果你同时驱动了尺寸和位移,在回弹阶段组件会有短暂的缩放和位移叠加,这会在目标平台某些设备上触发额外的绘制工单,如果操作频繁,GPU 负载会明显增加。
我的建议是:
- 入场动效时长控制在 500 ms 到 900 ms 之间,太短让人觉得僵硬,太长显得拖沓。
- 列表滚动时的悬浮按钮动画,一般用 200 ms 左右的快速缓动。
- 提示气泡、刷新图标这类循环动画,用 300 ms 加 Curves.linear 即可,不用刻意加弹性。
- 如果动画曲线里含有超过一次极值变换(比如 elasticOut),尽量只在单个维度使用,别在 4 个维度同时套用,不然那种“水中倒影”式的摆动会让用户觉得晕。
曲线选择背后其实也是数学复杂度的问题。Flutter 的 Curve 系统在计算基础曲线时有固定的闭式解,开销非常低;但自定义的贝塞尔曲线如果求值逻辑写得很复杂,动画每帧都会额外执行数学运算。我在项目里规定:团队自定义 Curve 里禁止用三角函数或者高阶多项式超过一次,真要复杂的轨迹就改用片段式 Tween 组合。
5. 动画不生效、闪烁、卡顿:问题排查与避坑实录
5.1 动画完全没触发,最容易被忽略的三道“暗门”
实际开发里,动画不触发是最让人抓狂的问题,尤其是没有任何报错、也没有闪一下的情况下。我统计了一下自己踩过的和身边朋友踩过的三种典型:
第一,Controller 的 forward() 在页面还没挂载时就被调用了。这种情况常见做法:在 initState 里直接 _controller.forward()。但是,如果你的页面外层包了路由过渡动画,路由本身的动画可能还没结束,页面里的 Controller 会因 vsync 不匹配而不触发。解决方案就是监听路由动画完成回调,延迟 100 ms 再 forward,或者改用 addStatusListener 判断外部动画状态。
第二,Tween 的 begin 和 end 被设置成了同一个值。这听起来不可能,但那行代码如果写成 Tween (begin: 0, end: 0.0),编译器不会报错,动画也不会跑。我在项目里甚至见过把 begin 和 end 都从同一个常量取出来导致实际一致的案例。排查时先确认这两个值确实不同。
第三,Tween 没有调用 animate 方法将其绑定到 Animation 上。很多初学者创建完 Tween 后直接就把值赋给 widget,比如 opacity: _tween.value,这会直接导致“静态值永远等于 begin”的既视感。正确写法是先 _tween.animate(_controller),再用这个 Animation 的 value 去驱动组件。
5.2 动画中间出现明显闪烁或错位,原因往往在布局层面
闪烁这个问题我很早就遇到过,当时觉得一定是 Tween 的数值算错了,后来好一番排查才发现问题其实出在 widget 的堆叠顺序上。当你用 Transform.translate 位移一个元素时,如果这个元素原来的位置是通过 Container 的 padding 或者 margin 固定的,而你又额外在外部包了一层 Transform,那么动画动画结束后,元素会同时受到两个位置体系约束,表现为或跳变或闪烁的错位。
我的应对方案是:所有需要移动的组件,都尽量让它的“静止位置”保持在上层布局的某个统一锚点,位移全部交给 Transform.translate 来做。不要在动画期间既改 Container 的 margin,又去改 Transform 的 offset。
还有一种闪烁情况:Opacity 在接近 0 的时候,因为渲染管线对不可见对象的剔除,会导致组件在完全消失前视觉上“抖”一下。处理方式是给 Opacity 一个最小值,或者改用 Visibility 的 maintainState 参数。
5.3 并发动画调度混乱:多个 Controller 互相打架怎么办
项目里遇到过一个更复杂的场景:用户快速滑动卡片列表,每次滑动会触发一张新卡片的入场动画,但旧卡片的动画还没结束。多个 AnimationController 同时在跑,结果导致卡片的 Transform 矩阵翻来覆去地叠加,最终视觉效果就是整张卡片像“鬼畜”一样颤抖。
解决方案有两条路。简单粗暴的路线是:每次新动画开始前,把之前的 Controller 先 stop 再 reset。但这种中断动画的方式体验很差,卡片会瞬间跳回初始尺寸再重新入场。优雅的路线是:用 TweenSequence 把同一个 Controller 的进度拆分成分段区间,每个区间的动画互不重叠,旧动画在当前 Controller 的某个进度段内完成,新动画在下一个进度段内启动。这样从用户视角看,动画是连贯的,没有跳变。
TweenSequence 的代码样式是这样的:
final TweenSequence<Offset> sequence = TweenSequence<Offset>([ TweenSequenceItem( tween: Tween<Offset>(begin: Offset(0, 0.1), end: Offset.zero), weight: 40, ), TweenSequenceItem( tween: Tween<Offset>(begin: Offset.zero, end: Offset(0, -0.05)), weight: 20, ), TweenSequenceItem( tween: Tween<Offset>(begin: Offset(0, -0.05), end: Offset.zero), weight: 40, ), ]);这种方式的核心控制权还是落在同一个 Controller 手里,不会出现两个独立 Controller 为了抢屏幕互不相让的问题。实际跑起来,即使在目标平台上连续快速触发多次,动画依然能保持顺序执行,没有错乱。我后来把这个序列封装成可复用的卡片入场动画类,组件里只需要一行代码就能支持连续手势操作。
6. 从动画到组件化:把 Tween 动画封装成可复用能力
6.1 封装一个通用的 “AnimationSwitcher” 组件
业务里最浪费时间的不是写动画本身,而是每次新页面都要把 Controller、Tween、AnimatedBuilder 这套模板代码重写一遍。我后来在项目里抽了一个通用组件叫“动画换场容器”,它接收一个 child、一个进场动画配置、一个离场动画配置,内部统一管理 Controller 生命周期。
组件架构大致是这样:
- 对外暴露 beginAnimation() 和 reverseAnimation() 两个方法。
- 内部维护一个 AnimationController 和一组 Tween 列表。
- 动画触发时按配置好的 duration、curve、tween 顺序执行。
- 支持 onComplete 回调,方便业务层在动画结束后联动数据加载。
这个组件的价值在于:一次写好,后面所有页面都直接复用,团队里其他人不用再纠结怎么组合 Tween,只要把动画参数扔进来就行。而且我在内部还做了状态保护,连续点击同一个按钮时,控制器会自动反向回放而不是再次从头播放,这种细小的交互差异很影响专业感。
6.2 动画与数据状态解耦:让动画只关心视觉呈现
动画跟业务状态过度耦合是很多项目后期的灾难。我在这个项目里定了一条纪律:Tween 动画代码里不允许直接读取任何业务数据结构。动画只接收“进度值”作为输入,至于这个进度值是由用户手势、网络请求还是定时器驱动,都是上层业务的事。
当时有一个闹心的现象:卡片数据在动画执行期间被刷新,导致新数据改变了卡片的某些布局尺寸,而动画层还停留在旧尺寸的插值里,视觉上出现短暂的空白或错位。后来我在动画组件里加了一个参数叫 “使用最新数据” 的开关,当开关打开时,动画组件会在每次新进度到来时,用最新的外部数据刷新 child 的布局。这样图表和动效各自负责各自的部分,不再互相干扰。
对于依赖动画做页面跳转配合的场景,我建议把 Tween 动画的触发逻辑单独放在一个 Controller 里,不让路由和动画共用同一个 widget 生命周期。我在项目里就是写了一个轻量级的动画代理类,页面继承它后,initState 里只需要注册动画目标,dispose 里自动释放,哪怕页面切换到一半时被回收也不会造成资源泄漏。
7. 最后再分享两个小经验
Tween 补间动画这套机制,理解起来不难,真正难的是在真实平台上把它用得顺滑、优雅。我在目标平台设备上调试了一个月之后,最想对刚接触 Flutter 跨平台开发的人说的建议是:永远不要一次性把 4 个以上维度的动画组合在同一个 Controller 上跑,哪怕 Tween 线性组合的数学开销很低,页面的重建次数和 GPU 合成压力会随维度指数上升。先优化到让画面肉眼可见地流畅,再考虑加更多动态效果。
还有一个小技巧:在开发阶段把 Flutter 自带的 Debug 模式下的 “慢动画” 选项打开,可以把动画速度降到十分之一,方便一点点观察每一帧的中间状态。如果你发现某段动画在慢放时出现数值跳变,那大概率是 begin/end 类型不匹配或者 Curve 曲线有尖点,及时修正能避免上线后被用户截图吐槽。我自己就靠这个功能提前发现了一个 ColorTween 颜色走位问题,省了一个工时的返工成本。