cubegao

Flutter Frame 调度机制与 Vsync 原理

2021-11-08

一、从消息列表的一个「诡异」现象说起

在企业 IM 的聊天页面中,我们曾观察到这样一个现象:短时间内连续收到 5 条消息,每条消息到达时都调用一次 setState,理论上应该触发 5 次重建。但用 DevTools 的 Timeline 观察,实际上只执行了 1 次 build

更进一步,如果这 5 条消息恰好跨越了两个 Vsync 信号的间隙(约 8ms),就会出现 2 次重建;如果在 16ms 窗口内全部到达,就只重建 1 次。

这个行为不是巧合,而是 Flutter Frame 调度机制的刻意设计。理解这个机制,才能回答一系列核心问题:

  • setState 之后 UI 为什么不是立即更新的?
  • 多个 setState 如何被合并为一帧?
  • 帧回调的四种类型分别在什么时机执行、各自解决什么问题?
  • 企业 IM 中消息列表滚动、动画播放、键盘弹起这些高频场景,底层如何协调帧资源?

本文将围绕 SchedulerBinding 和 Vsync 信号,把 Flutter 帧调度的完整机制讲清楚。

二、Vsync 信号:一切帧的起点

2.1 为什么需要 Vsync

屏幕以固定频率刷新(通常 60Hz,即每 16.67ms 一次)。如果 Flutter 在屏幕两次刷新之间提交了多帧画面,只有最后一帧能被显示,前面的工作全部浪费,这就是「过度绘制」(不是 overdraw,而是 redundant frame production)。

更差的情况是「画面撕裂」——GPU 正在写入一帧时,屏幕控制器恰好读取了一半,导致上下两部分是不同帧的内容。

Vsync(垂直同步)信号就是解决这两个问题的硬件机制:屏幕控制器在每次垂直消隐期发出一个脉冲信号,只有收到这个信号后,应用才可以开始准备下一帧。

2.2 Flutter 如何接入 Vsync

Flutter 的 Vsync 接入点在 Engine 层。Window 对象持有一个 onBeginFrame 回调,当硬件 Vsync 信号到达时,Engine 通过 Platform 层的 Choreographer(Android)或 CADisplayLink(iOS)触发这个回调,进入 Dart 侧的帧处理流程。

核心是 TickerProviderTicker

1
2
3
4
5
6
7
8
9
10
11
class Ticker {
void start() {
_animationId = SchedulerBinding.instance
.scheduleFrameCallback(_tick, rescheduling: true);
}

void _tick(Duration elapsed) {
_onTick(elapsed);
// rescheduling: true 意味着回调执行完后自动注册下一帧
}
}

Ticker 是 Vsync 信号在 Dart 侧的直接消费者。每个 AnimationController 内部都持有一个 Ticker,每收到一个 Vsync 信号就推进一帧动画。Tickerrescheduling 机制使得动画能连续推进——当前帧执行完毕后自动注册下一帧的回调,只要动画没结束,Ticker 就持续激活。

当没有任何 Ticker 需要回调时,Flutter 不会再向 Engine 请求 Vsync 信号,这就是 Flutter 的「按需渲染」策略——没有变化就不消耗资源。

三、SchedulerBinding:帧调度的总指挥

3.1 SchedulerBinding 的位置

SchedulerBinding 是 Flutter 框架层的核心 Mixin,负责管理帧回调的生命周期。它位于 Binding 体系的关键路径上:

1
2
3
GestureBinding → ServicesBinding → SchedulerBinding

RendererBinding → WidgetsBinding

SchedulerBinding 在 RendererBinding 之前初始化,这意味着 RendererBinding 的 drawFrame 方法可以作为 persistentCallbacks 注册到 SchedulerBinding 中。

3.2 帧生命周期

Flutter 一帧的完整生命周期如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
硬件 Vsync 信号到达

Engine 调用 Window.onBeginFrame

