Flutter和Vsync的协作原理
如果把屏幕刷新比作一场交响乐操作系统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 层负责“按需举手”ScheduleFrameEngine 层负责“承上启下”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驱动 FrameworkDart 层的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内部自己维护了三个专属的同步列表内部队列。绘制任务会按照严格的先后顺序在这三个队列中流转队列 ATransient Frame Callbacks临时帧回调队列存放内容所有的动画更新任务比如AnimationController的各种监听、手势滚动的位移计算。运作机制当onBeginFrame触发时Flutter 会一口气把这个队列清空执行完所有的动画计算为接下来的组件重绘做好数据准备。队列 BPersistent Frame Callbacks常驻帧回调队列存放内容最核心的Build ➔ Layout ➔ Paint任务。运作机制在动画计算完成后onDrawFrame随即触发。Flutter 开始执行这个队列里的任务找到所有被标记为 dirty 的 Element 进行build重建组件。深度遍历 RenderObject 树进行layout计算大小位置。深度遍历 RenderObject 树进行paint绘制并生成 Layer Tree。注意这个队列里的核心任务是常驻的每一帧都会执行它。队列 CPost 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还有哪些事会触发 VsyncsetState是我们最常用来触发重绘的方法但以下操作哪怕不调用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 commitVsync 信号到达时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 变脏重绘。