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

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

  1. 学习Flutter -- 框架总览
  2. 学习Flutter -- 启动过程做了什么
  3. 学习Flutter -- Widget 的组成
  4. 学习Flutter -- Element 的作用
  5. 学习Flutter -- RenderObject 布局过程

BoxConstraints(盒子约束)

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

dart 复制代码
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,传递给子节点的是一个确切的宽高值;

dart 复制代码
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)

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

dart 复制代码
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 父节点;如图所示:

基本流程是这样的:

  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 的一项重要的优化措施。

我们看一个例子,如图:

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

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

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

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

成为布局边界的条件

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

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

ini 复制代码
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 自身与布局相关的属性发生变化后,也会调用。

源码:

dart 复制代码
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 方法中。

源码:

dart 复制代码
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 类的源码:

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

performLayout()

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

dart 复制代码
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 对象。

dart 复制代码
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 方法,在该方法内实现子节点布局的算法即可。

dart 复制代码
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 方法。

dart 复制代码
  @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 组件的使用效果。

dart 复制代码
  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 的参数不同值的效果。

参考

深入理解 Flutter 布局约束

Flutter实战·第二版

相关推荐
foxhuli22930 分钟前
禁止ifrmare标签上的文件,实现自动下载功能,并且隐藏工具栏
前端
青皮桔1 小时前
CSS实现百分比水柱图
前端·css
影子信息1 小时前
vue 前端动态导入文件 import.meta.glob
前端·javascript·vue.js
青阳流月1 小时前
1.vue权衡的艺术
前端·vue.js·开源
样子20181 小时前
Vue3 之dialog弹框简单制作
前端·javascript·vue.js·前端框架·ecmascript
kevin_水滴石穿1 小时前
Vue 中报错 TypeError: crypto$2.getRandomValues is not a function
前端·javascript·vue.js
帅次2 小时前
Objective-C面向对象编程:类、对象、方法详解(保姆级教程)
flutter·macos·ios·objective-c·iphone·swift·safari
孤水寒月2 小时前
给自己网站增加一个免费的AI助手,纯HTML
前端·人工智能·html
CoderLiu2 小时前
用这个MCP,只给大模型一个figma链接就能直接导出图片,还能自动压缩上传?
前端·llm·mcp
伍哥的传说2 小时前
鸿蒙系统(HarmonyOS)应用开发之实现电子签名效果
开发语言·前端·华为·harmonyos·鸿蒙·鸿蒙系统