likes
comments
collection
share

5.学习Flutter -- RenderObject 布局过程

作者站长头像
站长
· 阅读数 8

通过一系列文章记录一下学习 Flutter 的过程,总结一下相关知识点。

BoxConstraints(盒子约束)

盒子约束,主要描述了最大和最小宽高的限制。在布局过程中,组件会通过约束确定自身或子节点的大小。盒子约束有四个属性,分别是最大/最小宽度,以及最大/最小高度,这四个属性的不同组合也就构成了不同的约束。先看下它的构造方法。

box.dart 
  /// Creates box constraints with the given constraints.
  const BoxConstraints({
    this.minWidth = 0.0,
    this.maxWidth = double.infinity,
    this.minHeight = 0.0,
    this.maxHeight = double.infinity,
  })

当一个 widget 告诉它的子级必须变成某个大小的时候,我们通常称这个 widget 对其子级使用 严格约束(tight)。

严格约束(Tight)

严格约束,给定了确切的大小,它宽高的 max = min,传递给子节点的是一个确切的宽高值;

box.dart 
/// Creates box constraints that is respected only by the given size.
BoxConstraints.tight(Size size)
  : minWidth = size.width,
    maxWidth = size.width,
    minHeight = size.height,
    maxHeight = size.height;

宽松约束(loose)

宽松约束,可以理解为给定的宽高是一个区间,传递给子节点的是不确定的宽高值。

box.dart 
	/// Creates box constraints that forbid sizes larger than the given size.
  BoxConstraints.loose(Size size)
  : minWidth = 0.0,
    maxWidth = size.width,
    minHeight = 0.0,
    maxHeight = size.height;

布局(Layout)过程

原理

Flutter 中组件的布局是通过 RenderObject 对象实现的,布局(Layout)过程主要是确定每个组件的位置和大小,一次布局过程是深度优先遍历 RenderObject Tree 的过程, 优先layout 子节点,然后在 layout 父节点;如图所示:

5.学习Flutter -- RenderObject 布局过程

基本流程是这样的:

  1. 父节点向子节点传递约束信息(constraints),限制子节点的最大和最小的宽高;

  2. 子节点根据约束信息确定自己的大小(size),子节点的 size 作为布局结果,可以被父节点使用;

  3. 父节点根据特定的布局规则(不同组件的算法不同)确定每一个子节点在父节点布局空间中的的位置(offset)

布局边界是什么?

relayoutBoundary,布局边界。若某个 RenderObject 的布局发生变化不会影响其父节点的布局,则该 RenderObject  就是 relayoutBoundary。

布局边界的作用

一句话:避免不必要节点的 relayout。

我们知道,Flutter 的布局过程是需要深度遍历 RenderObject Tree 的每个节点的,若某个子节点需要 relayout,再重新遍历一遍 RenderObject Tree 的每个节点,无疑是浪费性能且没有必要的。所以,relayoutBoundary 出现的作用就是将需要 relayout 的节点控制在最小范围内,避免向 relayoutBoundary 的父节点继续传播,当下一帧刷新时,relayoutBoundary 的父节点无需relayout,是 Flutter 中 relayout 的一项重要的优化措施。

我们看一个例子,如图:

5.学习Flutter -- RenderObject 布局过程

每一个 RenderObject 对象都有一个 _relayoutBoundary 属性指向它的布局边界节点,如果当前节点的布局发生变化,则该节点到其布局边界节点路径上的所有节点都需要 relayout。

  • 若 R3 节点需要重新布局,R3 的 relayoutBoundary 是 R1,最终需要重新布局的只有 R1、R3 两个节点;

  • 若 R5 节点需要重新布局,R5 的 relayoutBoundary 是它自己,最终需要重新布局的只有 R5 自己;

  • 若 R6 节点需要重新布局,R6 的 relayoutBoundary 是根节点 RenderView,最终整棵 RenderObject Tree 都需要重新布局。

成为布局边界的条件

每个 RenderObject 都有一个 relayoutBoundary 属性指向其布局边界,要么指向自己,要么等于父节点的 relayoutBoundary。

