Flutter 笔记 | Flutter 核心原理(五)Box 布局模型和 Sliver 布局模型

根据前文我们已经从宏观上得知:Layout流程的本质是父节点向子节点传递自己的布局约束Constraints,子节点计算自身的大小(Size),父节点再根据大小信息计算偏移(Offset)。在二维空间中,根据大小和偏移可以唯一确定子节点的位置。

Flutter中主要存在两种布局约束——BoxSliver,关键类及其关系如图6-1所示。

图6-1 Layout关键类及其关系

图6-1中,BoxConstraintsSliverConstraints分别对应Box布局和Sliver布局模型所需要的约束条件。ParentDataRenderObject所持有的一个字段,用于为父节点提供额外的信息,比如RenderBox通过BoxParentData向父节点暴露自身的偏移值,以用于Layout阶段更新和Paint阶段。Sliver通过SliverGeometry描述自身的Layout结果,相对Box更加复杂。

Box布局模型

Box类型的Constraints布局在移动UI框架中非常普遍,比如AndroidConstraintLayoutiOSAutoLayout都有其影子。Constraints布局的特点是灵活且高效。Flutter中Box布局的原理如图6-2所示。

在这里插入图片描述

下面主要介绍 Box布局模型中最常见的两种布局——AlignFlex。虽然Flutter源码中提供的Box布局组件远不止这两种,但万变不离其宗,只要深刻理解了BoxConstraints的本质,相信其他布局也不在话下。

Align布局流程分析

本节将分析Box布局中比较有代表性的Align布局,其关键类如图6-3所示。 了解了Align的布局原理之后,相信对于其他关联的Widget也能够触类旁通。

在这里插入图片描述

图6-3中,RenderShiftedBox表示一个可以对子节点在自身中的位置进行控制的单子节点容器,最常见的就是PaddingAlign,其他Widget可自行研究,每个Widget都对应一个实现自身布局规则的RenderObject子类,在此不再赘述。

下面正式分析Align的布局流程。Align对应的RenderObjectRenderPositionedBox,其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方法为例,其返回值为minWidthmaxWidth之间最接近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 Alignment关键类

图6-4中,RenderPositionedBoxAlign对应的RenderObject类型,其通过_resolvedAlignment字段持有Alignment的实例,Alignment就是Align对子节点位置的抽象表示。Algin实际持有的是AlignmentGeometry,它有多个子类,例如AlignmentDirectionalFractionalOffset,它们的主要差异在于坐标系的不同,具体可见图6-5和图6-6,在布局阶段,它们将统一转换为Alignment的布局进行处理。

这里以Alignment为例进行分析,其逻辑如代码清单6-4所示。

// 代码清单6-4 flutter/packages/flutter/lib/src/painting/alignment.dart

Alignment resolve(TextDirection? direction) => this;

alignChild方法最终会调用AlignmentalongOffset方法完成子节点偏移值的计算,如代码清单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 Align布局模型

由图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的坐标系中常用位置的坐标。FractionalOffsetAlignmentDirectional的功能类似,只是坐标系相对父节点的位置不同,如图6-6所示。

图6-6 AlignmentGeometry布局对比

事实上,通过坐标系就可以推断出AlignmentGeometry不同子类的实现细节,在此不再赘述。在实际开发中,应该根据业务场景选择合适的坐标系,而不是一味地借助Alignment进行Widget的定位。

Flex布局流程分析

本节分析Flex布局。Flex思想在前端领域由来已久,它为有限二维空间内的布局提供了一种灵活且高效的解决方案。Flex关键类的关系如图6-7所示,Flex是Flutter中行(Row)和列(Column)布局的基础和本质。

图6-7 Flex关键类

图6-7中,ColumnRow是常见的支持弹性布局的Widget,它们都继承自Flex,而Flex对应的RenderObjectRenderFlexRenderFlex实现弹性布局的关键,在于其子节点的parentData字段的类型为FlexParentData,其内部含有子节点的弹性系数(flex)等信息。需要注意的是,RenderFlex控制的是子节点的parentData字段的类型,而不是自身的字段,因而不是简单的重写(override)可以解决的,其类定义充分利用了Dart的mixin特性和泛型语法,远比图6-7所体现的关系要复杂。

由图6-7可知,行、列的布局的底层逻辑都将由RenderFlex统一完成,因此首先分析RenderFlexperformLayout方法,如代码清单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中所计算的minChildExtentmaxChildExtent共同约束。以上逻辑和代码清单6-9中对于非弹性节点的计算逻辑基本一致,区别在于Flex类型节点的主轴的最大值和最小值都做了约束,而非Flex类型节点则无此约束,因而会使用默认值,即minWidth = 0.0maxWidth = 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 CrossAxisAlignment不同模式对比

