根据前文我们已经从宏观上得知:Layout流程的本质是父节点向子节点传递自己的布局约束Constraints,子节点计算自身的大小(Size),父节点再根据大小信息计算偏移(Offset)。在二维空间中,根据大小和偏移可以唯一确定子节点的位置。
Flutter中主要存在两种布局约束——Box
和Sliver
,关键类及其关系如图6-1所示。
图6-1中,BoxConstraints
和SliverConstraints
分别对应Box
布局和Sliver
布局模型所需要的约束条件。ParentData
是RenderObject
所持有的一个字段,用于为父节点提供额外的信息,比如RenderBox
通过BoxParentData
向父节点暴露自身的偏移值,以用于Layout
阶段更新和Paint
阶段。Sliver
通过SliverGeometry
描述自身的Layout
结果,相对Box
更加复杂。
Box布局模型
Box
类型的Constraints
布局在移动UI框架中非常普遍,比如Android的ConstraintLayout
和iOS的AutoLayout
都有其影子。Constraints
布局的特点是灵活且高效。Flutter中Box
布局的原理如图6-2所示。
下面主要介绍 Box
布局模型中最常见的两种布局——Align
和Flex
。虽然Flutter源码中提供的Box
布局组件远不止这两种,但万变不离其宗,只要深刻理解了BoxConstraints
的本质,相信其他布局也不在话下。
Align布局流程分析
本节将分析Box
布局中比较有代表性的Align
布局,其关键类如图6-3所示。 了解了Align
的布局原理之后,相信对于其他关联的Widget
也能够触类旁通。
图6-3中,RenderShiftedBox
表示一个可以对子节点在自身中的位置进行控制的单子节点容器,最常见的就是Padding
和Align
,其他Widget
可自行研究,每个Widget
都对应一个实现自身布局规则的RenderObject
子类,在此不再赘述。
下面正式分析Align
的布局流程。Align
对应的RenderObject
为RenderPositionedBox
,其performLayout
方法如代码清单6-1所示。
// 代码清单6-1 flutter/packages/flutter/lib/src/rendering/shifted_box.dart
void performLayout() {
final BoxConstraints constraints = this.constraints;
final bool shrinkWrapWidth =
// 即使约束为infinity也要处理,使之变成有限长度,否则边界无法确定
_widthFactor != null || constraints.maxWidth == double.infinity;
final bool shrinkWrapHeight = _heightFactor != null || constraints.maxHeight
== double.infinity;
if (child != null) { // 存在子节点
child!.layout(constraints.loosen(), parentUsesSize: true); // 布局子节点
size = constraints.constrain(Size( // 开始布局自身,见代码清单6-2
shrinkWrapWidth ? child!.size.width * (_widthFactor ?? 1.0) : double.infinity,
shrinkWrapHeight ? child!.size.height * (_heightFactor ?? 1.0) : double.
infinity));
alignChild(); // 计算子节点的偏移,见代码清单6-3
} else { // 没有子节点时,一般大小为0,因为最大约束为infinity时shrinkWrapWidth为true
size = constraints.constrain(Size(shrinkWrapWidth ? 0.0 : double.infinity,
shrinkWrapHeight ? 0.0 : double.infinity));
}
}
以上逻辑中,shrinkWrapWidth
表示当前宽度是否需要折叠(Shrink
),当_widthFactor
被设置或者未对子Widget
做宽度约束时需要,当子Widget
存在时,其大小计算过程如代码清单6-2所示。计算完大小后会调用alignChild
方法完成子Widget
位置的计算。如果子Widget
不存在,则大小默认为0
。
// 代码清单6-2 flutter/packages/flutter/lib/src/rendering/box.dart
Size constrain(Size size) {
Size result = Size(constrainWidth(size.width), constrainHeight(size.height));
return result;
}
double constrainWidth([ double width = double.infinity ]) {
return width.clamp(minWidth, maxWidth); // 返回约束内最接近自身的值
}
double constrainHeight([ double height = double.infinity ]) {
return height.clamp(minHeight, maxHeight);
}
以上逻辑的核心在于clamp
方法,以constrainWidth
方法为例,其返回值为minWidth
到maxWidth
之间最接近width
的值。以a.clamp(b, c)
为例,将先计算a、b
的较大值x
,再计算x、c
的较小值,并作为最终的结果。下面分析子节点偏移值的计算,如代码清单6-3所示。
// 代码清单6-3 flutter/packages/flutter/lib/src/rendering/shifted_box.dart
void alignChild() {
_resolve(); // 计算子节点的坐标
final BoxParentData childParentData = child!.parentData! as BoxParentData;
// 存储位置信息
childParentData.offset = _resolvedAlignment!.alongOffset(size - child!.size as
Offset); // 偏移值
}
void _resolve() {
if (_resolvedAlignment != null) return;
_resolvedAlignment = alignment.resolve(textDirection);
}
以上逻辑首先会将Alignment
解析成_resolvedAlignment
,其关系如图6-4所示。
图6-4中,RenderPositionedBox
是Align
对应的RenderObject
类型,其通过_resolvedAlignment
字段持有Alignment
的实例,Alignment
就是Align
对子节点位置的抽象表示。Algin
实际持有的是AlignmentGeometry
,它有多个子类,例如AlignmentDirectional
、FractionalOffset
,它们的主要差异在于坐标系的不同,具体可见图6-5和图6-6,在布局阶段,它们将统一转换为Alignment
的布局进行处理。
这里以Alignment
为例进行分析,其逻辑如代码清单6-4所示。
// 代码清单6-4 flutter/packages/flutter/lib/src/painting/alignment.dart
Alignment resolve(TextDirection? direction) => this;
alignChild
方法最终会调用Alignment
的alongOffset
方法完成子节点偏移值的计算,如代码清单6-5所示。
// 代码清单6-5 flutter/packages/flutter/lib/src/painting/alignment.dart
Offset alongOffset(Offset other) {
final double centerX = other.dx / 2.0; // 定位坐标系的原点
final double centerY = other.dy / 2.0; // centerX、centerY为单位距离
return Offset(centerX + x * centerX, centerY + y * centerY); // 根据定位坐标系的坐标计算出在原始坐标系中对应的坐标,并作为偏移值返回
}
以上逻辑中,参数other
表示父节点大小减去子节点后剩余的偏移值,即图6-5中原始坐标系的A
点,其在原始坐标系中的坐标为(other.dx, other.dy)
。由return
语句可知,最终的定位坐标系(图6-5中的虚线坐标系)会在原始坐标系的基础上在X、Y
轴上各移动other
的一半距离。此时,定位坐标系原点在原始坐标系中的坐标为(centerX,centerY)
,即O2
点,原始坐标系的原点O1
在定位坐标系中位置为(–1,–1)
。
由图6-5可知,O1
点(–1,–1)
即父节点的左上角(topLeft
),如代码清单6-6所示。Alignment
的常量其实都是一些特殊坐标。
// 代码清单6-6 flutter/packages/flutter/lib/src/painting/alignment.dart
static const Alignment topLeft = Alignment(-1.0, -1.0); // 见图6-5,O1点,左上角
static const Alignment topCenter = Alignment(0.0, -1.0);
static const Alignment topRight = Alignment(1.0, -1.0); // 见图6-5,B点,右上角
static const Alignment centerLeft = Alignment(-1.0, 0.0);
static const Alignment center = Alignment(0.0, 0.0); // 见图6-5,O2点,中点
static const Alignment centerRight = Alignment(1.0, 0.0);
static const Alignment bottomLeft = Alignment(-1.0, 1.0);
static const Alignment bottomCenter = Alignment(0.0, 1.0);
static const Alignment bottomRight = Alignment(1.0, 1.0); // 见图6-5,A点,右下角
以上是Alignment
的坐标系中常用位置的坐标。FractionalOffset
和AlignmentDirectional
的功能类似,只是坐标系相对父节点的位置不同,如图6-6所示。
事实上,通过坐标系就可以推断出AlignmentGeometry
不同子类的实现细节,在此不再赘述。在实际开发中,应该根据业务场景选择合适的坐标系,而不是一味地借助Alignment
进行Widget
的定位。
Flex布局流程分析
本节分析Flex
布局。Flex
思想在前端领域由来已久,它为有限二维空间内的布局提供了一种灵活且高效的解决方案。Flex
关键类的关系如图6-7所示,Flex
是Flutter中行(Row
)和列(Column
)布局的基础和本质。
图6-7中,Column
和Row
是常见的支持弹性布局的Widget
,它们都继承自Flex
,而Flex
对应的RenderObject
是RenderFlex
。RenderFlex
实现弹性布局的关键,在于其子节点的parentData
字段的类型为FlexParentData
,其内部含有子节点的弹性系数(flex
)等信息。需要注意的是,RenderFlex
控制的是子节点的parentData
字段的类型,而不是自身的字段,因而不是简单的重写(override
)可以解决的,其类定义充分利用了Dart的mixin
特性和泛型语法,远比图6-7所体现的关系要复杂。
由图6-7可知,行、列的布局的底层逻辑都将由RenderFlex
统一完成,因此首先分析RenderFlex
的performLayout
方法,如代码清单6-7所示。
// 代码清单6-7 flutter/packages/flutter/lib/src/rendering/flex.dart
void performLayout() {
final BoxConstraints constraints = this.constraints;
final _LayoutSizes sizes = _computeSizes( // 第1步,对子节点进行布局,见代码清单6-8
layoutChild: ChildLayoutHelper.layoutChild, // 子节点布局函数,即child.layout,
// 见代码清单5-58
constraints: constraints,); // 当前节点(RenderFlex)给子节点的约束条件
final double allocatedSize = sizes.allocatedSize; // 所有子节点占用的空间大小
double actualSize = sizes.mainSize;
double crossSize = sizes.crossSize;
// 第2步,交叉轴大小的校正,见代码清单6-12
// 第3步,计算每个子节点在主轴的偏移值,见代码清单6-13
// 第4步,计算每个子节点在交叉轴的偏移值,见代码清单6-14
}
以上逻辑可分为4步。第1步,执行每个子节点的Layout
流程,计算出子节点所需要占用的空间大小,即主轴方向(即行的水平方向,列的垂直方向)的大小之和。此外,还将计算出交叉轴方向(即行垂直的方向,列的水平方向)的大小,取所有子节点中交叉轴方向最大值。第2步,对于交叉轴方向对齐方式为CrossAxisAlignment.baseline
的情况,重新计算交叉轴方向的大小。这种情况不能简单取交叉轴方向上的最大值,这部分内容后面将详细分析。第3步,根据主轴的对齐方式,确定布局的起始位置和间距。第4步,依次完成每个子节点的布局,即计算每个子节点的偏移值。
首先分析第1步,其逻辑如代码清单6-8所示。
// 代码清单6-8 flutter/packages/flutter/lib/src/rendering/flex.dart
_LayoutSizes _computeSizes( ...... ) {
int totalFlex = 0;
final double maxMainSize = // 计算在当前约束下主轴方向的最大值
_direction == Axis.horizontal ? constraints.maxWidth : constraints.maxHeight;
final bool canFlex = maxMainSize < double.infinity; // 在约束为infinity的情况下,
// 弹性布局没有意义
double crossSize = 0.0;
double allocatedSize = 0.0; // 分配给非弹性节点(non-flexible)的总大小
RenderBox? child = firstChild;
RenderBox? lastFlexChild; // 最后一个Flex类型子节点,使用方式见代码清单6-10
// 计算每个非Flex子节点占用空间的大小和弹性系数之和,见代码清单6-9
// 根据剩余空间,计算每个Flex子节点占用空间的大小,见代码清单6-10
final double idealSize = canFlex && mainAxisSize == MainAxisSize.max
// 最终的mainSize
? maxMainSize : allocatedSize; // 根据MainAxisSize类型计算主轴的实际大小
return _LayoutSizes(mainSize: idealSize, crossSize: crossSize, allocatedSize:
allocatedSize, );
}
以上逻辑中,水平方向(Axis.horizontal
)即Row
的布局,垂直方向即Column
的布局。canFlex
表示是否可以执行弹性布局,仅当主轴大小为有限值时才可以,因为无限大(infinity
)的值除以任意弹性系数,其值仍为无限大,因此此时没有意义。corssSize
表示交叉轴的大小,即Row
的高度和Column
的宽度。
首先计算每个非Flex
子节点占用空间的大小,如代码清单6-9所示。
// 代码清单6-9 flutter/packages/flutter/lib/src/rendering/flex.dart
while (child != null) {
final FlexParentData childParentData = child.parentData! as FlexParentData;
final int flex = _getFlex(child); // 第1步,获取当前子节点的弹性系数
if (flex > 0) { // Flex类型子节点
totalFlex += flex; // 记录flex之和,用于后续分配对应比例的空间
lastFlexChild = child; // 记录更新
} else {
final BoxConstraints innerConstraints; // 第2步,计算文节点对每个子节点的约束
if (crossAxisAlignment == CrossAxisAlignment.stretch) {
switch (_direction) { // stretch是比较特殊的交叉轴对齐类型,需要特殊处理
case Axis.horizontal: // 强制自身高度为最大高度,达到拉伸效果
innerConstraints = BoxConstraints.tightFor(height: constraints.maxHeight);
break;
case Axis.vertical: // 同上
innerConstraints = BoxConstraints.tightFor(width: constraints.maxWidth);
break;
}
} else { // 其他情况下仅限制最大宽和高,子节点仍会按照实际宽高进行布局,见图6-9
switch (_direction) {
case Axis.horizontal: // 注意,此时没有限制最大宽和度
innerConstraints = BoxConstraints(maxHeight: constraints.maxHeight);
break;
case Axis.vertical:
innerConstraints = BoxConstraints(maxWidth: constraints.maxWidth);
break;
}
}
final Size childSize = layoutChild(child, innerConstraints); // 第3步,布局子节点
allocatedSize += _getMainSize(childSize); // 累计非弹性节点占用的空间
crossSize = math.max(crossSize, _getCrossSize(childSize)); // 更新交叉轴方向的最大值
}
child = childParentData.nextSibling; // 第4步,遍历下一个节点
}
以上逻辑主要分为4步。第1步,获取当前子节点的弹性系数,如果大于0
则计入totalFlex
变量,用于后面计算每个弹性节点的大小。第2步, 计算父节点对每个子节点的约束,如果交叉轴是拉伸对齐(CrossAxisAlignment.stretch
)则会强制每个子节点交叉轴的大小为最大约束值,否则只限制交叉轴的最大值,而不会限制最小值。第3步,对子节点进行布局,并获取其主轴大小进行累加(用于计算剩余空间),保存交叉轴的最大值。第4步,遍历下一个节点,直至完成。
完成所有非弹性节点的布局之后,这些节点所占用的主轴空间便确定了,如果还有弹性节点,那么剩余空间会用于弹性节点的布局,具体逻辑如代码清单6-10所示。
// 代码清单6-10 flutter/packages/flutter/lib/src/rendering/flex.dart
final double freeSpace = // 第1步,计算剩余空间,用于Flex类型节点的布局
math.max(0.0, (canFlex ? maxMainSize : 0.0) - allocatedSize);
double allocatedFlexSpace = 0.0; // 已分配给Flex类型节点的空间,用于最后一个Flex类型节点的计算
if (totalFlex > 0) { // 存在Flex类型节点才需要处理
final double spacePerFlex = canFlex ? (freeSpace / totalFlex) : double.nan; // 单位距离
child = firstChild;
while (child != null) { // 开始遍历每个子节点
final int flex = _getFlex(child); // 第2步,获取弹性系数
if (flex > 0) { // 是Flex节点
final double maxChildExtent = canFlex // canFlex计算见代码清单6-8
// 注意最后一个节点的计算,出于精度考虑,是求差,这也是lastFlexChild 存在的意义
? (child == lastFlexChild ? (freeSpace - allocatedFlexSpace) :
spacePerFlex * flex)
: double.infinity; // 计算当前弹性节点所能分配的最大空间
late final double minChildExtent; // 第3步,子节点主轴约束的最小值,根据FlexFit决定
switch (_getFit(child)) { // 获取FlexFit类型
case FlexFit.tight: // Expanded的默认值
minChildExtent = maxChildExtent; // 和stretch效果类似,强制Flex类型子节点填充满
break;
case FlexFit.loose: // Flexible的默认值
minChildExtent = 0.0; // 不做限制
break;
}
final BoxConstraints innerConstraints; // 第4步,计算Flex类型子节点的约束
// 计算对Flex类型节点的约束,见代码清单6-11
final Size childSize = layoutChild(child, innerConstraints);
final double childMainSize = _getMainSize(childSize); // 第5步,Flex类型节点的空间占用
allocatedSize += childMainSize; // 继续累计实际使用的空间
allocatedFlexSpace += maxChildExtent; // 已经分配的弹性布局空间
crossSize = math.max(crossSize, _getCrossSize(childSize)); // 继续更新交叉轴的最大值
} // if
final FlexParentData childParentData = child.parentData! as FlexParentData;
child = childParentData.nextSibling; // 第6步,遍历下一个子节点
} // while
} // if
以上逻辑主要分为6步。第1步,计算剩余空间的大小,通常为主轴的最大值减去已分配给非弹性节点的总大小,剩余大小除以弹性系数即spacePerFlex
。第2步,遍历每个弹性节点,计算其主轴上的最大长度,通常为弹性系数乘以spacePerFlex
。第3步,计算当前子节点在主轴上的最小长度,对于FlexFit.tight
类型,强制主轴大小为最大长度,否则为0
。第4步,计算弹性节点对其子节点的约束,具体见代码清单6-11。第5步,在当前弹性节点完成布局之后,获取子节点的大小信息,做前面内容相同的计算。第6步,遍历下一个子节点,直至结束。
// 代码清单6-11 flutter/packages/flutter/lib/src/rendering/flex.dart
if (crossAxisAlignment == CrossAxisAlignment.stretch) {
switch (_direction) {
case Axis.horizontal:
innerConstraints = BoxConstraints(
minWidth: minChildExtent, maxWidth: maxChildExtent, // 对主轴方向也进行约束
minHeight: constraints.maxHeight, maxHeight: constraints.maxHeight,);
// 强制拉伸
break;
case Axis.vertical: // SKIP innerConstraints的计算
} // switch
} else { // 注意这里与代码清单6-9之间的差异
switch (_direction) {
case Axis.horizontal: // 主轴方向的最大空间和最小空间做了限制
innerConstraints = BoxConstraints( // // 注意,非Flex类型的节点没有此约束
minWidth: minChildExtent, maxWidth: maxChildExtent,
maxHeight: constraints.maxHeight,);
break;
case Axis.vertical: // SKIP innerConstraints的计算
} // switch 结束
} // if 结束
以上逻辑主要是计算每个弹性节点对其子节点的约束,对于交叉轴拉伸的情况,会强制其在交叉轴的大小为最大值,如果无须拉伸,则最大值为父节点约束的最大值,最小值为默认值0
。主轴的最大值则由代码清单6-10中所计算的minChildExtent
和maxChildExtent
共同约束。以上逻辑和代码清单6-9中对于非弹性节点的计算逻辑基本一致,区别在于Flex
类型节点的主轴的最大值和最小值都做了约束,而非Flex
类型节点则无此约束,因而会使用默认值,即minWidth = 0.0
而maxWidth = double.infinity
(以水平方向为例)。
注意: 当Row
或者Column
内存在一个主轴方向大小未知的Widget
(比如Text
文本)时,应当用Flexible
或者Expanded
进行封装,否则可能会出现主轴大小溢出的错误。
经过以上流程,子节点的大小计算完成,其代码清单6-8中的返回值_LayoutSizes
的3个字段及其作用如下。
-
mainSize
:主轴的大小。如果当前节点可进行弹性布局且mainAxisSize
属性为max
时,为布局约束的最大值,否则为实际分配的大小,即allocatedSize
。 -
crossSize
:交叉轴方向的大小,为所有子节点交叉轴方向大小的最大值,后面可能需要校正。 -
allocatedSize
:所有子节点在主轴方向所占用的空间大小,用于后面计算节点间隔等信息。
接下来进行交叉轴大小的校正逻辑,如代码清单6-12所示。
// 代码清单6-12 flutter/packages/flutter/lib/src/rendering/flex.dart
double maxBaselineDistance = 0.0; // 用于计算代码清单6-14中子节点在交叉轴方向的偏移值
if (crossAxisAlignment == CrossAxisAlignment.baseline) { // 仅交叉轴对齐方式为
// baseline时才计算
RenderBox? child = firstChild;
double maxSizeAboveBaseline = 0; // Baseline上方空间的最大值
double maxSizeBelowBaseline = 0; // Baseline下方空间的最大值
while (child != null) { // 开始遍历每个子节点
final double? distance = child.getDistanceToBaseline(textBaseline!, onlyReal:
true);
if (distance != null) { // distance是顶部相对Baseline的值
maxBaselineDistance = math.max(maxBaselineDistance, distance);
// 值同下,功能不同
maxSizeAboveBaseline = math.max( distance, maxSizeAboveBaseline,);
maxSizeBelowBaseline = math.max(child.size.height - distance,
maxSizeBelowBaseline,);
crossSize = math.max(maxSizeAboveBaseline + maxSizeBelowBaseline, crossSize);
} // 更新交叉轴的最大值,即crossSize
final FlexParentData childParentData = child.parentData! as FlexParentData;
child = childParentData.nextSibling;
} // while
} // if
以上逻辑主要是计算每个子元素Baseline
上方空间的最大高度和Baseline
下方空间的最大深度,其和即交叉轴方向的最大值。
如图6-8所示,fl
和ag
作为两个独立的子节点时,如果顶部对齐,则交叉轴最大值则为4个字母中的最大值;当对齐方式为baseline
时,将取字母f
的高度和字母g
在Baseline
下方的深度和作为最终交叉轴的大小。图6-8中,灰色部分即Baseline
模式下将多占用的高度。
至此,主轴和交叉轴的大小都确定了,每个子节点在主轴和交叉轴的大小信息也确定了,下面就要开始计算每个子节点的偏移值。由于Flex
支持主轴上不同的对齐方式,因此在正式计算偏移值之前,需要根据对齐方式确定布局的起始位置和间距,如代码清单6-13所示。
// 代码清单6-13 flutter/packages/flutter/lib/src/rendering/flex.dart
switch (_direction) { // 第1步,确定主轴和交叉轴的实际大小
case Axis.horizontal: // 以下逻辑基于约束计算实际大小
size = constraints.constrain(Size(actualSize, crossSize)); // 见代码清单6-2
actualSize = size.width; // 主轴实际大小
crossSize = size.height; // 交叉轴实际大小
break;
case Axis.vertical: // SKIP
} // switch
final double actualSizeDelta = actualSize - allocatedSize; // 第2步,计算剩余空间的大小
_overflow = math.max(0.0, -actualSizeDelta); // 判断是否存在溢出,用于Paint阶段绘制提示信息
final double remainingSpace = math.max(0.0, actualSizeDelta); // 剩余空间,用于计算间距
late final double leadingSpace; // 第1个节点的前部间距
late final double betweenSpace; // 每个节点的间距
final bool flipMainAxis = // 第3步,根据各direction参数,判断是否翻转子节点排列方向
!(_startIsTopLeft(direction, textDirection, verticalDirection) ?? true);
switch (_mainAxisAlignment) { // 根据主轴的对齐方式,计算间距等信息
case MainAxisAlignment.start: // 每个子节点按序尽可能靠近主轴的起始点排列
leadingSpace = 0.0;
betweenSpace = 0.0; // 没有间距
break;
case MainAxisAlignment.end: // 每个子节点按序尽可能靠近主轴的结束点排列
leadingSpace = remainingSpace; // 起始位置保证剩余空间填充满,即靠近结束点排列
betweenSpace = 0.0; // 没有间距
break;
case MainAxisAlignment.center: // 每个子节点按序尽可能靠近主轴的中点排列
leadingSpace = remainingSpace / 2.0;
betweenSpace = 0.0;
break;
case MainAxisAlignment.spaceBetween: // 每个子节点等距排列,两端的子节点边距为0
leadingSpace = 0.0;
betweenSpace = childCount > 1 ? remainingSpace / (childCount - 1) : 0.0;
break;
case MainAxisAlignment.spaceAround: // 每个子节点等距排列,两端的子节点边距为该距离的一半
betweenSpace = childCount > 0 ? remainingSpace / childCount : 0.0;
leadingSpace = betweenSpace / 2.0;
break;
case MainAxisAlignment.spaceEvenly:// 每个子节点等距排列,两端的子节点边距也为该距离
betweenSpace = childCount > 0 ? remainingSpace / (childCount + 1) : 0.0;
leadingSpace = betweenSpace;
break;
} // switch
以上逻辑主要分为3步。第1步,确定主轴和交叉轴的实际大小,constraints.constrain
的逻辑在代码清单6-2中已介绍过。第2步,计算剩余空间的大小,即actualSizeDelta
,如果为负数说明当前子元素在主轴的总大小已经超过了主轴的最大值,Paint
阶段会提示溢出。第3步,根据各direction
参数,判断是否翻转子节点排列方向,计算子节点布局的起始位置和间距,具体值由主轴对齐方式而定,在代码中已经详细注明,实际效果如图6-9所示。
至此,已经确定了每个子节点在主轴的偏移值,下面开始确定在交叉轴的偏移值,具体如代码清单6-14所示。
// 代码清单6-14 flutter/packages/flutter/lib/src/rendering/flex.dart
double childMainPosition = flipMainAxis ? actualSize - leadingSpace : leadingSpace; // 起点
RenderBox? child = firstChild;
while (child != null) { // 遍历每个子节点
final FlexParentData childParentData = child.parentData! as FlexParentData; // 确定偏移值
final double childCrossPosition; // 第1步,计算交叉轴方向的偏移值
switch (_crossAxisAlignment) {
case CrossAxisAlignment.start:// 每个子节点紧贴交叉轴的起始点排列
case CrossAxisAlignment.end:// 每个子节点紧贴交叉轴的结束点排列
childCrossPosition = // 计算偏移距离
_startIsTopLeft(flipAxis(direction), textDirection, verticalDirection)
== (_crossAxisAlignment == CrossAxisAlignment.start)
? 0.0 : crossSize - _getCrossSize(child.size);
break;
case CrossAxisAlignment.center: // 每个子节点紧贴交叉轴的中线排列
childCrossPosition = crossSize / 2.0 - _getCrossSize(child.size) / 2.0;
break;
case CrossAxisAlignment.stretch: // 每个子节点拉伸到和交叉轴大小一致
childCrossPosition = 0.0;
break;
case CrossAxisAlignment.baseline: // 每个子节点的Baseline对齐
if (_direction == Axis.horizontal) {
final double? distance = // 计算子节点顶部到Baseline的距离
child.getDistanceToBaseline(textBaseline!, onlyReal: true);
if (distance != null) // 交叉轴Baseline上方空间减去该距离所得即交叉轴的偏移值
childCrossPosition = maxBaselineDistance - distance;
else
childCrossPosition = 0.0;
} else { // 如果交叉轴为水平轴,则默认为0
childCrossPosition = 0.0;
} // if
break;
} // switch
// 第2步,如果方向翻转,则布局的实际位置需要减去自身所占空间
if (flipMainAxis) childMainPosition -= _getMainSize(child.size);
switch (_direction) { // 第3步,根据主轴方向更新子节点的偏移值
case Axis.horizontal:
childParentData.offset = Offset(childMainPosition, childCrossPosition);
break;
case Axis.vertical:
childParentData.offset = Offset(childCrossPosition, childMainPosition);
break;
} // switch
if (flipMainAxis) { // 第4步,更新主轴方向的偏移值
childMainPosition -= betweenSpace; // 在第2步基础上减去间距
} else { // 正常方向,累加当前节点大小和间距
childMainPosition += _getMainSize(child.size) + betweenSpace;
}
child = childParentData.nextSibling; // 下一个子节点
}
以上逻辑分为4步,主要负责计算交叉轴方向的偏移值并存储在子节点的parentData
字段的offset
字段中。第1步,根据交叉轴对齐的类型确定交叉轴方向上的偏移值,相关逻辑在代码中已经注明。第2步,更新childMainPosition
的值,flipMainAxis
表示是否翻转子节点。正常来说,Row
是从左到右排列,Column
是从上到下排列,如果Row
从右到左排列或者Column
从下到上排列,则flipMainAxis
为true
,此时布局的偏移值要减去自身大小,如图6-10所示。第3步,根据主轴方向更新子节点的偏移值。第4步,更新childMainPosition
,即主轴方向的偏移值,并遍历直到最后一个节点。
至此,Flex
的布局流程分析结束,由于存在水平Felx
布局、垂直Flex
布局,每种布局又存在正反两个方向,故代码稍显复杂,但其主流程还是十分简单清晰的。
总结
- 通过以上分析可以进一步体会到Layout流程的通用逻辑,即通过约束计算大小,通过大小确定偏移。
Sliver布局模型
列表是移动设备上最重要的UI元素,因为移动设备的屏幕大小有限,大部分信息都要通过列表进行展示。Web时代的跨平台方法最终没有流行的一大原因就是糟糕的列表渲染性能,Flutter中的列表通过Sliver
布局模型实现。
Sliver布局概述
Flutter中,列表的每个Item
被称为Sliver
,形象地表现了Flutter列表高效、轻量化、解耦的特点。本节分析开发者常用的ListView
等组件的底层结构。
Flutter中常见的列表有CustomScrollView、ListView、GridView和PageView
,其关系如图7-1所示。
由图7-1可见,Flutter常见的列表类最终都由Scrollable
类实现,而该类内部包含RawGestureDetector
等一系列负责处理手势、响应滑动的类,本节重点分析Sliver
的静态布局模型。Viewport
是负责列表展示的Widget
,其对应的RenderObject
为 RenderViewport
,它将统筹驱动Flutter列表的Layout
流程,下面开始详细分析。
RenderViewport布局流程分析
RenderViewport
关键类及其关系如图7-2所示。
RenderViewport
是一个可以展示无限内容的窗口,center
是子节点的入口,RenderViewport
可以通过center
遍历每一个子节点,ViewportOffset
字段表示当前列表的滑动距离,用于计算当前显示的是列表中哪一部分的内容。
下面以图7-3为例,分析RenderViewport
的布局流程。图7-3中,Viewport
的大小(主轴方向,下同)为1250
(每个Sliver
的大小为250
),即图中深灰色部分。Viewport
前后存在一定长度的缓存区,用于提升列表滑动的流畅性,即图中的浅灰色部分,大小各为250
。图7-3中,为了方便示意,每个子Sliver
间留有一定空隙。其中,center
参数被设置为第4
个子节点,但是因为anchor
为0.2
,所以子节点会向下偏移1/5
主轴长度的距离,因此图7-3中第1
个显示的为sliver3
。
下面开始分析RenderViewport
的布局过程,如代码清单7-1所示。
// 代码清单7-1 flutter/packages/flutter/lib/src/rendering/viewport.dart
// RenderViewport
void performLayout() {
switch (axis) { // 第1步,记录Viewport在主轴方向的大小
case Axis.vertical:
offset.applyViewportDimension(size.height);
break;
case Axis.horizontal:
offset.applyViewportDimension(size.width);
break;
}
if (center == null) { // 第2步,判断Viewport中是否有列表内容
_minScrollExtent = 0.0;
_maxScrollExtent = 0.0;
_hasVisualOverflow = false;
offset.applyContentDimensions(0.0, 0.0);
return;
}
final double mainAxisExtent;
final double crossAxisExtent;
switch (axis) { // 第3步,计算当前Viewport在主轴和交叉轴方向的大小
case Axis.vertical:
mainAxisExtent = size.height;
crossAxisExtent = size.width;
break;
case Axis.horizontal:
mainAxisExtent = size.width;
crossAxisExtent = size.height;
break;
}
// 第4步,见代码清单7-2
}
以上逻辑主要分为4步。第1步,记录Viewport
在主轴方向的大小。第2步,center
默认为第1个子节点,如果不存在,则说明该Viewport
中没有列表内容。第3步,计算当前Viewport
在主轴和交叉轴方向的大小,axis
字段表示列表当前是水平方向还是垂直方向。第4步,在解析完Viewport
本身的信息之后,开始进行子节点的布局流程,如代码清单7-2所示。
// 代码清单7-2 flutter/packages/flutter/lib/src/rendering/viewport.dart
final double centerOffsetAdjustment = center!.centerOffsetAdjustment;
double correction;
int count = 0;
do {
correction = _attemptLayout(mainAxisExtent, // 真正的列表布局逻辑,见代码清单7-3
crossAxisExtent, offset.pixels + centerOffsetAdjustment);
if (correction != 0.0) { // 需要校正,一般在SliverList等动态创建Sliver时进行
offset.correctBy(correction);
} else { // 无须校正
if (offset.applyContentDimensions(
math.min(0.0, _minScrollExtent + mainAxisExtent * anchor),
math.max(0.0, _maxScrollExtent - mainAxisExtent * (1.0 - anchor)), ))
break; // 直接退出while循环,结束布局流程
}
count += 1;
} while (count < _maxLayoutCycles); // 最大校正次数
以上逻辑中,_attemptLayout
方法负责子节点的Layout
,在完成子节点的Layout
之后,Viewport
会根据correction
的值判断是否需要重新进行布局,但最多不会超过10次,即_maxLayoutCycles
字段的默认值。这个逻辑一般不会触发,这里只需要关注主流程即可。当correction
为0时,说明本次子节点的Layout
符合预期,此时会更新offset
字段的相关值,第1个参数表示最小可滚动距离,一般为0
;第2个参数表示最大可滚动距离,一般为列表的长度减去Viewport
的大小(即列表中已显示的长度)。注意,这里的anchor
默认为0
。
下面具体分析子节点的布局流程,如代码清单7-3所示。
// 代码清单7-3 flutter/packages/flutter/lib/src/rendering/viewport.dart
double _attemptLayout( ...... ) {
_minScrollExtent = 0.0;
_maxScrollExtent = 0.0;
_hasVisualOverflow = false;
final double centerOffset = mainAxisExtent * anchor - correctedOffset;
final double reverseDirectionRemainingPaintExtent
= centerOffset.clamp(0.0, mainAxisExtent);
final double forwardDirectionRemainingPaintExtent
= (mainAxisExtent - centerOffset).clamp(0.0, mainAxisExtent);
switch (cacheExtentStyle) {
case CacheExtentStyle.pixel: // 默认方式
_calculatedCacheExtent = cacheExtent;
break;
case CacheExtentStyle.viewport:
_calculatedCacheExtent = mainAxisExtent * _cacheExtent;
break;
}
final double fullCacheExtent = mainAxisExtent + 2 * _calculatedCacheExtent!;
final double centerCacheOffset = centerOffset + _calculatedCacheExtent!;
final double reverseDirectionRemainingCacheExtent
= centerCacheOffset.clamp(0.0, fullCacheExtent);
final double forwardDirectionRemainingCacheExtent
= (fullCacheExtent - centerCacheOffset).clamp(0.0, fullCacheExtent); // 见代码清单7-4
}
以上逻辑主要是开始正式布局前进行相关字段计算。correctedOffset
通常就是用户的滑动距离offset.pixels
,centerOffset
是center
相对Viewport
顶部的偏移值, 对图7-3而言为250
(即sliver-3
的大小)。reverseDirectionRemainingPaintExtent
表示反(reverse
)方向的剩余可绘制长度,forwardDirectionRemainingPaintExtent
表示正(forward
)方向的剩余可绘制长度。cacheExtentStyle
一般为pixel
风格,此时缓冲区长度为cacheExtent
,默认为250
。fullCacheExtent
表示可绘制区域与前后缓冲区的总和,对图7-3而言为1750
(Viewport
的高度1250
,加上前后缓冲区各250
)。centerCacheOffset
表示center
相对于缓冲区顶部的偏移,reverseDirectionRemainingCacheExtent
和forwardDirectionRemainingCacheExtent
的含义如图7-3 所示。
接下来,Viewport
将基于以上信息组装SliverConstraints
对象,作为对子节点的约束。子节点将根据约束信息完成自身的布局,并返回SliverGeometry
作为父节点计算下一个子节点的SliverConstraints
的依据。布局过程的入口如代码清单7-4所示。
// 代码清单7-4 flutter/packages/flutter/lib/src/rendering/viewport.dart
final RenderSliver? leadingNegativeChild = childBefore(center!);
if (leadingNegativeChild != null) {
final double result = layoutChildSequence( ...... ); // 布局反方向的子节点
if (result != 0.0)
return -result;
}
return layoutChildSequence( // 布局正方向的子节点
child: center, scrollOffset: math.max(0.0, -centerOffset),
overlap: leadingNegativeChild == null ? math.min(0.0, -centerOffset) : 0.0,
layoutOffset: centerOffset >= mainAxisExtent ?
centerOffset: reverseDirectionRemainingPaintExtent,
remainingPaintExtent: forwardDirectionRemainingPaintExtent,
mainAxisExtent: mainAxisExtent,
crossAxisExtent: crossAxisExtent,
growthDirection: GrowthDirection.forward,
advance: childAfter,
remainingCacheExtent: forwardDirectionRemainingCacheExtent,
cacheOrigin: centerOffset.clamp(-_calculatedCacheExtent!, 0.0),
);
以上逻辑首先会布局center
之前的Sliver,即leadingNegativeChild
的反方向的节点,其流程和正方向节点布局的过程类似,在此不再赘述,主要分析后者。
首先分析参数,child
表示当前的Sliver
节点。scrollOffset
表示center Sliver
划过Viewport
顶部的距离,没有划过顶部的时候始终为0
。当anchor
为0
,center
为第1
个Sliver
时,scrollOffset
即offset.pixels
。overlap
将在后面内容详细分析。layoutOffset
为center Sliver
开始布局的偏移值,因为Viewport
顶部为坐标系的起点,所以reverseDirectionRemainingPaintExtent
即center Sliver
布局的起始距离。advance
是获取下一个Sliver
的方法,childAfter
的逻辑在前面内容已有类似分析,在此不再赘述。cacheOrigin
表示正方向的Sliver
对于顶部缓冲区的使用量,图7-4中,center Sliver
位于Viewport
内,当正方向的Sliver
进入缓冲区后,cacheOrigin
值会增大,直到缓冲区最大值。
下面具体分析layoutChildSequence
方法的逻辑,如代码清单7-5所示。
// 代码清单7-5 flutter/packages/flutter/lib/src/rendering/viewport.dart
// RenderViewportBase
double layoutChildSequence({ ..... }) {
final double initialLayoutOffset = layoutOffset;
final ScrollDirection adjustedUserScrollDirection =
applyGrowthDirectionToScrollDirection(offset.userScrollDirection,
growthDirection);
assert(adjustedUserScrollDirection != null);
double maxPaintOffset = layoutOffset + overlap;
double precedingScrollExtent = 0.0;
while (child != null) {
final double sliverScrollOffset = scrollOffset <= 0.0 ? 0.0 : scrollOffset;
final double correctedCacheOrigin = math.max(cacheOrigin, -sliverScrollOffset);
final double cacheExtentCorrection = cacheOrigin - correctedCacheOrigin;
child.layout(SliverConstraints( // 触发子节点的布局,见代码清单7-6
axisDirection: axisDirection,
growthDirection: growthDirection,
userScrollDirection: adjustedUserScrollDirection,
scrollOffset: sliverScrollOffset,
precedingScrollExtent: precedingScrollExtent,
overlap: maxPaintOffset - layoutOffset,
remainingPaintExtent:
math.max(0.0, remainingPaintExtent - layoutOffset + initialLayoutOffset),
crossAxisExtent: crossAxisExtent,
crossAxisDirection: crossAxisDirection,
viewportMainAxisExtent: mainAxisExtent,
remainingCacheExtent:
math.max(0.0, remainingCacheExtent + cacheExtentCorrection),
cacheOrigin: correctedCacheOrigin,
), parentUsesSize: true);
// 更新字段,用于计算下一个Sliver的SliverConstraints,见代码清单7-7
updateOutOfBandData(growthDirection, childLayoutGeometry);
child = advance(child);
}
return 0.0;
}
以上逻辑主要是计算SliverConstraints
实例,并调用child.layout
驱动子节点完成布局。接下来主要分析SliverConstraints
的计算过程以及各个值的含义和作用。
SliverConstraints
的字段信息如代码清单7-6所示。
// 代码清单7-6 flutter/packages/flutter/lib/src/rendering/sliver.dart
class SliverConstraints extends Constraints {
final AxisDirection axisDirection;
final GrowthDirection growthDirection;
final ScrollDirection userScrollDirection;
final double scrollOffset;
final double precedingScrollExtent;
final double overlap;
final double remainingPaintExtent;
final double crossAxisExtent;
final AxisDirection crossAxisDirection;
final double viewportMainAxisExtent;
final double cacheOrigin;
final double remainingCacheExtent;
}
以上字段中:
axisDirection
表示列表中forward Sliver
的增长方向,最常用的是AxisDirection.down
,即列表的正方向顺序向下递增,此时scrollOffset
向上增加,remainingPaintExtent
向下增加。growthDirection
表示Sliver
增长的方向,forward
表示与axisDirection
方向相同,是center Sliver
之后的节点;reverse
表示与axisDirection
方向相反,是center Sliver
之前的节点。userScrollDirection
表示用户滑动的方向。scrollOffset
表示center Sliver
滑过Viewport
的距离,以AxisDirection.down
为例,滑过Viewport
顶部的距离即scrollOffset
,如图7-4所示。precedingScrollExtent
表示当前Sliver
之前的Sliver
累计的滚动距离scrollExtent
。对center Sliver
而言,该值为0
,图7-4中,sliver-7
的precedingScrollExtent
为750
(前面两个Sliver
加自身的大小)。overlap
表示上一个Sliver
覆盖下一个Sliver
的大小。remainingPaintExtent
表示对当前节点而言,剩余的绘制区大小。crossAxisExtent
表示交叉轴方向的大小,通常为Viewport的交叉轴大小,主要用于SliverGrid
类型的布局。crossAxisDirection
表示交叉轴方向的布局顺序。viewportMainAxisExtent
表示主轴方向的大小,通常为Viewport
主轴的大小。cacheOrigin
表示当前Sliver
可使用的Viewport
顶部缓冲区的大小,即起始位置。remainingCacheExtent
表示剩余缓冲区的大小。
在明确SliverConstraints
每个字段的含义就可以分析代码清单7-5中,center Sliver
的SliverConstraints
是如何计算的,以及为何要如此计算。center Sliver
的SliverConstraints
是由Viewport
计算的,相对比较容易理解,而其正方向的后续Sliver
则依赖于之前Sliver
的布局结果,具体如代码清单7-7所示。
// 代码清单7-7 flutter/packages/flutter/lib/src/rendering/viewport.dart
final SliverGeometry childLayoutGeometry = child.geometry!;
if (childLayoutGeometry.scrollOffsetCorrection != null) // 如果需要校正,则直接返回
return childLayoutGeometry.scrollOffsetCorrection!;
final double effectiveLayoutOffset = layoutOffset + childLayoutGeometry.paintOrigin; // 第1步,计算effectiveLayoutOffset
if (childLayoutGeometry.visible || scrollOffset > 0) { // 第2步,判断当前Sliver可见或者在Viewport上前
updateChildLayoutOffset(child, effectiveLayoutOffset, growthDirection);
} else { // 第3步,通scrollOffset粗略估算Sliver大小
updateChildLayoutOffset(child, -scrollOffset + initialLayoutOffset, growthDirection);
}
maxPaintOffset = // 第4步,更新maxPaintOffest
math.max(effectiveLayoutOffset + childLayoutGeometry.paintExtent, maxPaintOffset);
scrollOffset -= childLayoutGeometry.scrollExtent; // 第5步,更新scrollOffset的值
precedingScrollExtent += childLayoutGeometry.scrollExtent; // 第6步,更新precedingScrollExtent的值
layoutOffset += childLayoutGeometry.layoutExtent; // 第7步,更新layoutOffset的值
if (childLayoutGeometry.cacheExtent != 0.0) { // 第8步,更新当前Sliver占用后缓冲区的剩余值
remainingCacheExtent -= childLayoutGeometry.cacheExtent - cacheExtentCorrection;
cacheOrigin = math.min(correctedCacheOrigin + childLayoutGeometry.cacheExtent, 0.0);
}
以上逻辑在当前Sliver
完成Layout
之后,获取其SliverGeometry
,完成了几个重要字段的更新。首先分析SliverGeometry
的各个字段,如代码清单7-8所示。
// 代码清单7-8 flutter/packages/flutter/lib/src/rendering/sliver.dart
class SliverGeometry with Diagnosticable {
final double scrollExtent; // 当前Sliver在列表中的可滚动长度,一般就是Sliver本身的长度
final double paintOrigin; // 当前Sliver开始绘制的起点,相对当前Sliver的布局起点而言
final double paintExtent; // 当前Sliver需要绘制在可视区域Viewport中的长度
final double layoutExtent; // 当前Sliver需要布局的长度,默认为paintExtent
final double maxPaintExtent; // 当前sliver的最大绘制长度
final double maxScrollObstructionExtent;
// 当Sliver被固定在Viewport边缘时占据的最大长度
final double hitTestExtent; // 响应点击的区域长度,默认为paintExtent
final bool visible; // 判断当前Sliver是否可见,若不可见则paintExtent为0
final bool hasVisualOverflow; // 当前Sliver是否溢出Viewport,通常是在滑入、滑出时发生
final double? scrollOffsetCorrection; // 校正值
final double cacheExtent; // 当前Sliver消耗的缓冲区大小
}
结合代码清单7-5,可以分析首个Sliver
(center sliver
)及后续Sliver
的布局流程。initialLayoutOffset
表示center Sliver
的布局偏移,如图7-3所示,它也将被用来计算remainingPaintExtent
的值。adjustedUserScrollDirection
表示当前的滚动方向,本章只考虑布局,故都认为是ScrollDirection.idle
状态。maxPaintOffset
用于计算overlap
,overlap
的值即maxPaintOffsetlayoutOffset
,对center Sliver
而言,其值即传入的参数overlap
,由代码清单7-5中maxPaintOffset
的初始值可知;对于后续Sliver
,其计算如代码清单7-7中第4步所示,这部分内容后面将介绍。precedingScrollExtent
表示当前Sliver
之前的所有Sliver
产生的滚动长度,将作为下一个Sliver
的约束。
在以上逻辑中,完成当前Sliver
的布局之后,便会开始下一个Sliver
的约束的计算。共有8个关键步骤:
- 第1步,
effectiveLayoutOffset
表示当前Sliver
相对于Viewport
的绘制偏移值,SliverGeometry
如同Box
布局中的Size
,只是说明了Sliver
的大小信息,但是偏移量并没有说明,因此还需要计算,本质是当前Sliver
的布局偏移layoutOffset
加上自身相对布局起点的偏移paintOrigin
,如图7-5所示。 - 第2步,首先判断一个条件:当前
Sliver
可见或者在Viewport上面,此时会计算其偏移值,虽然该方法名为updateChildLayoutOffset
,其实是用于绘制阶段的字段,具体逻辑如代码清单7-9所示。 - 第3步,对于
Viewport
下面的Sliver
,通过scrollOffset
粗略估算其所占大小即可,因为并不会进行真正绘制。 - 第4步,更新
maxPaintOffset
,表示当前Sliver
的Paint
将占用的最大空间,减去下一个Sliver
的layoutOffset
即overlap
的约束值。以图7-5为例,sliver-6
虽然滑出了Viewport
,但是paintExtent
为150
,而sliver-6
的layoutExtent
为0
(没错!Sliver
的scrollExtent
已经指明了其大小,layoutExtent
只有在真正进行布局时才会使用,因此此时为0
),所以sliver-7
的overlap
为150
,表示sliver-6
会有overlap
大小的区域绘制在sliver-7
之上。 - 第5步,更新
scrollOffset
的值,该值大于0
时表明Sliver
位于Viewport
上沿之上,该值小于0
时sliverScrollOffset
会取0
作为下一个Sliver
的scrollOffset
的约束值。 - 第6步和第7步的
precedingScrollExtent
和layoutOffset
的含义显而易见。 - 第8步中,如果当前
Sliver
占用了缓冲区大小,则要更新对应缓冲区的剩余值。
以上便是Viewport
布局的核心流程,其核心和Box
模型十分相似:确定每个子节点的大小和偏移值,下一个子节点基于此计算自己的大小和偏移值。只是列表的表现形式更加复杂,因而约束参数更多。
下面分析Sliver
的ParentData
实例的更新,如代码清单7-9所示。对AxisDirection.down
而言,paintOffset
即前面内容提及的effectiveLayoutOffset
。
// 代码清单7-9 flutter/packages/flutter/lib/src/rendering/viewport.dart
// RenderViewport
void updateChildLayoutOffset( ...... ) {
final SliverPhysicalParentData childParentData = child.parentData! as
SliverPhysicalParentData;
childParentData.paintOffset = computeAbsolutePaintOffset(child, layoutOffset,
growthDirection);
}
Offset computeAbsolutePaintOffset( ...... ) {
switch (applyGrowthDirectionToAxisDirection(axisDirection, growthDirection)) {
case AxisDirection.up:
return Offset(0.0, size.height - (layoutOffset + child.geometry!.paintExtent));
case AxisDirection.right:
return Offset(layoutOffset, 0.0);
case AxisDirection.down:
return Offset(0.0, layoutOffset);
case AxisDirection.left:
return Offset(size.width - (layoutOffset + child.geometry!.paintExtent), 0.0);
}
}
以上逻辑主要计算不同布局方向下的子节点偏移。最后分析updateOutOfBandData
方法,如代码清单7-10所示,主要是更新_maxScrollExtent
和_minScrollExtent
的值。在计算机术语中,out-of-band data
通常表示通过独立通道(即这两个字段)进行传输的数据。
// 代码清单7-10 flutter/packages/flutter/lib/src/rendering/viewport.dart
// RenderViewport
void updateOutOfBandData(GrowthDirection growthDirection, SliverGeometry
childLayoutGeometry) {
switch (growthDirection) {
case GrowthDirection.forward:
_maxScrollExtent += childLayoutGeometry.scrollExtent;
break;
case GrowthDirection.reverse:
_minScrollExtent -= childLayoutGeometry.scrollExtent;
break;
}
if (childLayoutGeometry.hasVisualOverflow) _hasVisualOverflow = true;
}
以上逻辑主要根据子节点的布局方向更新可滚动距离。至此,Viewport
布局的核心布局流程分析完毕,可以抽象成如图7-6所示的流程。
RenderSliverToBoxAdapter布局流程分析
上面分析了Viewport
的总体布局流程,本节将分析几种常见Sliver
的内部布局。首先分析常见的RenderSliver
类型。图7-7所示为RenderSliver
的继承关系。
RenderSliver
的子类中比较重要的有3类:
- 第1类是
RenderSliverSingleBoxAdapter
,它可以封装一个子节点; - 第2类是
RenderSliverMultiBoxAdaptor
,它可以封装多个子节点,比如SliverList、SliverGrid
; - 第3类是
RenderSliverPersistentHeader
,它是SliverAppBar
的底层实现,是对overlap
属性的典型应用。
下面以RenderSliverToBoxAdapter
为例分析RenderSliver
节点自身的布局,它可以将一个Box
类型的Widget
放在列表中使用,那么其中必然涉及SliverConstraints
到BoxConstraints
的转换,因为Box
类型的RenderObject
只接受BoxConstraints
作为约束,此外Box
类型的RenderObject
返回的Size
信息也需要转换为SliverGeometry
,否则Viewport
无法解析。
RenderSliverToBoxAdapter
的布局逻辑如代码清单7-11所示。
// 代码清单7-11 flutter/packages/flutter/lib/src/rendering/sliver.dart
// RenderSliverToBoxAdapter
void performLayout() {
if (child == null) {
geometry = SliverGeometry.zero;
return;
}
final SliverConstraints constraints = this.constraints; // 第1步,将SliverConstraints转换成BoxConstraints
child!.layout(constraints.asBoxConstraints(), parentUsesSize: true);
final double childExtent;
switch (constraints.axis) { // 第2步,根据主轴方向确定子节点所占用的空间大小
case Axis.horizontal:
childExtent = child!.size.width;
break;
case Axis.vertical:
childExtent = child!.size.height;
break;
}
assert(childExtent != null); // 第3步,根据子节点在主轴占据的空间大小以及当前约束绘制大小,见代码清单7-13
final double paintedChildSize = calculatePaintOffset(constraints, from: 0.0,
to: childExtent);
final double cacheExtent = calculateCacheOffset(constraints, from: 0.0,
to: childExtent);
assert(paintedChildSize.isFinite);
assert(paintedChildSize >= 0.0);
geometry = SliverGeometry( // 第4步,计算SliverGeometry
scrollExtent: childExtent,
paintExtent: paintedChildSize,
cacheExtent: cacheExtent,
maxPaintExtent: childExtent,
hitTestExtent: paintedChildSize,
hasVisualOverflow: childExtent > constraints.remainingPaintExtent ||
constraints.scrollOffset > 0.0,
);
setChildParentData(child!, constraints, geometry!); // 第5步,为子节点设置绘制偏移
}
以上逻辑主要分为5步。第1步将SliverConstraints
转换为BoxConstraints
,具体逻辑如代码清单7-12所示。第2步,根据主轴方向确定子节点将占用的空间大小。第3步是最核心的一步,将根据子节点在主轴占据的空间大小以及当前的约束确定绘制大小,具体逻辑见代码清单7-13。第4步,根据第3步的计算结果完成SliverGeometry
的计算,scrollExtent
和maxPaintExtent
即child
在主轴的大小,paintExtent
和hitTestExtent
为第3步计算的子节点实际可以绘制的大小,hasVisualOverflow
表示当前Sliver
是否超出Viewport
。第5步,给子节点设置绘制偏移,具体逻辑见代码清单7-14。
首先,分析SliverConstraints
转换为BoxConstraints
的逻辑,如代码清单7-12所示。
// 代码清单7-12 flutter/packages/flutter/lib/src/rendering/sliver.dart
BoxConstraints asBoxConstraints({
double minExtent = 0.0,
double maxExtent = double.infinity,
double? crossAxisExtent,
}) {
crossAxisExtent ??= this.crossAxisExtent;
switch (axis) {
case Axis.horizontal:
return BoxConstraints(
minHeight: crossAxisExtent, maxHeight: crossAxisExtent,
minWidth: minExtent, maxWidth: maxExtent, );
case Axis.vertical:
return BoxConstraints(
minWidth: crossAxisExtent, maxWidth: crossAxisExtent,
minHeight: minExtent, maxHeight: maxExtent,);
}
}
以上逻辑其实十分清晰,以垂直滑动的列表为例,子节点的宽度强制约束为SliverConstraints
的交叉轴大小,最小高度为默认值0.0
,最大高度为默认值double.infinity
,即当一个Box
位于垂直列表中时,其主轴方向的大小是没有限制的,这样十分符合直觉,因为列表本来就是无限大小的。但是具体的子节点应该计算得出一个有限大小的高度,因为一个无限大小的Box Widget
无论从交互性还是性能上来说都是不合理的。
其次,分析RenderSliverToBoxAdapter
是如何根据子节点的大小信息和Viewport
赋予的约束信息确定绘制大小和缓冲区的空间大小的,具体逻辑如代码清单7-13所示。
// 代码清单7-13 flutter/packages/flutter/lib/src/rendering/sliver.dart
double calculatePaintOffset(SliverConstraints constraints,
{ required double from, required double to }) {
assert(from <= to);
final double a = constraints.scrollOffset;
final double b = constraints.scrollOffset + constraints.remainingPaintExtent;
return (to.clamp(a, b) - from.clamp(a, b)).clamp(0.0, constraints.
remainingPaintExtent);
}
double calculateCacheOffset(SliverConstraints constraints,
{ required double from, required double to }) {
assert(from <= to);
final double a = constraints.scrollOffset + constraints.cacheOrigin;
final double b = constraints.scrollOffset + constraints.remainingCacheExtent;
return (to.clamp(a, b) - from.clamp(a, b)).clamp(0.0, constraints.
remainingCacheExtent);
}
以上逻辑相对抽象,结合图7-8更容易理解。对RenderSliverToBoxAdapter
的子节点而言,虽然其占有一定的空间大小,但其不一定需要进行绘制,例如图7-8中的sliver-1、sliver-2
和 sliver-5
,此外Sliver
只有一部分的区域需要绘制,这个绘制大小就是前面内容的paintedChildSize
,那么RenderSliverToBoxAdapter
如何计算呢?
对calculatePaintOffset
方法而言,需要理解a、b、from、to
这几个变量的含义。首先,以sliver-1
为例,其from
为 0
,to
为自身高度,而a、b
分别表示Viewport
的位置,如图7-8中a1/b1
所示,结合clamp
的作用,可以判断sliver-1
不在绘制区间内。其次,对sliver-3
而言,其a
为0
,和from
相同,而b
为Viewport
的高度,所以其 paintedChildSize
即from3
到 to3
的大小。最后,sliver-4
的b
值为图中b4
,即sliver-4
的SliverConstraints
字段的remainingPaintExtent
值,to4
是sliver-4
的大小,略大于b4
,此时Sliver
的paintedChildSize
即from4
到 b4
的大小。
结合以上分析可以总结:Viewport
外的Sliver
的paintedChildSize
为0
,因为(from, to)
不会落在(a, b)
区间;除此之外,Sliver
的paintedChildSize
为子节点和Viewport
的重叠部分。calculateCacheOffset
的计算过程类似,只是需要考虑上下缓冲区的大小,在此不再赘述。
最后,确定子节点的paintOffset
,如代码清单7-14所示。
// 代码清单7-14 flutter/packages/flutter/lib/src/rendering/sliver.dart
void setChildParentData( ......) {
final SliverPhysicalParentData childParentData =
child.parentData! as SliverPhysicalParentData;
switch (applyGrowthDirectionToAxisDirection(
constraints.axisDirection, constraints.growthDirection)) {
// SKIP AxisDirection.up / AxisDirection.left / AxisDirection.right
case AxisDirection.down:
childParentData.paintOffset = Offset(0.0, -constraints.scrollOffset);
break;
}
assert(childParentData.paintOffset != null);
}
以上逻辑主要是为了解决一些特殊的边界情况,以AxisDirection.down
为例,如图7-9所示:
对完全处于Viewport
内的Sliver
而言,constraints.scrollOffset
为0
,子节点的paintOffset
为(0,0)
。此时子节点从Sliver
的左上角开始绘制,RenderSliverToBoxAdapter
本身的偏移值由代码清单7-9中的逻辑决定,即图7-9中下方SliverToBoxAdapter2
的paintOffset
的值。对正在滑过Viewport
顶部的Sliver
(SliverToBoxAdapter1
)而言,由代码清单7-4可知,其layoutOffset
为0
,那么Sliver
本身的paintOffset
为(0, 0)
,而此时正是得益于子节点的paintOffset
字段的作用,RenderSliverToBoxAdapter
才能正确完成绘制,如代码清单7-15所示。
// 代码清单7-15 flutter/packages/flutter/lib/src/rendering/sliver.dart
// RenderSliverToBoxAdapter
void paint(PaintingContext context, Offset offset) {
if (child != null && geometry!.visible) {
final SliverPhysicalParentData childParentData =
child!.parentData! as SliverPhysicalParentData;
context.paintChild(child!, offset + childParentData.paintOffset);
}
}
由以上逻辑可知,子节点的绘制偏移由其本身和Sliver
容器共同决定。
总结:Sliver 布局流程过于复杂,无法简单总结。 需要指明的是,Sliver的复杂程度远不止于此,例如,SliverList可以实现内部Item的懒加载与动态回收;SliverGrid则可以在此基础上实现更复杂的网格布局;而在现实开发中,瀑布流布局等更加灵活的列表类型则需要开发者自行封装。
参考: 《Flutter内核源码剖析》