先看下有关 relayoutBoundary 部分的源码:

object.dart
 
  void layout(Constraints constraints, { bool parentUsesSize = false }) {
 		//判断是否是布局边界的 4 个条件,满足其一就是
    final bool isRelayoutBoundary = !parentUsesSize || sizedByParent || constraints.isTight || parent is! RenderObject;
  	//当前节点已经是布局边界,_relayoutBoundary 指向自己,否则指向父节点的 _relayoutBoundary
    final RenderObject relayoutBoundary = isRelayoutBoundary ? this : (parent! as RenderObject)._relayoutBoundary!;
 		...
    _relayoutBoundary = relayoutBoundary;

   	...
  }


从源码可知,满足以前四个条件之一,即可成为布局边界。

  • !parentUsesSize

当 parentUsesSize = false 时,表示父节点 layout 时不会使用当前节点的 size,(即当前节点的布局对父节点没有影响),则当前节点可成为布局边界。

  • sizedByParent

当 sizedByParent = true 时,表示当前节点的 size 只取决于父节点传递过来的约束,不依赖子节点的大小(即子节点的布局变化不会影响自身),则当前节点可成为布局边界。

  • constraints.isTight

父节点传递过来的约束是一个严格约束(固定宽高),与 sizedByParent = true  的效果一样,size 由 constrainits 唯一确定,则当前节点可成为布局边界。

  • parent is! RenderObject;

父节点的类型不是 RenderOject 类型, (主要针对根节点 RenderView,根节点的 parent 是 nil),则当前节点可成为布局边界。

markNeedsLayout()

当 RenderObject 需要布局时,会调用 markNeedsLayout  方法标记成 dirty,从而被 PipelineOwner  收集,在下一帧刷新时触发 Layout 操作。

该方法的调动时机有:

  • Render Object  被添加到 Render Tree
  • 子节点 adopt、drop、move
  • 通过子节点通过 markParentNeedsLayout 递归调用父节点的 markNeedsLayout。
  • Render Object 自身与布局相关的属性发生变化后,也会调用。

源码:

object.dart - RenderObject 类
  
	void markNeedsLayout() {
  	//如果布局边界是空的
    if (_relayoutBoundary == null) {
      _needsLayout = true;//将自身标记需要重新布局
      if (parent != null) {
        markParentNeedsLayout();//递归调用当前节点到其布局边界节点路径上所有节点的 markNeedsLayout方法
      }
      return;
    }
  	//
    if (_relayoutBoundary != this) {
      markParentNeedsLayout(); //自身不是布局边界,同上
    } else {
      _needsLayout = true;
      if (owner != null) {
        owner!._nodesNeedingLayout.add(this);//将布局边界节点添加到 PipelineOwner 的 _nodesNeedingLayout 中的
        owner!.requestVisualUpdate(); //请求更新 frame
      }
    }
  }

通过源码发现,该方法的作用有:

  • 将当前节点到其 relayoutBoundary 路径上的所有节点标记为 “需要布局” (needsLayout = true);
  • 将布局边界节点添加到 PipelineOwner 的指定列表中管理;
  • 最后通过 PipelineOwner 的实例请求重绘;在重绘过程中会对标记为 “需要布局” 的节点重新布局;

注意:通过 PipelineOwner 收集的所有的需要布局的节点,会在下一帧刷新时批量处理,而不是实时更新,避免不必要的 re-layout。

下面通过对 RenderObject 中相关方法的源码分析,进一步了解布局过程的细节。

layout()

layout 方法是 RenderObjcet 执行布局更新的主要入口,一般通过父节点调用子节点的 layout 方法执行布局更新。layout 是定义在 RenderObject  中的模板方法,执行了一些公共的逻辑,真正的布局逻辑在各个子类的 performLayout 方法中。

源码:

