目标:从"感觉有点卡"到"用 DevTools 定位到是哪一帧、哪个 Widget、哪行代码",并掌握本项目已经做过的优化手段背后的原理。
开篇:前端概念对照
| 前端 | Flutter |
|---|---|
| Chrome DevTools Performance 面板 | Flutter DevTools Performance / Timeline |
| React DevTools Profiler(组件重渲染高亮) | Repaint Rainbow / 重建计数(DevTools) |
React.memo / useMemo | const 构造 / select 精确订阅 / RepaintBoundary |
| 长列表虚拟滚动(react-window) | ListView.builder(默认就是懒加载) |
requestAnimationFrame 掉帧监控 | SchedulerBinding.addTimingsCallback(帧耗时回调) |
| Memory 快照 / Heap Snapshot | DevTools Memory(堆快照、泄漏检测) |
| Lighthouse 的 FCP/LCP | 首帧时间(firstFrameRasterized) |
关键差异:Flutter 直接调 Skia/Impeller 自绘,不经过 DOM——没有"重排重绘由浏览器优化"这层兜底,性能完全由你的 Widget 树决定。好处是可控,坏处是写得随意就会卡。
一、先把帧预算这件事说清
60Hz 屏幕 → 每帧 16.67ms 预算
├─ UI 线程:build → layout → paint(生成 Layer 树) ← 你的 Dart 代码在这里
└─ Raster 线程:把 Layer 树光栅化提交 GPU ← 复杂绘制/大图在这里
任一条超 16.67ms → 掉帧(jank)90% 的卡顿来自 UI 线程,也就是你的 build 方法。所以优化的第一原则是 让 build 又快又少(这正是 03 篇讲 rebuild/relayout/repaint 三种开销的实际意义)。
二、用 DevTools 定位:四步法
fvm flutter pub global activate devtools # 首次
fvm flutter run --profile # ⚠️ 必须 profile 模式,debug 模式性能不具参考性
# 运行后打开 DevTools(VSCode/Android Studio 可直接点开)| 步骤 | 做什么 | 看什么指标 |
|---|---|---|
| ① Performance 面板 | 录制一段卡顿操作 | 哪些帧超过 16.67ms(红色/超阈值条) |
| ② 选中慢帧看 Timeline | 展开 UI 线程的调用栈 | 哪个 build 耗时最长(自顶向下最宽的块) |
| ③ 开 "Track Widget Builds" | 重录 | 具体哪个 Widget 被重建了多少次 |
| ④ Memory 面板 | 操作进出页面多次 | 堆是否持续增长(不回落 = 泄漏嫌疑) |
两个必须开的辅助开关(开发期):
// 在 MaterialApp 外层或需要排查的子树上开
debugProfileBuildsEnabled = true; // 控制台打印每个 Widget 的 build 耗时
debugRepaintRainbowEnabled = true; // 重绘区域彩色闪烁,视觉定位 repaint 范围
// ⚠️ 仅 debug 使用,切勿带进 releaseRepaint Rainbow 的判读:只有真正变化的区域该闪;如果点一个按钮整屏都在闪,说明 repaint 范围失控,需要用 RepaintBoundary 隔离(第四节)。
三、优化手段一:减少 build 次数(收益最大)
对照 03 篇已讲的原则,这里是"怎么验证你做到了":
3.1 Riverpod 精确订阅:用 select
// ❌ 名字一变,整页重建
final name = ref.watch(userProvider).name;
// ✅ 只有 name 变化才重建
final name = ref.watch(userProvider.select((u) => u.name));这是本项目 08 篇强调 select 的原因。用 DevTools 的 Widget Build 计数验证:改前后对比重建次数。
3.2 把"变的部分"下沉为独立 Widget
// ❌ 每秒变化的时间戳会让整个页面(含列表)重建
Widget build(context) {
return Column(children: [
Text(clock.now.toString()), // 高频变化
const HeavyListView(), // 被连带重建 → 卡
]);
}
// ✅ 把高频变化的部分隔离成小 Widget
class _ClockText extends StatelessWidget {
@override
Widget build(context) => Text(context.watch(clockProvider).now.toString());
}
// 页面里:Column(children: [const _ClockText(), const HeavyListView()])原则:状态变化的影响范围越小越好。配合 const 构造(11 篇 讲过 const 兼容性),Flutter 会直接跳过未变子树。
3.3 动画相关:用 AnimatedBuilder 而非整页 setState
// ❌ 每帧重建整棵树
setState(() {}); // 在含列表的页面里
// ✅ 只重建需要动画的子树
AnimatedBuilder(
animation: controller,
builder: (context, child) => Transform.scale(scale: controller.value, child: child),
child: const HeavyChild(), // child 不参与重建,直接复用
)原理见 05 篇。
四、优化手段二:隔离重绘(RepaintBoundary)
RepaintBoundary(
child: MyComplexChart(), // 复杂但很少变的绘制被缓存为独立层
)什么时候加:
| 场景 | 建议 |
|---|---|
| 复杂自定义绘制(图表、画布) | ✅ 加 |
| 独立动画区域(转圈 loading、进度条) | ✅ 加(Flutter 的 CircularProgressIndicator 内部已处理) |
| 几乎不变的大块静态内容 | ✅ 加 |
| 到处乱加 | ❌ 每层都有内存开销,反而变慢 |
验证方法:开 debugRepaintRainbowEnabled,加之前整屏闪,加之后只有目标区域闪——这就是生效了。
五、优化手段三:列表与滚动
| 问题 | 错误做法 | 正确做法 |
|---|---|---|
| 长列表一次全建 | ListView(children: [...1000个]) | ListView.builder(懒加载,只建可视区 + 缓存区) |
| 列表项高度不定导致滚动计算抖动 | 不指定 | 能定时给 itemExtent(性能提升明显);不定高用 prototypeItem |
| 列表项重建过多 | 整项重建 | 列表项抽成 const 构造的独立 Widget;配合 select |
| 首屏列表加载慢 | 一次性拉全量 | 分页(见 12 篇)+ 骨架屏 |
| 图片加载卡顿 | 直接加载原图 | 指定 cacheWidth/cacheHeight 按显示尺寸解码;用 cached_network_image |
// 定高列表的性价比写法
ListView.builder(
itemExtent: 72, // 定高 → 滚动位置计算 O(1)
itemCount: items.length,
itemBuilder: (context, i) => DeviceTile(device: items[i]),
)六、优化手段四:图片与内存
Image.network(
url,
cacheWidth: 300, // 按实际显示尺寸解码,避免原图占内存
cacheHeight: 300,
)内存泄漏的四个高发点(对照 05 篇 dispose 泄漏专政):
AnimationController/TextEditingController未disposeStreamSubscription未cancelTimer未取消- 全局单例持有
BuildContext/ 页面引用
检测方法:DevTools Memory → 反复进出某页面 10 次 → 手动 GC → 堆不回落就查这四个点。也可写 Widget 测试断言 dispose 被调用(19 篇)。
七、首帧与启动优化
// 埋点:测量首帧耗时(对照线上监控,见 20 篇)
void main() {
WidgetsFlutterBinding.ensureInitialized();
final start = DateTime.now();
WidgetsBinding.instance.addPostFrameCallback((_) {
final cost = DateTime.now().difference(start).inMilliseconds;
AppLogger.I.d('首帧耗时 ${cost}ms');
ErrorReporter.report(type: 'perf_first_frame', message: '${cost}ms', extra: ...);
});
runApp(const MyApp());
}可优化项:
| 手段 | 说明 |
|---|---|
| 延迟初始化非首屏依赖 | 首屏不需要的 SDK 放到首帧后(addPostFrameCallback)或用 Future 懒加载 |
| 减少启动期同步 IO | 存储初始化已在 18 篇讲过用 overrides 注入,但要注意别阻塞首帧 |
| 预编译 shader(Impeller 前) | 老版本需 --bundle-sksl-path 预热着色器,防首次动画卡顿 |
| 减小包体与资源 | 图片压缩、字体子集化(见 23 篇) |
八、上线前的性能验收清单
- [ ] profile 模式下(非 debug)跑过核心路径,慢帧率达标
- [ ] 长列表用
builder+ 能定高就定高 - [ ] 图片指定
cacheWidth/Height,列表图有缓存 - [ ] Riverpod 订阅都用了
select(或已确认影响范围可接受) - [ ] 反复进出页面 10 次,Memory 无持续增长
- [ ] 所有 Controller/Subscription/Timer 都有 dispose 或 cancel
- [ ] 首帧耗时有埋点,能在线上看趋势(对接 20 篇监控)
性能问题的排查顺序:先测(profile + DevTools)→ 再定位(哪个 Widget/哪帧)→ 最后优化。不要凭感觉优化——03 篇里那 4 种优化手段都有适用场景,用错反而更复杂。
九、动手练习
练习 22.1(对比实验):写一个含 500 项列表的页面,先用 ListView(children:),profile 模式录一次;改成 ListView.builder 再录一次,对比慢帧数与内存占用。
练习 22.2(定位重建):写一个"整页 setState 更新一个计数器"的页面,开 Track Widget Builds,数一数重建次数;再改造成 _CounterText 独立 Widget + const 列表,对比重建次数差异。
练习 22.3(隔离重绘):放一个复杂绘制(CustomPaint 画网格)+ 一个小动画,开 Repaint Rainbow 观察整屏闪;加 RepaintBoundary 后再观察。
练习 22.4(泄漏检测):故意写一个"页面里创建 Timer 但不 cancel"的 bug,用 Memory 面板反复进出页面,观察堆增长,再修复并验证曲线回落。
十、自检清单
- [ ] 能说出 16.67ms 帧预算与 UI/Raster 双线程模型
- [ ] 知道为什么必须用 profile 模式测性能
- [ ] 会用 DevTools 的 Performance + Track Widget Builds 定位到具体 Widget
- [ ] 掌握四种优化手段(减 build / RepaintBoundary / 列表懒加载 / 图片内存)及其适用场景
- [ ] 能列出四个内存泄漏高发点并知道如何检测
相关文档
- A25 · 前端性能度量与诊断 —— 先度量再优化:LCP/INP/CLS、长任务定位、性能预算
- A10 · Web 性能优化:治理手段 —— 通用优化手段(两侧的帧预算思路完全相通)
- 03 · Flutter 渲染原理与生命周期 —— 三棵树、relayout/repaint、
RepaintBoundary - 速查 06 · Riverpod ——
select局部刷新(减 build 的常用手段)
导航:上一章 20 · 异常捕获与错误监控 | 下一章 23 · 打包签名与发布上架