测试说“这个页面感觉有点卡”,开发打开代码,先加 const、拆 Widget、减少 setState。改完以后,大家再凭手感判断有没有变快。
问题在于,“卡”可能是滚动掉帧,也可能是点击后迟迟没有反馈、接口回来后解析太慢,或者首屏内容出现得晚。它们不是同一种问题。
核心结论:第一步不是测 Rebuild 次数,也不是看平均 FPS,而是固定一个具体操作,记录“用户动作到可见结果”的时间线。只有先确定慢在哪个阶段,后面的指标才有意义。
先把“卡”改写成一句可测的话
“订单页很卡”无法复现,也无法比较。可以改成:
- 点击订单后,加载态多久出现;
- 数据返回后,内容多久完成显示;
- 快速滚动列表时,哪些帧超过预算;
- 页面首次打开时,首个界面和主要内容分别何时可见。
一次只选一个场景,并固定设备、构建模式、数据量、网络条件和操作步骤。否则两次结果的差异可能来自环境,而不是代码修改。
这条链路中任何一段变慢,用户都可能只说一句“卡”。
不同的“卡”,第一指标不同
| 现象 | 第一批指标 | 先回答的问题 |
|---|---|---|
| 滚动、动画不顺 | 单帧 UI 与 Raster 耗时、慢帧位置 | 是哪一帧没按时完成? |
| 点击后没反应 | 输入到首次反馈的耗时 | 事件没处理,还是反馈出现太晚? |
| 内容出现很慢 | 请求、解析、状态提交、首帧各阶段耗时 | 时间花在网络还是本地? |
| 首次打开慢 | TTID、TTFD | 首个界面和完整内容分别多晚? |
TTID(Time to Initial Display)关注初始界面何时显示,TTFD(Time to Full Display)关注主要内容何时完整可用。它们不能用一个“页面耗时”混在一起。
滚动卡,先看慢帧而不是平均 FPS
在 60 Hz 屏幕上,每帧间隔约为 16.7 ms;120 Hz 约为 8.3 ms。少数帧明显超时,平均 FPS 仍可能看起来不错,但用户已经感到停顿。
在目标真机的 Profile 模式下,用 DevTools Performance 录制问题发生的短时间窗口,先看:
- 慢帧是否稳定出现在同一操作;
- UI 侧还是 Raster 侧超出帧预算;
- 慢帧附近发生了哪些业务事件。
UI 侧慢,再检查同步计算、Build、Layout 和 Paint 记录;Raster 侧慢,再检查大图、复杂绘制、滤镜和纹理相关工作。两边都正常,点击却迟迟没有结果,就应继续查网络、平台调用和业务等待,不要硬在 Widget 树里找原因。
给业务链路留下时间节点
只有 Flutter 帧图时,经常只能看到“这一帧很慢”,却不知道它对应哪个业务动作。可以用 TimelineTask 标记关键节点:
import 'dart:developer';
Future<void> loadOrderDetail() async {
final task = TimelineTask()..start('load_order_detail');
try {
setState(() => _loading = true);
await WidgetsBinding.instance.endOfFrame;
task.instant('loading_visible');
final order = await orderRepository.loadOrder(orderId);
task.instant('data_ready');
if (!mounted) return;
setState(() {
_order = order;
_loading = false;
});
await WidgetsBinding.instance.endOfFrame;
task.instant('content_visible');
} catch (_) {
if (mounted) setState(() => _loading = false);
rethrow;
} finally {
task.finish();
}
}这样可以区分加载反馈晚、请求慢、数据处理慢,还是数据就绪后那一帧太重。线上长期指标应使用项目统一的性能埋点,并避免记录 Token、用户输入等敏感数据。
测量时最容易犯的错
在 Debug 模式下得出性能结论
Debug 包含断言、调试服务和额外检查。它适合找功能问题,性能分析优先使用 Profile,最终结论还要在 Release 与目标设备上验证。
一次录制就宣布优化有效
首次运行可能包含缓存预热、图片解码或 Shader 准备。冷启动与预热后的场景应分开记录,同一脚本重复测量,关注慢帧和长尾,而不是挑最好的一次。
先开一堆调试开关
重建统计、布局边界和重绘提示都能帮助验证具体假设,但它们不是第一指标。先找到慢的时间段和执行侧,再打开对应工具,证据会更干净。
一套够用的第一轮排查
- 把“感觉卡”改成一个具体动作和可见结果。
- 在目标真机记录构建模式、刷新率、数据量和网络条件。
- 用 Profile 模式只录制问题发生的短窗口。
- 滚动问题先看 UI、Raster 单帧耗时;响应问题先看动作到反馈的分段耗时。
- 找到最慢阶段后提出一个根因假设,一次只改一个主要变量。
- 用相同场景重新采集,比较慢帧、分段耗时和长尾是否改善。
最后记住这几点
- “感觉卡”不是指标,先定义具体操作和可见结果。
- 滚动卡先看单帧 UI、Raster 耗时,不要只看平均 FPS。
- 点击慢要测输入到反馈,内容慢要拆请求、处理和显示阶段。
- Profile 真机用于定位,Release 真机用于最终验证。
- Rebuild、重绘提示和各种优化手段,应在根因假设之后使用。
- 优化是否有效,要靠同场景的前后数据证明。
问答复盘
Q1:页面感觉卡,第一步应该打开重建统计吗?
答: 不应该。先确定具体场景和慢的时间段;只有怀疑 Build 成本时,重建统计才是对应证据。
Q2:平均 FPS 很高,为什么用户仍觉得卡?
答: 少数长帧会被平均值掩盖。用户能感受到短暂停顿,因此应查看具体慢帧和长尾。
Q3:UI 帧耗时高和 Raster 帧耗时高有什么区别?
答: UI 侧优先查 Dart、Build、Layout 等工作;Raster 侧优先查大图、滤镜和复杂绘制。两者需要不同证据和方案。
Q4:点击后接口很慢,也算 Flutter 掉帧吗?
答: 不一定。帧可能都按时完成,但业务结果迟迟未返回。此时应测输入、反馈、请求和内容显示的分段耗时。
Q5:60 Hz 设备不卡,120 Hz 设备为什么可能卡?
答: 120 Hz 每帧只有约 8.3 ms,比 60 Hz 的约 16.7 ms 更紧。测试时必须记录实际刷新率。
Q6:为什么冷启动和第二次打开要分开测?
答: 缓存、图片解码和运行时预热会改变成本。混在一起比较,无法判断优化作用在哪个场景。
Q7:怎样证明一次优化真的有效?
答: 在同一设备和脚本下重复采集,对比目标阶段的慢帧、分段耗时与长尾,而不是依赖主观手感。