5.学习Flutter -- RenderObject 布局过程
- 学习Flutter -- 框架总览
- 学习Flutter -- 启动过程做了什么
- 学习Flutter -- Widget 的组成
- 学习Flutter -- Element 的作用
- [学习Flutter -- RenderObject 布局过程]
BoxConstraints(盒子约束)
盒子约束,主要描述了最大和最小宽高的限制。在布局过程中,组件会通过约束确定自身或子节点的大小。盒子约束有四个属性,分别是最大/最小宽度,以及最大/最小高度,这四个属性的不同组合也就构成了不同的约束。先看下它的构造方法。
dart
box.dart
/// Creates box constraints with the given constraints.
const BoxConstraints({
this.minWidth = 0.0,
this.maxWidth = double.infinity,
this.minHeight = 0.0,
this.maxHeight = double.infinity,
})
当一个 widget 告诉它的子级必须变成某个大小的时候,我们通常称这个 widget 对其子级使用 严格约束(tight)。
严格约束(Tight)
严格约束,给定了确切的大小,它宽高的 max = min,传递给子节点的是一个确切的宽高值;
dart
box.dart
/// Creates box constraints that is respected only by the given size.
BoxConstraints.tight(Size size)
: minWidth = size.width,
maxWidth = size.width,
minHeight = size.height,
maxHeight = size.height;
宽松约束(loose)
宽松约束,可以理解为给定的宽高是一个区间,传递给子节点的是不确定的宽高值。
dart
box.dart
/// Creates box constraints that forbid sizes larger than the given size.
BoxConstraints.loose(Size size)
: minWidth = 0.0,
maxWidth = size.width,
minHeight = 0.0,
maxHeight = size.height;
布局(Layout)过程
原理
Flutter 中组件的布局是通过 RenderObject 对象实现的,布局(Layout)过程主要是确定每个组件的位置和大小,一次布局过程是深度优先遍历 RenderObject Tree 的过程, 优先layout 子节点,然后在 layout 父节点;如图所示:
基本流程是这样的:
-
父节点向子节点传递约束信息(constraints),限制子节点的最大和最下的宽高;
-
子节点根据约束信息确定自己的大小(size),子节点的 size 作为布局结果,可以被父节点使用;
-
父节点根据特定的布局规则(不同组件的算法不同)确定每一个子节点在父节点布局空间中的的位置(offset)
布局边界是什么?
relayoutBoundary,布局边界。若某个 RenderObject 的布局发生变化不会影响其父节点的布局,则该 RenderObject 就是 relayoutBoundary。
布局边界的作用
一句话:避免不必要节点的 relayout。
我们知道,Flutter 的布局过程是需要深度遍历 RenderObject Tree 的每个节点的,若某个子节点需要 relayout,再重新遍历一遍 RenderObject Tree 的每个节点,无疑是浪费性能且没有必要的。所以,relayoutBoundary 出现的作用就是将需要 relayout 的节点控制在最小范围内,避免向 relayoutBoundary 的父节点继续传播,当下一帧刷新时,relayoutBoundary 的父节点无需relayout,是 Flutter 中 relayout 的一项重要的优化措施。
我们看一个例子,如图:
每一个 RenderObject 对象都有一个 _relayoutBoundary 属性指向它的布局边界节点,如果当前节点的布局发生变化,则该节点到其布局边界节点路径上的所有节点都需要 relayout。
-
若 R3 节点需要重新布局,R3 的 relayoutBoundary 是 R1,最终需要重新布局的只有 R1、R3 两个节点;
-
若 R5 节点需要重新布局,R5 的 relayoutBoundary 是它自己,最终需要重新布局的只有 R5 自己;
-
若 R6 节点需要重新布局,R6 的 relayoutBoundary 是根节点 RenderView,最终整棵 RenderObject Tree 都需要重新布局。
成为布局边界的条件
每个 RenderObject 都有一个 relayoutBoundary 属性指向其布局边界,要么指向自己,要么等于父节点的 relayoutBoundary。
先看下有关 relayoutBoundary 部分的源码:
ini
object.dart
void layout(Constraints constraints, { bool parentUsesSize = false }) {
//判断是否是布局边界的 4 个条件,满足其一就是
final bool isRelayoutBoundary = !parentUsesSize || sizedByParent || constraints.isTight || parent is! RenderObject;
//当前节点已经是布局边界,_relayoutBoundary 指向自己,否则指向父节点的 _relayoutBoundary
final RenderObject relayoutBoundary = isRelayoutBoundary ? this : (parent! as RenderObject)._relayoutBoundary!;
...
_relayoutBoundary = relayoutBoundary;
...
}
从源码可知,满足以前四个条件之一,即可成为布局边界。
- !parentUsesSize
当 parentUsesSize = false 时,表示父节点 layout 时不会使用当前节点的 size,(即当前节点的布局对父节点没有影响),则当前节点可成为布局边界。
- sizedByParent
当 sizedByParent = true 时,表示当前节点的 size 只取决于父节点传递过来的约束,不依赖子节点的大小(即子节点的布局变化不会影响自身),则当前节点可成为布局边界。
- constraints.isTight
父节点传递过来的约束是一个严格约束(固定宽高),与 sizedByParent = true 的效果一样,size 由 constrainits 唯一确定,则当前节点可成为布局边界。
- parent is! RenderObject;
父节点的类型不是 RenderOject 类型, (主要针对根节点 RenderView,根节点的 parent 是 nil),则当前节点可成为布局边界。
markNeedsLayout()
当 RenderObject 需要布局时,会调用 markNeedsLayout 方法标记成 dirty,从而被 PipelineOwner 收集,在下一帧刷新时触发 Layout 操作。
该方法的调动时机有:
- Render Object 被添加到 Render Tree
- 子节点 adopt、drop、move
- 通过子节点通过 markParentNeedsLayout 递归调用父节点的 markNeedsLayout。
- Render Object 自身与布局相关的属性发生变化后,也会调用。
源码:
dart
object.dart - RenderObject 类
void markNeedsLayout() {
//如果布局边界是空的
if (_relayoutBoundary == null) {
_needsLayout = true;//将自身标记需要重新布局
if (parent != null) {
markParentNeedsLayout();//递归调用当前节点到其布局边界节点路径上所有节点的 markNeedsLayout方法
}
return;
}
//
if (_relayoutBoundary != this) {
markParentNeedsLayout(); //自身不是布局边界,同上
} else {
_needsLayout = true;
if (owner != null) {
owner!._nodesNeedingLayout.add(this);//将布局边界节点添加到 PipelineOwner 的 _nodesNeedingLayout 中的
owner!.requestVisualUpdate(); //请求更新 frame
}
}
}
通过源码发现,该方法的作用有:
- 将当前节点到其 relayoutBoundary 路径上的所有节点标记为 "需要布局" (needsLayout = true);
- 将布局边界节点添加到 PipelineOwner 的指定列表中管理;
- 最后通过 PipelineOwner 的实例请求重绘;在重绘过程中会对标记为 "需要布局" 的节点重新布局;
注意:通过 PipelineOwner 收集的所有的需要布局的节点,会在下一帧刷新时批量处理,而不是实时更新,避免不必要的 re-layout。
下面通过对 RenderObject 中相关方法的源码分析,进一步了解布局过程的细节。
layout()
layout 方法是 RenderObjcet 执行布局更新的主要入口,一般通过父节点调用子节点的 layout 方法执行布局更新。layout 是定义在 RenderObject 中的模板方法,执行了一些公共的逻辑,真正的布局逻辑在各个子类的 performLayout 方法中。
源码:
dart
object.dart
void layout(Constraints constraints, { bool parentUsesSize = false }) {
//1.确定当前组件的布局边界
final bool isRelayoutBoundary = !parentUsesSize || sizedByParent || constraints.isTight || parent is! RenderObject;
final RenderObject relayoutBoundary = isRelayoutBoundary ? this : (parent! as RenderObject)._relayoutBoundary!;
//2.当前组件没有被标记为需要重新布局,且父组件传递的约束没有发生变化,则无需重新布局,直接返回
if (!_needsLayout && constraints == _constraints) {
if (relayoutBoundary != _relayoutBoundary) {
_relayoutBoundary = relayoutBoundary;
visitChildren(_propagateRelayoutBoundaryToChild);
}
return;
}
_constraints = constraints;
if (_relayoutBoundary != null && relayoutBoundary != _relayoutBoundary) {
visitChildren(_cleanChildRelayoutBoundary);
}
_relayoutBoundary = relayoutBoundary;
//3. sizedByParent = true 时,需要重新计算 size
if (sizedByParent) {
performResize();
}
//4.执行布局(需要子类重写这个方法)
performLayout();
_needsLayout = false;
//5.标记重绘
markNeedsPaint();
}
layout 方法主要做了如下几件事:
-
确定当前组件的布局边界;
-
判断是否需要重新布局
-
- 若当前组件没有被标记为需要重新布局,且父组件传递的约束没有发生变化,则无需重新布局,只更新布局边界即可;
-
当 sizedByParent = true 时,表示当前组件 size 由父组件传递的约束决定,需要子类重写 performResize;
-
执行布局方法 performLayout,需要子类重写该方法;
-
标记需要重绘;
performResize()
当 sizedByParent = true 的渲染对象需要重写 performResize 方法,需要通过父类传递过来的 constraints 计算出 size。
基类 RenderObject 中的 performResize 是空方法。如子类 RenderBox 类的源码:
dart
@override
void performResize() {
// default behavior for subclasses that have sizedByParent = true
size = computeDryLayout(constraints);
}
performLayout()
RenderObject 中的 performLayout 方法是空方法,需要子类重写。我们看一下经常使用的布局组件 Center 对应的 RenderObject 子类 RenderPositionedBox,其 performLayout 方法源码如下:
dart
shifted_box.dart - RenderPositionedBox
@override
void performLayout() {
final BoxConstraints constraints = this.constraints;
final bool shrinkWrapWidth = _widthFactor != null || constraints.maxWidth == double.infinity;
final bool shrinkWrapHeight = _heightFactor != null || constraints.maxHeight == double.infinity;
if (child != null) {
//1. 对子组件进行布局(即对子组件调用 layout 方法),传入约束
child!.layout(constraints.loosen(), parentUsesSize: true);
//2. 根据子组件的大小确定自身大小
size = constraints.constrain(Size(
shrinkWrapWidth ? child!.size.width * (_widthFactor ?? 1.0) : double.infinity,
shrinkWrapHeight ? child!.size.height * (_heightFactor ?? 1.0) : double.infinity,
));
//3. 将子节点在父节点中的位置,保存在 child.parentData 中
alignChild();
} else {
size = constraints.constrain(Size(
shrinkWrapWidth ? 0.0 : double.infinity,
shrinkWrapHeight ? 0.0 : double.infinity,
));
}
}
//
void alignChild() {
_resolve();
final BoxParentData childParentData = child!.parentData! as BoxParentData;
childParentData.offset = _resolvedAlignment!.alongOffset(size - child!.size as Offset);
}
方法内主要有三个步骤:
- 对子组件进行布局(即对子组件调用 layout 方法),传入约束
- 根据子组件的大小确定自身大小;
- 将子节点在父节点中的位置,保存在 child.parentData 中
注意事项:
- 该方法由 layout 方法调用,当需要 relayout 时应该调用 layout 方法,而不是直接调用 performLayout。
-
当对子节点调用 layout 时,若需要使用子节点的 size , 在 parentUsesSize 参数需要为 true。
-
只有当 sizedByParent = false , 才需要计算当前 Render Object 的 size; 否则由上述的 performResize 方法计算;
自定义布局实践
自定义实现一个对齐组件 CustomAlign,功能和系统 Align 基本一致,主要演示一下布局的过程以及相关方法的实现。
定义 Widget
首先定义一个有单子组件的的 Widget,继承自 SingleChildRenderObjectWidget,重写 createRenderObject 方法并返回自定义的 RenderObject 对象。
dart
class CustomAlign extends SingleChildRenderObjectWidget {
final Alignment alignment;
const CustomAlign({ Key? key, required Widget child, this.alignment = Alignment.topLeft})
: super(key: key, child: child);
//返回自定义的 RenderObject 对象
@override
RenderObject createRenderObject(BuildContext context) {
return RenderCustomAlignObject(alignment: alignment);
}
//重写 update 方法,用于更新 alignment 属性
@override
void updateRenderObject(BuildContext context, covariant RenderCustomAlignObject renderObject) {
renderObject.alignment = alignment;
}
}
由于该 Widget 有一个 alignment 参数用于布局使用,故需要重写 updateRenderObject 方法对布局属性更新。
定义 RenderObject
然后实现自定义的 RenderOjbect 类 RenderCustomAlignObject,若直接继承 RenderOjbect的话,需要我们手动实现一些布局无关的方法(如事件分发等逻辑),为了更聚焦布局本身,我们在这里继承自 RenderShiftedBox。这样我们只需要重写 performLayout 方法,在该方法内实现子节点布局的算法即可。
dart
class RenderCustomAlignObject extends RenderShiftedBox {
Alignment alignment;
RenderCustomAlignObject({RenderBox? child, required this.alignment}): super(child);
@override
void performLayout() {
///super.performLayout(); 无需调用super.performLayout()
if (child == null) {
///没有child则不占用空间
size = Size.zero;
return;
}
//1.对子组件进行布局
child?.layout(constraints.loosen(), //传递约束(不对child的大小进行限制)
parentUsesSize: true); //parentUsesSize = true 表示需要使用到子组件的 size
//2.根据 child 的size 确定自身的 size
size = constraints.constrain(Size(
constraints.maxWidth == double.infinity
? child!.size.width
: double.infinity,
constraints.maxHeight == double.infinity
? child!.size.height
: double.infinity,
));
//3.根据自身 和 child 的size,算出 child 在父节点中的位置
// 最后保存在child.parentData 中
BoxParentData parentData = child?.parentData as BoxParentData;
parentData.offset = alignment.alongOffset(size - child!.size as Offset); //设置偏移
}
}
布局过程如代码中注释。
最终的绘制阶段会使用到上述布局计算好的偏移量 offset,我们看下 RenderShiftedBox 类源码中的 paint 方法。
dart
@override
void paint(PaintingContext context, Offset offset) {
final RenderBox? child = this.child;
if (child != null) {
final BoxParentData childParentData = child.parentData! as BoxParentData;
//绘制 child
//父节点自身的offset 加上子节点的 offset,便是子节点在屏幕上的偏移。
context.paintChild(child, childParentData.offset + offset);
}
}
使用
下面我们来测试一下 CustomAlign 组件的使用效果。
dart
Widget build(BuildContext context) {
return MediaQuery(
data: MediaQuery.of(context).copyWith(textScaleFactor: 1.0),
child: Scaffold(
backgroundColor: KimKidColor.eedCsCommonBackgroundGrayiii,
appBar: AppBar(
centerTitle: true,
title: Text("CustomAlign"),
),
body: Container(
width: 400,
height: 400,
color: Colors.red,
// CustomAlign...
child: CustomAlign(
alignment:Alignment.center,
child: Container(
child: Container(
width: 100,
height: 100,
color: Colors.green,
),
),
),
//... CustomAlign
)
));
}
效果
以下是分别设置 CustomAlign 的 alignment 的参数不同值的效果。
参考