布局过程
Layout(布局)过程主要是确定每一个组件的布局信息(大小和位置),Flutter 的布局过程如下:
- 父节点向子节点传递约束(constraints)信息,限制子节点的最大和最小宽高。
- 子节点根据约束信息确定自己的大小(size)。
- 父节点根据特定布局规则(不同布局组件会有不同的布局算法)确定每一个子节点在父节点布局空间中的位置,用偏移 offset 表示。
- 递归整个过程,确定出每一个节点的大小和位置。
可以看到,组件的大小是由自身决定的,而组件的位置是由父组件决定的。
下面是官网的一张图,它用三句话描述了 Flutter 布局过程的精髓:
Flutter 中的布局类组件很多,根据孩子数量可以分为单子组件和多子组件,下面我们先通过分别自定义一个单子组件和多子组件来直观理解一下Flutter的布局过程,之后会介绍一下布局更新过程和 Flutter 中的 Constraints(约束)。
单子组件布局示例
我们实现一个单子组件 CustomCenter
,功能基本和 Center
组件对齐,通过这个实例我们演示一下布局的主要流程。
首先,我们定义组件,为了介绍布局原理,我们不采用组合的方式来实现组件,而是直接通过定制 RenderObject
的方式来实现。因为居中组件需要包含一个子节点,所以我们直接继承 SingleChildRenderObjectWidget
。
class CustomCenter extends SingleChildRenderObjectWidget {
const CustomCenter2({Key? key, required Widget child})
: super(key: key, child: child);
RenderObject createRenderObject(BuildContext context) {
return RenderCustomCenter();
}
}
接着实现 RenderCustomCenter
。这里直接继承 RenderObject
会更接近底层一点,但这需要我们自己手动实现一些和布局无关的东西,比如事件分发等逻辑。为了更聚焦布局本身,我们选择继承自RenderShiftedBox
,它是RenderBox
的子类(RenderBox
继承自RenderObject
),它会帮我们实现布局之外的一些功能,这样我们只需要重写performLayout
,在该函数中实现子节点居中算法即可。
class RenderCustomCenter extends RenderShiftedBox {
RenderCustomCenter({RenderBox? child}) : super(child);
void performLayout() {
//1. 先对子组件进行layout,随后获取它的size
child!.layout(
constraints.loosen(), //将约束传递给子节点
parentUsesSize: true, // 因为我们接下来要使用child的size,所以不能为false
);
//2.根据子组件的大小确定自身的大小
size = constraints.constrain(Size(
constraints.maxWidth == double.infinity
? child!.size.width
: double.infinity,
constraints.maxHeight == double.infinity
? child!.size.height
: double.infinity,
));
// 3. 根据父节点子节点的大小,算出子节点在父节点中居中之后的偏移,然后将这个偏移保存在
// 子节点的parentData中,在后续的绘制阶段,会用到。
BoxParentData parentData = child!.parentData as BoxParentData;
parentData.offset = ((size - child!.size) as Offset) / 2;
}
}
布局过程请参考注释,在此需要额外说明有3点:
- 在对子节点进行布局时,
constraints
是CustomCenter
的父组件传递给自己的约束信息,我们传递给子节点的约束信息是constraints.loosen()
,下面看一下loosen
的实现源码:
BoxConstraints loosen() {
return BoxConstraints(
minWidth: 0.0,
maxWidth: maxWidth,
minHeight: 0.0,
maxHeight: maxHeight,
);
}
很明显,CustomCenter
约束子节点最大宽高不超过自身的最大宽高。
-
子节点在父节点(
CustomCenter
)的约束下,确定自己的宽高;此时CustomCenter
会根据子节点的宽高确定自己的宽高,上面代码的逻辑是,如果CustomCenter
父节点传递给它最大宽高约束是无限大时,它的宽高会设置为它子节点的宽高。注意,如果这时将CustomCenter
的宽高也设置为无限大就会有问题,因为在一个无限大的范围内自己的宽高也是无限大的话,那么实际上的宽高到底是多大,它的父节点会懵逼的!屏幕的大小是固定的,这显然不合理。如果CustomCenter
父节点传递给它的最大宽高约束不是无限大,那么是可以指定自己的宽高为无限大的,因为在一个有限的空间内,子节点如果说自己无限大,那么最大也就是父节点的大小。所以,简而言之,CustomCenter
会尽可能让自己填满父元素的空间。 -
CustomCenter
确定了自己的大小和子节点大小之后就可以确定子节点的位置了,根据居中算法,将子节点的原点坐标算出后保存在子节点的parentData
中,在后续的绘制阶段会用到,具体怎么用,我们看一下RenderShiftedBox
中默认的paint
实现:
void paint(PaintingContext context, Offset offset) {
if (child != null) {
final BoxParentData childParentData = child!.parentData! as BoxParentData;
//从child.parentData中取出子节点相对当前节点的偏移,加上当前节点在屏幕中的偏移,
//便是子节点在屏幕中的偏移。
context.paintChild(child!, childParentData.offset + offset);
}
}
performLayout 流程
可以看到,布局的逻辑是在 performLayout
方法中实现的。我们梳理一下 performLayout
中具体做的事:
- 如果有子组件,则对子组件进行递归布局。
- 确定当前组件的大小(size),通常会依赖子组件的大小。
- 确定子组件在当前组件中的起始偏移。
在Flutter组件库中,有一些常用的单子组件比如 Align、SizedBox、DecoratedBox
等,都可以打开源码去看看其实现。
下面我们看一个多子组件的例子。
多子组件布局示例
实际开发中我们会经常用到贴边左-右布局,现在我们就来实现一个 LeftRightBox
组件来实现左-右布局,因为LeftRightBox
有两个孩子,用一个 Widget 数组来保存子组件。
首先我们定义组件,与单子组件不同的是多子组件需要继承自 MultiChildRenderObjectWidget
:
lass LeftRightBox extends MultiChildRenderObjectWidget {
LeftRightBox({
Key? key,
required List<Widget> children,
}) : assert(children.length == 2, "只能传两个children"),
super(key: key, children: children);
RenderObject createRenderObject(BuildContext context) {
return RenderLeftRight();
}
}
接下来需要实现 RenderLeftRight
,在其 performLayout
中我们实现实现左-右布局算法:
class LeftRightParentData extends ContainerBoxParentData<RenderBox> {}
class RenderLeftRight extends RenderBox
with
ContainerRenderObjectMixin<RenderBox, LeftRightParentData>,
RenderBoxContainerDefaultsMixin<RenderBox, LeftRightParentData> {
// 初始化每一个child的parentData
void setupParentData(RenderBox child) {
if (child.parentData is! LeftRightParentData)
child.parentData = LeftRightParentData();
}
void performLayout() {
final BoxConstraints constraints = this.constraints;
RenderBox leftChild = firstChild!;
LeftRightParentData childParentData =
leftChild.parentData! as LeftRightParentData;
RenderBox rightChild = childParentData.nextSibling!;
//我们限制右孩子宽度不超过总宽度一半
rightChild.layout(
constraints.copyWith(maxWidth: constraints.maxWidth / 2),
parentUsesSize: true,
);
//调整右子节点的offset
childParentData = rightChild.parentData! as LeftRightParentData;
childParentData.offset = Offset(
constraints.maxWidth - rightChild.size.width,
0,
);
// layout left child
// 左子节点的offset默认为(0,0),为了确保左子节点始终能显示,我们不修改它的offset
leftChild.layout(
//左侧剩余的最大宽度
constraints.copyWith(
maxWidth: constraints.maxWidth - rightChild.size.width,
),
parentUsesSize: true,
);
//设置LeftRight自身的size
size = Size(
constraints.maxWidth,
max(leftChild.size.height, rightChild.size.height),
);
}
void paint(PaintingContext context, Offset offset) {
defaultPaint(context, offset);
}
bool hitTestChildren(BoxHitTestResult result, {required Offset position}) {
return defaultHitTestChildren(result, position: position);
}
}
可以看到,实际布局流程和单子节点并没有太大区别,只不过多子组件需要同时对多个子节点进行布局。另外和RenderCustomCenter
不同的是,RenderLeftRight
是直接继承自 RenderBox
,同时混入了ContainerRenderObjectMixin
和 RenderBoxContainerDefaultsMixin
两个 mixin,这两个 mixin
实现了通用的绘制和事件处理相关逻辑。
关于ParentData
上面两个例子中我们在实现相应的 RenderObject
时都用到了子节点的 parentData
对象(将子节点的offset
信息保存其中),可以看到 parentData
虽然属于 child
的属性,但它从设置(包括初始化)到使用都在父节点中,这也是为什么起名叫“parentData
”。实际上Flutter框架中,parentData
这个属性主要就是为了在 layout 阶段保存组件布局信息而设计的。
需要注意:“parentData 用于保存节点的布局信息” 只是一个约定,我们定义组件时完全可以将子节点的布局信息保存在任意地方,也可以保存非布局信息。但是,还是强烈建议大家遵循Flutter的规范,这样我们的代码会更容易被他人看懂,也会更容易维护。
Layout 流程分析
Layout开始于drawFrame()
代码中的flushLayout
方法。我们回忆一下:
void drawFrame() {
buildOwner!.buildScope(renderViewElement!); // 1.重新构建widget树
//下面是 展开 super.drawFrame() 方法
pipelineOwner.flushLayout(); // 2.更新布局
pipelineOwner.flushCompositingBits(); //3.更新“层合成”信息
pipelineOwner.flushPaint(); // 4.重绘
if (sendFramesToEngine) {
renderView.compositeFrame(); // 5. 上屏,会将绘制出的bit数据发送给GPU
...
}
}
需要注意的是,Flutter 在一帧的渲染中并没有对 Render Tree
中的每一个节点执行 Layout
,和Build
流程一样,Flutter 会标记那些需要Layout
的RenderObject
节点,并进行Layout
。
Flutter的 Build、Layout 等后续流程都使用了GC算法中的标记-清除(Mark-Sweep)思想,其本质是通过空间换取时间,达到UI的局部更新和渲染数据的高效生成。在Flutter中,标记阶段被称为Mark,清除阶段被称为Flush。因此,下面将按照这两个阶段进行源码剖析。
Mark阶段
RenderObject
的 markNeedsLayout
方法会将当前节点标记为需要 Layout
,但和Build
流程不同的是,Layout
的mark入口十分离散,markNeedsBuild
方法通常是由开发者通过setState
主动调用而导致的,因而调用点十分清晰。
Layout
过程是相对Render Tree
而言的,因为RenderObject
中保存了Layout
相关的信息。虽然无法枚举全部markNeedsLayout
的调用点,但是可以推测。例如,当一个表示图片的RenderObject
大小改变时,其必然需要Layout
:
double? get width => _width;
double? _width;
set width(double? value) {
if (value == _width) return; // 宽度未改变,即使内容改变了,也无须Layout
_width = value;
markNeedsLayout();
}
以上逻辑是RenderImage
的width
属性更新时的逻辑,它将会调用自身的markNeedsLayout
方法。
markNeedsLayout
当组件布局发生变化时,它需要调用 markNeedsLayout
方法来更新布局,它的功能主要有两个:
- 将自身到其
relayoutBoundary
路径上的所有节点标记为 “需要布局” 。 - 请求新的
frame
;在新的frame
中会对标记为“需要布局”的节点重新布局。
我们看看其核心源码:
void markNeedsLayout() { // RenderObject
if (_needsLayout) { return; } // 已经标记过,直接返回
if (_relayoutBoundary != this) { // 如果当前节点不是布局边界节点:父节点受此影响,也需要被标记
markParentNeedsLayout(); // 递归调用
} else { // 如果当前节点是布局边界节点:仅标记到当前节点,父节点不受影响
_needsLayout = true;
if (owner != null) {
// 将布局边界节点加入到 pipelineOwner._nodesNeedingLayout 列表中
owner!._nodesNeedingLayout.add(this);
owner!.requestVisualUpdate(); // 请求刷新
}
}
}
// 递归调用前节点到其布局边界节点路径上所有节点的markNeedsLayout方法
void markParentNeedsLayout() {
_needsLayout = true;
final RenderObject parent = this.parent! as RenderObject;
if (!_doingThisLayoutWithCallback) { // 通常会进入本分支
parent.markNeedsLayout(); // 继续标记
} else { // 无须处理
assert(parent._debugDoingThisLayout);
}
}
以上逻辑首先判断_needsLayout
字段是否为true
,若为true
则说明已经标记过,直接返回,否则会判断当前RenderObject
是否为Layout
的边界节点,每个RenderObject
都有一个_relayoutBoundary
字段,表示包括其自身在内的祖先节点中最近的“布局边界”。所谓布局边界,是指 该节点的Layout
不会对父节点产生影响,那么该节点及其子节点的Layout
所产生的副作用就不会继续向祖先节点传递。Flutter正是通过记录、存储和更新边界节点实现局部Layout
,以最大限度降低冗余无效的Layout
计算。
以图5-12为例,对于以上逻辑,如果当前节点不是布局边界则会调用祖先节点的markNeedsLayout
方法,直到当前节点是一个布局边界。此时,将该节点加入PipelineOwner
对象的_nodesNeedingLayout
列表,并会通过requestVisualUpdate
方法请求帧渲染,但是后者一般都在Build
流程的mark
阶段完成了。以上过程会将经过的每个节点的_needsLayout
属性标记为true
。
通常情况下,markNeedsLayout
方法是在Vsync信号到达后、Build
流程的Flush
阶段,伴随着Render Tree
的更新而触发的。
Flush阶段
下面进入Layout流程的Flush阶段,即flushLayout
方法,具体逻辑如下:
void flushLayout() { // 由 drawFrame() 触发
if (!kReleaseMode) { Timeline.startSync('Layout', arguments: ......); }
// Layout阶段开始
try {
while (_nodesNeedingLayout.isNotEmpty) { // 存在需要更新Layout信息的节点
final List<RenderObject> dirtyNodes = _nodesNeedingLayout;
_nodesNeedingLayout = <RenderObject>[];
// 先按照节点在树中的深度从小到大进行排序,优先处理祖先节点
dirtyNodes.sort((RenderObject a, RenderObject b) => a.depth - b.depth);
for (final RenderObject node in dirtyNodes) {
if (node._needsLayout && node.owner == this)
node._layoutWithoutResize(); // 真正的Layout逻辑
}
}
} finally {
if (!kReleaseMode) { Timeline.finishSync(); } // Layout阶段结束
}
}
以上逻辑将_nodesNeedingLayout
中需要重新布局 Layout 的RenderObject
节点按照深度进行排序,优先对深度较小的节点进行Layout,这些节点通常是祖先节点。然后将会触发这些需要执行Layout 的RenderObject
节点的_layoutWithoutResize
方法,因为每个加入的节点都是边界节点,所以这里的方法名以WithoutResize
结尾。对非布局边界节点而言,如果一个RenderObject
节点在Layout时改变大小了,其相对于父节点的Layout信息就变了。
思考题:为什么这里要先对 dirtyNodes
根据在树中的深度按照从小到大排序?从大到小不行吗?
- 之所以要按照深度进行排序,主要是因为祖先节点Layout过程是按照深度优先进行树遍历的,排序后,深度较小的是祖先节点,但是,如果从大到小排序,则会优先对深度较大的子节点进行Layout,那么当执行到祖先节点的Layout时,子节点可能因为祖先节点的影响而需要重新Layout,导致之前的Layout变成了一次无效计算。
下面看一下 _layoutWithoutResize
方法的实现:
void _layoutWithoutResize() { // RenderObject
try {
performLayout(); // 重新布局;会递归布局后代节点
markNeedsSemanticsUpdate();
} catch (e, stack) { ...... }
_needsLayout = false;
markNeedsPaint(); //布局更新后,UI也是需要更新的
}
void performLayout(); // 由RenderObject的子类负责实现
以上逻辑将执行当前节点的performLayout
方法,用于开始具体的Layout逻辑,然后将当前节点标记为需要Paint流程。
Layout是渲染管道中对开发者而言比较重要的一个流程,我们在进行UI开发时大部分工作其实就是通过Widget来布局内部的RenderObject
节点,使用的大部分Widget其实也是封装了不同的布局,比如Row、Colum、Stack、Center
等。
简单来说,Layout过程的本质是从布局边界节点开始的深度优先遍历,进入节点时携带父节点的constraints
信息(通过设置RenderObject
的_constraints
字段获得),对Box布局模型而言,子节点完成performLayout
后返回代表大小的Size
信息和代表位置的Offset
信息(通过设置_size
字段和offset
字段)。
Layout实例分析
下面以RenderView
的performLayout
方法为入口进行分析,代码如下:
// flutter/packages/flutter/lib/src/rendering/view.dart
// RenderView
void performLayout() {
assert(_rootTransform != null);
_size = configuration.size; // Embedder负责配置size信息
assert(_size.isFinite); // 必须是有限大小
if (child != null) child!.layout(BoxConstraints.tight(_size));
}
以上逻辑首先更新_size
字段,表示Embedder所提供的FlutterView
的大小,一般为屏幕的大小,然后将调用子节点的layout
方法:
// flutter/packages/flutter/lib/src/rendering/object.dart
void layout(Constraints constraints, { bool parentUsesSize = false }) {
RenderObject? relayoutBoundary; // 更新布局边界
if (!parentUsesSize || sizedByParent || constraints.isTight ||
parent is! RenderObject) { // 第1步,当前RenderObject是布局边界
relayoutBoundary = this;
} else { // 否则使用父类的布局边界
relayoutBoundary = (parent! as RenderObject)._relayoutBoundary;
}
if (!_needsLayout && constraints == _constraints &&
relayoutBoundary == _relayoutBoundary) { // 第2步,无须重新Layout
return;
}
_constraints = constraints; // 第3步,更新约束信息
if (_relayoutBoundary != null && relayoutBoundary != _relayoutBoundary) {
visitChildren(_cleanChildRelayoutBoundary); // 后面分析
}
_relayoutBoundary = relayoutBoundary; // 更新布局边界
if (sizedByParent) {// 第4步,子节点大小完全取决于父节点
performResize(); // 重新确定组件大小
}
performLayout(); // 第5步,子节点自身实现布局逻辑
markNeedsSemanticsUpdate();
_needsLayout = false; // 当前节点的Layout流程完成
markNeedsPaint(); // 标记当前节点需要重绘
}
以上逻辑主要分为 5 步。
布局边界(relayoutBoundary)
其中第 1 步对应的代码,我们单独来看:
RenderObject? relayoutBoundary; // 更新布局边界
// parent is! RenderObject 为 true 时则表示当前组件是根组件,因为只有根组件没有父组件。
if (!parentUsesSize || sizedByParent || constraints.isTight || parent is! RenderObject) {
relayoutBoundary = this; // 当前RenderObject是布局边界
} else { // 否则使用父类的布局边界
relayoutBoundary = (parent! as RenderObject)._relayoutBoundary;
}
这里主要是判断当前RenderObject
节点是否为一个布局边界节点。 如果一个组件满足以下四种条件之一,则它便是一个 relayoutBoundary
布局边界节点:
-
!parentUsesSize
:父组件的大小不依赖当前组件大小时,也就是说父节点不使用自身的size
信息,这种情况下父组件在调用子组件布局函数时会给子组件传递一个parentUsesSize = false
参数,表示父组件的布局算法不会依赖子组件的大小。比如RenderView
在调用layout方法时使用了默认参数,所以它是一个布局边界节点。 -
sizedByParent
:组件的大小只取决于父组件传递的约束,也就是说当前RenderObject
节点的大小完全由父节点控制,而不受子节点影响,这样的话后代组件的大小变化就不会影响自身的大小了,这种情况组件的sizedByParent = true
。比如RenderAndroidView
、RenderOffstage
,由于子节点的Layout止步于此节点,因此也是布局边界节点。 -
constraints.isTight
:父组件传递给自身的约束是一个严格约束(固定宽高),这种情况下即使自身的大小依赖后代元素,但也不会影响父组件。例如SliverConstraints
的isTight
字段恒为false
,BoxConstraints
在最小宽高大于或等于最大宽高时为true
。该指标为true
同样也说明当前RenderObject
节点的大小是固定的,因而其子节点无论如何Layout,其副作用都不会超出当前节点。 -
parent is! RenderObject
:组件为根节点;Flutter 应用的根组件是 RenderView,它的默认大小是当前设备屏幕大小。
以上4个条件的本质都是子节点的Layout所产生的副作用不会发散出当前节点,即可认为当前节点为边界节点。否则,当前节点会继承父节点的布局边界。
关于布局边界下面我们再看一个例子,假如有一个页面的组件树结构如图所示:
假如 Text3
的文本长度发生变化,则会导致 Text4
的位置和 Column2
的大小也会变化;又因为 Column2
的父组件 SizedBox
已经限定了大小,所以 SizedBox
的大小和位置都不会变化。所以最终我们需要进行 relayout
的组件是:Text3
、Column2
,这里需要注意:
-
Text4
是不需要重新布局的,因为Text4
的大小没有发生变化,只是位置发生变化,而它的位置是在父组件Column2
布局时确定的。 -
很容易发现:假如
Text3
和Column2
之间还有其他组件,则这些组件也都是需要relayout
的。
在本例中,Column2
就是 Text3
的 relayoutBoundary
(重新布局的边界节点)。每个组件的 renderObject
中都有一个 _relayoutBoundary
属性指向自身的布局边界节点,如果当前节点布局发生变化后,自身到其布局边界节点路径上的所有的节点都需要 relayout
。
下面回到 layout
方法的源码中继续分析:
第 2 步,判断是否可以直接返回,条件是:自身的_needsLayout
为true
即当前组件没有被标记为需要重新布局、父节点传递的布局约束(Constraints
)和之前一样没有变化、布局边界节点也和之前一样没有变化,这3个条件可以保证当前布局不会相对上一帧发生改变。
第 3 步,更新当前RenderObject
节点的约束信息。并且,如果布局边界改变,则要清理子节点的布局边界信息。
第 4 步,如果sizedByParent
属性为true
,则调用performResize
方法,具体实现取决于RenderObject
的子类,一般此类RenderObject
节点不会再实现performLayout
方法。
第 5 步,执行performLayout
方法,最后将_needsLayout
字段标记为true
,并将当前节点标记为需要Paint流程。
下面看一下 _cleanRelayoutBoundary
方法的实现:
// flutter/packages/flutter/lib/src/rendering/object.dart
void _cleanRelayoutBoundary() {
if (_relayoutBoundary != this) { // 自身不是布局边界,则需要清理
_relayoutBoundary = null;
_needsLayout = true; // 需要重新布局
visitChildren(_cleanChildRelayoutBoundary); // 遍历并处理所有子节点
}
}
static void _cleanChildRelayoutBoundary(RenderObject child) {
child._cleanRelayoutBoundary();
}
以上逻辑将清除RenderObject
节点的_relayoutBoundary
信息,并标记当前节点为需要Layout。注意,这里由于该节点的祖先节点已经在Layout过程中,它一定会被Layout,因此该节点无须再加入待Layout的队列。此外,当子节点本身是一个布局边界节点时,无须继续清理其子节点。布局边界节点就像一个结界,隔离了祖先节点和子节点的相互作用,从而使局部Layout成为可能。
到这里,我们可以简单总结一下 Layout 流程:
sizedByParent
在 layout
方法中,有如下逻辑:
if (sizedByParent) {
performResize(); //重新确定组件大小
}
上面我们说过 sizedByParent
为 true
时表示:当前组件的大小只取决于父组件传递的约束,而不会依赖后代组件的大小。前面我们说过,performLayout
中确定当前组件的大小时通常会依赖子组件的大小,如果 sizedByParent
为 true
,则当前组件的大小就不依赖子组件大小了,为了逻辑清晰,Flutter 框架中约定,当 sizedByParent
为 true
时,确定当前组件大小的逻辑应抽离到 performResize()
中,这种情况下 performLayout
主要的任务便只有两个:对子组件进行布局和确定子组件在当前组件中的布局起始位置偏移。
下面我们通过一个 AccurateSizedBox
示例来演示一下 sizedByParent
为 true
时我们应该如何布局:
AccurateSizedBox
Flutter 中的 SizedBox
组件会将其父组件的约束传递给其子组件,这也就意味着,如果父组件限制了最小宽度为100
,即使我们通过 SizedBox
指定宽度为50
,那也是没用的,因为 SizedBox
的实现中会让 SizedBox
的子组件先满足 SizedBox
父组件的约束。
还记得之前我们想在 AppBar
中限制 loading
组件大小的例子吗:
AppBar(
title: Text(title),
actions: <Widget>[
SizedBox( // 尝试使用SizedBox定制loading 宽高
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 3,
valueColor: AlwaysStoppedAnimation(Colors.white70),
),
)
],
)
实际结果如图:
之所以不生效,是因为父组件限制了最小高度,当然我们也可以使用 UnconstrainedBox + SizedBox
来实现我们想要的效果,但是这里我们希望通过一个组件就能搞定,为此我们自定义一个 AccurateSizedBox
组件,它和 SizedBox
的主要区别是 AccurateSizedBox
自身会遵守其父组件传递的约束,而不是让其子组件去满足 AccurateSizedBox
父组件的约束,具体:
AccurateSizedBox
自身大小只取决于父组件的约束和用户指定的宽高。(sizedByParent = true
)AccurateSizedBox
确定自身大小后,限制其子组件大小。
AccurateSizedBox
实现代码如下:
class AccurateSizedBox extends SingleChildRenderObjectWidget {
const AccurateSizedBox({
Key? key,
this.width = 0,
this.height = 0,
required Widget child,
}) : super(key: key, child: child);
final double width;
final double height;
RenderObject createRenderObject(BuildContext context) {
return RenderAccurateSizedBox(width, height);
}
void updateRenderObject(context, RenderAccurateSizedBox renderObject) {
renderObject
..width = width
..height = height;
}
}
class RenderAccurateSizedBox extends RenderProxyBoxWithHitTestBehavior {
RenderAccurateSizedBox(this.width, this.height);
double width;
double height;
// 当前组件的大小只取决于父组件传递的约束
bool get sizedByParent => true;
// performResize 中会调用
Size computeDryLayout(BoxConstraints constraints) {
//设置当前元素宽高,遵守父组件的约束
return constraints.constrain(Size(width, height));
}
// @override
// void performResize() {
// // default behavior for subclasses that have sizedByParent = true
// size = computeDryLayout(constraints);
// assert(size.isFinite);
// }
void performLayout() {
child!.layout(
BoxConstraints.tight(
Size(min(size.width, width), min(size.height, height))), // 限制child大小
// 父容器是固定大小,子元素大小改变时不影响父元素
// parentUseSize为false时,子组件的布局边界会是它自身,子组件布局发生变化后不会影响当前组件
parentUsesSize: false,
);
}
}
上面代码有三点需要注意:
-
我们的
RenderAccurateSizedBox
不再直接继承自RenderBox
,而是继承自RenderProxyBoxWithHitTestBehavior
,RenderProxyBoxWithHitTestBehavior
间接继承自RenderBox
,它里面包含了默认的命中测试和绘制相关逻辑,继承自它后就不用我们再手动实现了。 -
我们将确定当前组件大小的逻辑挪到了
computeDryLayout
方法中,因为RenderBox
的performResize
方法会调用computeDryLayout
,并将返回结果作为当前组件的大小。按照Flutter 框架约定,我们应该重写computeDryLayout
方法而不是performResize
方法,就像在布局时我们应该重写performLayout
方法而不是layout
方法;不过,这只是一个约定,并非强制,但我们应该尽可能遵守这个约定,除非你清楚的知道自己在干什么并且能确保之后维护你代码的人也清楚。 -
RenderAccurateSizedBox
在调用子组件的layout
方法时,将parentUsesSize
置为false
,这样的话子组件就会变成一个布局边界。
下面我们测试一下:
class AccurateSizedBoxRoute extends StatelessWidget {
const AccurateSizedBoxRoute({Key? key}) : super(key: key);
Widget build(BuildContext context) {
final child = GestureDetector(
onTap: () => print("tap"),
child: Container(width: 300, height: 300, color: Colors.red),
);
return Row(
children: [
ConstrainedBox(
constraints: BoxConstraints.tight(Size(100, 100)),
child: SizedBox(
width: 50,
height: 50,
child: child,
),
),
Padding(
padding: const EdgeInsets.only(left: 8),
child: ConstrainedBox(
constraints: BoxConstraints.tight(Size(100, 100)),
child: AccurateSizedBox(
width: 50,
height: 50,
child: child,
),
),
),
],
);
}
}
运行效果:
可以看到,当父组件约束子组件大小宽高是100x100
时,我们通过 SizedBox
指定子组件 Container
大小是为 50×50
是不能成功的, 而通过 AccurateSized
成功了。
这里需要强调一下:如果一个组件的的 sizedByParent
为 true
,那它在布局子组件时也是能将 parentUsesSize
置为 true
的,sizedByParent
为 true
表示自己是布局边界,而将 parentUsesSize
置为 true
或 false
决定的是子组件是否是布局边界,两者并不矛盾,这个不要混淆了。例如 Flutter 自带的 OverflowBox
组件的实现中,它的 sizedByParent
为 true
,在调用子组件layout
方法时,parentUsesSize
传的是 true
,详情可以查看 OverflowBox
的实现源码。
AfterLayout
我们之前绍过自定义的 AfterLayout
组件,现在我们就来看看它的实现原理。
AfterLayout
可以在布局结束后拿到子组件的代理渲染对象 (RenderAfterLayout
), RenderAfterLayout
对象会代理子组件渲染对象 ,因此,通过RenderAfterLayout
对象也就可以获取到子组件渲染对象上的属性,比如件大小、位置等。
AfterLayout
的实现代码如下:
class AfterLayout extends SingleChildRenderObjectWidget {
AfterLayout({
Key? key,
required this.callback,
Widget? child,
}) : super(key: key, child: child);
RenderObject createRenderObject(BuildContext context) {
return RenderAfterLayout(callback);
}
void updateRenderObject(
BuildContext context, RenderAfterLayout renderObject) {
renderObject..callback = callback;
}
///组件树布局结束后会被触发,注意,并不是当前组件布局结束后触发
final ValueSetter<RenderAfterLayout> callback;
}
class RenderAfterLayout extends RenderProxyBox {
RenderAfterLayout(this.callback);
ValueSetter<RenderAfterLayout> callback;
void performLayout() {
super.performLayout();
// 不能直接回调callback,原因是当前组件布局完成后可能还有其他组件未完成布局
// 如果callback中又触发了UI更新(比如调用了 setState)则会报错。因此,我们在 frame 结束的时候再去触发回调。
SchedulerBinding.instance
.addPostFrameCallback((timeStamp) => callback(this));
}
/// 组件在屏幕坐标中的起始点坐标(偏移)
Offset get offset => localToGlobal(Offset.zero);
/// 组件在屏幕上占有的矩形空间区域
Rect get rect => offset & size;
}
上面代码有三点需要注意:
-
callback
调用时机不是在子组件完成布局后就立即调用,原因是子组件布局完成后可能还有其他组件未完成布局,如果此时调用callback
,一旦callback
中存在触发更新的代码(比如调用了setState
)则会报错。因此我们在frame
结束的时候再去触发回调。 -
RenderAfterLayout
的performLayout
方法中直接调用了父类RenderProxyBox
的performLayout
方法:
void performLayout() {
if (child != null) {
child!.layout(constraints, parentUsesSize: true);
size = child!.size;
} else {
size = computeSizeForNoChild(constraints);
}
}
可以看到是直接将父组件传给自身的约束传递给子组件,并将子组件的大小设置为自身大小。也就是说 RenderAfterLayout
的大小和其子组件大小是相同的
- 我们定义了
offset
和rect
两个属性,它们是组件相对于屏幕的的位置偏移和占用的矩形空间范围。 但是实战中,我们经常需要获取的是子组件相对于某个父级组件的坐标和矩形空间范围,这时候我们可以调用RenderObject
的localToGlobal
方法,比如下面的的代码展示了Stack
中某个子组件获取相对于Stack
的矩形空间范围:
...
Widget build(context){
return Stack(
alignment: AlignmentDirectional.topCenter,
children: [
AfterLayout(
callback: (renderAfterLayout){
//我们需要获取的是AfterLayout子组件相对于Stack的Rect
_rect = renderAfterLayout.localToGlobal(
Offset.zero,
//找到 Stack 对应的 RenderObject 对象
ancestor: context.findRenderObject(),
) & renderAfterLayout.size;
},
child: Text('Flutter@wendux'),
),
]
);
}
Constraints(约束)
Constraints
(约束)主要描述了最小和最大宽高的限制,理解组件在布局过程中如何根据约束确定自身或子节点的大小对我们理解组件的布局行为有很大帮助,现在我们就通过一个实现 200x200
的红色 Container
的例子来说明。为了排除干扰,我们让根节点(RenderView
)作为 Container
的父组件,我们的代码是:
Container(width: 200, height: 200, color: Colors.red)
但实际运行之后,你会发现整个屏幕都变成了红色!为什么呢?我们看看 RenderView
的布局实现:
void performLayout() {
// configuration.size 为当前设备屏幕
_size = configuration.size;
if (child != null)
child!.layout(BoxConstraints.tight(_size)); // 强制子组件和屏幕一样大
}
可以发现,RenderView
中给子组件传递的是一个严格约束,即强制子组件大小为屏幕大小,所以 Container
便撑满了屏幕。
这里需要介绍一下两种常用的约束:
- 宽松约束:不限制最小宽高(为0),只限制最大宽高,可以通过
BoxConstraints.loose(Size size)
来快速创建。 - 严格约束:限制为固定大小;即最小宽度等于最大宽度,最小高度等于最大高度,可以通过
BoxConstraints.tight(Size size)
来快速创建。
那我们怎么才能让指定的大小生效呢?标准答案就是引入一个中间组件,让这个中间组件遵守父组件的约束,然后对子组件传递新的约束。
对于这个例子来讲,最简单的方式是用一个 Align
组件来包裹 Container
:
Widget build(BuildContext context) {
var container = Container(width: 200, height: 200, color: Colors.red);
return Align(
child: container,
alignment: Alignment.topLeft,
);
}
Align
会遵守 RenderView
的约束,让自身撑满屏幕,然后会给子组件传递一个宽松约束(最小宽高为0
,最大宽高为200
),这样 Container
就可以变成 200 x 200
了。
当然我们还可以使用其他组件来代替 Align
,比如 UnconstrainedBox
,但原理是相同的,可以查看源码验证。
总结
现在我们再来看一下官网关于Flutter布局的解释:
- “ 在进行布局的时候,Flutter 会以 DFS(深度优先遍历)方式遍历渲染树,并 将限制以自上而下的方式 从父节点传递给子节点。子节点若要确定自己的大小,则 必须 遵循父节点传递的限制。子节点的响应方式是在父节点建立的约束内 将大小以自下而上的方式 传递给父节点。”
是不是理解的更透彻了一些!
参考:
- 《Flutter实战·第二版》
- 《Flutter内核源码剖析》