SchedulerBinding.handleBeginFrame(now)

执行 transientCallbacks(Ticker 回调、动画更新)

SchedulerBinding.handleDrawFrame(now)

执行 persistentCallbacks(RendererBinding.drawFrame)

├── buildOwner.buildScope(Build 阶段)
├── pipelineOwner.flushLayout(Layout 阶段)
└── pipelineOwner.flushPaint / compositeFrame(Paint + Composite)

执行 postFrameCallbacks(帧完成后的钩子)

Engine 提交到 GPU 线程

下一个 Vsync 信号...

一帧内的执行顺序是严格保证的:transient → persistent → postFrame。每一类回调都有明确的职责边界,下一节展开。

四、四类帧回调的职责划分

Flutter 提供了四种帧回调类型,各自解决不同的问题。理解它们的差异,才能在做帧级优化时把逻辑放在正确的时机。

4.1 transientCallbacks(临时回调)

对应方法:SchedulerBinding.scheduleFrameCallback()

特点:一次性的「临时」回调。执行后自动移除,不会重新注册。需要 rescheduling: true 才会自动续期(这就是 Ticker 的实现方式)。

典型用途:

  • Ticker 驱动的动画:每帧更新动画值,动画结束后停止回调。
  • 短时效的视觉反馈:如 InkWell 的水波纹效果,在几百毫秒内完成,帧回调结束后自动清理。

在企业 IM 中,消息发送按钮的点击动效就依赖 transientCallbacks。点击后水波纹向外扩散,Ticker 在 300ms 内推进约 18 帧动画,结束后自动移除回调,不残留任何帧开销。

4.2 persistentCallbacks(持久回调)

对应方法:SchedulerBinding.addPersistentFrameCallback()

特点:注册后每帧都会执行,直到显式移除。

典型用途:

  • RendererBinding.drawFrame():Flutter 框架唯一注册的 persistentCallback,每帧执行 Build、Layout、Paint、Composite 全流程。
  • 官方不推荐开发者直接使用 persistentCallbacks,因为它会强制每帧都触发整个渲染管线,绕过 Flutter 的「脏标记」优化。

如果开发者自行添加 persistentCallbacks,哪怕 UI 没有任何变化,每帧都会走一遍 Build → Layout → Paint → Composite,严重浪费 CPU 和 GPU 资源。除非是在做自定义渲染引擎级别的扩展,否则不应使用。

4.3 postFrameCallbacks(帧后回调)

对应方法:SchedulerBinding.addPostFrameCallback()

特点:在当前帧的 Build / Layout / Paint 全部完成后执行。一次性回调,不重复触发。

这是最常用的帧回调类型,典型业务场景包括:

场景一:获取 Widget 尺寸

聊天页面需要在列表渲染完成后获取某条消息的实际高度,做滚动定位。如果在 build 阶段读取 RenderObject.size,此时 Layout 还没执行,拿到的值是 null 或上一次的旧值。正确的做法是:

1
2
3
4
5
6
void scrollToMessage(String msgId) {
SchedulerBinding.instance.addPostFrameCallback((_) {
final offset = _calculateOffset(msgId);
_scrollController.animateTo(offset, ...);
});
}

postFrameCallback 保证在 Layout 完成后执行,此时所有 RenderObject 的 size 已经是确定值。

场景二:首次打开聊天页面,将列表滚动到底部

1
2
3
4
5
6
7
8
void initState() {
super.initState();
SchedulerBinding.instance.addPostFrameCallback((_) {
if (_messages.isNotEmpty) {
_scrollController.jumpTo(_scrollController.position.maxScrollExtent);
}
});
}

这个回调在首帧渲染完成后执行,确保 ListViewmaxScrollExtent 已经计算完毕。

场景三:全局搜索的搜索框自动聚焦

