把 Flutter 布局理解成「CSS 那种算完再回头改」,会在自定义布局里立刻写出二次 layout 和整页 relayout。Flutter 的协议是严格的 约束向下、尺寸向上:父给出一个盒子,子必须在盒子里报 size,同一帧没有回流。BoxConstraints 和 performLayout 不是 API 细节,是这条不变量的执法点。
读完你应该能独立回答三个问题:一次 setState 为什么有时只脏一块、有时冲到根;parentUsesSize / 紧约束 / sizedByParent 各自挡住什么;intrinsic 宽高和 LayoutBuilder 为什么能把 O(N) 变成反复测量。
1. 先画边界:Layout 只产生几何,不产生像素
Layout 跑在 UI Runner,发生在 Build 之后、Paint 之前。它改的是 RenderObject.size、parentData.offset、变换矩阵,不调用 GPU,也不该在这里解码图片。
调用方向永远是父 → 子:
child.layout(constraints, parentUsesSize: true);
// 此后才能读 child.size,且只能读,不能回头换一套约束再 layout 一次
子在 performLayout 里:读 constraints → 给自己的孩子传 更紧或相等 的约束 → 设定 size(必须 constraints.isSatisfiedBy(size))→ 用 parentData.offset 摆子节点。
不变量:子不得放宽超出自己收到的盒子;父不得在子已经 layout 之后再改约束。 破坏第一条,子会画出盒子外,命中测试和 clip 各执一词;破坏第二条,一次遍历作废。
2. 一次对话:四个字段,缺一不可
一次合法的父子对话只有这些输出:
| 字段 | 谁写 | 含义 |
|---|---|---|
constraints | 父在 layout() 时传入 | 本节点的合法尺寸区间 |
size | 子在 performLayout/performResize | 选定的宽高,必须落在区间内 |
parentData.offset | 父在孩子 layout 之后 | 子在父坐标系里的原点 |
parentUsesSize | 父在调用时声明 | 父稍后是否依赖 child.size |
parentUsesSize: true 不是性能开关,是正确性声明。父要拿子的高度做自己的高度,就必须声明;框架据此决定子能不能当 relayout boundary。谎报 false,父读到的 size 可能是上一轮的,布局会静默错。
applyPaintTransform / hitTest 依赖同一套 offset。Layout 写错 offset,Paint 看起来「偏了」,其实是几何协议没遵守,不该去调 Canvas。
3. BoxConstraints 的代数:只能收紧,不能放松
BoxConstraints 是区间,不是一个点。常用操作都是在收紧:
tighten(width:, height:):把区间压成一点(紧约束)。deflate(EdgeInsets):减去 padding 后仍须合法,不够减时落到零。loosen():把 min 降到 0,max 保持。这是「放松下限」,仍然不能超过自己从父那里拿到的 max。constrain(Size):把任意愿望尺寸夹进区间。
子把 constraints.biggest 直接当 size,等于「能有多大占多大」,RenderColoredBox、全屏 CustomPaint 常这样。子把 constraints.smallest 当 size,等于「能有多小缩多小」。内容驱动的节点(文本、图片)在区间里按内容挑一点,再用 constrain 夹住。
跨过 max 再靠 clip 遮,是协议破坏:命中测试仍按 size,用户点到「看不见但能点」或「看得见点不中」。
4. Relayout boundary:脏标记的堤坝
markNeedsLayout 默认向上走。若不加阻拦,叶子改一句文案,根 RenderView 也会进 PipelineOwner 的待 layout 队列,整棵树按 depth 再走一遍。
节点成为 relayout boundary 的条件(满足其一即可):
- 父声明
parentUsesSize == false(父几何不依赖子 size)。 - 约束是紧的(
minWidth==maxWidth && minHeight==maxHeight),子再怎么变也撑不破钉子。 sizedByParent == true,尺寸只由约束决定,与孩子无关。
边界的意义:子树内部 relayout 停在这里,不再向上污染。列表行、Stack 里按 StackFit.expand 铺开的孩子、忽略子尺寸的装饰盒,能快,是因为它们经常是边界,不是因为它们的 performLayout 写得更短。
@override
void performLayout() {
size = constraints.biggest; // 自身是 boundary 的常见写法
child?.layout(BoxConstraints.tight(size), parentUsesSize: false);
}
这里孩子拿到紧约束,孩子也是 boundary。孩子内部再闹,传到这里结束。
5. sizedByParent 与 performResize:尺寸和孩子解耦
sizedByParent == true 时,框架先调 performResize 用约束定 size,再调 performLayout 只摆孩子。RenderProxyBox 的许多装饰节点、RenderColoredBox 走这条路。
这把「我多大」和「孩子多大」切开。孩子变了只需孩子 layout;自己的 size 钉在约束上,祖先不必知道。这是第三条 boundary 条件的来源。
反例是 RenderParagraph:size 依赖文本、字体、max 宽。它几乎必然 parentUsesSize: true 向上传。所以改文案会 relayout 文本,若父是按内容撑开的 Column,会继续向上,直到遇到紧约束(例如屏幕宽)或某个不读子 size 的祖先。
6. PipelineOwner:按 depth 一次冲洗
需要 layout 的节点被丢进 _nodesNeedingLayout,flushLayout 按 depth 升序处理。先父后子:父的 performLayout 会调用子的 layout;若子已经在队列里,这次调用会把它清掉,避免做两遍。
同一帧对已 layout 且约束未变的节点再 layout,走的是 fast path:直接返回。约束变了且该节点不是你可以合法二次 layout 的特例,debug 会断言「Laid out twice」。普通 RenderBox 不要学 sliver 里那套受控的二次测量。
生命周期:attach 进树才有 owner;detach 后不能 markNeedsLayout。在已经 dispose 的 State 里异步回调里碰 RenderObject,是在违反生命周期,不是布局算法 bug。
7. 两条贵路径:intrinsic 与 LayoutBuilder
Intrinsic 宽高(getMaxIntrinsicWidth 等)问的是「在另一根轴还不知道时,这根轴想要多少」。这是对协议的一次侧通道查询。正确实现必须 纯函数:只读约束和子 intrinsic,不写 size,不改 parentData。父若在 performLayout 里对每个孩子问 intrinsic 再 layout,复杂度从 O(N) 变成 O(N × 查询次数)。IntrinsicHeight Widget 之所以贵,就是因为它逼整条 cross-axis 先测量一遍。
dry layout(computeDryLayout)是「只问 size、不落 offset」的正规侧通道,供 ParentDataWidget、溢出报告、某些 overlay 使用。它同样必须无副作用。你自定义 performLayout 却不实现匹配的 dry layout,debug 会告警,release 里有的父会拿到错误预测尺寸。
LayoutBuilder 把 builder 推迟到父 layout 时。这是 Build 插入 Layout 的受控例外:约束直到现在才知道。它合法,但会让 Timeline 上 Build 和 Layout 缠在一起,并且 builder 每次父 layout 都可能重建子 Element。能用静态约束表达的,不要拖到 LayoutBuilder。
8. Sliver 不是另一套世界观
视窗列表走 RenderSliver,约束是 SliverConstraints:滚动轴上的 remaining paint extent、cache extent、cross-axis 的紧宽。返回的是 SliverGeometry(paintExtent、layoutExtent、scrollExtent),不是一个 Size。
协议精神没变:向下给剩余空间,向上报「我用了多少、还能滚多少」。没进 cache extent 的孩子不 inflate,这是列表能装一万行的原因——树的基数被视窗裁过,而不是 layout 函数更神。
在 sliver 孩子里用普通 BoxConstraints.expand() 当无限高,文本会试图在无限高里排一行超长或无限高,这是协议用错坐标系,不是文本 bug。
9. 常见协议破坏
- 在
performLayout里根据子 size 再给子一套不同约束并第二次layout。部分 sliver / 表格有受控二次测量;普通RenderBox做了,debug 炸、release 抖。 - 在
paint或手势回调里改size。命中测试用新几何,合成用旧 Layer,画面和点击分离。 - 忽略
constraints.constrain,画出盒子外再靠 clip 遮。 - 异步图像解码完成后只
markNeedsPaint却按解码尺寸改 layout:应markNeedsLayout,否则父还按 0×0 排。 - 对已 detach 的 child 调
layout。
自定义布局先写一张纸面表:每个孩子拿到什么 min/max,自己最终 size 是什么,谁是 boundary。再用 debugDumpRenderTree() 对实际约束。纸面表和 dump 对不上,代码先别写动画。
读 dump 时盯三列:constraints、size、relayoutBoundary。constraints 是 BoxConstraints(w=0.0-400.0, h=0.0-Infinity) 而 size 是有限高,说明这个节点在无限高里按内容落地——它几乎一定 parentUsesSize: true,不是 boundary。若父是 Column 且没有把垂直约束收紧,脏会继续向上。Flex 的协议是:非 Expanded 的孩子先在剩余空间里 layout(loose),再把剩余分给 flex 孩子(tight)。这仍然是一次遍历里的两次孩子循环,不是回流;孩子自己若再问 intrinsic,那才打开缺口。
baseline 是第三条次要协议:父问子「你的文字基线在哪」,用于 Wrap / 输入框对齐。实现必须在 layout 之后能回答,且不改 size。漏实现时父会退回底边,看起来像「文字偏了」,其实是协议缺了字段,不是 paint 平移错了。
ParentDataWidget(Positioned、Flexible、Expanded)不创造 RenderObject,只改孩子的 parentData。它在 Element 更新时把 flex、top/left 写进已有节点,然后 markNeedsLayout。你自定义多孩子布局却忘了配套 ParentData,孩子的 Flexible 会落到错误的类型上,运行时抛的是 parentData 类型断言,不是 layout 算错。协议的一部分是:父决定 parentData 的类型,孩子只读自己那份。
无限约束要当成错误输入来处理,不要当成「随便长」。ListView 在主轴给孩子无限 max,孩子若再放一个纵向 Column 且没有 shrinkWrap/内层滚动,Column 会在无限高里按 children 求和,可能成功但失去虚拟化,也可能断言失败。自定义 performLayout 里对 constraints.maxHeight.isInfinite 必须有分支:要么改成内容高度,要么强迫孩子紧约束到一个有限视窗。不处理无限,就是把父的滚动协议吞掉。
10. 设计取舍(为什么是现在这样)
- 单向约束:O(N)、可缓存 boundary。代价是「子决定父宽、父再决定子高」必须拆成两帧或改成更紧的约束表达,写过 CSS 的人会觉得死板。
- parentUsesSize 显式声明:让框架能自动立 boundary。代价是谎报会静默错,而不是编译失败。
- intrinsic 作为逃逸口:真需要「先量内容」时有路。代价是这条路默认贵,框架无法帮你立 boundary。
- Sliver 另起约束类型:滚动轴语义清晰。代价是两套
performLayout脑模型,混用时出现无限约束。
这些取舍决定了什么优化有意义。给每个叶子加 RepaintBoundary 挡不住 layout 洪水;把 Column 换成 CustomMultiChildLayout 而不立 boundary,同样会整页 relayout。
11. 怎么把原理用到排障上
一次「改了右下角,整页卡」,按协议提问:
- 这一帧是 layout 多还是 paint 多? Timeline 里
PipelineOwner.flushLayout和flushPaint分开。 - 脏节点的
_relayoutBoundary停在哪? dump render tree,看约束是否 tight、父是否 uses size。 - 有没有 intrinsic / LayoutBuilder / 在 paint 里 layout? 这三项把一次遍历打开缺口。
- 异步回包后 mark 的是 layout 还是 paint? 尺寸变了却只 mark paint,几何和像素会分家。
- 是否在错误的生命周期调 layout? dispose 之后、detach 之后,都不是布局问题。
能回答这五问,你才能决定是拆 State、收紧约束,还是写一个真的 boundary。
12. 小结
布局协议是单向数据流:约束向下、尺寸向上、一次遍历、脏标记停在 boundary。谁破坏单向,谁就失去 O(N) 和可预测性。几何稳定之后,下一篇才谈 Layer 树——那些像素哪些可以合在一张纹理上画完。