性能优化与 DevTools
Flutter 的性能问题几乎都落在两个线程上:UI 线程负责 build 与布局,Raster 线程负责绘制。卡顿要么是 Dart 侧算得太慢,要么是 GPU 侧画得太贵,凭感觉猜十次不如用 DevTools 看一次帧图。本章先讲怎么测、怎么判读,再给出常见问题与修法的对照表,最后覆盖启动时间、内存与包体积。
先测量再优化
flutter run --profile # 性能问题必须在 profile 模式复现,debug 模式数字无意义
flutter run --profile --trace-startup # 记录启动阶段耗时,退出时打印时间线
flutter build apk --analyze-size --target-platform android-arm64 # 分析包体积构成
flutter build apk --release --split-debug-info=build/symbols # 保留符号表,崩溃可符号化
Debug 模式里所有断言与 JIT 都在运行,滚动掉帧属于正常现象;任何性能结论都要在 profile(或 release)模式下得出。
DevTools 三大面板
| 面板 | 看什么 | 典型结论 |
|---|---|---|
| Performance | 帧图(UI / Raster 两条柱)、jank 标记 | UI 柱高 = build+layout 超时;Raster 柱高 = 绘制过重 |
| CPU Profiler | 火焰图与方法耗时占比 | 找到最热的 Dart 方法,通常是不必要的重建或 JSON 解析 |
| Memory | 堆快照、分配追踪、GC 事件 | 内存持续上涨 = 泄漏;大图 = 解码尺寸过大 |
打开方式:flutter run 后在终端按 v,或用 DevTools 独立客户端连接;同时开启 Track widget builds 与 Highlight repaints 两个开关,重建与重绘会直接画在屏幕上,比看数字直观得多。
60/120fps 与掉帧判定
| 目标 | 单帧预算 | 判定标准 |
|---|---|---|
| 60 fps | 16.7 ms(UI + Raster 各自) | 帧图出现红色 jank 柱子即为掉帧 |
| 120 fps | 8.3 ms | 高刷屏上 16 ms 的帧也会被感知为不流畅 |
| 首帧 | 越短越好 | 冷启动白屏时长,用 --trace-startup 量化 |
判断责任方有个简单办法:UI 线程高说明 Dart 侧重建/布局太慢,去看 Track widget builds;Raster 线程高说明绘制复杂(模糊、阴影、裁剪、大图),去看 Highlight repaints 与内存面板。
常见问题与修法对照表
| 现象 | 根因 | 修法 |
|---|---|---|
| 整页频繁重建 | setState 挂在高层,const 缺失 | 拆小组件、加 const、状态管理用 Provider.select 精确订阅 |
| 长列表滚动卡 | 一次性构建全部子项、无固定高度 | 用 ListView.builder,高度一致时加 itemExtent |
| 列表图片内存高 | 按原始尺寸解码大图 | Image.network(url, cacheWidth: 200) 限制解码尺寸 |
| 局部动画导致整屏重绘 | 动画与静态内容在同一层 | 动画区域加 RepaintBoundary |
用 Opacity 做淡入淡出 | 每次都触发 saveLayer,非常贵 | 改用 AnimatedOpacity 或直接给颜色加 alpha |
| 首屏白屏久 | main 里做了太多同步初始化 | 延迟非必要初始化,先 runApp 再补数据 |
| 列表项有阴影/模糊 | 绘制成本高 | 用图片代替模糊阴影,或降低 elevation |
// 1) const 化:构造函数是 const 的 Widget 不会因为父级重建而重建
const _header = Text('固定标题', style: TextStyle(fontSize: 18));
// 2) 列表只构建可见项,并给出固定高度让布局走快路径
ListView.builder(
itemExtent: 88, // 高度一致时能省掉逐项测量
itemCount: items.length,
itemBuilder: (context, i) => ProductTile(item: items[i]),
);
// 3) 图片按显示尺寸解码,避免 4000px 原图占几十 MB 内存
Image.network(item.cover, width: 120, cacheWidth: 240, fit: BoxFit.cover);
// 4) 把不断重绘的动画与静态内容隔离开
RepaintBoundary(child: AnimatedChart(data: data));
启动时间
main里只做必要的事:WidgetsFlutterBinding.ensureInitialized()、错误上报初始化、runApp。数据库预热、埋点 SDK、远程配置都放到首帧之后。- 用
Future.microtask或首帧回调延后初始化,避免和首帧抢时间。 - Android 侧还可以检查
MainActivity的启动主题与flutter_engine预热;iOS 侧关注AppDelegate里的同步操作。 - 启动阶段默认就慢的场景(如需要登录态)应先渲染骨架屏,而不是等数据齐了再显示界面。
内存与泄漏
排查顺序:Memory 面板里连点两次 GC 再拍快照,进入并退出可疑页面,重复操作若干次后对比快照,看同类对象数量是否线性增长。常见泄漏点:AnimationController/StreamSubscription/Timer 没在 dispose 里释放;addListener 之后忘记 removeListener;闭包捕获了 BuildContext 并在异步回调里使用。DevTools 的 Memory Allocation 视图能直接定位到分配这些对象的调用栈。
包体积
| 手段 | 效果 | 代价 |
|---|---|---|
--split-per-abi | 单 ABI 包体积下降约 30%~40% | 需分别上传多个 APK |
--split-debug-info | 产物中移除调试符号,明显变小 | 必须保存符号表才能还原崩溃栈 |
--analyze-size | 输出各模块体积占比 | 仅用于分析,不产出可发布包 |
| 移除未使用资源 | 图标、字体、多语言包按需裁剪 | 需要人工核对 |
| 字体子集化 | 中文字体可省数 MB | 需重新生成字体文件 |
优化顺序建议:先用 --analyze-size 看清大头在哪里,再决定动手。多数项目的体积大头是引擎与字体资源,而不是业务代码。
验收指标与取舍
| 指标 | 建议验收线 | 说明 |
|---|---|---|
| 滚动流畅度 | 列表连续滚动无 jank 帧 | 在最低端的目标机型上测,中端机达标说明不了问题 |
| 冷启动 | 首帧 < 2 s(中端机) | 用 --trace-startup 量化,白屏时间要单独统计 |
| 内存 | 反复进出页面后堆占用不增长 | 快照对比法,允许有缓存上限但不允许线性上涨 |
| 包体积 | 单 ABI APK 增量可控 | 每次发版对比 --analyze-size 输出 |
优化的取舍比技巧更重要:
- 用户无感的 200 ms 不值得为它引入复杂的懒加载框架,可维护性也是成本。
- 包体积与开发速度通常不可兼得:为省 3 MB 砍掉国际化与埋点 SDK,只在工具类小应用里划算;业务应用优先保功能。
- “越快越好”无法验收,把它换成上面表格里的具体数字,团队才有共同语言。
- 优化代码要留痕:
itemExtent、cacheWidth这类改动必须写注释说明为什么这么写,否则后人重构时会顺手删掉。
常见坑
| 坑 | 现象 | 正确做法 |
|---|---|---|
| 在 debug 模式下评估性能 | 结论全是错的 | 一律用 flutter run --profile |
| 只优化不给指标 | 改完不知道有没有变好 | 优化前后各取一次帧图与耗时数据 |
满屏 RepaintBoundary | 图层过多,合成开销上升 | 只包真正高频重绘的区域 |
| 过早使用复杂状态管理 | 重建范围反而变大 | 先用 const/setState,确有必要再上框架 |
| 忽略图片解码尺寸 | 内存高、低端机闪退 | 用 cacheWidth/cacheHeight |
| 一次性做太多优化 | 改动风险高、收益难衡量 | 按帧图热点逐个击破 |
小结:性能优化的纪律是“profile 模式测量 → 看帧图定位 UI 还是 Raster → 按对照表修 → 再测一次对比”;重建问题靠 const、拆组件与精确订阅,长列表靠 ListView.builder 与 itemExtent,重绘靠 RepaintBoundary,图片靠 cacheWidth;启动、内存与包体积分别用 --trace-startup、DevTools 快照对比与 --analyze-size 量化,不要凭感觉下手。