Skip to content

Flutter 对照:同一条渲染骨架,不同的宿主和中间表示

本页是客户端渲染专题的一部分。主例子仍然是浏览器渲染管线浏览器运行时与帧调度;Flutter 的价值是让我们看清:声明式 UI 不等于浏览器,组件重建也不等于重新绘制整屏。

先放回总图

Flutter 是交互终端层的一种实现。它把用户输入、平台事件和异步结果转换成一个可交互的画面,但不负责把业务操作变成多方共同相信的事实。与服务端确认、离线队列和重试的边界,仍要回到计算机系统顶层图谱中的“本地状态与共享事实”问题。

为了建立可迁移的理解,先看与浏览器共用的骨架:

text
输入 / 平台事件 / 异步结果
          -> 状态转换
          -> UI 描述
          -> 更新计算与无效化
          -> 布局
          -> 绘制命令
          -> 图层合成与呈现
          -> 下一次输入和反馈

下表是本章的导航,不是逐词翻译表。相同位置的对象解决相近问题,但生命周期和所有权不同。

骨架位置浏览器内核的主对象Flutter 的主对象最容易犯的错
输入与调度OS 事件、浏览器事件循环、DOM eventembedder 平台消息、PlatformDispatcherSchedulerBinding以为回调结束后画面一定已显示
状态与 UI 描述JS 状态、DOM、框架 VDOMDart 状态、Widget 配置把不可变 Widget 当成屏幕上的实体
保留的运行时树DOM/CSSOM、布局对象、框架实例Element以为每次 build 都从零创建原生控件
布局style -> layout tree / fragmentsRenderObject 的 constraints -> size -> children以为父给子一个确定宽高,而不是约束
绘制display list、paint chunksPaintingContextCanvasPicturepaint 等同于 GPU 像素已经变化
合成与提交compositor layers、GPU process、swapLayer tree、SceneBuilder、engine、raster threadRepaintBoundary 当成性能开关
命中测试与语义hit test、accessibility treerender hit test、Semantics tree只修视觉,不检查手势与辅助技术

1. Flutter 的分层:不要把四棵树混成一棵

浏览器常从 DOM、CSSOM、layout tree、paint/compositor layers 讲起。Flutter 的对应关系更清晰地暴露在 framework API 中,但也因此更容易被名字误导。一次正常的 Flutter UI 生命周期至少涉及以下对象:

text
Dart app state
  -> Widget tree          配置:应当长成什么样
  -> Element tree         持久关系:位置、生命周期、BuildContext、State
  -> RenderObject tree    几何和绘制:约束、尺寸、命中测试、paint
  -> Layer tree           合成边界和可复用的图层结构
  -> Scene / engine       交给引擎的场景
  -> Skia 或 Impeller     栅格化、GPU 命令和屏幕提交

1.1 Widget:廉价、不可变的配置值

Widget 是描述,不是视觉对象,也通常不是状态容器。build 返回新的 widget 配置,框架用 runtimeTypekey 判断新旧配置能否更新同一个既有 Element。这个判断类似浏览器框架把新的 UI 描述与既有 DOM 对齐,但 Flutter 的框架核心直接以 Element 为保留节点。

dart
Widget build(BuildContext context) {
  return Row(
    children: [
      Text('count: $count'),
      FilledButton(onPressed: increment, child: const Text('add')),
    ],
  );
}

这段代码每次 build 都会产生新的 RowTextFilledButton widget 配置;它不意味着每次都重新创建对应的 RenderFlex、文本段落或平台窗口。配置是否能复用,取决于下层 Element 的更新规则。

**可验证的判断:**在 DevTools 的 Widget Inspector 中,看到某次 build 发生,只能证明描述被重新计算;要证明布局或绘制也发生,需要看 Layout Explorer、Performance Overlay、Timeline 或具体的 debug 标记。

1.2 Element:把声明和持久生命周期接起来

Element 由 framework 持有,维护 parent/child 关系、BuildContext、dirty 标记和组件实例。StatefulWidget 本身仍是配置;可变的 StateStatefulElement 关联,跨可匹配的 widget 更新存活。

text
StatefulWidget(新配置)
          |
          | updateWidget
          v
StatefulElement ---- owns ----> State
          |
          | build
          v
child widgets -> child elements -> child render objects

setState(fn) 的关键不是“立即重画”,而是:先同步执行 fn 修改本地状态,再调用 Element.markNeedsBuild()。该 Element 会进入 build owner 的 dirty 列表,等待下一次 frame 的 build phase。多次调用能合并到同一帧,但不能依赖它作为持久化或网络确认机制。

