如果把屏幕刷新比作一场交响乐,操作系统(OS)就是那个挥舞指挥棒的指挥家,而 Flutter 则是台上的首席演奏家。它们之间的协作并不是 Flutter 盲目地输出画面,而是一套严密的“请求-响应”机制。
Flutter 和 Vsync(垂直同步)的协作原理,核心可以用一句话概括:“按需申请,准时唤醒,双轨并行”。
协作的核心流程:四步交谊舞
Flutter 与 Vsync 的协作不是单向的监听,而是有来有回的互动,整个生命周期包含以下四个阶段:
1. 提交申请 (Request Frame)
当你的 Flutter 应用发生状态改变(如点击按钮、触发动画、滚动列表),Flutter 的 Framework 并不会立即去画图,而是举手向 Engine 申请:“我下一帧有新内容,下次 Vsync 来了叫我”。
代码触发:
setState()->SchedulerBinding.scheduleFrame()。向下传递:这个请求穿过 Dart 层,到达 Engine 层的
Animator,最后由VsyncWaiter向操作系统(Android 的Choreographer或 iOS 的CADisplayLink)注册一个回调。
2. 信号下发与对齐 (Vsync Alignment)
硬件屏幕按固定频率(如 60Hz 屏幕每 16.6ms)发出 Vsync 信号。
OS 响应:操作系统收到硬件信号后,唤醒 Flutter Engine 注册的监听器。
时间戳传递:OS 不仅唤醒 Engine,还会附带一个非常关键的数据——预期显示时间戳(Target Presentation Time)。Flutter 会用这个时间戳来计算动画的进度,确保即使 UI 线程有一点点延迟,动画计算出来的位移也是准确的。
3. 异步唤醒与渲染 (Awake & Produce)
Engine 收到 OS 的通知后,立刻往UI 线程的事件队列里塞入一个刷新任务。
Framework 动工:UI 线程被唤醒,执行
window.onBeginFrame和window.onDrawFrame。生成产物:经过 Layout 和 Paint,生成Layer Tree,并迅速将其打包扔给Raster 线程。
4. 消费与上屏 (Consume & Present)
Raster 线程拿到 Layer Tree 后,调用 GPU(通过 Skia 或 Impeller)进行光栅化,把结构化的树状指令变成屏幕像素点,最终提交给系统的 Framebuffer(帧缓冲区),等待下一个 Vsync 到来时被硬件屏幕刷出来。
协同机制中的两大核心智慧
为了保证极致的流畅度,Flutter 在与 Vsync 协作时设计了两个非常聪明的机制:
机制一:背压控制 (Backpressure)
如果 UI 线程或者 Raster 线程执行太慢,上一帧还没画完,下一帧的 Vsync 信号又来了,会发生什么?
Flutter 的处理方式:如果前一帧的
Layer Tree还没有被 Raster 线程消费掉,或者 Raster 线程还在跟 GPU 缠斗,Flutter Engine 就会故意拒绝向 OS 申请下一个 Vsync 信号。这样做是为了防止 UI 线程源源不断地产生新的帧数据,导致内存暴涨或者引发更严重的排队延迟。宁可主动掉帧,也不做无用功。
机制二:流水线并发 (Pipelining)
Flutter 充分利用了现代手机多核 CPU 的优势。在 Vsync 的协同下,UI 线程和 Raster 线程是串行且并发的:
| 时间周期 | UI 线程 (Dart) | Raster 线程 (GPU 提交) | 屏幕显示 (Display) |
| Vsync 周期 1 | 生产【第 1 帧】 | 闲置 | 显示上一帧 |
| Vsync 周期 2 | 生产【第 2 帧】 | 光栅化【第 1 帧】 | 显示上一帧 |
| Vsync 周期 3 | 生产【第 3 帧】 | 光栅化【第 2 帧】 | 真正显示【第 1 帧】 |
这种协作模式意味着:只要 UI 线程在 16.6ms 内搞定,Raster 线程也在 16.6ms 内搞定,虽然整个画面从计算到显示延迟了 2 个 Vsync 周期,但用户感官上依然是完美的 60fps 丝滑。
总结
Flutter 与 Vsync 的协作,本质上是用硬件的时钟信号来规范软件的生产节奏。
Framework 层负责“按需举手”(ScheduleFrame),Engine 层负责“承上启下”(VsyncWaiter 翻译信号),从而保证 Dart 代码的执行、GPU 指令的转换,全都能死死地卡在屏幕刷新的黄金时间点上。
状态改变 (setState/动画) ➔ Engine 申请 Vsync ➔ 硬件下发 Vsync 信号 ➔ UI 线程响应 (Animate ➔ Build ➔ Layout ➔ Paint) ➔ 产出 Layer Tree ➔ Raster 线程接管 (光栅化) ➔ GPU 提交 ➔ 屏幕呈现
追问1:每一次的绘制信号是怎么传递到Flutter主线程中的
为了把 Vsync 信号精准地送进Flutter UI 线程(也就是 Dart 代码运行的主线程),Flutter Engine 实现了一套非常精妙的异步唤醒与消息循环机制。
当信号产生时,它并不是直接“调用”Dart 代码,而是通过底层事件循环(Message Loop)唤醒处于休眠状态的 Flutter 主线程。
整个传递过程可以分为以下三个核心环节:
1. 跨线程投递:把信号变成“待办任务”
当操作系统的原生主线程捕获到 Vsync 信号后,Engine 层的VsyncWaiter会立刻将“执行下一帧”的操作打包成一个 C++ 的闭包函数(通常是Engine::BeginFrame)。
由于此时还在原生线程,Engine 会调用 Flutter 内部的线程通信工具:
// 伪代码:向 UI 线程的流水线投递任务 ui_task_runner->PostTask(begin_frame_closure);这个操作做两件事:
将这个任务塞进 Flutter UI 线程的Task Queue(任务队列)中。
如果此时 Flutter UI 线程正在睡觉(休眠状态),就必须把它暴力唤醒。
2. 底层唤醒:如何叫醒“装睡”的 Flutter 主线程
如果此时没有用户交互,Flutter UI 线程为了省电通常处于休眠状态。为了让它立刻起来干活,Flutter 在不同平台上利用了操作系统的底层多路复用机制(I/O Multiplexing):
在 Android 上(基于 Epoll / ALooper): Flutter UI 线程在没事干的时候,会阻塞在 Linux 的
epoll_wait(或者 Android 的ALooper_pollOnce)上。 当PostTask被调用时,Engine 会向一个特定的eventfd(计数信号量文件描述符)或者管道中写入一个 8 字节的数字。操作系统检测到这个描述符可读,会立刻激活并唤醒Flutter UI 线程。在 iOS 上(基于 CFRunLoop): Flutter UI 线程在没事干的时候,会阻塞在系统的
CFRunLoop上。PostTask会触发一个绑定在 RunLoop 上的CFRunLoopSource(自定义事件源),并调用CFRunLoopWakeup,强行把 UI 线程从休眠中拉出来。
3. 消息循环消费:从 C++ 正式跃入 Dart 世界
被唤醒后的 Flutter UI 线程,会立刻开始处理它的 Message Loop(消息循环):
读取队列:UI 线程从自己的 Task Queue 中取出刚刚投递进来的
begin_frame_closure任务。执行 C++ 引擎逻辑:执行
Engine::BeginFrame(pipeline_time),这一步拿到了系统传过来的精准时间戳。越过 C++/Dart 边界:Engine 通过 Dart VM 的引脚,直接调用 Dart 层的全局单例指针:
PlatformDispatcher.instance.onBeginFrame驱动 Framework:Dart 层的
SchedulerBinding接过接力棒,正式开始调用你的 Widget 树进行Animate -> Build -> Layout -> Paint的绘制流程。
横向传递链路总结
硬件 Vsync 信号 ➔ 操作系统原生主线程 ➔ Engine 层的VsyncWaiter➔写入 eventfd / 触发 RunLoopSource(跨线程唤醒)➔ Flutter UI 线程被唤醒 ➔ 从 TaskQueue 取出任务执行 ➔ 调用 Dart VM 的onBeginFrame➔ 你的 Flutter 页面开始刷新。
正因为有了这套机制,Flutter 主线程在没有界面动画时可以做到完全不占 CPU 产生零功耗,而一旦 Vsync 信号到达,又能以微秒级的延迟瞬间被唤醒响应。
追问2:绘制任务在哪个队列
很多人误以为它在 Dart 的Event Queue(事件队列)里,但实际上绝对不是。如果放进普通的事件队列,绘制任务就会被排在网络请求、定时器等耗时任务后面,导致界面疯狂卡顿。
真正的答案是:它存在于C++ 层的 UI 线程任务队列中,并在进入 Dart 层后,转为由SchedulerBinding自主管理的专用回调队列。
1. 底层:C++ 层的 UI 线程任务队列
在 C++ Engine 层,Flutter 拥有自己的线程基础设施(基于 FML,即 Flutter Media Layer)。
队列名称:
fml::MessageLoop的Task Queue(任务队列)。如何运作:当 Vsync 信号到达,
VsyncWaiter封装好BeginFrame任务后,通过PostTask投递到UI Task Runner的 Task Queue 中。特点:这是一个底层的 C++ 队列。一旦 UI 线程被唤醒,它会优先把这个队列里的绘制任务取出来执行。执行时会通过 JNI/Runtime 直接穿透到 Dart 虚拟机的最深处,直接调用
onBeginFrame。
2. 上层:Dart 层的 SchedulerBinding 专用回调队列
当信号穿透到 Dart 层后,并没有进入我们熟知的 DartEvent Queue或Microtask Queue。
为了保证绘制的绝对高优先级,Flutter 在SchedulerBinding内部自己维护了三个专属的同步列表(内部队列)。绘制任务会按照严格的先后顺序在这三个队列中流转:
队列 A:Transient Frame Callbacks(临时帧回调队列)
存放内容:所有的动画更新任务(比如
AnimationController的各种监听、手势滚动的位移计算)。运作机制:当
onBeginFrame触发时,Flutter 会一口气把这个队列清空,执行完所有的动画计算,为接下来的组件重绘做好数据准备。
队列 B:Persistent Frame Callbacks(常驻帧回调队列)
存放内容:最核心的Build ➔ Layout ➔ Paint任务。
运作机制:在动画计算完成后,
onDrawFrame随即触发。Flutter 开始执行这个队列里的任务:找到所有被标记为 dirty 的 Element 进行
build(重建组件)。深度遍历 RenderObject 树进行
layout(计算大小位置)。深度遍历 RenderObject 树进行
paint(绘制并生成 Layer Tree)。
注意:这个队列里的核心任务是常驻的,每一帧都会执行它。
队列 C:Post Frame Callbacks(帧后回调队列)
存放内容:你在代码中通过
WidgetsBinding.instance.addPostFrameCallback注册的回调。运作机制:当这一帧的 Layer Tree 已经安全打包并提交给 Raster 线程后,Flutter 才会来清空这个队列。通常用于在“界面已经渲染完成”后,获取某个组件的宽高或进行页面的重定向。
总结:与 Dart 双队列的横向对比
为了让你更直观地理解,我们可以看看这几个队列在 Flutter UI 线程中的优先级排序:
高优先级 ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ 低优先级C++ UI Task Queue(Vsync任务) ➔Dart Microtask Queue➔SchedulerBinding 绘制队列➔Dart Event Queue(点击事件/网络/Timer)
Vsync 来临:C++ 队列优先响应,通过硬通道直接调用 Dart 绘制队列(A➔B➔C)。
绘制期间:如果产生了 Microtask(微任务),会在当前绘制阶段结束后、下一个普通事件开始前被清空。
绘制结束:线程空闲下来,才会去处理
Event Queue里的普通网络请求或点击事件。
这种独立于标准事件循环的设计,正是 Flutter 能够保持界面高爽快度、不被业务代码轻易阻塞的底层秘诀。
追问3:如果没有触发setstate 是不是就不用等待vsync
简单直接地回答:是的。如果界面没有任何变化,Flutter 根本不会去向系统申请 Vsync 信号,UI 线程会直接原地“躺平”进入休眠状态,以实现零功耗。
不过需要修正一个小细节:不仅是setState,只要没有任何引发界面重绘的操作,就不需要等待 Vsync。
1. Flutter 是“按需驱动”,而不是“死循环”
很多游戏引擎(比如 Unity 或 Unreal)采用的是死循环渲染模式:不管画面有没有动,底层的while(true)循环都会以每秒 60 次或 120 次的频率疯狂重绘。
而 Flutter 是一种应用图形引擎,它极其克制,采用的是按需驱动(On-Demand)模式:
没有需求:不主动向系统申请 Vsync,不打扰操作系统。
有需求了:举手申请一次 Vsync,系统给一个信号,Flutter 就只画那一帧。画完如果又没事了,继续睡觉。
2. 除了setState,还有哪些事会触发 Vsync?
setState是我们最常用来触发重绘的方法,但以下操作哪怕不调用setState,也会向 Engine 申请 Vsync 信号:
正在运行的动画:比如
AnimationController,它底层有一个Ticker(时钟)。只要动画没播放完,Ticker就会在每一帧结束时自动调用scheduleFrame()提前预约下一个 Vsync。用户手指在滚动列表:当你滑一个
ListView时,手势系统(Gesture)会不断计算滚动偏移量(Offset),这也属于重绘需求,会疯狂申请 Vsync。输入框的光标闪烁:哪怕你什么都不动,只要
TextField获取了焦点,那个一闪一闪的垂直光标其实是一个微型的定时动画,它也会每隔几百毫秒申请一次 Vsync。视频播放或纹理更新:如果你用了
VideoPlayer,视频解码器有新画面时,会通过底层的 Texture 机制直接向 Engine 申请刷新。
3. 横向对比:有更新需求 vs 无更新需求
为了更直观,我们可以看看这两种状态下,Flutter 的运行状态有什么区别:
场景 A:用户疯狂点击按钮(有更新需求)
触发setState➔ 产生 Dirty 节点 ➔ 呼叫scheduleFrame()➔VsyncWaiter向系统申请 Vsync➔ 信号到达 ➔ 唤醒 UI 线程 ➔ 经历 Build/Layout/Paint ➔ 刷新屏幕。
场景 B:手机放在桌上,画面静止(无更新需求)
代码安静 ➔ 没有任何 Dirty 节点 ➔ 没人调用scheduleFrame()➔VsyncWaiter保持沉默➔ 系统的 Vsync 信号按时路过 ➔ Flutter Engine 抬头看了一眼并无视 ➔UI 线程继续在epoll_wait或RunLoop中深度睡眠。
总结
你完全不需要担心 Flutter 会在后台悄悄空转。在界面完全静止时,Flutter 对 Vsync 是“听而不闻”的。只有当你通过setState、动画、滚动等操作“拍了拍”它,它才会打个激灵,向系统申请下一个 Vsync 信号来把新画面画出来。
追问4:产生 Dirty 节点的作用是什么
在 Flutter 中,“产生 Dirty(脏)节点” 是整个渲染流水线的起点和核心优化手段。
如果把 Flutter 的组件树比作一棵大树,那 “Dirty 节点” 就像是被贴上了“此处已过期,需要重新装修”的红色标签。它的核心作用可以用两个词概括:触发引擎和局部刷新。
1. 承上启下的“发信器”(触发 Vsync)
正如前面提到的,Flutter 是按需驱动的。如果没有人举手,线程就会一直睡觉。
当你在代码中调用setState()时,底层其实只做了一件事:把当前的 Element 节点标记为 “Dirty”(也就是调用了element.markNeedsBuild())。
它的作用:一旦一个节点变脏,它就会把自己塞进
BuildOwner的一个全局_dirtyElements列表中。后续联动:紧接着,Flutter 会顺手调用
scheduleFrame()。这就告诉了 Engine 的VsyncWaiter:“我这里有脏活要干,下次 Vsync 信号来了记得叫醒 UI 线程!”
结论:产生 Dirty 节点,是向操作系统申请 Vsync 信号的唯一合法理由。
2. 局部刷新的“精确导航”(极致的性能优化)
手机屏幕每秒刷新 60 到 120 次,如果每次 Vsync 信号到来时,Flutter 都要把几千个组件全部从头到尾重新构建、重新计算大小、重新绘制,那手机早就烫得能烤肉了。
Dirty 节点的核心作用,就是实现精确到点的局部更新:
跳过无关节点:当 Vsync 信号把 UI 线程唤醒后,Flutter 的 Framework 会直接跑去翻
_dirtyElements列表。它只会对列表里的脏节点执行build(重建)。那些没有变脏的节点(Clean 节点),Flutter 会直接复用旧数据,连看都不看一眼。按需向下传导:一个节点变脏,通常只会影响它自己和它的子树。Flutter 会顺着这个脏节点往下更新,绝对不会逆流而上去影响它的父节点或兄弟节点。
3. “脏”也分三种:精确控制性能
在 Flutter 底层,并不是只有一种“脏”。根据界面变化的剧烈程度,Flutter 把“脏”分成了三级,以此来压榨每一帧的性能:
| 脏标类型 | 触发方法 | 作用 | 耗时程度 |
| Element 脏 | markNeedsBuild() | 对应的 Widget 需要重新执行build方法。 | 🟥 高(需要重建组件) |
| Layout 脏 | markNeedsLayout() | 对应的 RenderObject 大小或位置变了,需要重新计算布局。 | 🟨 中(重新算格子) |
| Paint 脏 | markNeedsPaint() | 宽高没变,只是颜色、背景、或文字变了,需要重新录制绘制指令。 | 🟩 低(只是重新涂色) |
举个例子:
如果你只是改变了一个容器的背景颜色,Flutter 只会触发
markNeedsPaint()。它会聪明地跳过 Build 和 Layout 阶段,直接在 Paint 阶段重新画个颜色。这种“脏”对性能的消耗极低。
总结
可以把 Dirty 节点理解为 Flutter 里的Git Status。
当你想提交修改(更新画面)时,你必须先git add把修改的文件标记为modified(产生 Dirty 节点)。等到git commit(Vsync 信号到达)时,Git 只需要打包那些被修改的文件,而不需要把整个硬盘的代码重新复制一遍。
追问5:三棵树在这里面扮演了什么角色
既然有三棵树,那为什么我前面只提到了 “Element 变脏” 呢?因为在这三棵树的组合里,只有 Element 树有资格、有能力挂上 “Dirty(脏)” 这个标签。
为了让你彻底闭环这个逻辑,我们来看看这三棵树在 Vsync 信号和 “变脏” 过程中各自扮演什么角色。
1. 三棵树的角色分工
我们可以用一个“剧组”来打比方:
Widget 树(配置文件):它是不变量(Immutable)。就像是一张张设计图纸。它极其轻量,每次
setState都会被全部推倒重建。因为它是不可变的,所以它无法被标记为 “Dirty”。Element 树(核心管理者):它是生命周期管理者。它是连接 Widget 和 RenderObject 的桥梁。它在内存中是常驻的,“Dirty” 标签就是打在它身上的。
RenderObject 树(重体力劳动者):它是真正的渲染对象。负责算格子(Layout)和涂颜色(Paint)。它不关心业务逻辑,只听 Element 的指挥。
2. 当一个节点变脏时,三棵树是如何协同的?
我们把前面讲到的 Vsync 流程,套入到这三棵树的生命周期里:
第一阶段:Element 树举手(setState)
当你调用setState()时,其实是触发了当前Element 树上的某个节点的方法:element.markNeedsBuild()。 此时,这个 Element 节点被贴上了Dirty标签,并进入了备战队列。
第二阶段:Vsync 信号到达,UI 线程唤醒
系统的 Vsync 信号来了,通知 Flutter 开始干活。
第三阶段:三棵树的“横向大联轴”
Element 树开始清空自己的 Dirty 队列。对于每一个脏了的 Element 节点,它会做以下三件事:
[旧 Widget 树] ──(丢弃旧图纸)──> 生成 [新 Widget 树] │ (对比新旧图纸) │ ▼ [Element 树] 重新执行 build() │ (把新尺寸/颜色传下去) │ ▼ [RenderObject 树] 局部重绘向 Widget 树要新图纸:Element 会重新调用你写的
build()方法,在Widget 树中生成一个全新的 Widget 节点。新旧对比(核心优化):Element 拿着这个新 Widget 节点,去跟自己之前持有的旧 Widget 节点进行对比(对比
runtimeType和key)。如果完全一样:太好了,Element 直接复用,省下大把性能。
如果类型一样但参数变了:Element 保持不动,但会用新 Widget 的参数去更新自己。
指挥 RenderObject 树干活:Element 对比完图纸后,发现颜色或者尺寸变了,它就会向下转告RenderObject 树:“小老弟,你的配置变了,去给自己打个
markNeedsLayout或markNeedsPaint的标签,呆会儿把新画面画出来。”
总结
这就是为什么在讨论 Vsync 传递和局部刷新时,重点都在Element 树上:
Widget 树频繁死掉并新生,它是没有状态的,谈不上“脏”。
RenderObject 树纯听指挥,Element 没让它动,它就绝对不动。
Element 树才是那个手握 Dirty 标签的幕后大老板。它负责在 Vsync 到来时,对比新旧Widget,然后精确地把重绘指令下发给对应的RenderObject。
通过这三棵树的分工,Flutter 成功做到了:既能让开发者享受响应式编程(随便重构 Widget),又能让底层保持极高的渲染性能(通过 Element 过滤,只让必要的 RenderObject 变脏重绘)。