Flutter 基础体系 · 第 17/80 篇。示例基于当前稳定 Flutter 与 Dart 3 语言能力;Android、iOS、桌面和 Web 差异会明确说明。
Flutter 性能优化:帧流水线、重建、栅格、内存和 DevTools
Flutter 性能优化的目标不是让某个函数“更快”,而是让一次用户可见的帧在规定时间内完成,并在长期运行、不同屏幕刷新率、不同渲染后端和不同内存压力下保持可预测。
一帧通常经历以下路径:
flowchart LR
V[vsync 到达] --> B[Build 构建 Widget/Element]
B --> L[Layout 布局 RenderObject]
L --> P[Paint 绘制 DisplayList/Layer]
P --> C[Compositing 合成]
C --> R[Raster 栅格化到 GPU/Surface]
R --> S[屏幕显示]
这里有两个必须区分的概念:
- UI 线程通常负责 Dart 代码、build、layout、paint 和帧调度;
- Raster 线程通常负责将绘制指令栅格化并提交到 GPU 或平台 Surface。
这是常见实现路径,而不是应用可以依赖的绝对线程契约。具体线程模型会受平台、渲染后端和引擎实现影响。优化时应以 DevTools 的实际时间线为证据,而不是假设某个操作一定发生在某个线程。
一、帧时间预算:为什么“能运行”不等于“流畅”
屏幕刷新率为 Hz 时,理想帧间隔为:
例如:
| 刷新率 | 理想帧间隔 |
|---|---|
| 60 Hz | 16.67 ms |
| 90 Hz | 11.11 ms |
| 120 Hz | 8.33 ms |
这只是时间预算,不是 Flutter 保证应用每帧都能使用的时间。操作系统、输入处理、平台消息、GPU 竞争和设备温度都会占用资源。
一次帧渲染可以近似表示为:
因为 UI 线程和 Raster 线程在很多阶段可以并行。如果某帧中:
- UI 线程耗时 20 ms,Raster 线程耗时 4 ms;
- UI 线程耗时 4 ms,Raster 线程耗时 20 ms;
在 60 Hz 下都可能错过帧截止时间。
但这不是说 UI 和 Raster 永远完全独立。Raster 往往需要等待 UI 线程提交新的绘制指令;UI 线程也可能因为上一帧的管线阻塞而受到影响。因此更准确的判断是:
- UI 线程是否按时生成了这一帧的绘制数据;
- Raster 线程是否按时完成了这一帧的栅格化;
- 两者之间是否存在等待、排队或背压。
不要把所有耗时相加后与 16.67 ms 比较,也不要只看 Dart 函数耗时。 应在时间线上观察帧的实际阶段和阻塞关系。
1. “丢帧”究竟是什么
如果下一次 vsync 到来时,上一帧尚未完成,系统只能:
- 延迟显示上一帧;
- 重复显示旧帧;
- 或等待更晚的帧。
用户看到的是动画跳动、滚动不连续、点击反馈延迟。高刷新率设备尤其敏感:120 Hz 下 8.33 ms 的预算大约只有 60 Hz 的一半。
因此性能验证至少应覆盖:
- 60 Hz 中端 Android 设备;
- 高刷新率设备;
- iOS 真机;
- 桌面窗口缩放和大屏;
- Web 的目标浏览器。
不要只在开发机 Debug 模式下判断流畅度。Debug 模式包含额外断言和诊断开销,Release 模式的编译和渲染行为更接近生产。
二、Flutter 帧流水线:Build、Layout、Paint 和 Raster 分别做什么
1. Build:把状态描述成 Widget 树
Widget 是不可变配置。build 的结果是新的 Widget 描述,Flutter 再通过 Element 树将描述与已有对象匹配。
例如:
class CounterText extends StatelessWidget {
const CounterText({super.key, required this.value});
final int value;
@override
Widget build(BuildContext context) {
return Text('count: $value');
}
}
调用 build 会创建一个新的 Text Widget 对象,但这不等价于“屏幕一定重绘”。框架会根据运行时类型、Key 和父子关系,尝试复用已有 Element 和 RenderObject。
2. Element:决定哪些状态和对象可以复用
Widget、Element、RenderObject 的关系可以简化为:
- Widget:不可变配置;
- Element:Widget 在树中的运行时位置,持有生命周期和状态;
- RenderObject:负责布局、绘制和命中测试。
当父节点重新 build 时,Flutter 会比较新旧 Widget。对同一位置,如果类型和 Key 匹配,通常可以更新原有 Element,而不必销毁并重建整个子树。
例如:
Column(
children: [
const Text('固定标题'),
Text('value: $value'),
],
)
当 value 改变时,Column 及其返回路径上的节点可能重新执行 build,但第一个 const Text 仍可复用。const 的价值主要是减少不必要的对象创建和帮助框架识别稳定配置,而不是一个“禁止重建”的开关。
3. Layout:计算 RenderObject 的尺寸和位置
布局通常遵循“约束向下传递,尺寸向上返回,位置由父节点决定”的模型:
- 父 RenderObject 向子节点传递
BoxConstraints; - 子节点在约束范围内选择尺寸;
- 父节点根据子节点尺寸决定位置;
- 必要时继续向上影响祖先。
典型约束可以表示为:
当某个 RenderObject 的尺寸、父约束或布局相关属性变化时,可能触发布局。布局结果变化后,通常还需要重新绘制;但仅仅重新 build 不必然导致 layout。
一个常见反例是:
Widget build(BuildContext context) {
return Container(
width: MediaQuery.sizeOf(context).width,
child: expensiveChild,
);
}
如果只需要屏幕宽度,却让一个范围很大的组件直接依赖整个 MediaQuery,屏幕尺寸、文字缩放、无障碍设置等变化都可能使它重新 build。可以使用更窄的依赖:
final width = MediaQuery.sizeOf(context).width;
更重要的是,不要为了“减少 build”而违反布局模型,例如在需要约束的地方随意使用无限尺寸。错误布局会表现为异常、反复布局或大面积无效工作,而不是性能优化。
4. Paint:把布局结果转换成绘制指令
Paint 阶段通常将 RenderObject 的绘制操作记录成 DisplayList 或等价的绘制数据。常见操作包括:
- 绘制文字、路径、图片;
- 应用裁剪;
- 应用变换;
- 创建透明度、模糊、阴影等效果;
- 创建或更新合成层。
Paint 较慢不一定是 Dart 代码慢,也可能是绘制指令复杂、层结构不利于合成,或者后续 Raster 代价很高。
5. Raster:把绘制指令变成像素
Raster 阶段需要将绘制指令栅格化。它关注的是:
- 需要处理多少像素;
- 绘制是否涉及复杂路径;
- 是否存在离屏缓冲;
- 是否需要纹理上传;
- Shader 是否首次编译;
- 层是否能有效复用。
一个只覆盖 100×100 像素的绘制和一个覆盖整个 4K 窗口的绘制,即使 Dart 代码相同,Raster 成本也可能完全不同。
三、重建:什么会重新 build,什么不会
1. setState 的范围
setState 标记的是调用它所在的 State 对应 Element:
class CounterPage extends StatefulWidget {
const CounterPage({super.key});
@override
State<CounterPage> createState() => _CounterPageState();
}
class _CounterPageState extends State<CounterPage> {
int count = 0;
@override
Widget build(BuildContext context) {
return Column(
children: [
Text('count: $count'),
ExpensivePanel(),
ElevatedButton(
onPressed: () => setState(() => count++),
child: const Text('increment'),
),
],
);
}
}
这里按钮点击会使 _CounterPageState.build 再次执行。ExpensivePanel 是否也执行 build,取决于它如何出现在新树中以及框架是否能够复用相应子树;不能简单概括为“整个页面都会重建”或“子组件一定不会重建”。
一个可靠的结构优化是把不变子树作为参数传入:
class CounterPage extends StatefulWidget {
const CounterPage({super.key, required this.panel});
final Widget panel;
@override
State<CounterPage> createState() => _CounterPageState();
}
class _CounterPageState extends State<CounterPage> {
int count = 0;
@override
Widget build(BuildContext context) {
return Column(
children: [
Text('count: $count'),
widget.panel,
ElevatedButton(
onPressed: () => setState(() => count++),
child: const Text('increment'),
),
],
);
}
}
调用方可以传入一个稳定的 Widget 实例:
CounterPage(
panel: const ExpensivePanel(),
)
这样做的依据是:父组件每次 build 时复用同一个子 Widget 配置,框架无需因为父状态变化而重新描述该子树。它不是普遍适用的“缓存 Widget”规则;如果子树依赖变化的参数,就不能为了避免 build 而强行复用旧实例。
2. const 的正确边界
const 可以在编译期构造不可变对象:
const Padding(
padding: EdgeInsets.all(16),
child: Text('static'),
)
它能减少运行时对象创建,特别适合静态图标、间距、标签和固定布局。但以下情况不能靠 const 解决:
- 子树依赖动态数据;
- 大量复杂绘制导致 Raster 慢;
- 图片解码和上传占用时间;
build中执行同步 I/O 或复杂计算;- 频繁布局导致 layout 慢。
“所有 Widget 都加 const 就不会重建”是错误理解。重建是 Element 更新过程,const 只是使配置对象更稳定、更容易复用。
3. InheritedWidget、Provider 和选择性依赖
依赖 InheritedWidget 的组件,在其依赖值变化时可能收到通知。例如 MediaQuery.of(context)、主题和本地化都属于常见依赖。
状态管理库通常在此基础上提供选择性订阅。原则是:组件只订阅它实际需要的字段。
反例:
final model = context.watch<CartModel>();
return Text('${model.total}');
如果 CartModel 的任何字段变化都会通知该组件,那么即使 total 没变,也可能重新 build。
更窄的选择器应表达为“只监听 total”,具体 API 取决于所用状态管理库。这里不能把某个库的选择器写成 Flutter 内置 API。性能判断也应通过 DevTools 的 Widget rebuild 信息验证,而不是根据代码形式猜测。
4. Key:身份匹配,不是性能魔法
Key 用于在同级子节点中识别身份,尤其是列表插入、删除、排序时:
ListView(
children: items.map((item) {
return ListTile(
key: ValueKey(item.id),
title: Text(item.name),
);
}).toList(),
)
没有 Key 时,如果列表前端插入一个元素,框架可能按位置匹配旧 Element,导致有状态子项错配或产生更多更新。ValueKey 适合稳定业务 ID;UniqueKey 每次都不同,会主动破坏复用,不应为了“保证刷新”而滥用。
Key 不能减少已经发生的业务数据变化,也不能替代列表虚拟化。它解决的是身份和状态迁移问题。
四、列表和滚动:从子树规模控制成本
ListView(children: [...]) 会一次性创建所有子项。对于固定且很短的列表,这很简单;对于长列表,应优先使用 builder:
class MessageList extends StatelessWidget {
const MessageList({super.key, required this.messages});
final List<String> messages;
@override
Widget build(BuildContext context) {
return ListView.builder(
itemCount: messages.length,
itemBuilder: (context, index) {
return ListTile(
key: ValueKey(messages[index]),
title: Text(messages[index]),
);
},
);
}
}
ListView.builder 的核心不是“build 永远只执行一次”,而是只为当前需要的可视区域和缓存区域创建子项。滚动时,离开区域的子项可能被销毁,重新进入时可能再次创建,因此子项必须正确处理生命周期和状态持久化。
列表优化需要分别观察:
- 创建成本:每个子项 build 是否包含 JSON 解析、日期格式化、大量对象创建;
- 布局成本:是否嵌套了无法确定边界的滚动容器;
- 绘制成本:是否每项都使用阴影、模糊、复杂 CustomPainter;
- 图片成本:是否加载了远大于显示尺寸的图片;
- 状态成本:是否因 Key 错误导致状态错位或子树反复初始化。
不要把 shrinkWrap: true 当作通用修复方案。它通常要求滚动视图测量更多甚至全部子项,长列表中可能显著增加布局成本。嵌套滚动应有明确的约束和滚动协调方案,而不是通过 shrinkWrap 消除异常就结束诊断。
五、同步计算、异步计算和 Isolate
Dart 的异步 Future 不会自动把 CPU 密集型工作移出 UI isolate:
final result = await heavyCalculation();
如果 heavyCalculation() 在返回 Future 前已经同步执行了大量计算,UI 线程仍会被阻塞。
可以使用 isolate:
import 'dart:convert';
import 'package:flutter/foundation.dart';
List<String> parseNames(String source) {
final decoded = jsonDecode(source) as List<dynamic>;
return decoded
.cast<Map<String, dynamic>>()
.map((item) => item['name'] as String)
.toList();
}
Future<List<String>> loadNames(String source) {
return compute(parseNames, source);
}
前提是:
- 顶层函数或静态函数可被 isolate 调用;
- 参数和返回值必须可跨 isolate 传输;
- 不能直接捕获 UI 状态、BuildContext 或不可发送对象;
- 序列化和 isolate 启动本身也有成本。
在移动端,compute 常用于较重的 JSON 解析、压缩或纯计算。对很小的数据使用它可能比直接计算更慢。Web 的 isolate 能力和并行模型受浏览器、编译目标和部署方式限制,不能不加验证地假设拥有与 Android/iOS 相同的并行效果。
网络等待、文件等待和数据库等待属于 I/O,不应通过 isolate 解决;应使用异步 API,并确保回调恢复后只做足够轻量的 UI 更新。
六、栅格性能:为什么 GPU 也会成为瓶颈
“栅格”是将绘制指令转换为像素的过程。它不是“把 Widget 画出来”这么简单,而是同时受面积、绘制操作、层结构、纹理和着色器影响。
1. 大面积绘制比小面积绘制更贵
以下代码逻辑简单,但会绘制一个覆盖整个区域的渐变:
DecoratedBox(
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [Color(0xff202020), Color(0xff606060)],
),
),
child: child,
)
如果该区域是一个全屏、持续动画的背景,Raster 需要在每帧处理大量像素。优化方向不是盲目减少 Widget 数量,而是:
- 缩小实际绘制区域;
- 避免不必要的全屏重绘;
- 将不会变化的内容缓存或移出动画重绘区域;
- 用 DevTools 的 Raster 时间和重绘图层确认是否确实是瓶颈。
2. saveLayer 和离屏缓冲
某些效果需要先把内容绘制到离屏缓冲,再进行合成,例如:
- 某些混合模式;
- 背景模糊;
- 复杂裁剪;
- 需要对一组内容统一应用透明度的场景。
离屏缓冲通常会增加内存带宽和纹理处理成本。Opacity、ClipPath、阴影和滤镜并不必然昂贵,但它们可能导致额外层或离屏绘制,实际代价取决于区域大小和设备。
例如:
Opacity(
opacity: 0.5,
child: largeWidget,
)
如果只是单个颜色或图片透明度,可以考虑直接把透明度放入颜色或绘制参数;如果是大块动态子树,需用时间线验证是否产生了高昂的合成成本。不能简单声称“Opacity 一定慢”。
3. RepaintBoundary:隔离变化,不是越多越好
RepaintBoundary 将子树划分为独立的重绘边界:
RepaintBoundary(
child: ExpensiveStaticChart(data: data),
)
当外部兄弟区域变化时,边界内内容可能复用已有绘制结果;当边界内部变化时,仍然需要重绘该边界。
它适合:
- 内容绘制昂贵;
- 自身变化频率低;
- 经常被周围动画或滚动影响;
- 边界缓存收益大于额外层和纹理成本。
它不适合被当作“禁止重绘”开关。边界本身会带来额外合成和内存成本;如果每个列表项都加边界,可能导致层数量过多,反而使 Raster 和内存变差。
4. CustomPainter 的重绘判定
class ProgressPainter extends CustomPainter {
const ProgressPainter(this.progress);
final double progress;
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()..color = const Color(0xff42a5f5);
canvas.drawArc(
Offset.zero & size,
-1.57,
progress * 6.28,
false,
paint,
);
}
@override
bool shouldRepaint(covariant ProgressPainter oldDelegate) {
return oldDelegate.progress != progress;
}
}
shouldRepaint 返回 true 时,Painter 对应区域需要重新绘制。判断应基于真正影响绘制的字段。如果 Painter 还依赖颜色、线宽或路径,却只比较 progress,更新后会出现视觉错误。
如果动画每帧改变 progress,返回 false 会导致画面不更新;返回 true 则是正确行为。性能优化应减少每帧创建的大对象和绘制面积,而不是错误地阻止必要重绘。
5. 动画中的分工
Implicit Animation 适合少量状态变化,由框架管理控制器;显式 AnimationController 适合精确控制、反向、暂停和多个动画联动;Hero 会在路由过渡期间构建飞行中的视觉对象;CustomPainter 适合直接控制绘制。
它们的共同性能边界是:动画 tick 触发的更新如果使大范围子树 build、layout、paint 或 raster,就会按帧重复支付成本。
例如:
class FadeBox extends StatefulWidget {
const FadeBox({super.key});
@override
State<FadeBox> createState() => _FadeBoxState();
}
class _FadeBoxState extends State<FadeBox>
with SingleTickerProviderStateMixin {
late final AnimationController controller;
late final Animation<double> opacity;
@override
void initState() {
super.initState();
controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
);
opacity = CurvedAnimation(
parent: controller,
curve: Curves.easeOut,
);
controller.forward();
}
@override
void dispose() {
controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return FadeTransition(
opacity: opacity,
child: const RepaintBoundary(
child: LargeStaticPanel(),
),
);
}
}
这里 AnimationController 的生命周期与 State 绑定,必须在 dispose 中释放。FadeTransition 比在每个 tick 中手动 setState 重建整个页面更容易把变化限制在动画相关路径中,但最终是否更快仍取决于子树和合成结果。
七、图片、纹理和内存:内存问题不只发生在 Dart 堆
Flutter 应用至少需要关注三类内存:
- Dart Heap:Widget、List、JSON、闭包、业务对象;
- Native/External Memory:引擎对象、字体、插件资源、解码后的图片等;
- GPU Memory:纹理、离屏缓冲和合成相关资源。
1. 压缩文件大小不等于解码后占用小
一张 JPEG 文件可能只有 500 KB,但解码为 RGBA 后,粗略内存为:
其中 和 是像素宽高,4 表示每像素约 4 字节。
例如 4000×3000 的图片:
这还没有计算缓存、纹理副本和其他中间资源。如果它只显示为 400×300,直接解码原图就是明显浪费。
可以指定目标解码尺寸:
Image.network(
imageUrl,
cacheWidth: 800,
cacheHeight: 600,
fit: BoxFit.cover,
)
cacheWidth 和 cacheHeight 是解码缓存尺寸提示,具体是否精确采用以及最终内存布局由平台和图片编解码器决定。它们不是 CSS 缩放,也不是把服务器下载文件变小;网络传输仍应通过服务端缩略图、合适格式和 CDN 尺寸策略优化。
2. 图片缓存有收益也有代价
图片缓存减少重复解码和网络等待,但缓存越大,内存占用越高。生产环境应根据页面类型、图片尺寸和设备内存验证缓存策略,不要仅凭“缓存命中率高”判断优化成功。
大图列表中的典型失败路径是:
- 列表快速滚动;
- 每项加载高分辨率原图;
- 多张图片同时解码并上传 GPU;
- 内存峰值升高;
- 系统回收或杀死进程;
- 回到页面时又重复加载,形成抖动。
解决路径需要同时控制服务端尺寸、客户端解码尺寸、预加载范围和页面生命周期。
3. 生命周期泄漏
需要释放的对象包括但不限于:
@override
void dispose() {
_controller.dispose();
_focusNode.dispose();
_textController.dispose();
_subscription.cancel();
super.dispose();
}
常见泄漏并非 Dart 永久保留普通局部变量,而是长生命周期对象持有 State:
Timer持有回调;- Stream subscription 未取消;
AnimationController未 dispose;- 插件回调注册后未注销;
- 闭包捕获了大型对象或页面上下文;
- isolate、原生资源、文件句柄未关闭。
异步回调还可能在 State 已销毁后返回:
Future<void> load() async {
final result = await repository.fetch();
if (!mounted) return;
setState(() {
data = result;
});
}
mounted 检查主要防止已销毁 State 上调用 setState;它不能解决任务本身继续占用资源的问题。更完整的实现应在 dispose 中取消可取消的请求或订阅。
八、DevTools:用证据定位是 Build、Raster 还是内存
性能优化必须先建立可重复场景,再收集证据。建议使用 profile 或 release 构建验证,而不是只使用 Debug。
启动常见方式:
flutter run --profile
指定设备:
flutter devices
flutter run --profile -d <device-id>
flutter devices 输出可用设备和标识;flutter run --profile 会以 profile 模式运行,保留性能分析能力,同时比 Debug 更接近生产。不同平台对 profile 支持和行为存在差异,Web 还受浏览器开发者工具和渲染后端影响。
1. Performance 页面和时间线
DevTools 的 Performance 页面用于查看帧时间线、Dart 事件、GPU/Raster 相关活动和时间段分析。
诊断步骤应是:
- 启动 profile 应用;
- 打开目标页面;
- 执行固定动作,例如连续滚动、打开弹窗或播放 Hero 动画;
- 记录慢帧发生的时间;
- 在时间线上展开该帧;
- 判断 UI 侧是 build、layout、paint 还是同步 Dart 计算;
- 判断 Raster 侧是否有大面积绘制、离屏层、纹理上传或 Shader 问题;
- 修改一项因素后重复同一动作。
如果 UI 侧明显超时而 Raster 正常,应优先查:
- build 中的同步计算;
- 大量 Widget 创建;
- 状态通知范围过大;
- JSON 解析、排序、格式化;
- 频繁 layout。
如果 Raster 侧明显超时而 UI 正常,应优先查:
- 大面积重绘;
- 复杂路径;
- 图片纹理;
- blur、mask、saveLayer;
- 层结构和阴影;
- Shader 首次编译。
2. Performance Overlay
可以在代码中临时打开性能叠加层:
import 'package:flutter/material.dart';
void main() {
runApp(
const MaterialApp(
showPerformanceOverlay: true,
home: HomePage(),
),
);
}
叠加层展示 UI 线程和 Raster 线程的帧性能曲线。它适合在真机上快速观察滚动和动画,但不能代替时间线:曲线能说明哪一侧超时,却不一定告诉你具体是哪一个 Widget 或绘制操作导致问题。
只应在诊断时启用,不能作为生产 UI 的一部分。
3. Widget Inspector 和重建信息
DevTools Inspector 可查看 Widget 树、RenderObject、布局边界和重建相关信息。诊断重建时,可以:
- 选中怀疑的 Widget;
- 观察其父子关系;
- 打开重建统计或相关调试信息;
- 触发一次状态变化;
- 比较哪些区域被重新 build 或 repaint。
应避免只看 build 次数。一个 Widget build 次数较多,但每次极轻,可能不是问题;一个 Widget 只 build 一次,却触发巨大 Raster 或图片解码,也可能成为瓶颈。
4. Memory 页面和分配追踪
内存问题要区分:
- 页面进入后是否持续增长;
- 离开页面后是否回落;
- 大量对象属于哪种类型;
- 是否存在监听器、闭包或缓存持有路径;
- 图片和外部内存是否增长。
可执行的验证流程是:
- 记录进入页面前快照;
- 重复进入、退出页面若干次;
- 强制执行可用的 GC 或等待回收;
- 再记录快照;
- 比较 State、Controller、List、图片对象数量;
- 查看对象引用路径;
- 修复 dispose、订阅取消或缓存边界;
- 重复测试确认增长趋势消失。
一次快照变大不等于泄漏。GC 可能尚未运行,缓存也可能按设计保留对象。泄漏的证据通常是“重复相同生命周期后,无法回到相近基线,并且引用路径仍然存在”。
5. CPU Profiler
CPU Profiler 用于发现 Dart 代码中消耗 CPU 的函数,例如:
- 大型列表排序;
- 图片元数据处理;
- 日期和正则表达式密集计算;
- build 中的复杂转换;
- 日志序列化;
- 同步 JSON 解码。
采样分析会有统计误差,短函数可能不容易被捕获。应通过重复场景和较长采样窗口提高信号,不能因某个函数出现在顶部就立即断定它是根因,还要检查调用频率和是否处于每帧路径。
九、常见误判与失败表现
1. “减少 Widget 数量就一定更快”
Widget 是轻量配置对象,真正昂贵的可能是:
- layout 规模;
- paint 指令;
- Raster 像素量;
- 图片解码;
- 文本排版;
- 合成层。
一个结构清晰但 Widget 较多的页面,可能比一个 Widget 数量少、使用大面积模糊和复杂路径的页面更快。
2. “加 RepaintBoundary 就能解决卡顿”
如果瓶颈在 UI isolate 的 JSON 解析,RepaintBoundary 无效;如果边界包住了每帧变化的巨大区域,也无效;如果边界数量过多,还会增加层管理和内存成本。
3. “Future 会自动开线程”
Future 只描述异步结果,不保证计算移出当前 isolate。CPU 密集任务需要 isolate 或其他平台并行机制;网络请求则应使用真正异步的 I/O API。
4. “看起来是动画卡,其实是首次 Shader 编译”
某些复杂视觉效果第一次出现时可能触发 Shader 编译或资源准备,表现为首帧或首次交互卡顿。应分别测试冷启动、首次进入页面和重复进入页面。Shader 预热能力、后端行为和平台支持具有版本与平台差异,不能假设某个预热策略在所有设备都等价有效。
5. “模拟器流畅,所以真机没问题”
模拟器的 CPU、GPU、分辨率和驱动路径与真实设备不同。Android 低端设备、iOS 真机、高刷新率设备和浏览器环境都应单独验证。
十、平台差异
Android
Android 设备型号、GPU、刷新率和系统调度差异很大。低端设备常见问题是 UI 线程计算、图片解码和 Raster 同时竞争。应重点测试真实 APK 的 profile/release 行为,以及冷启动、后台恢复和内存压力。
iOS
iOS 的系统调度、纹理和合成路径与 Android 不同。不能用 Android 上的帧时间直接推断 iOS。刘海、安全区域、动态文字大小和后台恢复也可能改变布局与缓存行为。
桌面
桌面窗口通常更大,绘制面积增加;窗口缩放会频繁触发布局和重绘。高分辨率屏幕下,单帧像素量显著增加。桌面还可能有鼠标悬停、窗口动画和多窗口等移动端少见的事件路径。
Web
Web 的 Flutter 应用运行在浏览器调度和渲染后端之上。CanvasKit、WebAssembly 相关后端以及浏览器版本会影响文本、图片、合成和内存表现;浏览器的标签页降频、后台暂停、网络缓存和开发者工具也会影响测量。
Web 还需要区分:
- Flutter/Dart 代码耗时;
- 浏览器主线程任务;
- Canvas/WebGL/WebGPU 或对应后端的绘制;
- 浏览器合成和页面布局。
不能把移动端的 UI/Raster 线程模型原样套到 Web。Web 性能测试必须固定浏览器、窗口尺寸、缩放比例和渲染后端,并在生产构建中验证。
十一、把性能纳入错误处理和可观测性
性能异常经常不是单纯的“慢”,而是数据异常、资源失败或生命周期错误造成的连锁结果。例如:
- 图片 URL 错误导致重复重试;
- 重试回调不断触发状态更新;
- 页面离开后订阅仍然存在;
- 内存和 UI 工作持续增长;
- 最终出现丢帧或进程被杀。
因此性能日志应与错误日志关联,但必须控制隐私和体积。可以记录:
- 页面或业务场景标识;
- 操作类型;
- 帧耗时区间;
- 图片尺寸和解码尺寸;
- 资源加载失败类型;
- 是否处于冷启动或恢复流程;
- 设备、平台和应用版本。
不要记录完整用户文本、Token、URL 中的敏感参数或原始业务数据。性能埋点本身也不能在每帧执行昂贵序列化。
对于异常路径,应保证:
try {
final data = await repository.fetch();
if (!mounted) return;
setState(() => value = data);
} catch (error, stackTrace) {
logger.error(
'load failed',
error: error,
stackTrace: stackTrace,
);
if (!mounted) return;
setState(() => failed = true);
}
这里的关键不是把错误吞掉,而是:
- 记录错误和堆栈;
- 避免失败后无限重试;
- 页面销毁后不再更新状态;
- 向用户提供有限、可恢复的失败状态;
- 不让错误处理本身制造更多帧工作。
Zone、全局错误捕获、Crash 上报和性能数据应保持关联,但全局捕获不能代替局部资源生命周期管理,也不能捕获所有原生层或进程级崩溃。
十二、一个可执行的性能验证闭环
对一个具体页面,可以按以下顺序建立证据链:
- 定义场景:例如冷启动后打开商品列表,连续滚动 10 秒,再打开详情页;
- 固定环境:设备型号、刷新率、系统版本、网络条件、构建模式;
- 测 UI:确认是否存在 build、layout、同步计算长任务;
- 测 Raster:确认是否存在大面积重绘、离屏层、复杂路径或纹理上传;
- 测内存:重复进入退出页面,观察是否回到相近基线;
- 修改单个变量:例如将原图替换为目标尺寸图片;
- 重复同一场景:避免把不同测试条件下的数据直接比较;
- 检查失败路径:断网、快速返回、后台恢复和低内存;
- 在目标平台复测:至少覆盖实际交付的主要平台;
- 保存基线:记录关键页面在代表设备上的帧、内存和启动指标。
优化是否成立,应满足因果验证:
如果只是 Widget 数量减少,但时间线、帧率和内存没有改善,就不能称为有效优化。相反,如果代码结构变化很小,却显著减少了 Raster 面积或图片解码内存,那就是更有价值的改动。
Flutter 性能的核心始终是同一件事:让状态变化只影响必要的 Widget、必要的布局和绘制区域,并让每帧所需的像素、计算和资源都处于目标设备能够按时完成的范围内。DevTools 的作用不是提供一个“性能分数”,而是把这条因果链具体化:哪一帧、哪个线程、哪个阶段、哪类对象和哪种资源造成了实际成本。
系列导航与关联阅读
- 系列入口:Flutter 完整学习路线:从 Dart 与 Widget 到多端架构和应用发布
- 上一篇:Flutter 测试体系:Unit、Widget、Golden、Integration 和 Mock 边界
- 下一篇:Flutter 可访问性与国际化:Semantics、焦点、Locale 和文本适配
- 延伸:Flutter 动画体系:Implicit、Controller、Hero、CustomPainter 和性能
- 延伸:Flutter 错误处理与可观测性:Zone、日志、Crash、性能和隐私
官方资料
本文依据 Flutter 与 Dart 官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论