深色模式
FrameTiming 数据采集与分析
概述
FrameTiming 可以在 Release 模式中持续提供 UI、Raster、调度等待和整帧延迟数据,适合发现性能退化集中在哪些版本、设备、页面和交互场景。它只提供帧级结果,监控系统还需要补充刷新率、业务上下文和帧外响应时间。
本文关注数据采集、统计和分析。字段结构与回调原理见 FrameTiming,帧的生成过程见 Flutter 帧流水线。
监控口径
frameBudget 是人为选择的目标阈值,不是 FrameTiming 自带的逐帧截止时间。用阶段耗时和它比较,可以稳定回答“UI 或 Raster 是否超过目标预算”;仅凭这个布尔结果,不能严格证明画面是否真的错过系统呈现时机。
采集前先分开四个概念:
| 指标 | 参考判断 | 含义 |
|---|---|---|
| UI 阶段超预算 | buildDuration > targetBudget | UI 阶段未达到设定目标 |
| Raster 阶段超预算 | rasterDuration > targetBudget | Raster 阶段未达到设定目标 |
| 调度超预算 | vsyncOverhead > targetBudget | VSync 到达后,UI 阶段迟迟没有开始 |
| 内部高延迟 | totalSpan > targetBudget | 从 VSync 到 Raster 完成超过目标周期 |
Flutter API 文档建议用 buildDuration 或 rasterDuration 是否超过目标显示帧率的预算来发现 missed frame,用 totalSpan 发现高延迟。工程监控更适合把结果命名为“阶段超预算”,把“确认卡顿”留给具有逐帧 Deadline 或实际呈现数据的平台指标。
一个样本可以同时命中多个条件。例如 UI 阶段生成了复杂场景,既可能让 buildDuration 超预算,也可能让后续 rasterDuration 超预算。
不要使用下面的公式判断阶段是否超预算:
text
buildDuration + rasterDuration > targetBudgetUI 和 Raster 位于不同任务运行器,可以按相邻帧形成流水线。两者之和超过刷新周期时,吞吐量仍可能稳定,但 totalSpan 会暴露输入到画面反馈的额外延迟。
帧预算
目标帧预算由选定的目标帧率决定:
text
targetBudget = 1000 ms / targetFps| 刷新率 | 帧预算 |
|---|---|
| 60 Hz | 约 16.67 ms |
| 90 Hz | 约 11.11 ms |
| 120 Hz | 约 8.33 ms |
目标帧率有两种常见口径:
- 产品目标:例如所有设备统一按 60 FPS 统计,适合跨设备、跨版本比较。
- 显示目标:按设备报告的刷新率统计,适合衡量应用是否发挥 90 Hz 或 120 Hz 屏幕能力。
单窗口应用可以从当前 FlutterView 所在的 Display 读取报告刷新率:
dart
double reportedRefreshRate() {
final views = WidgetsBinding.instance.platformDispatcher.views;
if (views.isEmpty) {
return 60;
}
final refreshRate = views.first.display.refreshRate;
return refreshRate > 0 ? refreshRate : 60;
}这个值适合提供目标参考,但不是某个 FrameTiming 的可靠 Deadline,原因包括:
- 手机可能使用动态或自适应刷新率,系统会根据内容、功耗和运行状态调整实际节奏。
FrameTiming在 Release 模式按批返回,回调发生时读取的刷新率可能不是历史帧发生时的值。FrameTiming不携带刷新率或 View 标识,多窗口场景无法只靠对象本身关联显示设备。- 应用可以主动以低于屏幕刷新率的节奏更新,屏幕刷新率不等于应用目标帧率。
相邻帧的 vsyncStart 差值也不能无条件充当预算。只有动画或滚动连续请求帧时,它才近似反映 Flutter 收到的 VSync 节奏;静态页面、按需出帧和已经跳过多个周期时,差值会是多个刷新周期。
因此,线上系统应保留原始耗时,同时记录 target_fps、budget_source 和设备报告刷新率。跨设备趋势可以再增加固定耗时分桶,例如 8.33 ms、11.11 ms、16.67 ms、33.33 ms 和 50 ms,避免阈值变化后无法重新分析历史数据。
如果目标是严格判断用户是否看到了掉帧,需要平台提供的截止时间或呈现结果。例如 Android 12 及以上的 FrameMetrics.DEADLINE、Frame Timeline 或 Perfetto,以及 iOS Instruments、MetricKit 的 Hitch 指标。FrameTiming 负责解释 Flutter 内部阶段,它不单独承担最终呈现判定。
基础采集
下面的监控器完成三件事:注册一次回调、遍历每批全部帧、把轻量样本暂存在内存中。
dart
import 'dart:ui';
import 'package:flutter/scheduler.dart';
import 'package:flutter/widgets.dart';
typedef FrameSample = ({
int frameNumber,
double targetFps,
int targetBudgetUs,
int buildUs,
int rasterUs,
int vsyncOverheadUs,
int totalUs,
bool uiOverBudget,
bool rasterOverBudget,
bool schedulingOverBudget,
bool highInternalLatency,
});
class FrameMonitor {
FrameMonitor({required this.readTargetFps});
final double Function() readTargetFps;
late final TimingsCallback _callback = _onTimings;
final List<FrameSample> _pending = [];
bool _started = false;
void start() {
if (_started) {
return;
}
_started = true;
SchedulerBinding.instance.addTimingsCallback(_callback);
}
void stop() {
if (!_started) {
return;
}
SchedulerBinding.instance.removeTimingsCallback(_callback);
_started = false;
}
List<FrameSample> takePending() {
final result = List<FrameSample>.unmodifiable(_pending);
_pending.clear();
return result;
}
void _onTimings(List<FrameTiming> timings) {
final targetFps = readTargetFps();
final budgetUs = (
Duration.microsecondsPerSecond / targetFps
).round();
for (final timing in timings) {
final buildUs = timing.buildDuration.inMicroseconds;
final rasterUs = timing.rasterDuration.inMicroseconds;
final overheadUs = timing.vsyncOverhead.inMicroseconds;
final totalUs = timing.totalSpan.inMicroseconds;
_pending.add((
frameNumber: timing.frameNumber,
targetFps: targetFps,
targetBudgetUs: budgetUs,
buildUs: buildUs,
rasterUs: rasterUs,
vsyncOverheadUs: overheadUs,
totalUs: totalUs,
uiOverBudget: buildUs > budgetUs,
rasterOverBudget: rasterUs > budgetUs,
schedulingOverBudget: overheadUs > budgetUs,
highInternalLatency: totalUs > budgetUs,
));
}
}
}在 runApp() 前启动,能够接收第一帧的 Timing:
dart
final frameMonitor = FrameMonitor(
readTargetFps: reportedRefreshRate,
);
void main() {
WidgetsFlutterBinding.ensureInitialized();
frameMonitor.start();
runApp(const MyApp());
}takePending() 应由低频任务定期调用。示例为了展示字段而保留原始样本,正式实现应设置内存上限,正常帧尽量直接累计到计数器或直方图,避免列表持续增长。
采集链路
addTimingsCallback() 的底层跟踪开销较低,真正容易制造新卡顿的是回调中的业务代码。回调运行时不要执行:
- 网络请求或数据库写入。
- JSON 编码和压缩。
- 大量日志输出。
- 百分位排序。
- 复杂设备信息查询。
- 无上限的 Map 或 List 写入。
推荐的数据链路如下:
text
FrameTiming 批量回调
↓
轻量计数器、直方图或有界环形缓冲区
↓
按页面、交互和会话聚合
↓
后台批量编码
↓
定时、退到后台或会话结束时上传Release 模式大约每秒回调一次,收到的列表按时间升序排列。不能假设一次回调只包含一帧,也不能把回调时间当作帧发生时间。
统计指标
Flutter 官方建议关注 Build 和 Raster 耗时的平均值、P90、P99 与最差值。线上监控还可以按会话和场景补充以下指标:
text
frame_count
ui_over_budget_frame_count
raster_over_budget_frame_count
scheduling_over_budget_frame_count
high_internal_latency_frame_count
build_average / p90 / p99 / max
raster_average / p90 / p99 / max
vsync_overhead_average / p90 / p99 / max
total_span_average / p90 / p99 / max基础比率可以这样定义:
text
UI 超预算率 = ui_over_budget_frame_count / frame_count
Raster 超预算率 = raster_over_budget_frame_count / frame_count
内部高延迟率 = high_internal_latency_frame_count / frame_count
受影响会话率 =
出现过严重慢帧的会话数 / 总会话数不要简单相加 UI 与 Raster 超预算帧数,同一帧可能同时命中两类。若需要总超预算帧数,应按帧编号去重后统计 uiOverBudget || rasterOverBudget。
平均值容易掩盖偶发卡顿,P90、P99、最差帧和受影响会话率通常更敏感。静态页面只在变化时出帧,因此不同页面的 frame_count 天然不同;比较慢帧率时应同时保留采样时长、动画或滚动时长和帧数。
采样策略
逐帧上传成本很高,也没有必要。常见策略是:
- 正常帧只更新本地计数器和直方图。
- 阶段超过目标预算的帧保留少量原始样本。
- 超过两倍预算或连续慢帧时,附带更多现场上下文。
- 按用户、会话或设备档位做稳定采样,避免每次启动随机改变分组。
- 为单次会话和单个页面设置样本上限。
原始数据应优先记录微秒整数,展示和报表阶段再换算为毫秒,避免多次浮点换算引入不一致。
现场上下文
FrameTiming 不包含路由、交互和 Widget 信息,而且回调存在批处理延迟。发现慢帧后再读取当前页面,拿到的可能已经不是卡顿发生时的现场。
应用应持续维护低成本的上下文事件,例如:
text
12:00:00.100 进入 product_detail
12:00:00.350 开始 hero_transition
12:00:00.420 product_image 开始加载
12:00:00.450 blur_header 开始显示
12:00:00.510 Frame 1821 Raster = 38 ms
12:00:00.600 hero_transition 结束慢帧样本可以关联:
- 当前路由和上一个路由。
- 页面切换、滚动和拖动等交互。
- 正在执行的动画。
- 可见列表范围和数据量。
- 图片尺寸、加载状态和缓存状态。
- 模糊、阴影、透明、裁剪等视觉效果。
- 视频、地图和 WebView 等 Platform View。
- 应用版本、设备型号、系统版本和刷新率。
- 功能开关与实验分组。
上下文应在事件发生时写入有界缓冲区,而不是等 Timing 回调到达后临时采集。不要持续序列化完整 Widget Tree,它的数据量和运行成本很高,也可能包含用户输入等敏感信息。
帧号关联
FrameTiming.frameNumber 可以和帧生成阶段的 PlatformDispatcher.frameData.frameNumber 对应。若业务本来就会注册当前帧的回调,可以保存当时的轻量上下文:
dart
final contextByFrame = <int, Map<String, Object>>{};
void captureInCurrentFrame() {
final frameNumber = WidgetsBinding
.instance
.platformDispatcher
.frameData
.frameNumber;
if (frameNumber >= 0) {
contextByFrame[frameNumber] = capturePerformanceContext();
}
}
void onTimings(List<FrameTiming> timings) {
for (final timing in timings) {
final context = contextByFrame.remove(timing.frameNumber);
reportFrame(timing, context);
}
}captureInCurrentFrame() 必须在帧回调或帧生成过程中调用。在普通事件里直接读取 frameData.frameNumber,得到的可能是上一帧。不要为了采集上下文额外调用 scheduleFrame(),否则监控代码会改变应用原本的出帧行为。
帧号只在同一次 Engine 会话中有意义。上传时应组合 session_id、engine_id 和 frame_number,并为 -1 或找不到上下文的情况准备时间关联方案。
时间关联
当业务上下文只记录了系统时间,可以使用 rasterFinishWallTime 把帧映射到相同时间轴。帧开始时间可以用 Raster 完成的墙上时间减去 totalSpan 近似得到:
dart
({int startWallUs, int endWallUs}) frameWallRange(
FrameTiming timing,
) {
final endWallUs = timing.timestampInMicroseconds(
FramePhase.rasterFinishWallTime,
);
return (
startWallUs: endWallUs - timing.totalSpan.inMicroseconds,
endWallUs: endWallUs,
);
}应用可以维护最近几秒的上下文环形缓冲区,收到慢帧后提取该时间范围附近的事件。墙上时钟可能被系统校准,这种方式适合关联日志,不如同一单调时钟或帧号精确。
帧外数据
仅靠 FrameTiming 会漏掉目标帧被请求之前的阻塞:
dart
void onPressed() {
doHeavyWork(); // 同步执行 100 ms。
setState(() {});
}setState() 在任务结束后才请求新帧。随后产生的帧可能只有 5 ms Build 和 4 ms Raster,用户却已经等待了 100 ms。
关键交互需要另外记录:
text
input_to_callback_us
callback_duration_us
callback_to_frame_start_us
input_to_raster_finish_us已知的同步业务操作可以显式计时:
dart
T measureSync<T>(
String name,
T Function() action,
void Function(String name, int elapsedUs) onCost,
) {
final stopwatch = Stopwatch()..start();
try {
return action();
} finally {
stopwatch.stop();
onCost(name, stopwatch.elapsedMicroseconds);
}
}异步数据库、文件或网络等待不会持续占用 UI Isolate,但仍会增加用户等待时间。它们应作为业务响应延迟单独统计,不能伪装成 UI 或 Raster 慢帧。
数据分析
单帧指标只能确定优先排查方向,常见组合如下:
| 数据表现 | 优先方向 |
|---|---|
buildDuration 高,rasterDuration 正常 | Dart 帧回调、Build、Layout、Paint |
rasterDuration 高,buildDuration 正常 | 图层、模糊、阴影、裁剪、透明混合、纹理 |
| UI 与 Raster 同时高 | UI 是否生成了过于复杂的场景,再检查 Raster |
两者正常,vsyncOverhead 高 | UI 任务运行器被其他事件占用或调度等待 |
两者正常,totalSpan 高 | vsyncOverhead、阶段间排队、流水线背压 |
| 所有帧指标正常,交互仍然慢 | 帧请求前的业务任务、异步等待或平台链路 |
分析聚合数据时,先按应用版本、设备档位、系统版本、刷新率和页面分组,再比较 P90、P99 与受影响会话率。只有极少数低端设备异常,和所有设备在同一版本同时退化,处理优先级与根因方向通常不同。
UI 定位
buildDuration 过高只说明 UI 帧阶段耗时,根因可能是 Dart 计算、Widget 重建、Layout、Paint 或 GC。线上数据用于定位场景,具体代码仍应在 Profile 模式复现后检查 DevTools:
- Track Widget Builds:查看参与重建的 Widget。
- Track Layouts:查看执行 Layout 的 RenderObject。
- Track Paints:查看执行 Paint 的 RenderObject。
- CPU Profiler:查看 Dart 调用栈和同步计算热点。
- 自定义 Timeline 事件:关联关键业务方法与慢帧。
增强追踪会增加额外耗时,不能把开启后的绝对数值直接当成线上性能。
Raster 定位
Raster 线程处理 Layer Tree 和绘制指令,不再处理 Widget Tree。Flutter 没有公共 API 可以从 FrameTiming 直接得到某个 Widget 的 Raster 耗时。
复现后可以依次检查:
- 重绘区域是否超出预期。
RepaintBoundary是否正确隔离变化频率不同的子树。- 是否存在大面积
BackdropFilter、saveLayer()、Opacity、Clip 或阴影。 - 图片解码尺寸是否远大于实际显示尺寸。
- Raster Cache 数量和占用是否发生异常变化。
- Platform View、视频或外部纹理是否参与当前画面。
- 必要时使用平台 GPU Capture 或系统 Trace。
RepaintBoundary 并非越多越好。额外图层也会增加合成和缓存成本,优化前后应使用相同场景和设备比较数据。
平台边界
FrameTiming.rasterFinish 表示 Flutter Raster 阶段结束,不保证这一帧最终被系统合成器显示。完整体验还可能受到以下环节影响:
- Android 或 iOS 系统合成。
- GPU 驱动和缓冲区交换。
- Platform View 和原生页面。
- 外部纹理生产速度。
- 应用切换前后台和系统负载。
这部分需要结合 Android FrameMetrics、JankStats、Perfetto,或 iOS Instruments、MetricKit。Flutter 帧指标与平台呈现指标应分开存储,再通过时间和会话关联。
数据边界
一套可靠的监控应明确以下限制:
FrameTiming只覆盖已经完成 Raster 的 Flutter 帧。- 静态页面没有新帧是正常状态,不应计为卡顿。
- 帧数不是屏幕实际显示次数。
- Cache 数量和占用不能直接证明缓存命中效果。
- 页面或性能区域与慢帧同时出现,只能说明相关,不能直接证明因果。
- 多窗口数据缺少公开 View 标识,需要单独设计口径。
targetBudget只能表示选定的性能目标,不能证明系统是否实际显示或重复了某一帧。- 监控回调、上下文和上传代码本身也需要控制 CPU、内存和网络开销。
线上监控负责发现退化并缩小范围,DevTools、系统 Trace 和稳定复现路径负责完成根因定位。两者作用不同,不能用一份慢帧报表替代性能分析。
