Flutter渲染原理
Flutter 之所以快,是因为它绕过了原生控件(OEM Widgets)。传统原生开发:Dart -> 原生桥接 -> 原生控件 -> 操作系统绘图。Flutter:Dart -> 渲染引擎 -> GPU。Flutter 就像一个自备画板和画笔的画家,它不需要向操作系统借用任何按钮或文本框,它直接在屏幕上“画”出一切。了解了布局原理后,你就能理解为什么有时候给一个组件设了width: 100却不
如果把“三棵树”看作是静态的结构,那么渲染原理就是让这些结构动起来的流水线(Pipeline)。
Flutter 的渲染原理可以概括为:从开发者定义的 Widget 开始,经过一系列复杂的计算,最终转化为 GPU 能够理解的像素指令。
这条流水线通常被称为 Frame Pipeline,每当屏幕刷新(通常是 60Hz 或 120Hz)时,它就会运行一次。
1. 渲染流水线的 5 个阶段
当一个每一帧开始时,Flutter 会依次执行以下步骤:
-
Animate(动画阶段):更新动画的值(比如插值计算)。
-
Build(构建阶段):也就是我们之前聊过的 Diff 算法发生的地方。重新生成 Widget 树,更新 Element 树。
-
Layout(布局阶段):确定每个对象在屏幕上的位置和大小。
-
Paint(绘制阶段):生成绘制指令(并不是真的画像素,而是生成一张“施工图纸”)。
-
Composite(层叠阶段):将多个图层叠加在一起,交给 GPU 处理。
2. 布局协议:Constraints go down, Sizes go up
这是理解 Flutter 布局的核心原理,简单到可以一句话总结:
-
约束向下(Constraints go down):父节点告诉子节点:“你的最大宽度是 300,最小高度是 100”。
-
尺寸向上(Sizes go up):子节点在约束范围内决定自己的大小,然后告诉父节点:“好的,那我决定占用 200x150 的空间”。
-
父节点决定位置:父节点拿到子节点的 Size 后,决定把它摆在哪个坐标。
为什么这么快? 因为每个 RenderObject 在布局时通常只需要遍历一次(线性时间复杂度),不像某些传统的 UI 框架可能需要多次往返计算。
3. 绘制原理:并不是直接画在屏幕上
在 Paint(绘制阶段),Flutter 并不直接操作像素。
-
Recording(录制):
RenderObject会在一个Canvas上录制它的绘制动作(比如drawRect,drawText)。 -
Display List(显示列表):这些动作会被记录在一个显示列表中。
-
Layering(分层):为了性能,Flutter 会把 UI 分成不同的 Layer(图层)。如果页面一部分在动,另一部分没动,Flutter 只需要重新录制动的那个图层。
4. 引擎层:Skia 与 Impeller
最后,这些指令会跨越 Dart 层,进入由 C++ 编写的 Flutter Engine。
-
Skia / Impeller:这是 Flutter 的底层渲染引擎。它负责把绘制指令转化为真正的 GPU 信号。
-
注:Impeller 是 Flutter 最新的渲染后端,旨在彻底解决着色器编译导致的掉帧(Jank)问题。
-
-
GPU 对接:最终,像素被填充到屏幕的 Framebuffer(帧缓冲)中,你就看到了画面。
总结:高性能的秘密
Flutter 之所以快,是因为它绕过了原生控件(OEM Widgets)。
-
传统原生开发:Dart -> 原生桥接 -> 原生控件 -> 操作系统绘图。
-
Flutter:Dart -> 渲染引擎 -> GPU。
Flutter 就像一个自备画板和画笔的画家,它不需要向操作系统借用任何按钮或文本框,它直接在屏幕上“画”出一切。
避坑指南: 了解了布局原理后,你就能理解为什么有时候给一个组件设了
width: 100却不起作用——因为父组件传下来的“强制约束”优先级更高。
追问1:各个阶段的耗时情况
在 Flutter 的 16.6ms(对应 60fps)或 8.3ms(对应 120fps)的“帧预算”里,每一微秒都非常珍贵。
如果某个阶段超时,就会发生我们常说的卡顿(Jank)。以下是各个阶段的耗时特征与潜在的“性能杀手”:
1. 各阶段耗时对比表
通常情况下,一个健康的帧耗时分布如下(以 60fps 为例):
| 阶段 | 预期耗时比例 | 耗时特征 | 主要影响因素 |
| Animate | < 5% | 极快 | 动画曲线计算、插值。 |
| Build | 10% - 40% | 最易波动 | Widget 树深度、setState 频率、复杂的业务逻辑。 |
| Layout | 10% - 20% | 稳定 | UI 复杂度、节点数量、多重嵌套约束计算。 |
| Paint | 5% - 15% | 较快 (录制) | 特效数量(阴影、模糊、圆角裁剪)。 |
| Raster / GPU | 30% - 50% | 最重负载 | 像素填充、着色器编译、复杂的图层合成。 |
2. 深度拆解:谁在浪费时间?
Build (构建阶段) —— “逻辑陷阱”
-
为什么耗时: 这是开发者代码运行最多的地方。
-
耗时陷阱: * 在
build方法里写耗时的计算(比如jsonDecode)。-
Widget 树过深。
-
没有使用
const导致不必要的重复构建。
-
-
表现: CPU 占用率飙升,DevTools 里 Build 柱状图变红。
Layout & Paint (布局与绘制) —— “结构陷阱”
-
为什么耗时: $O(n)$ 复杂度,通常很快。
-
耗时陷阱: * Intrinsic (固有特性) 计算:某些组件(如
IntrinsicHeight)会触发二次甚至多次布局计算,导致耗时从 $O(n)$ 变成 $O(n^2)$。-
Overpaint (过度绘制):绘制了太多看不见的层。
-
SaveLayer:使用
Opacity(非 0 或 1)或Clip会触发昂贵的离屏渲染。
-
Raster (光栅化/GPU 阶段) —— “硬件陷阱”
-
为什么耗时: 这是 Engine 层将指令转为像素的过程。
-
耗时陷阱: * Shader Compilation (着色器编译):这是旧版 Skia 引擎的顽疾,第一次运行动画时会卡顿(Impeller 引擎已大幅优化)。
-
大量图片解码:图片过大或过多。
-
3. 如何监控耗时?
你可以通过 Flutter 提供的 Performance Overlay(性能图层) 来直观查看:
-
上方的图表 (GPU Thread):反映了 GPU 处理光栅化的时间。如果这里高,说明 UI 太复杂(阴影、裁剪、层级太多)。
-
下方的图表 (UI Thread):反映了 Dart 执行 Build、Layout 的时间。如果这里高,说明你的代码逻辑太重或 Widget 树太乱。
4. 性能优化的小窍门(针对耗时阶段)
-
针对 Build:多用
const,缩小setState的范围(局部刷新)。 -
针对 Layout:避免使用
Intrinsic系列组件,尽量给图片或容器指定明确的宽高。 -
针对 Paint:使用
RepaintBoundary。RepaintBoundary 的妙用:它就像给某个组件套了一个隔离罩。当其他部分刷新时,被套住的部分如果没变,就直接复用上次生成的“图纸”,跳过绘制阶段。
一句话总结:
Build 决定了你代码的质量,Raster 决定了你 UI 的负担。只要 UI 线程耗时控制在 8ms 以内,用户就能感受到如丝般顺滑。
追问2:layout一定触发paint吗
是的,在 Flutter 的标准流程中,Layout(布局)变动几乎必然会触发 Paint(重绘)。
你可以把这看作是一个“连锁反应”。
1. 为什么“动了地盘”就得“重画”?
从逻辑上讲,Paint 依赖于 Layout 的结果。 Paint 阶段需要知道两个核心数据:
-
Size(尺寸):我要画多大?
-
Offset(偏移量):我要画在哪?
一旦进入 Layout 阶段,说明这个组件的尺寸或位置可能发生了变化。如果“地盘”变了,旧的“装修方案”(即上一次录制的 Paint 指令)就失效了。例如:
-
一个
Text组件宽度从 100 变到了 200,文字可能从两行变成了一行,绘制内容完全不同,必须重画。 -
一个
Container变长了,它的背景色填充区域也得跟着变长。
2. 底层机制:markNeedsLayout 的连带责任
在 Flutter 源码中,当一个 RenderObject 被标记为需要布局(调用 markNeedsLayout())时,它会顺便把自己的绘制记录(Display List)清空。
虽然代码上 markNeedsLayout 和 markNeedsPaint 是两个独立的标志位,但在渲染管线(Pipeline)执行时:
-
布局清理阶段:如果
_needsLayout为 true,执行performLayout()。 -
完成布局后:如果新计算出来的
size和旧的size不一样,Flutter 会自动调用markNeedsPaint(),强制将其加入重绘名单。
3. 有没有“布局了但没重绘”的例外?
理论上存在一种极端的性能优化场景: 如果 Layout 运行完后,发现 Size 居然和原来一模一样,且父组件给的约束也没有导致位置偏移,Flutter 有时会尝试跳过 Paint。
但在实际开发中,这种情况极少。因为:
-
大部分 Layout 触发都是因为数据变了,数据变了通常意味着 UI 长相也要变。
-
Parent 影响:即便你自己没变,如果你的兄弟节点变大了把你挤到了新位置(Offset 变了),你依然需要重绘,除非你被包裹在
RepaintBoundary里。
4. 性能总结:两者的“消费”级别
我们可以把渲染流水线的开销比作花钱:
-
只触发 Paint(例如改颜色):“小额消费”。只需让 GPU 重新填色,CPU 计算量极小。
-
触发 Layout(例如改宽度):“大额消费”。因为 Layout 必然带着 Paint,你不仅要重新计算几何逻辑(CPU 忙),还得重新生成绘制指令(GPU 也忙)。
一句话总结: Layout 是 Paint 的上游。上游的水混了(地盘变了),下游的水(画面)不可能清澈。所以,为了性能,我们应尽量避免频繁触发 Layout。
追问3:paint一定是layout触发的吗
不是。Paint 绝对不一定是 Layout。
事实上,Flutter 性能之所以强大的一个核心原因,就是它实现了 Layout 和 Paint 的解耦。你可以只触发重绘(Paint),而不触发重新布局(Layout)。
1. 为什么“换件衣服”不需要“重搭架子”?
-
Layout(布局):解决的是几何问题(Size 和 Position)。比如:这个框多宽?它摆在左边还是右边?
-
Paint(绘制):解决的是视觉问题(Color, Shadow, Image, Text Style)。比如:这个框是红的还是绿的?
核心逻辑:
如果你只是改了一个 Container 的颜色,或者把一张图片的滤镜从彩色换成了黑白,组件的大小和位置并没有发生任何变化。此时,Flutter 足够聪明,它知道不需要去重新计算复杂的几何布局逻辑,只需要拿起“画笔”在原来的位置重新涂色即可。
2. 触发机制的区别
在 Flutter 源码底层,这两个过程由不同的标记位控制:
| 动作 | 触发方法 | 连锁反应 | 性能消耗 |
| 改变大小/位置 | markNeedsLayout() |
Layout $\rightarrow$ Paint (全套流程) | 高 |
| 仅改变外观 | markNeedsPaint() |
仅 Paint (跳过 Layout) | 低 |
3. 典型的“仅 Paint”场景
以下操作通常只会触发重绘,而不会触发布局:
-
颜色变换:修改
BoxDecoration的color。 -
透明度变化:修改
Opacity组件的值(只要不是从 0 变到非 0 导致组件占位变化)。 -
部分动画:比如一个旋转动画(Rotate),如果只是在绘制阶段旋转 Canvas,而它在布局树中的占位没变。
-
Shader 效果:各种渐变、模糊等视觉滤镜。
4. 性能优化的“黄金准则”
既然我们知道 Paint 可以独立于 Layout 运行,那么优化 UI 的一个重要手段就是:尽量把“频繁变动”的东西限制在 Paint 层。
-
反面教材:通过不断改变组件的
margin或padding来实现震动效果(这会疯狂触发 Layout,性能差)。 -
正面教材:使用
Transform变换或CustomPainter在绘制阶段做位移(这只触发 Paint,性能极佳)。
5. 总结
Layout 是“因”,Paint 是“果”。
改变“因”(布局),必然导致“果”(绘制)跟着变。
改变“果”(绘制),并不需要去追溯“因”(布局)。
这种单向依赖关系,保证了 Flutter 在处理复杂视觉特效(如水波纹、高性能动画)时,不需要重新计算整棵树的几何结构。
追问4:SizedBox 这种“定死尺寸”的组件是如何通过“强力约束”来帮 Flutter 节省布局耗时的
Flutter 性能优化的工具箱里,SizedBox 就像是一个“布局断路器”。
它之所以能节省时间,核心在于它向子组件传递了**“强约束(Tight Constraints)”**,并利用了 “布局边界(Relayout Boundary)” 机制。
1. 什么是“强力约束”?
在 Flutter 中,父组件传递给子组件的是一个 BoxConstraints 对象(包含最大/最小宽和高)。
-
弱约束(Loose):最小宽高为 0,最大宽高为 300。子组件在这个范围内“随便长”。
-
强约束(Tight):最小宽度 = 最大宽度 = 200。子组件没有选择权,必须长成这个尺寸。
SizedBox(width: 200, height: 200) 做的就是这件事:它向下传递了一个宽高完全固定的强约束。
2. 它是如何节省耗时的?
A. 减少子组件的计算(Sizes go up 阶段被简化)
在正常的布局流中,子组件需要计算自己的内容(比如文本多长、图片比例)来决定尺寸。
但是,当子组件收到的是强约束时,RenderObject 的布局逻辑会直接触发一个优化:
“既然父组件已经定死了我的尺寸,那无论我里面的内容怎么变,我的
size永远等于那个约束值。”
子组件甚至不需要遍历自己的子节点去计算尺寸,直接返回这个固定值。这在 UI 结构复杂时能节省大量的递归计算。
B. 充当“重布局边界(Relayout Boundary)”
这是性能提升的大头。
如果一个组件的尺寸变化不会导致父组件重新布局,那么这个组件就是一个重布局边界。
-
没有 SizedBox 时:如果你改了
Container里的文字,Container变宽了,它会通知父组件重新布局,父组件再通知爷爷组件... 最终可能导致半棵树重新计算布局。 -
有了 SizedBox 时:如果你把
SizedBox作为父容器,且它的宽高是固定的。即便里面的文字变了,SizedBox告诉它的父组件:“别担心,虽然我里面的孩子在闹腾,但我自己的个头(Size)一点没变,你不需要重新布局我。”
3. 实际场景对比
| 场景 | 布局流程 | 耗时 |
| 无固定宽高 | Text 改变 $\rightarrow$ 重新计算 Text 尺寸 $\rightarrow$ 父级重新布局 $\rightarrow$ 刷新整棵树 |
高 |
套了 SizedBox |
Text 改变 $\rightarrow$ SizedBox 拦截 $\rightarrow$ 布局更新到此为止 |
极低 |
4. 开发者的“性能补丁”
当你遇到以下场景,请反射性地想起 SizedBox:
-
图片占位:加载网络图片前,给一个
SizedBox固定宽高,防止图片加载出来后 UI 突然“跳动”(引起整页重布局)。 -
列表项:如果列表里的每一项高度都固定,配合
itemExtent,Flutter 可以直接跳过对每一个子项的布局测量。 -
动画组件:如果动画只改变内部的颜色或局部位置,不改变外层轮廓,用
SizedBox包起来可以防止动画波及到外层的布局。
一句话总结:
SizedBox通过**“强行规定结果”,让 Flutter 跳过了“商量尺寸”**的繁琐过程,并把布局变化的连锁反应挡在了自己内部。
追问5:Container可以实现这个效果吗
Container 可以实现类似效果,但它实际上是一个“全能替补”,在追求极致性能时,它不如 SizedBox 纯粹。
我们可以从底层实现和性能意识两个角度来拆解:
1. 源码揭秘:Container 是什么?
如果你点开 Container 的源码,你会发现它其实是一个 “组合组件”(StatelessWidget)。它内部根据你传入的参数,嵌套了多层功能单一的组件:
-
如果你设置了
color$\rightarrow$ 它会套一个ColoredBox。 -
如果你设置了
padding$\rightarrow$ 它会套一个Padding。 -
如果你设置了
width或height$\rightarrow$ 它会套一个ConstrainedBox(这才是核心)。
结论:当你写 Container(width: 100, height: 100) 时,Flutter 最终还是通过内部的约束组件来实现“强力约束”的。
2. Container vs SizedBox 的性能差异
虽然 Container 能干 SizedBox 的活,但它们在“性格”上有显著区别:
| 特性 | SizedBox | Container |
| 底层类型 | RenderObjectWidget (更底层) |
StatelessWidget (组合层) |
| 编译优化 | 支持 const 构造 |
部分场景支持,但参数多时不支持 |
| 布局开销 | 极小。直接传递约束,逻辑单一。 | 略高。因为它要检查 padding, margin, decoration 等十几个参数是否为空。 |
| 语义化 | 明确告诉引擎:我只要一个固定大小的框。 | 告诉引擎:我可能要画画、要留白、要变形。 |
3. 为什么高手更偏爱 SizedBox?
A. “Const” 是性能王道
SizedBox 经常可以配合 const 使用:
Dart
const SizedBox(width: 100, height: 100)
这意味着这棵 Widget 树在编译期就确定了,由于 Widget 是不可变的,Flutter 在重新构建(Build)时可以直接跳过这个节点的创建,甚至在 Diff 算法阶段直接复用。而 Container 往往因为参数复杂,很难享受这种全量 const 优化。
B. 避免“布局陷阱”
Container 有一个很复杂的特性:它的尺寸逻辑会根据子组件和父约束自动调整。
-
如果
Container没有子组件,它会尽量长得跟父组件一样大。 -
如果有子组件,它会跟随子组件的大小。
这种灵活性有时会导致意外的重布局。而 SizedBox 的意图非常明确:不管里面有什么,外面是什么,我就占这么大地方。这种明确性有助于 Flutter 更好地建立重布局边界(Relayout Boundary)。
4. 总结:什么时候用哪个?
-
用
SizedBox的场景:-
仅仅为了给子组件限制固定宽高。
-
在组件之间留白(替代
Padding增加间距)。 -
定义一个空的占位符。
-
-
用
Container的场景:-
你需要设置
decoration(圆角、阴影、背景图)。 -
你需要设置
margin。 -
你需要复杂的对齐方式(
alignment)。
-
一句话建议:
如果你只是想“定死尺寸”,请务必使用
SizedBox。这不仅是性能微优,更是向其他开发者和 Flutter 引擎传递了清晰的意图。
更多推荐


所有评论(0)