如图6-8所示,flag作为两个独立的子节点时,如果顶部对齐,则交叉轴最大值则为4个字母中的最大值;当对齐方式为baseline时,将取字母f的高度和字母gBaseline下方的深度和作为最终交叉轴的大小。图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-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从下到上排列,则flipMainAxistrue,此时布局的偏移值要减去自身大小,如图6-10所示。第3步,根据主轴方向更新子节点的偏移值。第4步,更新childMainPosition,即主轴方向的偏移值,并遍历直到最后一个节点。

图6-10 更新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常见的列表类及其关系

由图7-1可见,Flutter常见的列表类最终都由Scrollable类实现,而该类内部包含RawGestureDetector等一系列负责处理手势、响应滑动的类,本节重点分析Sliver的静态布局模型。Viewport是负责列表展示的Widget,其对应的RenderObjectRenderViewport,它将统筹驱动Flutter列表的Layout流程,下面开始详细分析。

RenderViewport布局流程分析

RenderViewport关键类及其关系如图7-2所示。

图7-2 RenderViewport关键类及其关系

RenderViewport是一个可以展示无限内容的窗口,center是子节点的入口,RenderViewport可以通过center遍历每一个子节点,ViewportOffset字段表示当前列表的滑动距离,用于计算当前显示的是列表中哪一部分的内容。

图7-3 Flutter的布局流程(未开始滑动)

下面以图7-3为例,分析RenderViewport的布局流程。图7-3中,Viewport的大小(主轴方向,下同)为1250(每个Sliver的大小为250),即图中深灰色部分。Viewport前后存在一定长度的缓存区,用于提升列表滑动的流畅性,即图中的浅灰色部分,大小各为250。图7-3中,为了方便示意,每个子Sliver间留有一定空隙。其中,center参数被设置为第4个子节点,但是因为anchor0.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.pixelscenterOffsetcenter相对Viewport顶部的偏移值, 对图7-3而言为250(即sliver-3的大小)。reverseDirectionRemainingPaintExtent表示反(reverse)方向的剩余可绘制长度,forwardDirectionRemainingPaintExtent表示正(forward)方向的剩余可绘制长度。cacheExtentStyle一般为pixel风格,此时缓冲区长度为cacheExtent,默认为250fullCacheExtent表示可绘制区域与前后缓冲区的总和,对图7-3而言为1750Viewport的高度1250,加上前后缓冲区各250)。centerCacheOffset表示center相对于缓冲区顶部的偏移,reverseDirectionRemainingCacheExtentforwardDirectionRemainingCacheExtent 的含义如图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。当anchor0center为第1Sliver时,scrollOffsetoffset.pixelsoverlap将在后面内容详细分析。layoutOffsetcenter Sliver开始布局的偏移值,因为Viewport顶部为坐标系的起点,所以reverseDirectionRemainingPaintExtentcenter Sliver布局的起始距离。advance是获取下一个Sliver的方法,childAfter的逻辑在前面内容已有类似分析,在此不再赘述。cacheOrigin表示正方向的Sliver对于顶部缓冲区的使用量,图7-4中,center Sliver位于Viewport内,当正方向的Sliver进入缓冲区后,cacheOrigin值会增大,直到缓冲区最大值。

图7-4 Flutter列表(滑动2个Sliver的距离)

下面具体分析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-7precedingScrollExtent750(前面两个Sliver加自身的大小)。
  • overlap表示上一个Sliver覆盖下一个Sliver的大小。
  • remainingPaintExtent表示对当前节点而言,剩余的绘制区大小。
  • crossAxisExtent表示交叉轴方向的大小,通常为Viewport的交叉轴大小,主要用于SliverGrid类型的布局。
  • crossAxisDirection表示交叉轴方向的布局顺序。
  • viewportMainAxisExtent表示主轴方向的大小,通常为Viewport主轴的大小。
  • cacheOrigin表示当前Sliver可使用的Viewport顶部缓冲区的大小,即起始位置。
  • remainingCacheExtent表示剩余缓冲区的大小。