GlobalKey 能让状态在树中移动时仍被识别为同一个 Element/State;它不是普通列表 key 的替代品。列表的稳定 key 解决的是兄弟节点重排时的身份匹配。滥用 global key 会让重父化、依赖更新和可读性付出不必要成本。

1.3 RenderObject:布局、绘制、命中测试的责任主体

RenderObject 才是 Flutter 中与浏览器 layout object 最接近的长期对象。它管理:

  • 父子约束和自身 size
  • performLayout() 与子对象的几何位置;
  • paint() 中写入绘制上下文的指令;
  • pointer hit test;
  • 语义节点的配置;
  • 更新传播的 dirty 标记:markNeedsLayoutmarkNeedsPaintmarkNeedsCompositingBitsUpdate

当 widget 属性改变,Element 会调用相关 RenderObject 的 updateRenderObject。这里是否触发布局、重绘或只更新语义,取决于属性影响的真实范围。比如一个颜色变化通常要求 paint;一个边距变化往往影响 layout;一个仅用于语义的 label 不应因而改变视觉绘制。把所有 setter 都写成 markNeedsLayout() 会使局部变更扩大成不必要的子树计算。

1.4 Layer 与 Scene:不是每一个 Widget 都有一层

Flutter Layer tree 服务于合成:它表达变换、裁剪、不透明度、纹理、物理形状和可缓存绘制记录等。RenderObjectpaint 期间通过 PaintingContext.push... 创建或复用 layer;最后 RenderView.compositeFrame()SceneBuilder 组装 Scene,提交给 engine。

因此,以下两句话都不准确:

  • “每个 widget 都对应一个 GPU layer。”绝大多数 widget 不会。
  • “加上 RepaintBoundary 总会更快。”它创建一个独立的绘制与合成边界,可能隔离高频变化,也可能增加 layer、内存、栅格化或合成负担。

与浏览器中的 compositing layer 一样,边界应由变化频率、可遮挡关系、动画方式和性能证据决定,不能从组件层级或直觉决定。

2. 一帧如何发生:从 setState 到屏幕

以下是典型单窗口、正常运行时的因果链。实现细节会随平台和 engine 演进,但阶段边界相对稳定。

text
用户触摸 / 键盘 / 定时器 / Future 完成
  -> embedder 将平台消息交给 Dart isolate
  -> 事件处理器修改状态,调用 setState / notifier
  -> dirty Element 被登记;SchedulerBinding 请求下一帧
  -> vsync 到来,framework 的 drawFrame 开始
       transient callbacks:动画 ticker 等
       build:重建 dirty elements,更新 widgets / render objects
       layout:按约束计算需要重新布局的 render objects
       compositing bits:判断哪些节点需要合成边界
       paint:录制绘制,构造或复用 layer tree
       semantics:更新可访问性树
  -> RenderView.compositeFrame() 构造 Scene 并提交 engine
  -> engine 在栅格线程 / GPU 后端栅格化,提交平台 surface
  -> 显示器在下一次刷新中显示结果

2.1 scheduleFrame、vsync 与“下一帧”

Flutter 不应该在每次状态变化时同步完成整套 layout/paint。SchedulerBinding 协调对下一帧的请求,平台的 vsync 信号提供刷新节奏。TickerAnimationController 通常在 transient frame callback 中推进动画值,然后标脏相应子树。

这与浏览器的 requestAnimationFrame 有相同目标:把与视觉有关的工作靠近显示刷新,并允许多个状态变化在一帧内批处理。但不能将 API 一一等同:浏览器有 HTML 事件循环、task/microtask 与渲染机会;Flutter 有 Dart isolate 的 event loop 和 framework 的 scheduler phases。它们都要求避免在输入回调里做长同步计算,否则主执行线程不能及时进入下一帧。

可以用下面的时序理解“调用返回”与“画面出现”的区别:

text
t0  tap callback: setState 返回
t0  当前 Dart 调用栈继续执行
t1  下一个 vsync: build/layout/paint 提交 scene
t2  raster/GPU 完成并被系统合成
t3  人眼实际看到更新

所以,不能在 setState 后立即读取屏幕像素来断言新画面已经可见。需要在正确阶段测量布局时,可使用 addPostFrameCallback;但它仅表示 framework frame 的后置回调时机,并不证明 GPU 已把该帧展示给用户。

