测试说“这个页面感觉有点卡”,开发打开代码,先加 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 录制问题发生的短时间窗口,先看:

  1. 慢帧是否稳定出现在同一操作;
  2. UI 侧还是 Raster 侧超出帧预算;
  3. 慢帧附近发生了哪些业务事件。

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 准备。冷启动与预热后的场景应分开记录,同一脚本重复测量,关注慢帧和长尾,而不是挑最好的一次。

先开一堆调试开关

重建统计、布局边界和重绘提示都能帮助验证具体假设,但它们不是第一指标。先找到慢的时间段和执行侧,再打开对应工具,证据会更干净。

一套够用的第一轮排查

  1. 把“感觉卡”改成一个具体动作和可见结果。
  2. 在目标真机记录构建模式、刷新率、数据量和网络条件。
  3. 用 Profile 模式只录制问题发生的短窗口。
  4. 滚动问题先看 UI、Raster 单帧耗时;响应问题先看动作到反馈的分段耗时。
  5. 找到最慢阶段后提出一个根因假设,一次只改一个主要变量。
  6. 用相同场景重新采集,比较慢帧、分段耗时和长尾是否改善。

最后记住这几点

  • “感觉卡”不是指标,先定义具体操作和可见结果。
  • 滚动卡先看单帧 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:怎样证明一次优化真的有效?

答: 在同一设备和脚本下重复采集,对比目标阶段的慢帧、分段耗时与长尾,而不是依赖主观手感。