在明确SliverConstraints每个字段的含义就可以分析代码清单7-5中,center SliverSliverConstraints是如何计算的,以及为何要如此计算。center SliverSliverConstraints是由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,可以分析首个Slivercenter sliver)及后续Sliver的布局流程。initialLayoutOffset表示center Sliver的布局偏移,如图7-3所示,它也将被用来计算remainingPaintExtent的值。adjustedUserScrollDirection表示当前的滚动方向,本章只考虑布局,故都认为是ScrollDirection.idle状态。maxPaintOffset用于计算overlapoverlap的值即maxPaintOffsetlayoutOffset,对center Sliver而言,其值即传入的参数overlap,由代码清单7-5中maxPaintOffset 的初始值可知;对于后续Sliver,其计算如代码清单7-7中第4步所示,这部分内容后面将介绍。precedingScrollExtent表示当前Sliver之前的所有Sliver产生的滚动长度,将作为下一个Sliver的约束。

在以上逻辑中,完成当前Sliver的布局之后,便会开始下一个Sliver的约束的计算。共有8个关键步骤:

图7-5 Flutter列表(滑动4个Sliver的距离)

  • 第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,表示当前SliverPaint将占用的最大空间,减去下一个SliverlayoutOffsetoverlap的约束值。以图7-5为例,sliver-6 虽然滑出了Viewport,但是paintExtent150,而sliver-6layoutExtent0(没错!SliverscrollExtent已经指明了其大小,layoutExtent只有在真正进行布局时才会使用,因此此时为0),所以sliver-7overlap150,表示sliver-6会有overlap大小的区域绘制在sliver-7之上。
  • 第5步,更新scrollOffset的值,该值大于0时表明Sliver位于Viewport上沿之上,该值小于0sliverScrollOffset会取0作为下一个SliverscrollOffset的约束值。
  • 第6步和第7步的precedingScrollExtentlayoutOffset 的含义显而易见。
  • 第8步中,如果当前Sliver占用了缓冲区大小,则要更新对应缓冲区的剩余值。

以上便是Viewport布局的核心流程,其核心和Box模型十分相似:确定每个子节点的大小和偏移值,下一个子节点基于此计算自己的大小和偏移值。只是列表的表现形式更加复杂,因而约束参数更多。

下面分析SliverParentData实例的更新,如代码清单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所示的流程。

图7-6 Viewport的核心布局流程

RenderSliverToBoxAdapter布局流程分析

上面分析了Viewport的总体布局流程,本节将分析几种常见Sliver的内部布局。首先分析常见的RenderSliver类型。图7-7所示为RenderSliver的继承关系。

图7-7 RenderSliver的继承关系

RenderSliver的子类中比较重要的有3类:

  • 第1类是RenderSliverSingleBoxAdapter,它可以封装一个子节点;
  • 第2类是RenderSliverMultiBoxAdaptor,它可以封装多个子节点,比如SliverList、SliverGrid
  • 第3类是RenderSliverPersistentHeader,它是SliverAppBar的底层实现,是对overlap属性的典型应用。

下面以RenderSliverToBoxAdapter为例分析RenderSliver节点自身的布局,它可以将一个Box类型的Widget放在列表中使用,那么其中必然涉及SliverConstraintsBoxConstraints的转换,因为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的计算,scrollExtentmaxPaintExtentchild在主轴的大小,paintExtenthitTestExtent为第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-2sliver-5,此外Sliver只有一部分的区域需要绘制,这个绘制大小就是前面内容的paintedChildSize,那么RenderSliverToBoxAdapter如何计算呢?

图7-8 calculatePaintOffset方法原理演示

calculatePaintOffset方法而言,需要理解a、b、from、to这几个变量的含义。首先,以sliver-1为例,其from0to为自身高度,而a、b分别表示Viewport的位置,如图7-8中a1/b1所示,结合clamp的作用,可以判断sliver-1不在绘制区间内。其次,对sliver-3而言,其a0,和from相同,而bViewport的高度,所以其 paintedChildSizefrom3to3 的大小。最后,sliver-4b值为图中b4,即sliver-4SliverConstraints字段的remainingPaintExtent 值,to4sliver-4的大小,略大于b4,此时SliverpaintedChildSizefrom4b4的大小。

结合以上分析可以总结:Viewport外的SliverpaintedChildSize0,因为(from, to)不会落在(a, b)区间;除此之外,SliverpaintedChildSize为子节点和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所示:

图7-9 setChildParentData方法原理演示

对完全处于Viewport内的Sliver而言,constraints.scrollOffset0,子节点的paintOffset(0,0)。此时子节点从Sliver的左上角开始绘制,RenderSliverToBoxAdapter本身的偏移值由代码清单7-9中的逻辑决定,即图7-9中下方SliverToBoxAdapter2paintOffset的值。对正在滑过Viewport顶部的SliverSliverToBoxAdapter1)而言,由代码清单7-4可知,其layoutOffset0,那么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内核源码剖析》

猜你喜欢

转载自blog.csdn.net/lyabc123456/article/details/130957375