2.2 build、layout、paint 分别能改什么

阶段问题输入与输出典型错误
buildUI 配置应是什么?状态 -> widget 配置;Element 更新子关系build 做网络请求或不可控副作用,导致重复执行
layout每个 render object 多大、放哪里?constraints -> size / child offsets让子对象在无界约束下要求无限大小
paint已知几何后画什么?size / style -> Canvas 与 layer 操作把改变几何的工作偷偷放入 paint
compositing哪些结果作为独立合成单元?layer 信息 -> Scene为静态小部件过度增加边界
semantics辅助技术能理解和操作什么?render tree -> semantics nodes视觉可点但读屏器或键盘不可用

一个重要方向性规则是:**约束从父向子传递,尺寸从子向父返回,父在其后决定子的位置。**这常被缩写为“constraints go down, sizes go up, parents set positions”。它并不表示每种 RenderObject 都是简单矩形,也不替代文本、sliver 等专用布局协议;但它足以解释大量 RenderFlex overflowViewport was given unbounded height 和嵌套滚动问题。

3. 与浏览器内核逐段对照

本节用浏览器作主坐标。需要先回看浏览器管线中的导航、解析、style/layout/paint/composite 区分;这里关心的是哪些抽象能迁移,哪些不能。

3.1 UI 描述:DOM/CSS 与 Widget/Element

浏览器首先解析 HTML 建立 DOM,解析 CSS 建立 CSSOM,并根据级联、继承和选择器计算样式。React、Vue 等框架可以在其上游生成或修改 DOM,但浏览器内核最终消费的是 DOM/CSS 语义。

Flutter 直接在 Dart 中构建 widget 描述,没有 HTML parser、CSS cascade 或浏览器兼容性历史。它把布局和绘制策略封装在 widget 与 RenderObject 类中,例如 FlexStackCustomPaint。这带来两个结果:

  • 样式解析、选择器匹配和 CSS 级联不是 Flutter 的每帧成本来源;
  • Flutter 的实现应用必须更明确地选择布局、裁剪、文本和滚动对象,不能期待 CSS 的隐式规则自动介入。

两者共同点是“描述与实际呈现对象分离”。不同点是浏览器暴露一个跨页面、脚本和开发者工具共享的 DOM 平台;Flutter 的 widget/element/render object 属于同一应用 framework 的私有运行时协议。

3.2 样式和布局:fragmentation 对 constraints

现代浏览器布局要处理 block、inline、flex、grid、table、多列、分页、书写模式和 CSS fragmentation。布局产物往往不是“一个 DOM 节点对应一个框”,而是 fragments;同一元素可以在不同分片上下文产生多个几何片段。

Flutter 的常规 box protocol 更直接:父 RenderBox 传 BoxConstraints(minWidth, maxWidth, minHeight, maxHeight),子在范围内选择 Size。但 Flutter 也有专用协议:RenderSliverSliverConstraintsSliverGeometry 表达可滚动区域的按需布局,避免给长列表中的每个孩子都做完整 box layout。

要解决的事浏览器Flutter
流式文档排版CSS formatting contexts、inline layout、fragments无通用 HTML 流;用布局 widget 组合,文本由段落系统排版
线性/二维布局Flexbox、GridFlex/Row/ColumnWrap、自定义 RenderObject
长内容滚动layout + scroll container;合成/异步滚动策略因引擎而异viewport + sliver,按可见范围创建/布局子项
尺寸协商CSS intrinsic sizing、min/max、百分比等constraints,另有 intrinsic measurement API,须谨慎使用

两者共同的性能警告是:如果父为了决定尺寸反复询问子节点的 intrinsic size,或一个变化让大面积祖先/兄弟重新排版,局部状态更新会变成大规模 layout。先用 Timeline 证明布局成本,再设计缓存、列表虚拟化或约束收紧方案。

3.3 绘制、栅格化与合成:display list 对 Scene

浏览器将 layout 的视觉结果生成绘制记录,再按绘制块、图层、裁剪和效果安排栅格化与合成。现代架构通常把合成工作与页面主线程尽可能分离,但实际能否脱离主线程取决于属性、图层提升、浏览器实现和平台。

Flutter 的 framework paint 通过 Canvas API 记录图形操作,常由 Picture 保存;engine 接收 Scene 后在其 raster 流程中执行图形后端工作。Skia 是长期使用的二维图形库;Impeller 是 Flutter 面向较可预测渲染行为的现代渲染器,具体默认后端和平台支持会随 Flutter 版本变化。因此,文档或排障中应记录所用 Flutter 版本、目标平台和 renderer,而不要把“Flutter 永远使用 X”当作不变事实。

