Skip to content

目标:从"感觉有点卡"到"用 DevTools 定位到是哪一帧、哪个 Widget、哪行代码",并掌握本项目已经做过的优化手段背后的原理。


开篇:前端概念对照 ​

前端Flutter
Chrome DevTools Performance 面板Flutter DevTools Performance / Timeline
React DevTools Profiler(组件重渲染高亮)Repaint Rainbow / 重建计数(DevTools)
React.memo / useMemoconst 构造 / select 精确订阅 / RepaintBoundary
长列表虚拟滚动(react-window)ListView.builder(默认就是懒加载)
requestAnimationFrame 掉帧监控SchedulerBinding.addTimingsCallback(帧耗时回调)
Memory 快照 / Heap SnapshotDevTools 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 定位:四步法 ​

bash
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 面板操作进出页面多次堆是否持续增长(不回落 = 泄漏嫌疑)

两个必须开的辅助开关(开发期):

dart
// 在 MaterialApp 外层或需要排查的子树上开
debugProfileBuildsEnabled = true;          // 控制台打印每个 Widget 的 build 耗时
debugRepaintRainbowEnabled = true;         // 重绘区域彩色闪烁,视觉定位 repaint 范围
// ⚠️ 仅 debug 使用,切勿带进 release

Repaint Rainbow 的判读:只有真正变化的区域该闪;如果点一个按钮整屏都在闪,说明 repaint 范围失控,需要用 RepaintBoundary 隔离(第四节)。


三、优化手段一:减少 build 次数(收益最大) ​

对照 03 篇已讲的原则,这里是"怎么验证你做到了":

3.1 Riverpod 精确订阅:用 select ​

dart
// ❌ 名字一变,整页重建
final name = ref.watch(userProvider).name;

// ✅ 只有 name 变化才重建
final name = ref.watch(userProvider.select((u) => u.name));

这是本项目 08 篇强调 select 的原因。用 DevTools 的 Widget Build 计数验证:改前后对比重建次数。

3.2 把"变的部分"下沉为独立 Widget ​

dart
// ❌ 每秒变化的时间戳会让整个页面(含列表)重建
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 ​

dart
// ❌ 每帧重建整棵树
setState(() {});   // 在含列表的页面里

// ✅ 只重建需要动画的子树
AnimatedBuilder(
  animation: controller,
  builder: (context, child) => Transform.scale(scale: controller.value, child: child),
  child: const HeavyChild(),          // child 不参与重建,直接复用
)

原理见 05 篇。


四、优化手段二:隔离重绘(RepaintBoundary) ​

dart
RepaintBoundary(
  child: MyComplexChart(),      // 复杂但很少变的绘制被缓存为独立层
)

什么时候加:

场景建议
复杂自定义绘制(图表、画布)✅ 加
独立动画区域(转圈 loading、进度条)✅ 加(Flutter 的 CircularProgressIndicator 内部已处理)
几乎不变的大块静态内容✅ 加
到处乱加❌ 每层都有内存开销,反而变慢

验证方法:开 debugRepaintRainbowEnabled,加之前整屏闪,加之后只有目标区域闪——这就是生效了。


五、优化手段三:列表与滚动 ​

问题错误做法正确做法
长列表一次全建ListView(children: [...1000个])ListView.builder(懒加载,只建可视区 + 缓存区)
列表项高度不定导致滚动计算抖动不指定能定时给 itemExtent(性能提升明显);不定高用 prototypeItem
列表项重建过多整项重建列表项抽成 const 构造的独立 Widget;配合 select
首屏列表加载慢一次性拉全量分页(见 12 篇)+ 骨架屏
图片加载卡顿直接加载原图指定 cacheWidth/cacheHeight 按显示尺寸解码;用 cached_network_image
dart
// 定高列表的性价比写法
ListView.builder(
  itemExtent: 72,                    // 定高 → 滚动位置计算 O(1)
  itemCount: items.length,
  itemBuilder: (context, i) => DeviceTile(device: items[i]),
)

六、优化手段四:图片与内存 ​

dart
Image.network(
  url,
  cacheWidth: 300,                   // 按实际显示尺寸解码,避免原图占内存
  cacheHeight: 300,
)

内存泄漏的四个高发点(对照 05 篇 dispose 泄漏专政):

  1. AnimationController / TextEditingController 未 dispose
  2. StreamSubscription 未 cancel
  3. Timer 未取消
  4. 全局单例持有 BuildContext / 页面引用

检测方法:DevTools Memory → 反复进出某页面 10 次 → 手动 GC → 堆不回落就查这四个点。也可写 Widget 测试断言 dispose 被调用(19 篇)。


七、首帧与启动优化 ​

dart
// 埋点:测量首帧耗时(对照线上监控,见 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 / 列表懒加载 / 图片内存)及其适用场景
  • [ ] 能列出四个内存泄漏高发点并知道如何检测

相关文档 ​


导航:上一章 20 · 异常捕获与错误监控 | 下一章 23 · 打包签名与发布上架