深色模式
Flutter 帧流水线
概述
Flutter 生成一帧画面需要经过两个主要阶段:UI 任务运行器准备场景,Raster 任务运行器把场景转换为像素。VSync 为这套流程提供与屏幕刷新同步的时间基准。不过,用户感受到的停顿不一定发生在这两个阶段内,帧被调度之前的耗时也可能让界面迟迟没有变化。
本文讨论 iOS、Android 和桌面端等原生 Flutter 应用。Web 端由浏览器和 Web 渲染器执行,线程结构与性能工具不同,不能直接套用本文的线程模型。
排查时至少要区分三类问题:UI 帧阶段卡顿、Raster 帧阶段卡顿和帧外卡顿。本文所说的“帧外”是指 UI 与 Raster 两个执行阶段之外,包括帧调度前的耗时以及 VSync 到达后等待 UI 阶段开始的时间;它不等于 FrameTiming 记录范围之外。
VSync
VSync 是 Vertical Synchronization 的缩写,即垂直同步信号。应用请求新帧后,操作系统提供的 VSync 会触发 Flutter 开始处理这次绘制。
VSync 本身不执行构建或绘制,它只提供时间基准。动画的 Ticker 和 AnimationController 根据帧时间戳推进,而不是自行创建一套与屏幕无关的定时器。没有待处理的新帧时,Flutter 也不需要在每个 VSync 都重新构建相同画面。
不同刷新率对应不同的单帧预算:
| 刷新率 | 单帧预算 |
|---|---|
| 60 Hz | 约 16.67 ms |
| 90 Hz | 约 11.11 ms |
| 120 Hz | 约 8.33 ms |
屏幕刷新率越高,留给每个阶段的时间越短。
UI 阶段
UI 阶段运行在 UI 任务运行器上,应用 Dart 代码和 Flutter Framework 代码都在这里执行。DevTools 和日常交流通常把它称为 UI 线程;“Build 线程”并不是准确名称。
这里的 UI、Raster 和 Platform 首先表示任务职责,不保证永远对应三个独立的操作系统线程。当前 iOS 和 Android 默认让 UI 与 Platform 任务运行器共享平台主线程;Raster 通常仍在独立线程执行,部分 Platform View 组合模式还可能临时合并 Raster 与 Platform 线程。分析 Timeline 时应以实际线程轨道为准。
一帧开始后,UI 线程通常会处理这些工作:
- 推进动画并执行帧回调。
- 调用需要重建部分的
build()。 - 更新 Element Tree 和 RenderObject Tree。
- 执行 Layout 和 Paint。
- 构建
Scene并通过FlutterView.render()提交给引擎。
FrameTiming.buildDuration 大致从 PlatformDispatcher.onBeginFrame 被调用开始,到 FlutterView.render() 提交场景为止。因此,性能工具里的 Build 时间不能简单等同于某个 Widget 的 build() 执行时间,它还包含动画回调、布局、绘制和场景合成等 UI 帧工作。
这里还有一个容易混淆的边界:UI 线程也负责 Dart 事件循环,但并非它执行的所有代码都会计入 buildDuration。点击回调在请求新帧前同步计算了 200 ms,这仍然是 UI 线程阻塞,却属于帧外耗时。
Raster 阶段
UI 阶段提交的 Scene 还不是最终像素。它在引擎侧表示为 Layer Tree,随后进入帧流水线。Raster 任务运行器从流水线取出 Layer Tree,执行 Preroll 和 Paint,把裁剪、阴影、模糊、透明混合和图片采样等指令编码给 Impeller 或 Skia,再通过 GPU 或软件后端绘制到 Surface,并交给平台呈现。
Raster 线程本身仍是 CPU 线程。“GPU 耗时”是部分性能工具沿用的简化标签,它表示栅格化与图形提交阶段的耗时,不表示所有工作都只在 GPU 中执行。
FrameTiming.rasterDuration 记录场景在 Raster 线程上完成栅格化所用的时间。UI 阶段回答“这一帧画什么”,Raster 阶段回答“如何把它变成像素”。
流水线
单帧内部存在明确的前后依赖:UI 阶段先生成 Scene,Raster 阶段才能处理对应的 Layer Tree。但它们运行在不同的任务运行器上,不是同一条执行线:
从事件发生到 UI 阶段开始之前的工作不在 Build 或 Raster 阶段内。连续出帧时,两个任务运行器可以按帧形成流水线:Raster 处理第 N 帧期间,UI 可能已经在准备第 N+1 帧。下面的时间表表达的是这种重叠关系,不表示 Raster 必须等到下一个 VSync 才开始:
下面是两个阶段都接近一整个刷新周期时的流水线示意。实际任务耗时较短时,Raster N 会在 Build N 提交后立即开始,不一定跨入周期 N+1。
text
时间 周期 N 周期 N+1 周期 N+2
UI Build N Build N+1 Build N+2
Raster Raster N-1 Raster N Raster N+1
Display Frame N-1 Frame N Frame N+1以 60 Hz 为例,为维持流水线吞吐量,UI 和 Raster 阶段通常各自都不能超过约 16.67 ms,而不是把预算固定平分给两个线程。若还要降低输入到显示的延迟,则应让整帧从 VSync 到 Raster 完成也尽量控制在一个刷新周期内。
Raster 长时间来不及消费场景时,流水线会产生背压,UI 线程也不能无限准备后续帧。因此两个阶段是并行协作关系,并非完全互不影响。
卡顿分类
狭义的渲染卡顿是未按刷新节奏完成画面,屏幕继续显示旧帧。实际排障还要覆盖“没有及时产生新帧”的情况,否则 Flutter Frames 中两根柱子都很正常,用户却已经等得不耐烦。
| 分类 | 发生位置 | 主要观测指标 | 典型现象 |
|---|---|---|---|
| UI 帧阶段卡顿 | 一帧开始后的 UI 工作 | buildDuration、UI 帧柱 | 动画、布局或状态画面更新不及时 |
| Raster 帧阶段卡顿 | Layer Tree 提交之后 | rasterDuration、Raster 帧柱 | 业务状态已更新,像素没有及时显示 |
| 帧外卡顿 | UI 与 Raster 阶段之外 | vsyncOverhead、Timeline、CPU、业务耗时、平台 Trace | 新帧未及时请求,或已收到 VSync 却未及时开始 UI 阶段 |
这三类按执行阶段划分,不是三条互斥的物理线程。UI 任务运行器既会执行帧内的 Build、Layout 和 Paint,也会执行帧外的点击回调、微任务和其他 Dart 事件;Platform 任务、工作线程和系统显示链路也可能造成帧外停顿。同一次问题还可能跨越多个分类。
UI 阶段卡顿
UI 阶段超过帧预算时,新的 Layer Tree 无法按时提交给 Raster 线程。当前画面会停在旧状态,恢复后动画通常根据已经过去的时间跳到较新的进度。
常见原因包括:
- 单次重建范围过大,或者一帧内发生大量重复重建。
build()、帧回调或布局过程中执行耗时同步计算。- 复杂布局、Intrinsic 测量或重复 Layout。
- Paint 阶段遍历和记录了过多绘制指令。
- 频繁更新状态,在连续多帧中制造过量 UI 工作。
常见表现包括:
- DevTools 中 UI 柱超过帧预算并标红。
- 滚动和动画停住,恢复后位置或进度发生跳变。
- 简化 Widget Tree、布局或 Paint 后,慢帧明显减少。
UI 阶段卡顿表示:新场景正在生成,但没有按时提交。它不能覆盖 UI 线程上的所有阻塞。
Raster 卡顿
Raster 阶段卡顿时,UI 线程可能已经处理事件、更新状态并生成新的 Layer Tree,但场景尚未转换成能够显示的像素。在流水线产生背压之前,Dart 业务逻辑甚至可以继续执行。
常见原因包括:
- 大面积使用
BackdropFilter或ImageFilter.blur()。 - 复杂阴影、裁剪和透明图层混合。
- 不必要的
saveLayer()。 - 超大纹理的上传、缩放和采样。
- 单帧需要栅格化的对象或像素过多。
常见表现包括:
- DevTools 中 Raster 柱超过帧预算并标红。
- 点击日志已经输出,按钮的按压态或颜色变化稍后才显示。
- 简单页面流畅,出现复杂图片、模糊或阴影时开始掉帧。
- 动画状态持续推进,屏幕停顿后直接显示较新的进度。
Raster 卡顿表示:新场景已经生成,但还没有按时画出来。
帧外卡顿
帧外卡顿不是 Flutter 官方定义的第三个渲染阶段,而是一个排障分类:耗时没有落入目标帧的 buildDuration 或 rasterDuration。它可能表现为 vsyncOverhead 增大,也可能因为尚未请求新帧而根本没有 FrameTiming 样本。
常见原因包括:
- 点击回调、微任务或其他 Dart 事件在请求新帧前执行大量同步计算。
- UI Isolate 执行同步 I/O、阻塞式 FFI 或超长任务,导致后续事件和帧请求排队。
- Platform Channel 对应的平台任务繁忙,结果迟迟没有返回;在当前 iOS 和 Android 默认线程模型下,它还可能直接与 Dart 代码争用同一个平台主线程。
- 数据库、文件、网络或资源加载尚未完成,界面又没有及时显示加载状态。
- 状态已经变化,但自定义调度逻辑没有请求新帧。
- Platform View、系统合成或显示链路出现 Flutter 帧柱无法完整解释的延迟。
其中,等待网络或数据库返回更准确地说是响应延迟,不一定属于狭义的掉帧。把它纳入帧外卡顿,是为了按用户感知排查“界面为什么停住”,而不是把所有慢操作都解释成渲染慢帧。
一个典型例子是点击按钮后先同步解析大量 JSON,再调用 setState():
dart
onPressed: () {
final data = parseLargeJsonSynchronously();
setState(() => result = data);
}前面的解析发生在 UI Isolate,却位于这次新帧的 Build 之前。Flutter Frames 可能只显示解析结束后的正常 UI 与 Raster 耗时;Timeline 和 CPU Profiler 才能定位这段 200 ms 的同步任务。若动画同时运行,帧已经提前请求,这个长任务还可能表现为很高的 vsyncOverhead,并妨碍已有帧事件及时执行。
统计边界
FrameTiming 提供四个理解卡顿边界的重要指标:
buildDuration:UI 线程构建并提交场景的时间。rasterDuration:Raster 线程栅格化这一帧的时间。vsyncOverhead:收到 VSync 到 UI 阶段真正开始之间的时间。totalSpan:从 VSync 开始到 Raster 完成的总时间。
vsyncOverhead 不属于 buildDuration 或 rasterDuration,但仍位于 FrameTiming.totalSpan 内。本文按执行阶段把它归入帧外卡顿;若按统计范围划分,它又是帧计时的一部分。明确这个口径,才能避免把“帧外”误解成“FrameTiming 完全看不到”。
若 UI 与 Raster 柱都不高,而 totalSpan 仍然异常,应先检查 vsyncOverhead、线程争用和流水线背压。真正位于 FrameTiming 之外的耗时通常发生在目标帧被请求之前,Flutter Frames 中没有能覆盖这段停顿的样本。此时只盯着红色慢帧,会漏掉问题发生的位置。
对比例子
假设点击按钮后,回调记录日志并把按钮改成红色:
| 观察结果 | 优先检查 |
|---|---|
| 点击后日志也延迟,附近没有对应慢帧 | 帧外的 Dart 事件、同步任务或平台调用 |
| 日志及时出现,UI 柱标红 | Build、Layout、Paint 和帧回调 |
| 日志及时出现,Raster 柱标红 | 模糊、阴影、裁剪、图层和纹理 |
| UI 与 Raster 都标红 | UI 是否生成了过于复杂的场景,再检查 Raster 成本 |
| 两根柱都正常,但按钮很晚才变化 | 帧调度前的业务链路、Platform 线程和完整 Timeline |
这些现象只能帮助缩小范围,不能代替时间线证据。线程调度、流水线背压和平台事件分发可能让一次卡顿同时出现多种表现。
排查方法
卡顿分析应在真机的 Profile 模式下进行。Debug 模式包含断言、调试信息等额外开销,帧时间不能代表正式版本性能。
sh
flutter run --profile打开 DevTools 的 Performance 页面,从事件发生前开始录制完整 Timeline,再复现卡顿:
- 先查看 Flutter Frames,确认 UI 或 Raster 是否超过当前设备的帧预算。
- UI 柱标红时,检查 Dart 调用栈、帧回调、Build、Layout 和 Paint。
- Raster 柱标红时,检查图片、模糊、阴影、裁剪、透明混合和图层数量。
- 没有对应慢帧时,沿完整 Timeline 向前检查 UI Isolate 长任务、异步等待和帧请求时机。
- Flutter Timeline 无法解释时,再使用 Android 系统 Trace、Android Studio Profiler 或 Instruments 检查平台线程和系统显示链路。
Performance Overlay 中,上方标记为 GPU 的图表对应 Raster 阶段,下方图表对应 UI 阶段。DevTools 的 Flutter Frames 图表则为每一帧分别显示 UI 与 Raster 耗时。它们适合定位帧内瓶颈,但不能独自证明不存在帧外卡顿。Flutter Web 应改用 Chrome DevTools 的 Performance 面板分析。