这里有三个必须分开的概念:

  1. rebuild:重新计算 widget 配置与更新 Element;
  2. repaint:RenderObject 的 paint 被重新执行或绘制记录失效;
  3. rasterize/composite:engine/GPU 将图层结果变成提交到 surface 的像素。

性能讨论若不先标出是哪一个,就无法解释“build 很少但掉帧”“没有 layout 但 GPU 很忙”“加边界后内存上涨”等现象。

3.4 输入、命中测试与语义:不能只看像素

浏览器有 DOM event target、捕获/冒泡、CSS pointer-events、焦点顺序和 accessibility tree。Flutter 的 pointer event 经 framework hit test 找到 RenderObject 路径,再由 GestureBinding 和 gesture arena 解决多个识别器之间的手势竞争;文本输入和焦点还牵涉 FocusTextInput 与平台 IME。

手势 arena 是 Flutter 很有代表性的差异:同一 pointer sequence 可能同时被滚动、点击、长按、拖拽等 recognizer 观察,最后由它们接受或拒绝来决定胜者。这不是 DOM 冒泡的直接替代。遇到“按钮看得到却点不到”“横向卡片抢走纵向滚动”时,先检查 hit test path、吸收/忽略 pointer 的组件、滚动 recognizer 和 arena 结果,而不是盲目修改视觉层级。

无障碍也需要单列验收:Flutter 的 Semantics 由 render tree 导出并可通过 Semantics widget 补充或合并;视觉上存在的自定义 Canvas 内容,若没有可操作的语义节点,对屏幕阅读器就是不可见的。这个原则与浏览器中自绘控件必须补语义与键盘模型完全一致。

4. 局部更新为什么能成立:dirty 传播与边界

“声明式 UI 每次状态改变都重建整棵树,所以性能一定差”是把配置重建、布局、绘制和 GPU 栅格化混为一谈。Flutter 的可扩展性来自多个层级的局部失效:

text
状态变化
  -> 只标脏关联 Element(build)
  -> 属性 setter 只标脏受影响 RenderObject(layout / paint / semantics)
  -> paint 期间按 Layer 边界保留或替换绘制记录
  -> engine 仅为需要更新的场景部分执行后续工作(具体效果依平台和内容而异)

这不保证“只做一丁点工作”。以下情况会放大更新范围:

  • 状态被放在过高层,导致大子树都重新 build;
  • 改变了父约束、字体度量、屏幕方向或媒体查询等,会令下游 layout 改变;
  • 模糊、裁剪、半透明叠加、频繁变化的图片或 shader 令 raster/compositing 昂贵;
  • 长列表没有使用 lazily built 的 sliver/列表组件;
  • 频繁创建大对象、同步解码或 JSON 处理挤占 Dart UI isolate;
  • 过多或错误位置的 RepaintBoundary 带来额外图层和缓存压力。

4.1 RepaintBoundary 的正确问题

它值得用在“这个子树的绘制变化频率和周围不同,且隔离后可避免重复 paint 或能独立合成”的地方。例如一个不断刷新的小图表放在稳定的复杂页面中,边界可能隔离其重绘。

先问四个问题:

  1. 证据表明瓶颈是 paint/raster,而不是 build、layout、网络或图片解码吗?
  2. 这个子树真的比周围更频繁变化吗?
  3. 新边界是否会增加很多 layer、离屏缓冲或 GPU 内存?
  4. 加前和加后的 frame timing、raster 时间、内存与真实设备交互是否比较过?

这与浏览器的 will-change 或强行 layer promotion 类似:它们是有成本的提示/边界,不是常规样式。

5. 线程、isolate 和平台:不要用“单线程”掩盖问题

Flutter 应用的 Dart UI 逻辑通常在主 isolate 执行,platform embedder、engine raster 工作、I/O 和 GPU 驱动在不同线程或进程边界上配合。精确线程拓扑取决于平台和 engine;正确的工程结论不是“Flutter 单线程”,而是:buildlayoutpaint 与多数 widget 状态必须在 UI isolate 的帧预算内完成。

对 60 Hz 屏幕,连续帧的总预算约 16.7 ms;对 120 Hz 则约 8.3 ms。预算不是只给 Dart 的,UI 阶段、raster/GPU、平台合成、输入延迟和其他系统工作都会竞争它。任何“固定安全阈值”都要结合真实设备刷新率和测量解释。

