通过例子掌握Flutter的布局原理
Posted 一叶飘舟
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了通过例子掌握Flutter的布局原理相关的知识,希望对你有一定的参考价值。
引言
一切的开始来自于这个神奇的网站(深入理解Flutter布局约束),其中提供了30个让奇奇怪怪的布局例子让我大涨见识(30个相当不讲武德!!),有这样的
这样的
还有这样的
不得不说确实覆盖了很多场景!可是对于我这种记性不好的懒鬼来说,看完30个例子真的是太!费!劲!了!而且看完就忘!!实际中大概率不会出现一模一样的情况。所以我就在寻思,这背后究竟是啥原理可以我下次不用反复复习这30个例子呢?这就引出了今天的主题:Flutter的布局原理
本期如果点赞超过50,加更一期(15个代表例子的详细解析) 来了来了!15个例子最详细的解析 让你彻底掌握Flutter布局原理
正片开始:先宏观看看Flutter组件的分类
在原生上我们知道一个View控件的渲染过程大致分为onMeasure()
[知道有多大],onLayout()
[知道该放那],onDraw()
[知道长啥样]三个过程。但Flutter的UI体系思路和这个不太一样,首先在Flutter的组件体系中,并非所有的Widget都会渲染到最后的页面上,整个Widget大概可以分为三类组合类、代理类、绘制类 -[这点面试必问!!]-
平时我们使用到最多的StatelessWidget
和StatefulWidget
其实只是组合类的控件,实际上他并不负责绘制,所有我们在屏幕上看到的UI最终几乎都会通过RenderObjectWidget实现。而RenderObjectWidget中有个createRenderObject()
方法生成RenderObject
对象,RenderObject
实际负责实际的layout()和paint()。例如我们最常使用的Container组件其实只是一个组合类的控件,在其中封装了多个负责绘制的原子组件。想详细了解RenderObjectWidget可以看看深入研究Flutter布局原理写得非常好
开胃小菜:RenderObject的的绘制过程
那RenderObject是如何完成渲染的呢,在原来我一直在错误的使用 setState()?中分析过,Flutter的渲染流程关键在于drawFrame()
方法中
void drawFrame()
//在这之前已经完成了build()
pipelineOwner.flushLayout();
pipelineOwner.flushCompositingBits();
pipelineOwner.flushPaint();
renderView.compositeFrame(); // this sends the bits to the GPU
pipelineOwner.flushSemantics(); // this also sends the semantics to the OS.
复制代码
整个过程和原生分为三个阶段build()
,layout()
,paint()
。build()
方法由组合类和代理类Widget实现,layout()
和paint()
由RenderObject实现。这里设计思路和原生不太一样,在原生中layout()
方法一般由ViewGroup实现,他需要规定child控件的位置。这样他的子节点就只用关心绘制即可。
而Flutter中的layout()
更接近理解为Measure(!!理解这点非常重要,不能用原生的思路去学习),它的职能主要是计算控件自身的尺寸和位置偏移。这里的计算是一个从最顶级的节点开始传递约束,从下开始返回测量结果的过程
测量结果我们很好理解,就是一个控件实际的宽高。而约束是个啥玩意儿??
硬菜来了: What's Constraints
约束Constraints 在Flutter中是一种布局协议,Flutter中有两大布局协议BoxConstraints和SliverConstraints。对于非滑动的控件例如Padding,Flex等一般都使用BoxConstraints盒约束。
BoxConstraints(
this.minWidth,
this.maxWidth,
this.minHeight,
this.maxHeight,
);
复制代码
看起来非常好理解,在盒约束中,只会限制子控件的最大最小宽高。经过我搜刮了网上几乎所有的布局原理文章之后,对于这个约束这个约束可以这样总结。 首先这个约束可以根据最大最小值分为两大类
- 1、 tight(紧约束):当max和min值相等时,这时传递给子类的是一个确定的宽高值。
const BoxConstraints.expand(
double width,
double height,
) : minWidth = width ?? double.infinity,
maxWidth = width ?? double.infinity,
minHeight = height ?? double.infinity,
maxHeight = height ?? double.infinity;
复制代码
这个约束的使用的地方主要有两个
一个在Container中,当Container的 child==null&&||(constraints == null || !constraints.isTight))时。 另一个ModalBarrier,这个组件我们不太熟悉,但查看调用发现被嵌套在了Route中,所以每次我们push一个新Route的时候,默认新的页面就是撑满屏幕的模式。
- 2、loose(松约束):当max和min不相等的时候,这种时候对子类的约束是一个范围,称为松约束。
BoxConstraints.loose(Size size)
: minWidth = 0.0,
maxWidth = size.width,
minHeight = 0.0,
maxHeight = size.height;
复制代码
在我们最场使用的Scaffold组件中就采用了这种布局,所以Scaffold对于子布局传递的是一个松的约束。
酣畅过瘾: 举几个栗子
了解了上面的基础概念之后,我们先来看看如何对一些场景进行分析。当我们想知道一个控件的布局过程是怎样的,可以参考:
- 在你的代码中找到一个 Column 并跟进到它的源代码。为此,请在 (android Studio/IntelliJ) 中使用 command+B(macOS)或 control+B(Windows/Linux)。你将跳到 basic.dart 文件中。由于 Column 扩展了 Flex,请导航至 Flex 源代码(也位于 basic.dart 中)。
- 向下滚动直到找到一个名为 createRenderObject() 的方法。如你所见,此方法返回一个 RenderFlex。它是 Column 的渲染对象,现在导航到 flex.dart 文件中的 RenderFlex 的源代码。
- 向下滚动,直到找到 performLayout() 方法,由该方法执行列布局。
根据这个方法,我们试着分析几个有意思的栗子。本期如果点赞超过50,加更一期(20个例子的详细解析)
案例1(来自样例一)
如图,如果我们直接返回一个红色Container,这个时候他会撑满整个屏幕。首先Container是一个组合类的Widget,并不负责渲染。查看他的build方法,在这种情况下返回了三层RenderObject RenderDecoratedBox,RenderLimitedBox,RenderConstrainedBox
这三个类都继承自RenderProxyBox,这个类混入了RenderProxyBoxMixin,布局方法就在里面:
@override
void performLayout()
if (child != null)
child.layout(constraints, parentUsesSize: true);
size = child.size;
else
performResize();
其实从这个类的名称我们可知一二,这是个代理类的渲染对象,如果他有子节点的的时候他会把自己父节点的约束constraints
传递给节点,然后使用子节点的尺寸作为自己的。 那么最外层的RenderDecoratedBox的约束是什么呢。前面其实也提到了在紧约束中,BoxConstraints.expand被用在了Route上,所以每个页面默认是撑满屏幕的。这个约束就一直向下传递
但在最下面的RenderConstrainedBox重写了performLayout方法
@override
void performLayout()
if (child != null)
child.layout(_additionalConstraints.enforce(constraints), parentUsesSize: true);
size = child.size;
else
size = _additionalConstraints.enforce(constraints).constrain(Size.zero);
其实和上面差不多,不过对RenderConstrainedBox我们可以添加约束信息_additionalConstraints属性,查看Container的build方法可知这个约束在这种情况下为BoxConstraints.expand ,在layout的时候也会被考虑。由于RenderConstrainedBox下没有child了所以走
size = _additionalConstraints.enforce(constraints).constrain(Size.zero);
///返回新的框约束,它尊重给定的约束,同时与原始约束尽可能接近
BoxConstraints enforce(BoxConstraints constraints)
return BoxConstraints(
minWidth: minWidth.clamp(constraints.minWidth, constraints.maxWidth),
maxWidth: maxWidth.clamp(constraints.minWidth, constraints.maxWidth),
minHeight: minHeight.clamp(constraints.minHeight, constraints.maxHeight),
maxHeight: maxHeight.clamp(constraints.minHeight, constraints.maxHeight),
);
关键在于enforce这个方法,这里他接受到的参数是来自页面中的紧约束(max=min=屏幕宽度),这样无论你为自身添加的约束_additionalConstraints是多少,他都会返回一个紧约束(max=min=屏幕宽度),而这个约束被再次向下传递
最终这个Container渲染的时候,撑满了整个屏幕!
案例2(来自样例9)
这个例子案例1外层增加了一个约束,并且内部也为自己设置了高度。你可能会猜想 Container 的尺寸会在 70 到 150 像素之间,但并不是这样,Container任然撑满了整个屏幕。我们还是来看Render树的结构 整个树构造变成了这样,我们一步步来看。 首先最外层的ConstrainedBox接收到来自页面的紧约束(max=min=屏幕宽度),计算采用上面的计算方式还是得到了一个同样的约束,之后这个约束向下传递,就变成和案例一一样的情况
总结
1、Widget大概可以分为三类组合类、代理类、绘制类
2、所有我们在屏幕上看到的UI最终几乎都会通过RenderObjectWidget实现。而RenderObjectWidget中有个createRenderObject()
方法生成RenderObject
对象,RenderObject
实际负责实际的layout()和paint()。
3、Container组件其实只是一个组合类的控件,在其中封装了多个负责绘制的原子组件。
4、layout() 职能主要是计算控件自身的尺寸和位置偏移
5、整个布局过程就是向下约束 向上传值的过程
6、盒约束中有两种:
- tight(紧约束):当max和min值相等时,这时传递给子类的是一个确定的宽高值。
- loose(松约束):当max和min不相等的时候,这种时候对子类的约束是一个范围,称为松约束
一句话总结:
Container类布局说明
通过上面的介绍我们知道Flutter的layout() 职能主要是计算控件自身的尺寸和位置偏移 ,整个布局过程就是向下约束 向上传值的过程。
Case 1:直接返回Container
渲染树树如下
上期文章提到这这种情况下,Container会在渲染树上生成三个节点,页面会传给子节点一个撑满屏幕的紧约束,这个约束向下传递。最后到ContainerBox的时候收到这个约束,通过_additionalConstraints.enforce(constraints)
计算自身大小,这个方法会根据自身的属性与接受来的约束决定大小,计算后任然撑满屏幕,这个大小信息不断上传,最后Container布满了屏幕。(如果到这里还不太明白的可以先看看总结了30个例子之后,我悟到了Flutter的布局原理)
Case 2:Container指定宽高
渲染树树如下
对比Case 1其实整个渲染树结构没有变,区别只是为Container加上了宽高限制,这个宽高限制会附加在ContainedBox上。但是由于上面传递下来的约束是撑满屏幕的紧约束,所以计算后任然是撑满屏幕和Case 1一样。
Case 3:Container外层设置约束
你可能会猜想 Container 的尺寸会在 70 到 150 像素之间,但并不是这样,Container任然撑满了整个屏幕。我们还是来看Render树的结构
首先最外层的ConstrainedBox接收到来自页面的紧约束(max=min=屏幕宽度),计算采用上面的计算方式还是得到了一个同样的约束,之后这个约束向下传递,到最后任然和Case 1一样,这个例子把最外层的ConstrainedBox改成Container,渲染树的结构和最终结果也一样。
Align类布局说明
Aling组件我们一般会在需要控制child控件的相对位置时候使用,例如居中,或者顶部。我们常用的Center组件就是继承于它,根据上期的提到的方法,我们先查看Align组件的布局规则。Align组件对应的RenderObject对象是RenderPositionedBox
其performLayout()如下
@override
void performLayout()
//缩放因子,这里一般为null,这里我们不关注
final bool shrinkWrapWidth = _widthFactor != null || constraints.maxWidth == double.infinity;
final bool shrinkWrapHeight = _heightFactor != null || constraints.maxHeight == double.infinity;
if (child != null)
//使用constraints.loosen()模式测量子节点,并且标志parentUsesSize位true
child.layout(constraints.loosen(), parentUsesSize: true);
//根据子节点的大小与缩放因子计算自生的宽高
size = constraints.constrain(Size(shrinkWrapWidth ? child.size.width * (_widthFactor ?? 1.0) : double.infinity,
shrinkWrapHeight ? child.size.height * (_heightFactor ?? 1.0) : double.infinity));
//根据alignment确定子节点的位置
alignChild();
else
size = constraints.constrain(Size(shrinkWrapWidth ? 0.0 : double.infinity,
shrinkWrapHeight ? 0.0 : double.infinity));
//生成一个松约束,范围为0到max
BoxConstraints loosen()
return BoxConstraints(
minWidth: 0.0,
maxWidth: maxWidth,
minHeight: 0.0,
maxHeight: maxHeight,
);
Align组件里有一些缩放相关的规则,可以忽略。这里我们主要关注里面测量的部分,根据代码这个布局过程其实分三步
- 1、通过使用constraints.loosen()模式测量子节点,并且标志parentUsesSize位true
- 2、根据子节点的大小与缩放因子计算自生的宽高
- 3、根据alignment确定子节点的位置 在第一步中,RenderPositionedBox将上级传递的约束变成了
松约束
范围为0到max,将这个松约束传递给子节点进行测量大小。最后根据alignment确定子节点的位置。下面我们看看实际的例子
Case 4:Center下面放指定宽高的Container
渲染树树如下
RenderPositionedBox将上级传递的约束变成了将页面传来的紧约束变成了松约束,一直传递到了ConstrainedBox中,而它的performLayout会执行:
size = _additionalConstraints.enforce(constraints).constrain(Size.zero);
//以自身约束_additionalConstraints为主,同时尊重给定的约束
BoxConstraints enforce(BoxConstraints constraints)
return BoxConstraints(
minWidth: minWidth.clamp(constraints.minWidth, constraints.maxWidth),
maxWidth: maxWidth.clamp(constraints.minWidth, constraints.maxWidth),
minHeight: minHeight.clamp(constraints.minHeight, constraints.maxHeight),
maxHeight: maxHeight.clamp(constraints.minHeight, constraints.maxHeight),
);
ConstrainedBox上的_additionalConstraints是一个宽高都为100的紧约束,最后参考父节点传来的松约束(0-屏幕宽度)算出自己宽高任然为100,之后这个尺存向上传递。Center计算自己高度的时候任然为屏幕宽高(Center撑满了屏幕,想想为什么会是这样),最后根据alignment=Alignment.center算出了hild的位置。
@protected
void alignChild()
final BoxParentData childParentData = child.parentData;
//通过child的parentData属性,告诉child的应该放在哪个位置
childParentData.offset = _resolvedAlignment.alongOffset(size - child.size);
Case 5:Align下面放指定宽高为100的Container
这个案例其实和上面的一样,因为Center本身就是继承自Align。只不过这个case中,Container被放在了右下角。提这个例子是想回应下上一个例子中提到的在计算最后Center撑满了屏幕。因为只有Align这个组件比child大的时候,设置alignment才有意义(可以直接看RenderPositionedBox的计算规则)。
Case 6: Center下放一个宽高无限的Container
渲染树树如下
结构和上面没有变化,不过在ConstrainedBox计算自身宽高的时候,由于这时_additionalConstraints
是double.infinity
所以计算后的宽高是撑满屏幕。其实到这里大家不难发现,ConstrainedBox的宽高计算关键在于理解_additionalConstraints.enforce(constraints)
这个方法:根据自身的约束同时尊重父节点的约束。所以,如果这如果我们指定Container宽高为0,则Container会消失。但如果我们不指定width和height会发生什么呢。下个例子看看。
Case 7:Center下放一个不指定宽高的Container
可能这个时候你觉得Container会消失,但并不这样的,不指定Container的宽高和将其设置为0是不一样的,这里渲染树和上面任然保持一致,所以就不贴图了。而变成和屏幕一样大小的关键在于Container组件的布局行为,查看Container的build方法:
发现Container在不设置约束,并且没有子节点的时候,会给自己添加一个宽高都等于double.infinity的紧约束,所以最后撑满了屏幕。
Case 8:Center下放一个具有Padding的Container下面再放一个Container
有了上面的基础,这个例子很容易明白。但因为我们设置了padding值,所以整个树的结构也发生了改变,这个例子主要分析一下padding的布局行为,我们先看渲染树
这是由于在Center下面增加了一个带有color和padding属性的Container,所以整个结构中增加了两层,其他的组件的约束规则我们都很熟悉了,这里看下RenderPadding的performLayout:
@override
void performLayout()
_resolve();
if (child == null)
//如果子节点为空,则自己的宽高由paddign属性决定
size = constraints.constrain(Size(
_resolvedPadding.left + _resolvedPadding.right,
_resolvedPadding.top + _resolvedPadding.bottom,
));
return;
//返回一个小于_resolvedPadding的松约束
final BoxConstraints innerConstraints = constraints.deflate(_resolvedPadding);
child.layout(innerConstraints, parentUsesSize: true);
final BoxParentData childParentData = child.parentData;
childParentData.offset = Offset(_resolvedPadding.left, _resolvedPadding.top);
//自身的高度由子节点与padding相加
size = constraints.constrain(Size(
_resolvedPadding.left + child.size.width + _resolvedPadding.right,
_resolvedPadding.top + child.size.height + _resolvedPadding.bottom,
));
我们看到在测量child的时候使用的是constraints.deflate(_resolvedPadding)
这个约束。根据源码上的注释可知,这个结果由当前的约束减去padding得到。所以在case中,RenderPadding向下传递的是一个范围为(0-屏幕宽度减padding)的松约束。而后面在子节点计算完宽高了之后,根据子节点的宽高加上padding形成自己的宽高,向上传递。最后显示在屏幕上形成两个居中叠加的Container。 这里大家可以猜想一下,如果去掉Center会是什么样,欢迎评论区告诉我你的答案~
Case 9:Center下增加一个松约束的ConstrainedBox下Container
渲染树如下:
首先Center将屏幕的约束改为0-屏幕宽高的松约束,之后由于ConstrainedBox上添加了一个松约束宽高范围(70-150),之后这个约束向下传递。最后底部的ConstrainedBox收到这个约束,他自己的additionalConstraints属性是一个宽高无穷的紧约束,这样在调用_additionalConstraints.enforce(constraints)之后计算结果取最大约束150。这个尺寸向上传递,最后这个Container的大小变成了150。这里如果我们为Contianer添加的宽高是0,则取最小的约束70。
Flex类(包含Row,Cloumn)
在日常开发中常使用的Row,Cloumn都是Flex的子类,只是Row和Cloumn会分别为Flex的direction
属性设置为不同的值。在Column中direction: Axis.vertical,在Row中direction: Axis.horizontal。Flex对应的RenderObject为RenderFlex,其performLayout比较长,可以分为子节点设置了flex属性(即按比例分配)和未设置该属性,我们通过未设置的方式看看这个过程:
@override
void performLayout()
//遍历子元素
while(child!=null)
///省略设置Flex的时候
*************
//如果子节点没有设置flex的时候:
BoxConstraints innerConstraints;
//如果cross轴上alignment等于CrossAxisAlignment.stretch(即填满纵轴,默认不是这种模式)
if (crossAxisAlignment == CrossAxisAlignment.stretch)
switch (_direction)
//如果主轴方向是水平,则垂直方向为高度强制为当前的最大约束值
case Axis.horizontal:
innerConstraints = BoxConstraints(minHeight: constraints.maxHeight,
maxHeight: constraints.maxHeight);
break;
//如果主轴是垂直方向,则水平方向宽度强制为当前的最大约束值
case Axis.vertical:
innerConstraints = BoxConstraints(minWidth: constraints.maxWidth,
maxWidth: constraints.maxWidth);
break;
else
//如果cross轴上alignment等于CrossAxisAlignment.center(默认),start,end,baseline
switch (_direction)
case Axis.horizontal:
//如果主轴方向是水平,则垂直方向高度为松约束范围0-maxHeight
innerConstraints = BoxConstraints(maxHeight: constraints.maxHeight);
break;
//如果主轴方向是垂直,则水平方向宽度为为松约束0-maxWidth
case Axis.vertical:
innerConstraints = BoxConstraints(maxWidth: constraints.maxWidth);
break;
//测量子布局
child.layout(innerConstraints, parentUsesSize: true);
//计算主轴上的尺寸
allocatedSize += _getMainSize(child);
//纵轴上的高度取最大的一个
crossSize = math.max(crossSize, _getCrossSize(child));
看起来比较复杂,因为Flex(Cloumn,Row)组件在测量尺寸的过程需要对横轴和纵轴进行计算。首先Flex会遍历每一个子节点,先检查crossAxisAlignment == CrossAxisAlignment.stretch
是否成立,这个条件表示对应的Flex组件在纵轴上是否为撑满。以Row为例,如果条件成立则Row会占满垂直方向,Cloumn则会占满水平方向。看代码可知因为这个条件下对应的纵轴约束是一个minWidth和maxWidth都等于constraints.maxWidth的紧约束,所以对子节点的约束在纵轴上是紧约束。不过一般我们使用Flex类的组件的时候,如果不设置都走下面的分支,该流程分4步:
- 1、根据Flex的方向生成一个松约束innerConstraints
- 2、测量子布局
- 3、累加计算主轴的尺寸
- 4、纵轴上的高度取最大的 以Row为例,在大多数我们使用Flex的时候,对于子节点在水平上是一个0-double.infinity的松约束,即表示Row不会约束子节点的宽度,想要多少都行(可能出现OverFlow)。而垂直方向则是一个0-maxHeight的松约束。还有点儿懵么?看几个例子就明白了
Case 10:Row下面包含两个带Text的Container
简单来说Text对应的RenderObject是RenderParagraph,每个文字会生成一个节点,而RenderParagraph会向其传递一个0-max的松约束,最后RenderParagraph的尺寸就是所有文字的和。这样整个渲染树的结构变成这个样子:
Row的每一个子节点都收到一个宽高为0-屏幕尺寸的约束,之后'Hello!'计算自己的宽度为50,高20。而'Goodbye!'计算自己的宽度为100,高20。DecoratedBox收到这个宽高渲染了对应的颜色。之后RendexFlex将两个节点的宽相加,高取最大,得到了自己的尺寸宽:50+100,高max(20,20)=20。
Case 11:Row下面包含两个带Text的Container(超长)
这个例子和上面的渲染树结构一致,不同的是这个例子中出现了OverFlow。因为前面我们提到了RenderFlex传递给RenderParagraph的是一个0-屏幕的宽约束,这个约束被传递给每一个文字。RenderParagraph的宽度等于文字的和,所以导致了OverFlow。
其他类型如Scaffold,OverflowBox等
好上面就是我们日常中最常用的几个组件的布局规则了,这里我们再看看一些其他类型的布局规则,加深我们的理解
Case 12:Scafflod下套一个Container
这个例子还是比较有意思,我们看现象。整个Container的蓝色在垂直方向占满屏幕,这是因为Cloum(或者Row)中有个mainAxisSize
属性,默认为MainAxisSize.max
即尽力撑开,所以最后Cloumn撑满了竖直方向
Case 13: OverflowBox下放一个超长的Container
OverflowBox 允许其子容器设置为任意大小,在这种情况下,容器的宽度为 4000 像素,并且太大而无法容纳在 OverflowBox 中,但是 OverflowBox 会全部显示,而不会发出警告。所以如果你想最快速的解决OverFlow类型的错误,有时可以考虑使用这个组件。
Case 14:SizeBox下面放一个Container
SizeBox也是我们经常用到的一个组件,这个组件背后其实对应的就是ConstrainedBox类的组件。不过由于SizeBox有具体的width和height,所以他这里自身的_additionalConstraints属性是一个紧约束。不过在本例中由于他接受到来自页面的一个宽高都为屏幕尺寸的紧约束,所以导致自身的属性失去了作用,和样例3类似。
Case 15:Row下面嵌套Expanded
前面演示Row的时候,我只说了flex为0的情况,这里给大家留个作业,如果是Row下嵌套Expanded的情况下,整个布局的流程又是什么样呢?欢迎大家留言说出你的看法~
总结
通过上面的介绍,现在对于使用BoxConstrain协议的组件我们应该可以了然于胸,整个布局行为其实用一句话来概括就是: 父节点传递约束,子节点向上传递尺寸,最后由父节点决定你的位置
。 对于约束传递不同的组件会产生不同的行为,例如Route组件中会向子节点传递一个宽高都为屏幕尺寸的紧约束,使页面的直接节点变成撑满模式。而Align类型的组件则是对子Widget传递一个0-max的松约束。ConstrainedBox有点类似安卓上的布局行为,我自己身上有宽高的属性。但我又得结合父节点给我的约束才能决定我自己的大小。
以上是关于通过例子掌握Flutter的布局原理的主要内容,如果未能解决你的问题,请参考以下文章