Skip to content

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-delayInterval + CurvedAnimation分时段、分曲线
transition: all .3s隐式动画(AnimatedContainer)见 05 篇
Web Animations API / GSAP TimelineTweenSequence / 多个 Animation 编排时间轴
Vue <Transition> / React Router 转场PageRouteBuilder / GoRouter 自定义转场页面切换动画
Framer Motion 的 staggerChildrenInterval 逐个延迟交错动画
will-change: transformTransform + 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 一句话定位 ​

LottieRive
本质播放 AE 导出的动画运行一个带状态机的交互式动画
源头After Effects → bodymovin 插件 → JSONRive 编辑器(.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 对比表 ​

维度LottieRive
可交互❌✅ State Machine
体积中(JSON 可能几百 KB)小(二进制)
性能中(JSON 解析 + 重建)✅ 较好(原生渲染)
设计师上手✅ 快(AE 导出)中(需学新工具)
多端复用✅ 最好中
动态改颜色/文本有限✅ 支持运行时改属性
调试靠 JSON 肉眼看✅ Rive 编辑器可视化
离线/本地✅✅

2.4 Lottie 集成要点 ​

dart
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) ​

dart
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();

三个必须知道的点:

  1. 状态机名和输入名必须和 Rive 编辑器里完全一致(字符串硬编码,改了就失效 → 建议集中放常量)
  2. findInput<T>() 的泛型要和编辑器里定义的类型一致(double / bool / 触发器用 findSMITrigger)
  3. 用 .riv 二进制,声明方式和 assets 一样

三、复杂动效编排(L2:纯代码,最常用) ​

这一节不需要任何第三方包,是日常最实用的部分。

3.1 Interval:把一个动画切成多段 ​

前端类比:CSS 的 animation-delay + 分时段 keyframes。

dart
// 在 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。

dart
// 第 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 的多个百分比节点。

dart
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 ​

dart
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 统一封装,避免每个路由重复写:

dart
// 封装一个通用转场
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隔离重绘
dart
// ✅ 好: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 篇提过,这里强调):

dart
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,不动布局属性。

③ 动画期间别做重活:

dart
// ❌ 每帧都做 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 的数量相似的动效复用同一套参数
dart
// 预热示意:在 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 个,视觉噪音大且耗电
动效没有传达信息(纯粹因为"好看")去掉,它只会拖慢感知速度
dart
// 尊重系统的"减弱动效"设置
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 卡顿

相关文档 ​