耗时 JSON 解析、图像处理、压缩、复杂排序或纯计算应考虑 isolate、原生实现或后端下沉;但 isolate 之间消息传递也有成本,且 widget/RenderObject 本身不能跨 isolate 操作。先用 profile build 或 DevTools Timeline 证明工作在哪一段,再决定并行化位置。

6. 一个贯通例子:列表项状态改变

假设用户点选一条任务,客户端先显示“提交中”,随后收到服务端确认。

text
tap
  -> hit test 找到 ListTile / GestureDetector
  -> gesture arena 决定 click,而非 scroll,触发回调
  -> state: task.status = submitting;setState
  -> 下一帧 build 生成带 loading 配置的目标列表项
  -> Element 更新该项关联 RenderObject 的属性
  -> 若文本、图标尺寸或行布局改变:layout;若只换颜色/图标:通常 paint
  -> Layer / Scene 提交,用户看见“提交中”
  -> async request 返回成功或失败
  -> 以请求 ID/版本号合并结果,避免旧响应覆盖新操作
  -> 状态改为 confirmed / failed,下一帧展示结果

这里必须区分三件事实:

  • setState 后的“提交中”是本地意图已经表达;
  • 请求已经发出不等于服务端已经持久化;
  • 服务端返回成功也要考虑超时重试、幂等键、版本顺序和应用恢复后的读回。

因此 UI 实现不能把一个乐观动画当作共享事实。错误与重试设计属于客户端状态机和服务端协议的共同边界;画面只应诚实表达当前已知状态。这是客户端渲染专题与后续共享事实主题相连的地方。

7. 用证据定位 Flutter 渲染问题

故障定位与练习配合使用。先将“卡”“没更新”“点不到”翻译成可观察的阶段,再选择工具。

用户现象优先怀疑可收集的证据不应直接下的结论
数据变了,文字未变状态所有权、通知范围、key/Element 身份日志中的状态版本、Widget Inspector、build 调用“Flutter 没有刷新”
点击后有延迟同步回调、gesture 冲突、网络或帧预算DevTools Timeline、手势路径、请求时间线“一定是 GPU 慢”
页面滚动掉帧UI build/layout、raster、图片解码、过度效果Performance Overlay、frame chart、raster/UI 时间“多加 RepaintBoundary”
列表重排后状态串行缺失或不稳定 key元素树、列表数据 identity、复现步骤“State 自动跟数据走”
自绘控件无法使用hit test / Semantics / focusSemantics debugger、读屏器、键盘测试“画出来就已经可访问”
首屏慢初始化阻塞、资源/字体/图片、首帧前同步工作startup timeline、冷启动设备测试“build 次数多就是根因”

推荐的最小验证闭环:

  1. 在 profile(非 debug)模式复现,并记录设备、刷新率、Flutter 版本与 renderer;
  2. 抓一段包含输入到掉帧的 Timeline,分别看 UI 与 raster 阶段;
  3. 以一个可证伪假设修改,例如减少某个列表项的重绘范围;
  4. 在同一场景复测 frame timing、内存和正确性;
  5. 验证命中测试、语义和服务端确认状态没有被性能改动破坏。

8. 学到哪里算掌握

完成本页后,应能不看资料回答下列问题:

  • WidgetElementRenderObjectLayer 分别保存什么,为什么不能互换?
  • 为什么 setState 返回不代表用户已经看到画面?
  • 一个属性变化为何可能只 repaint,也可能扩大为整段 layout?
  • 为什么 RepaintBoundary 和浏览器 layer promotion 都需要证据而不是惯例?
  • Flutter 的 constraints 模型如何解释无界高度和 Flex overflow?
  • 视觉更新、手势命中、无障碍语义、服务端确认为什么是四个不同验收面?

最小练习是实现一个可滚动任务列表:稳定 key、乐观“提交中”状态、服务端成功/失败回写、无障碍 label,并用 profile Timeline 比较“把状态放在页面根部”和“下沉到单个列表项”时的 UI/raster 时间。提交复盘时,写出假设、测量条件、结果和仍未验证的部分,而不是只写“优化成功”。

资料与时效边界

本页对对象职责和阶段关系的描述来自上述官方资料;“某项优化一定提升性能”的说法一律不在这里作事实承诺,必须由目标版本、设备和 trace 验证。

学习规划与研究资料库