理论上,某个组件的布局变化后,就可能会影响其他组件的布局,所以当有组件布局发生变化后,最笨的办法是对整棵组件树 relayout(重新布局)!但是对所有组件进行 relayout 的成本还是太大,所以我们需要探索一下降低 relayout 成本的方案。实际上,在一些特定场景下,组件发生变化后我们只需要对部分组件进行重新布局即可(而无需对整棵树 relayout )
布局边界(relayoutBoundary)
假如有一个页面的组件树结构如图所示。
假如 Text3 的文本长度发生变化,则会导致 Text4 的位置和 Column2 的大小也会变化;又因为 Column2 的父组件 SizedBox 已经限定了大小,所以 SizedBox 的大小和位置都不会变化。所以最终我们需要进行 relayout 的组件是:Text3、Column2,这里需要注意:
- Text4 是不需要重新布局的,因为 Text4 的大小没有发生变化,只是位置发生变化,而它的位置是在父组件 Column2 布局时确定的。
- 很容易发现:假如 Text3 和 Column2 之间还有其他组件,则这些组件也都是需要 relayout 的。
在本例中,Column2 就是 Text3 的 relayoutBoundary (重新布局的边界节点)。每个组件的 renderObject 中都有一个 _relayoutBoundary
属性指向自身的布局边界节点,如果当前节点布局发生变化后,自身到其布局边界节点路径上的所有的节点都需要 relayout。
那么,一个组件是否是 relayoutBoundary 的条件是什么呢?这里有一个原则和四个场景,原则是“组件自身的大小变化不会影响父组件”,如果一个组件满足以下四种情况之一,则它便是 relayoutBoundary :
-
当前组件父组件的大小不依赖当前组件大小时;这种情况下父组件在布局时会调用子组件布局函数时并会给子组件传递一个 parentUsesSize 参数,该参数为 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;
}
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
方法
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 方法,所以这时一个递归的过程,递归结束后整个组件树的布局也就完成了。
-
请求重绘。
总结
在进行布局的时候,Flutter 会以 DFS(深度优先遍历)方式遍历渲染树,并 将限制以自上而下的方式 从父节点传递给子节点。子节点若要确定自己的大小,则 必须 遵循父节点传递的限制。子节点的响应方式是在父节点建立的约束内 将大小以自下而上的方式 传递给父节点。