布局(Layout)过程
Layout布局过程注意是确定每一个组件的布局信息(大小和位置),Flutter的布局过程如下:
- 父节点向子节点传递约束(constraints)信息,限制子节点的最大和最小宽高。
- 子节点根据约束信息确定自己的大小(size)。
- 父节点根据特定的布局规则(不同布局组件会有不同的布局算法)确定每一个子节点在父节点布局空间的位置,用偏移offset表示。
- 递归整个过程,确定每一个节点的大小和位置。
总结:组件的大小是由自身决定的,组件的位置由父组件决定的。
Flutter布局类组件很多,根据子组件数量可以分为单子组件和多子组件。
单子组件布局示例(CustomCenter)
实现一个单子组件CustomCenter,功能基本和Center组件对齐。
首先,直接通过定制RenderObject的方式来实现,因为居中组件需要包含一个子节点,所以直接继承子SingleChildRenderObjectWidget。
class CustomCenter extends SingleChildRenderObjectWidget {
const CustomCenter2({Key? key, required Widget child})
: super(key: key, child: child);
@override
RenderObject createRenderObject(BuildContext context) {
return RenderCustomCenter();
}
}
接着实现RenderCustomCenter。这里直接继承RenderObject会更接近底层一点,但是需要自己手动实现一些和布局无关的功能,比如事件分发等逻辑。为了更聚焦布局本身,选择继承自RenderShiftedBox,它会帮我们实现布局之外的一些功能,我们只需重写performLayout,在该函数中实现子节点居中算法即可:
class RenderCustomCenter extends RenderShiftedBox {
RenderCustomCenter({RenderBox? child}) : super(child);
@override
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;
}
}
额外说明:
- 在对子节点进行布局时,constraints是CustomCenter的父组件传递给自己的约束信息,传递给子节点的约束信息是constraints.loosen(),loosen源码:
BoxConstraints loosen() {
return BoxConstraints(
minWidth: 0.0,
maxWidth: maxWidth,
minHeight: 0.0,
maxHeight: maxHeight,
);
}
CustomCenter约束子节点最大宽高不超过自身的最大宽高。
- 子节点在父节点(CustomCneter)的约束下,确定自己的宽高;此时CustomCenter会根据子节点的宽高确定自己的宽高,上面的代码逻辑是:如果CustomCenter父节点传递给他最大宽高约束是无限大时,它的宽高会设置为它子节点的宽高。注意,如果这时将CustomCenter的宽高也设置为无限大就会有问题,因为在一个无限大的范围内自己的宽高也是无限大的话,那么实际上的宽高到底是多少,这个是未知的。屏幕的大小是固定的,所以这样不合理,需要设置一个界限。如果CustomCenter父节点传递给他的最大宽高约束不是无限大,那么是可以指定自己的宽高为无限大,因为在一个有限的空间里,子节点如果说自己无限大,那么最大也就是父节点的大小。所以,CustomCenter会尽可能让自己填满父元素的空间。
- CustomCenter确定了自己的大小和子节点的大小之后就可以确定子节点的位置了,根据居中算法,将子节点的原点坐标算出后保存在子节点的parentData中,在后续的绘制阶段会用到,具体实现看RenderShiftedBox中默认paint实现:
@override
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方法中实现的,具体流程:
- 如果有子组件,则对子组件进行递归布局
- 确定当前组件的大小(size),通常会依赖子组件的大小。
- 确定子组件在当前组件中的起始偏移。
Flutter组件库中,一些常用的单子组件有:Align、SizeBox、DecoratedBox等。
多子组件布局示例(LeftRightBox)
实现一个LeftRightBox组件来实现左-右布局,因为LeftRightBox有两个子组件,用一个Widget数组来保存子组件。
定义组件,和单子组件不同的是多子组件需要继承自MultiChildRenderObjectWidget:
class LeftRightBox extends MultiChildRenderObjectWidget {
LeftRightBox({
Key? key,
required List<Widget> children,
}) : assert(children.length == 2, "只能传两个children"),
super(key: key, children: children);
@override
RenderObject createRenderObject(BuildContext context) {
return RenderLeftRight();
}
}
在performLayout中实现左-右布局算法:
class LeftRightParentData extends ContainerBoxParentData<RenderBox> {}
class RenderLeftRight extends RenderBox
with
ContainerRenderObjectMixin<RenderBox, LeftRightParentData>,
RenderBoxContainerDefaultsMixin<RenderBox, LeftRightParentData> {
// 初始化每一个child的parentData
@override
void setupParentData(RenderBox child) {
if (child.parentData is! LeftRightParentData)
child.parentData = LeftRightParentData();
}
@override
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),
);
}
@override
void paint(PaintingContext context, Offset offset) {
defaultPaint(context, offset);
}
@override
bool hitTestChildren(BoxHitTestResult result, {required Offset position}) {
return defaultHitTestChildren(result, position: position);
}
}
布局流程和单子节点并没有太大的区别,只不过多子组件需要同时多多个子节点进行布局。另外和RenderCustomCenter不同的是,RenderLeftRight是直接继承自RenderBox,同时混入了ContainerRenderObjectMixin和RenderBoxContainerDefaultsMixin两个mixin,这两个mixin实现了通用的绘制和事件处理相关逻辑。
ParentData
单子组件和多子组件的布局中都用到了子节点的parentData对象(将子节点的offset信息保存其中),可以看到parentData虽然属于child的属性,但它从设置(包括初始化)到使用都在父节点中,这也是为什么名称叫(parentData),在Flutter框架中,parentData这个属性主要是为了在layout阶段保存布局信息而设计的。
注意:parentData用于保存节点的布局信息只是一个月的,定义组件时完全可以将子节点的布局信息保存在任意地方,也可以保存非布局信息。但是还是强烈建议大家遵循Flutter的规范,这样代码会更容易被他人读懂,更容易维护。
布局更新
理论上,某个组件的布局变化后,就可能会影响其他组件的布局,所以当有组件布局发生变化后,最笨的办法是对整棵组件树relayout(重新布局)!但是对所有组件进行relayout的成本较大,所以需要探索一下降低relayout成本的方案。实际上,在一些特定的场景下,组件发生变化后只需要对部分组件进行重新布局即可,而无需对整棵树relayout。
- 布局边界(relayoutBoundary)
假如Text3的文本长度发生变化,则会导致Text4的位置和Column2的大小也会发生变化;又因为Column2的父组件SizedBox已经限定了大小,所以SizeBox的大小和位置都不会变化,最终需要进行relayout的组件是Text3、Column2。
注意: - Text4是不需要重新绘制的,因为Text4的大小没有发生变化,只是位置发生变化,位置的确定是由Column2布局时确定的。
- 假如Text3和Column2之间还有其他组件,则这些组件也是需要relayout。这里指的是Text3被其他组件包裹,那么从Column2到Text3中间的父子关系的组件都会重绘,平级的不会重绘。
Column2就是Text3的relayoutBoundary(重新布局的边界点)。每个组件的renderObject中都有一个relayoutBoundary属性指向自身的布局边界节点,如果当前节点布局发生变化后,自身到其布局边界节点路径上的所有节点都需relayout。
那么一个组件是否是relayoutBoundary的条件是什么?
这里有一个原则和四个场景,原则是组件自身的大小变化不会影响到父组件,如果一个组件满足以下四种场景之一,则它便是relayoutBoundary:
- 当前组件父组件的大小不依赖当前组件的大小时;这种情况下父组件在布局时会调用子组件布局函数并给子组件传递一个parentUseSize参数,该参数为false时表示父组件的布局算法不会依赖于子组件的大小。
- 组件的大小只取决于父组件传递的约束,而不会依赖后代组件的大小。这样的话后代组件的大小变化就不会影响自身大小,这种情况组件的sizedByParent属性必须为true。
- 父组件传递给自身的约束是一个严格约束(固定宽高);这种情况下即使自身的大小依赖于后代元素,但也不会影响父组件。
- 组件为根组件;Flutter应用的根组件是RenderView,它的默认大小是当前设备的屏幕大小。
对应的代码实现是:
// parent is! RenderObject 为 true 时则表示当前组件是根组件,因为只有根组件没有父组件。
if (!parentUsesSize || sizedByParent || constraints.isTight || parent is! RenderObject) {
_relayoutBoundary = this;
} else {
_relayoutBoundary = (parent! as RenderObject)._relayoutBoundary;
}
代码中if里的判断条件和上面的4条一一对应,其中除了第二条之外,其他都很直观。
markNeedsLayout
当组件布局发生变化时,它需要调用markNeedsLayout方法来更新布局,它的功能主要有两个:
- 将自身到其relayoutBoundary路径上的所有节点标记为“需要布局”。
- 请求新的frame;在新的frame中会对标记为“需要布局”的节点重新布局。
核心源码:
void markNeedsLayout() {
_needsLayout = true;
if (_relayoutBoundary != this) { // 如果不是布局边界节点
markParentNeedsLayout(); // 递归调用前节点到其布局边界节点路径上所有节点的方法 markNeedsLayout
} else {// 如果是布局边界节点
if (owner != null) {
// 将布局边界节点加入到 pipelineOwner._nodesNeedingLayout 列表中
owner!._nodesNeedingLayout.add(this);
owner!.requestVisualUpdate();//该函数最终会请求新的 frame
}
}
}
flushLayout()
markNeedsLayout执行完毕后,就会将其relayoutBoundary节点添加到pipelineOwner._nodesNeedingLayout列表中,然后请求新的frame,新的frame到来时就会执行drawFrame方法:
void drawFrame() {
pipelineOwner.flushLayout(); //重新布局
pipelineOwner.flushCompositingBits();
pipelineOwner.flushPaint();
...
}
flushLayout()中会对之前添加到_nodesNeedingLayout中的节点重新布局,核心源码:
void flushLayout() {
while (_nodesNeedingLayout.isNotEmpty) {
final List<RenderObject> dirtyNodes = _nodesNeedingLayout;
_nodesNeedingLayout = <RenderObject>[];
//按照节点在树中的深度从小到大排序后再重新layout,为什么需要按照从小到大排序?
for (final RenderObject node in dirtyNodes..sort((a,b) => a.depth - b.depth)) {
if (node._needsLayout && node.owner == this)
node._layoutWithoutResize(); //重新布局
}
}
}
_layoutWithoutResize实现:
void _layoutWithoutResize() {
performLayout(); // 重新布局;会递归布局后代节点
_needsLayout = false;
markNeedsPaint(); //布局更新后,UI也是需要更新的
}
Layout流程
如果组件有子组件,则在performLayout中需要调用子组件的layout方法对子组件进行布局,layout核心流程:
void layout(Constraints constraints, { bool parentUsesSize = false }) {
RenderObject? relayoutBoundary;
// 先确定当前组件的布局边界
if (!parentUsesSize || sizedByParent || constraints.isTight || parent is! RenderObject) {
relayoutBoundary = this;
} else {
relayoutBoundary = (parent! as RenderObject)._relayoutBoundary;
}
// _needsLayout 表示当前组件是否被标记为需要布局
// _constraints 是上次布局时父组件传递给当前组件的约束
// _relayoutBoundary 为上次布局时当前组件的布局边界
// 所以,当当前组件没有被标记为需要重新布局,且父组件传递的约束没有发生变化,
// 且布局边界也没有发生变化时则不需要重新布局,直接返回即可。
if (!_needsLayout && constraints == _constraints && relayoutBoundary == _relayoutBoundary) {
return;
}
// 如果需要布局,缓存约束和布局边界
_constraints = constraints;
_relayoutBoundary = relayoutBoundary;
// 后面解释
if (sizedByParent) {
performResize();
}
// 执行布局
performLayout();
// 布局结束后将 _needsLayout 置为 false
_needsLayout = false;
// 将当前组件标记为需要重绘(因为布局发生变化后,需要重新绘制)
markNeedsPaint();
}
过程:
- 确定当前组件的布局边界
- 判断是否需要重新布局,如果没必要会直接返回,反之需要重新布局。不需要布局时需要同时满足三个条件:
当前组件没有被标记为需要重新布局。 父组件传递的约束没有发生变化。 当前组件的布局边界也没有发生变化。 - 调用performLayout()进行布局,因为performLayout()中又会调用子组件的layout方法,所以这是一个递归的过,递归结束后整个组件树的布局也就完成了。
- 请求绘制
sizedByParent
在layout方法中,有如下逻辑:
if (sizedByParent) {
performResize(); //重新确定组件大小
}
sizedByParent为true时表示:当前组件的大小只取决于父组件传递的约束,而不会依赖于后代组件的大小。performLayout中确定当前组件的大小时通常会依赖子组件的大小,如果sizedByParent为true,则当前组件的大小就不依赖于子组件的大小了,Flutter框架中约定:当sizedByParent为true时,确定当前组件大小的逻辑应抽离到performResize()中,这种情况下,performLayout主要的任务便只有两个:对子组件进行布局和确定子组件在当前组件中的布局起始位置偏移。
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自身会遵守其父组件传递的约束而不是让其子组件去满足AccurateSizeBox父组件的约束:
- AccurateSizeBox自身大小只取决于父组件的约束和用户指定的宽高。
- 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;
@override
RenderObject createRenderObject(BuildContext context) {
return RenderAccurateSizedBox(width, height);
}
@override
void updateRenderObject(context, RenderAccurateSizedBox renderObject) {
renderObject
..width = width
..height = height;
}
}
class RenderAccurateSizedBox extends RenderProxyBoxWithHitTestBehavior {
RenderAccurateSizedBox(this.width, this.height);
double width;
double height;
// 当前组件的大小只取决于父组件传递的约束
@override
bool get sizedByParent => true;
// performResize 中会调用
@override
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);
// }
@override
void performLayout() {
child!.layout(
BoxConstraints.tight(
Size(min(size.width, width), min(size.height, height))),
// 父容器是固定大小,子元素大小改变时不影响父元素
// parentUseSize为false时,子组件的布局边界会是它自身,子组件布局发生变化后不会影响当前组件
parentUsesSize: false,
);
}
}
注意:
- RenderAccurateSizedBox不再直接继承自RenderBox,而是继承自RenderProxyBoxWithHitTextBehavior,RenderProxyBoxWithHitTestBehavior时间接继承自RenderBox的,它里面包含了默认的命中测试和绘制相关的逻辑,继承自它后就不用再手动实现了。
- 将确定当前组件大小的逻辑挪到了computeDryLayout方法中,因为RenderBox的performResize方法会调用conputeDryLayout,并将返回结果作为当前组件的大小。按照Flutter框架约定,应该重写computeDryLayout方法而不是performResize方法,就像在布局时应该重写performLayout方法而不是layout方法;不过这只是一个约定,并非强制,但是应该尽可能遵守。
- RenderAccurateSizedBox在调用子组件layout时,将parentUsesSize置为false,这样的话子组件就会变成一个布局边界。
测试:
class AccurateSizedBoxRoute extends StatelessWidget {
const AccurateSizedBoxRoute({Key? key}) : super(key: key);
@override
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,
),
),
),
],
);
}
}
注意:如果一个组件的sizedByParent为true,那它在布局子组件时也是能将parentUsesSize置为true,sizedByParent为true表示自己是布局边界,而将parentUsesSize置为true或false决定的是子组件是否是布局边界,两者并不矛盾。Flutter中自带的OverflowBox组件的实现中,它的sizedByParent为true,在调用子组件layout方法时,parentUsesSize传的是true。
AfterLayout
AfterLayout可以在布局结束后拿到子组件的代理渲染对象(RenderAfterLayout),RenderAfterLayout对象会带来子组件渲染对象,因此通过RenderAfterLayout对象也就可以获取到子组件渲染对象上的属性,比如组件大小、位置等。
实现:
class AfterLayout extends SingleChildRenderObjectWidget {
AfterLayout({
Key? key,
required this.callback,
Widget? child,
}) : super(key: key, child: child);
@override
RenderObject createRenderObject(BuildContext context) {
return RenderAfterLayout(callback);
}
@override
void updateRenderObject(
BuildContext context, RenderAfterLayout renderObject) {
renderObject..callback = callback;
}
///组件树布局结束后会被触发,注意,并不是当前组件布局结束后触发
final ValueSetter<RenderAfterLayout> callback;
}
class RenderAfterLayout extends RenderProxyBox {
RenderAfterLayout(this.callback);
ValueSetter<RenderAfterLayout> callback;
@override
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的大小和其子组件大小是相同的 3. 定义offset和rect两个属性,它们是组件相对于屏幕的位置偏移和占用的矩形空间范围。实战中,进程需要获取的是子组件相对于某个父组件的坐标和矩形空间范围,这时候可以调用RenderObject的localToGlobal方法,比如:
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(约束)主要描述了最小和最大宽高的限制,理解组件在布局过程中如何根据约束确定自身或子节点的大小对理解组件的布局行为有很大帮助,限制通过实现一个200*200的红色Container的例子来说明。为了排除感染,让根节点(RenderView)作为Container的父组件:
Container(width: 200, height: 200, color: Colors.red)
运行后,整个屏幕都是红色的,看RenderView布局实现:
@override
void performLayout() {
//configuration.size 为当前设备屏幕
_size = configuration.size;
if (child != null)
child!.layout(BoxConstraints.tight(_size)); //强制子组件和屏幕一样大
}
先介绍两种常用的约束:
- 宽松约束:不限制最小宽高(为0),只限制最大宽高,可以通过BoxConstraints.loos(Size size)来快速创建。
- 严格约束:限制为固定大小;即最小宽度等于最大宽度,最小高度等于最大高度,可以通过BoxConstraints.tight(Size size)来快速创建。
RenderView中给子组件传递的是一个严格约束,即强制子组件大小等于屏幕大小,所以Container便撑满了屏幕。如果要解决就需要引入一个中间组件,让这个中间组件遵守父组件的约束,然后对子组件传递新的约束。
@override
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*200.
总结
Flutter官网的图:
官方的解释:
在进行布局的时候,Flutter会以DFS(深度优先遍历)方式遍历渲染树,并将限制以自上而下的方式从父节点传递给子节点。子节点若要确定自己的大小,则必须遵守父节点传递的限制。子节点的响应方式是在父节点建立的约束内将大小以自下而上的方式传递给父节点。