object.dart
 
  void layout(Constraints constraints, { bool parentUsesSize = false }) {
 		//1.确定当前组件的布局边界
    final bool isRelayoutBoundary = !parentUsesSize || sizedByParent || constraints.isTight || parent is! RenderObject;
    final RenderObject relayoutBoundary = isRelayoutBoundary ? this : (parent! as RenderObject)._relayoutBoundary!;
 		//2.当前组件没有被标记为需要重新布局,且父组件传递的约束没有发生变化,则无需重新布局,直接返回
    if (!_needsLayout && constraints == _constraints) {
      if (relayoutBoundary != _relayoutBoundary) {
        _relayoutBoundary = relayoutBoundary;
        visitChildren(_propagateRelayoutBoundaryToChild);
      }
      return;
    }
    _constraints = constraints;
    if (_relayoutBoundary != null && relayoutBoundary != _relayoutBoundary) {
      visitChildren(_cleanChildRelayoutBoundary);
    }
    _relayoutBoundary = relayoutBoundary;
		//3. sizedByParent = true 时,需要重新计算 size
    if (sizedByParent) {
       performResize();
    }
		//4.执行布局(需要子类重写这个方法)
    performLayout();
   
    _needsLayout = false;
  	//5.标记重绘
    markNeedsPaint();
  }

layout 方法主要做了如下几件事:

  1. 确定当前组件的布局边界;

  2. 判断是否需要重新布局

    1. 若当前组件没有被标记为需要重新布局,且父组件传递的约束没有发生变化,则无需重新布局,只更新布局边界即可;
  3. 当 sizedByParent = true 时,表示当前组件 size 由父组件传递的约束决定,需要子类重写 performResize;

  4. 执行布局方法 performLayout,需要子类重写该方法;

  5. 标记需要重绘;

performResize()

当 sizedByParent = true 的渲染对象需要重写 performResize 方法,需要通过父类传递过来的 constraints 计算出 size。

基类 RenderObject  中的 performResize 是空方法。如子类 RenderBox 类的源码:


@override
  void performResize() {
    // default behavior for subclasses that have sizedByParent = true
    size = computeDryLayout(constraints);
  }

performLayout()

RenderObject  中的 performLayout  方法是空方法,需要子类重写。我们看一下经常使用的布局组件 Center 对应的 RenderObject 子类 RenderPositionedBox,其 performLayout 方法源码如下:

shifted_box.dart - RenderPositionedBox

@override
  void performLayout() {
    final BoxConstraints constraints = this.constraints;
    final bool shrinkWrapWidth = _widthFactor != null || constraints.maxWidth == double.infinity;
    final bool shrinkWrapHeight = _heightFactor != null || constraints.maxHeight == double.infinity;

    if (child != null) {
      //1. 对子组件进行布局(即对子组件调用 layout 方法),传入约束
      child!.layout(constraints.loosen(), parentUsesSize: true);
      //2. 根据子组件的大小确定自身大小
      size = constraints.constrain(Size(
        shrinkWrapWidth ? child!.size.width * (_widthFactor ?? 1.0) : double.infinity,
        shrinkWrapHeight ? child!.size.height * (_heightFactor ?? 1.0) : double.infinity,
      ));
      //3. 将子节点在父节点中的位置,保存在 child.parentData 中
      alignChild();
    } else {
      size = constraints.constrain(Size(
        shrinkWrapWidth ? 0.0 : double.infinity,
        shrinkWrapHeight ? 0.0 : double.infinity,
      ));
    }
  }

	//
  void alignChild() {
    _resolve();
    final BoxParentData childParentData = child!.parentData! as BoxParentData;
    childParentData.offset = _resolvedAlignment!.alongOffset(size - child!.size as Offset);
  }

方法内主要有三个步骤:

  1. 对子组件进行布局(即对子组件调用 layout 方法),传入约束
  2. 根据子组件的大小确定自身大小;
  3. 将子节点在父节点中的位置,保存在 child.parentData 中

注意事项:

  • 该方法由 layout 方法调用,当需要 relayout  时应该调用 layout 方法,而不是直接调用 performLayout。
  • 当对子节点调用 layout 时,若需要使用子节点的 size , 在 parentUsesSize 参数需要为 true。

  • 只有当 sizedByParent = false , 才需要计算当前 Render Object 的 size; 否则由上述的 performResize  方法计算;

自定义布局实践

