29 · 动画进阶:Rive / Lottie / 复杂动效与设计稿还原
先看定位,避免和 05 篇 混淆:
篇 讲什么 心智 05 篇 隐式动画、显式动画( AnimationController/Tween/Curve)、Hero、动画 dispose动画怎么写 28 篇 CustomPainter自绘自己画 本篇 Rive/Lottie 选型、复杂动效编排、页面转场、设计稿还原、动效性能 动效怎么"交付"和"落地" ⚠️ 易变信息说明:Rive / Lottie 相关包的版本号、API 细节变化频繁, 按本库约定(正文写原理与判断框架)不写死版本,请以 pub.dev 为准。 本篇讲选型逻辑、集成模式、性能陷阱——这些是稳定的。
📌 代码说明:HaierEnergy 当前未引入 Rive/Lottie,本篇以通用可运行示例为主, 但涉及路由转场的部分会结合项目真实技术栈 GoRouter 14(见 13 篇)。
开篇:前端概念对照
| 前端 | Flutter | 说明 |
|---|---|---|
Lottie(lottie-web) | lottie 包 | 同一套 AE 导出的 JSON,双端通用 |
Rive(rive-js / @rive-app/canvas) | rive 包 | 有状态机,可交互 |
CSS @keyframes + animation-delay | Interval + CurvedAnimation | 分时段、分曲线 |
transition: all .3s | 隐式动画(AnimatedContainer) | 见 05 篇 |
| Web Animations API / GSAP Timeline | TweenSequence / 多个 Animation 编排 | 时间轴 |
Vue <Transition> / React Router 转场 | PageRouteBuilder / GoRouter 自定义转场 | 页面切换动画 |
Framer Motion 的 staggerChildren | Interval 逐个延迟 | 交错动画 |
will-change: transform | Transform + RepaintBoundary | 提前提示合成层 |
核心认知差(前端最容易误解的一点):
前端动画多由 CSS/浏览器合成器驱动(脱离主线程); Flutter 动画是 Dart 代码每帧回调驱动(AnimationController 在 vsync 回调里更新 value), 所以动画帧里做了重活,就会掉帧。这是本篇性能章节的重点。
一、需求分层:先决定要不要上重型方案
和设计/产品对需求时,先分层,避免"为了一个小加载动画引入整个 Rive 运行时":
| 层级 | 典型需求 | 方案 | 成本 |
|---|---|---|---|
| L1 反馈 | 按钮按压、hover、切换、淡入淡出 | 隐式动画 / 显式动画(05 篇) | 极低,别过度设计 |
| L2 编排 | 列表逐项入场、多个元素协同 | Interval + AnimatedBuilder | 低,纯代码 |
| L3 复杂图形 | 图表生长、波浪、数据可视化 | CustomPainter(28 篇)+ 动画驱动 | 中,要写代码 |
| L4 设计交付 | 设计师给的成套动效(AE/Figma 导出) | Lottie / Rive | 高,要引入运行时 |
| L5 可交互动效 | 角色跟随手势、状态机驱动的吉祥物 | Rive(State Machine) | 最高,需设计师配合 |
💡 选型口诀:能用 L1~L3 解决的,不要上 L4/L5。 引入 Rive/Lottie = 引入一个运行时 + 一个二进制资源 + 一套协作流程。
二、Lottie vs Rive:怎么选(重点)
2.1 一句话定位
| Lottie | Rive | |
|---|---|---|
| 本质 | 播放 AE 导出的动画 | 运行一个带状态机的交互式动画 |
| 源头 | After Effects → bodymovin 插件 → JSON | Rive 编辑器(.riv 二进制) |
| 交互性 | ❌ 只能播放/倒放/控制进度 | ✅ State Machine,可响应输入、切换状态 |
| 文件 | JSON(可读、体积偏大) | .riv(二进制、体积小) |
| 运行时 | 需 JSON 解析 | 有自己的渲染引擎,性能通常更好 |
| 生态 | 极广(Web/iOS/Android/Flutter 通用一份 JSON) | 较新,但增长快 |
| 设计师门槛 | 会 AE 就行(设计师最熟) | 要学 Rive 编辑器 |
2.2 决策树
需求是什么?
│
├─ 只是"播一段动效"(加载、空状态、成功/失败、运营插画)
│ └─ 设计师只会 AE? ── 是 ──→ Lottie ✅
│ └─ 否(愿意用 Rive)──→ Rive(体积更小、性能更好)
│
├─ 需要"和用户的操作互动"(跟随手势、状态切换、游戏化)
│ └─→ Rive ✅(State Machine,Lottie 做不到)
│
├─ 需要多端共用同一份动效资源(Web + App)
│ └─→ Lottie ✅(JSON 通用性最好)
│
└─ 只是简单的图标动效
└─→ 别引入,用 L1/L2 代码写(AnimatedIcon / CustomPainter)2.3 对比表
| 维度 | Lottie | Rive |
|---|---|---|
| 可交互 | ❌ | ✅ State Machine |
| 体积 | 中(JSON 可能几百 KB) | 小(二进制) |
| 性能 | 中(JSON 解析 + 重建) | ✅ 较好(原生渲染) |
| 设计师上手 | ✅ 快(AE 导出) | 中(需学新工具) |
| 多端复用 | ✅ 最好 | 中 |
| 动态改颜色/文本 | 有限 | ✅ 支持运行时改属性 |
| 调试 | 靠 JSON 肉眼看 | ✅ Rive 编辑器可视化 |
| 离线/本地 | ✅ | ✅ |
2.4 Lottie 集成要点
Lottie.asset(
'assets/animations/loading.json',
width: 200,
height: 200,
fit: BoxFit.contain,
repeat: true, // 循环
animate: true, // 暂停/恢复(列表滚动时关掉可省性能)
controller: _controller, // 可选:精确控制进度 0~1
)关键要点:
| 点 | 说明 |
|---|---|
| 资源声明 | assets/animations/ 要在 pubspec.yaml 的 flutter: assets: 下声明 |
| 控制进度 | 传 controller(AnimationController),用 .value = 0.5 精确定位 |
| 性能开关 | 在 ListView 里大量 Lottie 时,用 animate: false 暂停不可见的 |
| 包体积 | 复杂 JSON 可能几百 KB,注意评估;用 flutter build apk --analyze-size 检查 |
| 降级 | 加载失败要有静态兜底图,不能白屏 |
2.5 Rive 集成要点(State Machine)
RiveAnimation.asset(
'assets/animations/character.riv',
stateMachines: const ['State Machine 1'], // ① 必须指定状态机名
onInit: (artboard) {
// ② 拿到状态机控制器,之后可读写输入
final controller = StateMachineController.fromArtboard(artboard, 'State Machine 1');
artboard.addController(controller!);
_numInput = controller.findInput<double>('numValue'); // 数值输入
_trigger = controller.findSMITrigger('triggerName'); // 触发器
},
)
// ③ 运行时改变状态 → 动效响应
_numInput?.value = 75;
_trigger?.fire();三个必须知道的点:
- 状态机名和输入名必须和 Rive 编辑器里完全一致(字符串硬编码,改了就失效 → 建议集中放常量)
findInput<T>()的泛型要和编辑器里定义的类型一致(double/bool/ 触发器用findSMITrigger)- 用
.riv二进制,声明方式和 assets 一样
三、复杂动效编排(L2:纯代码,最常用)
这一节不需要任何第三方包,是日常最实用的部分。
3.1 Interval:把一个动画切成多段
前端类比:CSS 的 animation-delay + 分时段 keyframes。
// 在 0~0.4 时间段内完成淡入,之后保持
final fade = Tween<double>(begin: 0, end: 1).animate(
CurvedAnimation(
parent: controller,
curve: const Interval(0.0, 0.4, curve: Curves.easeOut),
),
);
// 在 0.2~0.8 时间段内完成位移(与淡入有重叠 → 更自然)
final slide = Tween<Offset>(begin: const Offset(0, 0.3), end: Offset.zero).animate(
CurvedAnimation(
parent: controller,
curve: const Interval(0.2, 0.8, curve: Curves.easeOutCubic),
),
);⚠️
Interval的begin/end是相对于父动画的 0~1,不是秒。
3.2 交错动画(Staggered):列表逐项入场
前端类比:Framer Motion 的 staggerChildren。
// 第 i 项:延迟 i * 0.1,各自占 0.4 的时长
Widget buildItem(int i, Widget child) {
final start = (i * 0.1).clamp(0.0, 1.0);
final end = (start + 0.4).clamp(0.0, 1.0);
return AnimatedBuilder(
animation: controller,
builder: (context, _) {
final t = CurvedAnimation(
parent: controller,
curve: Interval(start, end, curve: Curves.easeOut),
).value;
return Opacity(
opacity: t,
child: Transform.translate(
offset: Offset(0, 20 * (1 - t)), // 从下往上 20px
child: child,
),
);
},
);
}要点:
| 坑 | 解法 |
|---|---|
列表很长,全部创建 Interval 浪费 | 只给可见的前 N 项做交错,其余直接显示 |
clamp 忘了写 | end 可能 > 1 → Interval 抛断言错误 |
| 滚动时重复播放 | 用 ScrollController 判断首次进入视口才播一次(或用 visibility_detector 类方案) |
3.3 TweenSequence:多段连续变化
前端类比:CSS keyframes 的多个百分比节点。
final anim = TweenSequence<double>([
TweenSequenceItem(tween: Tween(begin: 0.0, end: 1.0), weight: 1), // 0→1
TweenSequenceItem(tween: Tween(begin: 1.0, end: 0.5), weight: 1), // 1→0.5(回弹感)
TweenSequenceItem(tween: Tween(begin: 0.5, end: 1.0), weight: 1), // 0.5→1
]).animate(CurvedAnimation(parent: controller, curve: Curves.easeInOut));3.4 曲线选择(设计感的关键)
| 场景 | 推荐曲线 | 感觉 |
|---|---|---|
| 元素进入屏幕 | Curves.easeOut / easeOutCubic | 快进慢出,自然 |
| 元素离开屏幕 | Curves.easeIn | 慢起快走 |
| 元素在屏幕内移动 | Curves.easeInOut | 两端都缓 |
| 想要回弹 | Curves.easeOutBack / bounceOut | 弹性、活泼 |
| 机械、精确 | Curves.linear | 匀速(进度条、加载) |
| 模拟物理 | Curves.decelerate | 减速 |
💡 经验法则:进入用
easeOut,离开用easeIn。 这是 Material Design 动效规范的基石,能解决 80% 的"感觉不对"。
四、页面转场(结合项目 GoRouter 14)
4.1 基础:PageRouteBuilder
Navigator.of(context).push(
PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) => const DetailPage(),
transitionDuration: const Duration(milliseconds: 300),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return SlideTransition(
position: Tween<Offset>(
begin: const Offset(1, 0), // 从右侧进入
end: Offset.zero,
).animate(CurvedAnimation(parent: animation, curve: Curves.easeOut)),
child: child,
);
},
),
);4.2 在项目(GoRouter 14)里统一配置
本项目用 GoRouter 14(见 13 篇),用 CustomTransitionPage 统一封装,避免每个路由重复写:
// 封装一个通用转场
CustomTransitionPage<T> slideTransition<T>({
required Widget child,
required LocalKey key,
}) {
return CustomTransitionPage<T>(
key: key,
child: child,
transitionDuration: const Duration(milliseconds: 300),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return SlideTransition(
position: Tween<Offset>(
begin: const Offset(1, 0),
end: Offset.zero,
).animate(CurvedAnimation(parent: animation, curve: Curves.easeOut)),
child: child,
);
},
);
}
// 路由里用
GoRoute(
path: '/device/:id',
pageBuilder: (context, state) => slideTransition(
key: state.pageKey, // ← 必须用 state.pageKey
child: const DeviceDetailPage(),
),
)两个必须注意的点:
| 坑 | 说明 |
|---|---|
key: state.pageKey | 必须传。不传会导致路由状态异常、转场错乱 |
CustomTransitionPage vs MaterialPage | 要自定义转场就用 CustomTransitionPage;不需要就用默认的 |
4.3 常见转场类型
| 类型 | 实现 | 适用 |
|---|---|---|
| 滑动(iOS 风格) | SlideTransition + Offset(1,0) | 详情页、层级推进 |
| 淡入(Android/ Material) | FadeTransition | 模态、弹窗 |
| 缩放 | ScaleTransition | 图片查看器 |
| 组合(滑 + 淡) | 嵌套两个 Transition | 主流 App 的做法 |
| 共享元素 | Hero(见 05 篇) | 图片/卡片跳详情 |
4.4 返回手势冲突(真机必测)
自定义转场后,iOS 侧滑返回可能与转场冲突。检查:
- iOS 用
CupertinoPage/右滑手势时,SlideTransition方向要和手势一致 - Android 物理返回键走的是
Navigator.pop,转场应反向播放 - 真机测试:模拟器和真机的返回手势行为有差异
五、设计稿还原:从 Figma 到代码
5.1 设计师该给你什么(协作清单)
动效要能落地,需求阶段就要对齐,不然后期反复:
| 必须确认 | 说明 |
|---|---|
| 时长 | 多少 ms?(Material 建议:小动效 150~200ms,页面转场 300~400ms) |
| 曲线 | easeOut / easeIn / easeInOut / 自定义贝塞尔 |
| 触发时机 | 点击时?进入页面时?数据加载完? |
| 是否循环 | 加载类通常循环,反馈类播放一次 |
| 降级方案 | 低端机/无障碍模式下怎么办? |
| 资源格式 | AE 导出 JSON(Lottie)还是 .riv(Rive)? |
💡 Figma 的坑:Figma 只能表达"帧",不能导出动效参数。 设计师在 Figma 里画的动效演示(Prototype),只是示意,时长和曲线仍需口头/文档确认。 真要精确交付,需要 AE(Lottie)或 Rive 编辑器。
5.2 还原度自检
| 检查项 | 怎么验 |
|---|---|
| 时长一致 | 录屏逐帧比对,或用 DevTools 慢放 |
| 曲线一致 | 观察"启动/收尾"是否一样快(easeOut 收尾慢) |
| 尺寸一致 | 改动画不影响布局(用 Transform 而非改 width/height) |
| 不抖动 | 动画中文字不模糊(见 5.3) |
5.3 ⚠️ 动画中的文字模糊(高频坑)
对文字做 Scale 动画时,Flutter 默认会重新光栅化,导致文字发虚。
| 方案 | 说明 |
|---|---|
用 Transform.scale 而非改 fontSize | ✅ 走合成层,不重新布局 |
| 避免对含文字的 Widget 做非整数倍缩放 | 半像素会导致模糊 |
必要时包 RepaintBoundary | 隔离重绘 |
// ✅ 好:Transform 走合成,不触发 layout
Transform.scale(scale: 1.1, child: const Text('标题'))
// ❌ 差:改 fontSize 会触发布局重算,卡且可能抖
Text('标题', style: TextStyle(fontSize: 16 * scale))六、动效性能(必读)
6.1 Flutter 动画的真实开销来源
| 开销 | 原因 | 优化 |
|---|---|---|
| build 重建范围过大 | 每帧 setState 重建整棵子树 | 用 AnimatedBuilder 只包动画部分 |
| layout 重算 | 动画改了影响布局的属性 | 用 Transform / Opacity(走合成层) |
| paint 过重 | 动画里 CustomPainter 画复杂路径 | 缓存 Path/Paint,降帧 |
| shader 编译卡顿 | 首次用到某动画时编译着色器 | 预热(见下) |
6.2 三条硬规则
① 用 AnimatedBuilder 的 child 参数(05 篇提过,这里强调):
AnimatedBuilder(
animation: controller,
child: const ExpensiveWidget(), // ← 不随动画重建,只构建一次
builder: (context, child) => Opacity(opacity: controller.value, child: child),
)② 优先"合成层友好"的属性:
| ✅ 便宜(走合成层,GPU 处理) | ❌ 贵(触发 layout/paint) |
|---|---|
Transform (translate/scale/rotate) | 改 width / height |
Opacity | 改 fontSize |
ClipRect(简单情况) | 改 padding / margin |
| — | 复杂 ClipPath |
| — | 大面积阴影(BoxShadow)动画 |
口诀:动 Transform,不动布局属性。
③ 动画期间别做重活:
// ❌ 每帧都做 JSON 解析 / 排序 / 网络请求
controller.addListener(() {
final data = jsonDecode(hugeJson); // 每帧执行 → 必掉帧
setState(() {});
});
// ✅ 提前算好,动画只读结果
final data = jsonDecode(hugeJson); // 只算一次
controller.addListener(() => setState(() {}));6.3 Shader 编译卡顿(Jank)
现象:第一次播放 Rive/Lottie 或某个复杂动画时明显卡一下,之后就流畅了。
原因:Flutter 的 Skia/Impeller 需要为该动画的着色器(shader)做运行时编译,首次编译耗时可能达几十~几百 ms。
缓解:
| 方案 | 说明 |
|---|---|
| 预热(Warm-up) | 在启动时(Splash 页)离屏播放一次所有复杂动画,让 shader 提前编译 |
用 --enable-impeller | 新渲染器(Impeller)在设计上消除了运行时 shader 编译卡顿 |
| 减少不同 shader 的数量 | 相似的动效复用同一套参数 |
// 预热示意:在 Splash 阶段离屏播放一次
// (用 Offstage 或 1px 大小 + 透明,播放完移除)
Offstage(
offstage: true,
child: Lottie.asset('assets/animations/complex.json', animate: true),
)6.4 性能验收
| 工具 | 看什么 |
|---|---|
| DevTools · Performance | 每帧 build/paint 耗时是否超 16.67ms(60fps 预算) |
| Performance Overlay(彩虹条) | 出现红色/超出上边界 = 掉帧 |
flutter run --profile | ⚠️ 必须用 profile 模式测,debug 模式本身就很慢,数据不准 |
| 真机低端机 | 用最差的设备测,别只在旗舰机验 |
⚠️ 最容易犯的错:在 debug 模式下测性能并得出"很卡"的结论。 debug 模式有 JIT、断言、额外检查,性能数据完全不可信。
七、什么时候"不该"加动画
动效是手段不是目的,以下情况应主动减配:
| 情况 | 建议 |
|---|---|
用户开了无障碍/减弱动效(MediaQuery.disableAnimations) | 必须尊重,直接跳过动画 |
| 低端机 | 降级为时长更短或纯静态 |
| 动画阻塞了用户操作(点了没反应等动画播完) | 缩短时长,或允许打断 |
| 同一页面 >3 个循环动画 | 减到 1~2 个,视觉噪音大且耗电 |
| 动效没有传达信息(纯粹因为"好看") | 去掉,它只会拖慢感知速度 |
// 尊重系统的"减弱动效"设置
final disableAnimations = MediaQuery.of(context).disableAnimations;
Widget build(BuildContext context) {
if (disableAnimations) return const ContentWidget(); // 直接显示,无动画
return FadeTransition(opacity: animation, child: const ContentWidget());
}💡 判断标准:如果把这个动画去掉,用户完成任务是更快还是更慢? 更快 → 这个动画是负担,删掉。
八、动手练习
练习 A:交错入场列表(L2,无需第三方包)
用 3.2 的 Interval 写法,做一个 20 项的列表,进入页面时从上到下逐项淡入上移。 验证:
- 把
clamp去掉,看会不会抛断言错误(体会为什么必须 clamp) - 列表改成 100 项,观察性能变化,再改成"只给前 10 项做交错"
练习 B:GoRouter 自定义转场(结合项目)
把 4.2 的 slideTransition 加到项目一个真实路由上。 验证:
- 去掉
key: state.pageKey,观察路由行为是否异常 - 改成
FadeTransition,对比两种观感
练习 C:Lottie 集成与性能对比(L4)
引入 lottie 包,加载一个 loading.json。 验证:
- 在
ListView里放 20 个 Lottie,滚动观察掉帧 - 加上"不可见时
animate: false"的优化,再对比一次 - 用
--profile模式测,记录前后帧耗时
练习场:见专题 README 的「配套练习场」(
lib/main_playground.dart)。
九、自检清单
学完本文,你应该能够:
- [ ] 说出 L1~L5 五层需求分层,并判断一个动效需求该用哪层
- [ ] 说明 Lottie 与 Rive 的本质区别(播放 vs 状态机交互)
- [ ] 面对"要加个动效"的需求,能照着 2.2 决策树选 Lottie / Rive / 纯代码
- [ ] 用
Interval实现多段编排与交错动画(知道为什么要clamp) - [ ] 用
TweenSequence实现多段连续变化 - [ ] 说出"进入用
easeOut、离开用easeIn"这条经验法则 - [ ] 在 GoRouter 14 里用
CustomTransitionPage配置统一转场(知道state.pageKey必须传) - [ ] 列出向设计师索要动效参数时的协作清单(时长/曲线/时机…)
- [ ] 解释为什么对文字该用
Transform.scale而不是改fontSize - [ ] 说出"动 Transform,不动布局属性"这句口诀及原因
- [ ] 解释 shader 编译卡顿的现象、原因与缓解方法
- [ ] 知道必须用
--profile模式测性能(debug 数据不可信) - [ ] 会读取
MediaQuery.disableAnimations并尊重系统设置
十、常见误区
| 误区 | 正解 |
|---|---|
| "动效越炫越好" | ❌ 动效服务于信息传达和感知速度,纯装饰是负担 |
| "Rive 比 Lottie 好,就用 Rive" | ❌ 设计师只会 AE 时 Lottie 更现实;需要交互才必须 Rive |
"改 width/height 做动画" | ❌ 触发布局重算,卡。用 Transform |
"对文字改 fontSize 做缩放" | ❌ 会触发重布局且模糊。用 Transform.scale |
| "debug 模式看着卡就是卡" | ❌ debug 有 JIT 和断言,性能数据不可信,用 --profile |
| "动画里顺便处理下数据" | ❌ 每帧执行重活必掉帧,数据要提前算好 |
| "Lottie 文件不大,随便加" | ⚠️ 复杂 JSON 可达几百 KB,且每个实例都是运行时开销,要评估 |
| "所有页面都加转场" | ⚠️ 转场会增加感知延迟,高频切换的页面(如 Tab)不该有 |
| "慢放动画看效果用 debug 就行" | ⚠️ 用 DevTools 的 Slow Animations(--time-dilation),而不是靠 debug 卡顿 |
相关文档
- 05 · Flutter 交互与动画 —— 隐式/显式动画基础、
Hero、动画 dispose(本篇的前置) - 28 · 自定义渲染实战 ——
CustomPainter(L3 复杂图形,本篇波浪动画的基础) - 13 · 路由与导航 —— GoRouter 14 配置(本篇转场的落地点)
- 11 · 组件与主题 —— 动效如何纳入项目组件体系
- 22 · 性能分析与优化实战 —— 系统化的性能排查方法
- 17 · 常见陷阱与速查表 —— 高频坑速查