全局搜索页打开时,需要自动让搜索框获取焦点并弹出键盘。如果在 build 中调用 FocusScope.of(context).requestFocus(),可能因为 Widget 尚未挂载而失败。放在 postFrameCallback 中,首帧渲染完成后执行,问题解决。

4.4 non-rendering tasks(非渲染任务)

对应方法:SchedulerBinding.scheduleTask()

特点:在帧之间空闲时执行,优先级低于渲染。主要用于数据预处理、缓存预热等非 UI 任务。在 Flutter 实际业务中用得不多,因为 Dart 的 Event Loop 已经提供了异步调度能力。

五、setState 合并机制:为什么 5 次 setState 只触发 1 次 build

回到开篇的问题。setState 的源码只有两行核心逻辑:

1
2
3
4
void setState(VoidCallback fn) {
fn();
_element.markNeedsBuild();
}

markNeedsBuild 将当前 Element 加入 BuildOwner._dirtyElements 列表,然后调用 SchedulerBinding.ensureVisualUpdate(),最终经过 Engine 层向硬件注册下一个 Vsync 回调。

关键点在于:并不会立即 build。多次 setState 只是反复把同一个 Element 标记为脏(Set 去重保证只有一个),然后注册帧回调(多次注册只会保留一个)。

当 Vsync 信号真正到达时,handleDrawFrame 遍历脏 Element 列表,调用一次 rebuild(),就完成了所有脏 Element 的重建。这就是「批量合并更新」的底层实现。

六、业务实践:帧调度的性能权衡

6.1 聊天列表滚动中的帧预算管理

在企业 IM 中,聊天页面同时涉及消息列表滚动(Build + Layout)、消息气泡图片解码(IO + Paint)、表情面板动画(Ticker),都在共享每帧 16.67ms 的预算。

优化策略:

  • 图片解码下沉到 IO 线程:通过 ImageCache 的异步解码,避免占用帧时间。
  • 动画使用 TickerMode 控制:消息列表快速滚动时,通过 TickerMode.of(context) 暂停非关键动画(如消息气泡的进入动效),把帧预算留给滚动的 Build/Layout。
  • 输入状态的帧后处理:输入法联想词列表的更新放在 postFrameCallback 中,不与当前帧的渲染争抢资源。

6.2 帧回调的生命周期管理

一个容易忽视的问题是帧回调的内存泄漏。scheduleFrameCallback 返回一个 int 类型的 id,如果 Widget 被销毁但回调没有取消,回调闭包会持有 Widget 的引用,导致整个 Widget 子树无法被 GC。

正确做法是在 dispose 中取消所有帧回调:

1
2
3
4
5
6
7
void dispose() {
_animationController.dispose(); // 内部会取消 Ticker
for (final id in _frameCallbackIds) {
SchedulerBinding.instance.cancelFrameCallbackWithId(id);
}
super.dispose();
}

七、总结

Flutter 的帧调度机制有三层关键设计:

第一层:Vsync 硬件同步。 一切帧活动的起点是屏幕的 Vsync 信号。Flutter 通过 Ticker 按需订阅 Vsync,无变化时不请求信号,实现按需渲染。

第二层:SchedulerBinding 帧生命周期。 一帧内严格按 transient → persistent → postFrame 顺序执行回调,确保动画在 Build 之前、尺寸读取在 Layout 之后。四类回调各司其职,开发者需要理解各自的应用场景。

第三层:批量合并与去重。 多次 setState 被合并为一帧,脏 Element 通过 Set 去重。这个机制是 Flutter「看起来像响应式,实际是批处理」的根本原因。

在企业 IM 这种消息密集、交互高频的场景中,理解帧调度机制不是锦上添花,而是做帧级性能优化的基本功。定位到具体卡顿的帧、分析该帧内各类回调的耗时占比、判断瓶颈在 transient(动画计算)还是 persistent(Build/Layout/Paint),才能真正做到有的放矢地优化。

扫描二维码,分享此文章