自定义实现一个对齐组件 CustomAlign,功能和系统 Align 基本一致,主要演示一下布局的过程以及相关方法的实现。

定义 Widget

首先定义一个有单子组件的的 Widget,继承自 SingleChildRenderObjectWidget,重写 createRenderObject 方法并返回自定义的 RenderObject 对象。

class CustomAlign extends SingleChildRenderObjectWidget {
  final Alignment alignment;

  const CustomAlign({ Key? key, required Widget child, this.alignment = Alignment.topLeft})
      : super(key: key, child: child);

  //返回自定义的 RenderObject 对象
  @override
  RenderObject createRenderObject(BuildContext context) {
    return RenderCustomAlignObject(alignment: alignment);
  }

  //重写 update 方法,用于更新 alignment  属性
  @override
  void updateRenderObject(BuildContext context, covariant RenderCustomAlignObject renderObject) {
    renderObject.alignment = alignment;
  }
}

由于该 Widget 有一个 alignment 参数用于布局使用,故需要重写 updateRenderObject 方法对布局属性更新。

定义 RenderObject

然后实现自定义的 RenderOjbect  类 RenderCustomAlignObject,若直接继承 RenderOjbect的话,需要我们手动实现一些布局无关的方法(如事件分发等逻辑),为了更聚焦布局本身,我们在这里继承自 RenderShiftedBox。这样我们只需要重写 performLayout 方法,在该方法内实现子节点布局的算法即可。


class RenderCustomAlignObject extends RenderShiftedBox {
  Alignment alignment;

  RenderCustomAlignObject({RenderBox? child, required this.alignment}): super(child);

  @override
  void performLayout() {
    ///super.performLayout(); 无需调用super.performLayout()
    if (child == null) {
      ///没有child则不占用空间
      size = Size.zero;
      return;
    }
		//1.对子组件进行布局
    child?.layout(constraints.loosen(), //传递约束(不对child的大小进行限制)
                  parentUsesSize: true); //parentUsesSize = true 表示需要使用到子组件的 size
		
    //2.根据 child 的size 确定自身的 size
    size = constraints.constrain(Size(
      constraints.maxWidth == double.infinity
          ? child!.size.width
          : double.infinity,
      constraints.maxHeight == double.infinity
          ? child!.size.height
          : double.infinity,
    ));
		
    //3.根据自身 和 child 的size,算出 child 在父节点中的位置
    //  最后保存在child.parentData 中
    BoxParentData parentData = child?.parentData as BoxParentData;
    parentData.offset = alignment.alongOffset(size - child!.size as Offset); //设置偏移
  }
 
}

布局过程如代码中注释。

最终的绘制阶段会使用到上述布局计算好的偏移量 offset,我们看下 RenderShiftedBox 类源码中的 paint 方法。

  @override
  void paint(PaintingContext context, Offset offset) {
    final RenderBox? child = this.child;
    if (child != null) {
      
      final BoxParentData childParentData = child.parentData! as BoxParentData;
      //绘制 child
      //父节点自身的offset 加上子节点的 offset,便是子节点在屏幕上的偏移。
      context.paintChild(child, childParentData.offset + offset);
    }
  }

使用

下面我们来测试一下 CustomAlign 组件的使用效果。

  Widget build(BuildContext context) {
    return MediaQuery(
        data: MediaQuery.of(context).copyWith(textScaleFactor: 1.0),
        child: Scaffold(
            backgroundColor: KimKidColor.eedCsCommonBackgroundGrayiii,
            appBar: AppBar(
              centerTitle: true,
              title: Text("CustomAlign"),
            ),
            body: Container(
              width: 400,
              height: 400,
              color: Colors.red,
              // CustomAlign...
              child: CustomAlign(
                alignment:Alignment.center,
                child: Container(
                  child: Container(
                    width: 100,
                    height: 100,
                    color: Colors.green,
                  ),
                ),
              ),
              //... CustomAlign
            )
        ));
  }

效果

以下是分别设置  CustomAlign 的 alignment 的参数不同值的效果。

5.学习Flutter -- RenderObject 布局过程

参考

深入理解 Flutter 布局约束

Flutter实战·第二版