RenderBox使用说明书&原理浅析

时间:2024-02-01 16:17:36

本文基于1.12.13+hotfix.8版本源码分析。

0、目录

一、RenderBox的用法

1、RenderBox的使用基本流程

2、RenderObjectWidget

3、非容器控件的hitTest

二、容器类型的RenderBox

1、简介

2、单个子对象

3、多个子对象

4、getXxxIntrinsicXxx和computeXxxIntrinsicXxx的作用、用法

5、容器类控件的hitTest

一、RenderBox的用法

1、RenderBox的使用基本流程

在flutter中,我们最常接触的,莫过于各种各样的widget了,但是,实际负责渲染的RenderObject是很少接触的(它们之间的关联可以看看闲鱼的这篇文章:https://www.yuque.com/xytech/flutter/tge705)。而作为一名天天向上的程序员,我们自然要去学习一下它的原理,做到知其然且知其所以然。本文会先来看看RenderBox的用法,以此抛砖引玉,便于后面继续深入flutter的绘制原理。

使用RenderBox进行绘制,我们需要做三件事:

(1)测量

第一步,我们需要确定视图大小,并赋值给父类的size属性。测量有两种情况,第一种是size由自身决定,第二种是由parent决定。

首先,由自身决定size的情况,需要在performLayout方法中完成测量,通过父类的constraints可得到满足约束的值:

  @override
  void performLayout() {
    size = Size(
      constraints.constrainWidth(200),
      constraints.constrainHeight(200),
    );
  }

第二种情况,size由parent决定,这种情况下视图大小应该完全通过parent提供的constraints测量,不存在其它因素。这种情况下,只要parent的约束不发生变化,就不会重新测量。

这种情况需要重写sizedByParent并返回true,然后在performResize中完成测量。

  @override
  void performResize() {
    size = Size(
      constraints.constrainWidth(200),
      constraints.constrainHeight(200),
    );
  }

  @override
  bool get sizedByParent => true;

看到这里,你可能会疑惑了,这两个方法什么时候会被调用?顺序是怎样的?答案在RenderObject的layout方法中:

  void layout(Constraints constraints, { bool parentUsesSize = false }) {
    //计算relayoutBoundary
    ......
    //layout
    _constraints = constraints;
    if (sizedByParent) {
        performResize();
    }
    performLayout();
    ......
  }
}
(2)绘制

RenderBox的绘制与android原生的view绘制非常相似,同样是Paint+Canvas的组合,而且api也非常接近,会非常容易上手。

  @override
  void paint(PaintingContext context, Offset offset) {
    Paint paint = Paint()
      ..color = _color
      ..style = PaintingStyle.fill;
    context.canvas.drawRect(
        Rect.fromLTRB(
          0,
          0,
          size.width,
          size.height,
        ),
        paint);
  }

这样是不是就万事大吉了呢?如果通过上面的代码进行绘制,你会发现,不管在外层怎么设置位置,绘制出来的矩形都是固定在屏幕左上角的!怎么回事?

这里就是flutter中绘制与android的最大不同:在这里绘制的坐标系是全局坐标系,即原点在屏幕左上角,而非视图左上角。

细心的同学可能已经发现,paint方法中还有一个offset参数,这就是经过parent的约束后,当前视图的偏移量,绘制时应该将它考虑进去:

  @override
  void paint(PaintingContext context, Offset offset) {
    Paint paint = Paint()
      ..color = _color
      ..style = PaintingStyle.fill;
    context.canvas.drawRect(
        Rect.fromLTRB(
          offset.dx,
          offset.dy,
          offset.dx + size.width,
          offset.dy + size.height,
        ),
        paint);
  }
(3)更新

在flutter中,是由Widget的配置发生变更而引起的rebuild,而这就是我们要实现的第三步:当视图属性发生变更时,标记重新布局或重新绘制,当屏幕刷新时就会做相应的刷新。

这里涉及到两个方法:markNeedsLayout、markNeedsPaint。顾名思义,前者标记重布局,后者标记重绘。

我们需要做的,就是根据属性的影响范围,在更新属性时,调用合适的标记方法,例如color变化时调用markNeedsPaint,width变化时调用markNeedsLayout。另外,++两者都需要更新的情况下,只调用markNeedsLayout即可,不需要两个方法都调。++

  set width(double width) {
    if (width != _width) {
      _width = width;
      markNeedsLayout();
    }
  }

  set color(Color color) {
    if (color != _color) {
      _color = color;
      markNeedsPaint();
    }
  }

2、RenderObjectWidget

(1)简介

上面讲了一大堆RenderBox的用法,但是,这玩意儿怎么用到我们熟知的Widget里面去?

按照正常流程,我们得实现一个Element和一个Widget,然后在Widget中创建Element,在Element中创建和更新RenderObject,另外还得管理一大堆状态,处理非常繁琐。所幸flutter为我们封装了这一套逻辑,即RenderObjectWidget。

相信看到这里的同学都对StatelessWidget和StatefulWidget不会陌生,但其实,StatelessWidget和StatefulWidget仅负责属性、生命周期等的管理,在它们的build方法实现中都会创建RenderObjectWidget,通过它来实现与RenderObject的关联。

举个栗子,我们经常使用的Image是个StatefulWidget,对应的state的build方法中实际返回了一个RawImage对象,而这个RawImage是继承自LeafRenderObjectWidget的,这正是RenderObjectWidget的一个子类;再比如Text,它build方法中创建的RichText是继承自MultiChildRenderObjectWidget,这同样是RenderObjectWidget的一个子类。

我们再看看RenderObjectWidget顶部的注释即可明白:

RenderObjectWidgets provide the configuration for [RenderObjectElement]s,
which wrap [RenderObject]s, which provide the actual rendering of the
application.

大概意思就是RenderObject才是实际负责渲染应用的,而RenderObjectWidget提供包装了RenderObject的配置,方便我们使用。

另外,flutter还分别实现了几个子类,进一步封装了RenderObjectWidget,它们分别是LeafRenderObjectWidget、SingleChildRenderObjectWidget、MultiChildRenderObjectWidget。其中,LeafRenderObjectWidget是叶节点,不含子Widget;SingleChildRenderObjectWidget仅有一个child;而MultiChildRenderObjectWidget则是含有children列表。这几个子类根据child的情况分别创建了对应的Element,所以通过这几个子类,我们只需要关注RenderObject的创建和更新。

(2)用法

以最简单的LeafRenderObjectWidget为例,我们需要实现createRenderObject、updateRenderObject两个方法:

  class CustomRenderWidget extends LeafRenderObjectWidget {
  CustomRenderWidget({
    this.width = 0,
    this.height = 0,
    this.color,
  });

  final double width;
  final double height;
  final Color color;

  @override
  RenderObject createRenderObject(BuildContext context) {
    return CustomRenderBox(width, height, color);
  }

  @override
  void updateRenderObject(BuildContext context, RenderObject renderObject) {
    CustomRenderBox renderBox = renderObject as CustomRenderBox;
    renderBox
      ..width = width
      ..height = height
      ..color = color;
  }
}

3、非容器控件的hitTest

通过上面的内容,我们已经可以实现自定义控件并用到界面开发中,但是距离一个完整的控件还差最后一步:命中测试。当用户使用手势,flutter会将手势信息交由控件进行检查是否命中。

RenderBox中命中测试的方法有仨:hitTest、hitTestSelf、hitTestChildren,其中hitTest默认实现是调用另外两个方法的:

  bool hitTest(BoxHitTestResult result, { @required Offset position }) {
    if (_size.contains(position)) {
      // 从这里也能看到,当命中children时,不会再进行自身的命中测试
      if (hitTestChildren(result, position: position) || hitTestSelf(position)) {
        result.add(BoxHitTestEntry(this, position));
        return true;
      }
    }
    return false;
  }

所以重写命中测试方法有两个方案,一是重写hitTest,这种方法需要将命中测试的信息加到BoxHitTestResult中;二是重写hitTestSelf和hitTestChildren,这种方法就简单地返回是否命中即可。

非容器类型的控件,只需要重写hitTestSelf,返回true即命中,例如RawImage中:

  @override
  bool hitTestSelf(Offset position) => true;

二、容器类型的RenderBox

1、介绍

在绘制篇中,我们已经了解到如何使用RenderObjectWidget和RenderBox进行基础的绘制,在本篇中,我们将继续学习RenderBox如何管理子对象。首先,我们来看看RenderBox顶部的一段注释:

For render objects with children, there are four possible scenarios:
* A single [RenderBox] child. In this scenario, consider inheriting from
  [RenderProxyBox] (if the render object sizes itself to match the child) or
  [RenderShiftedBox] (if the child will be smaller than the box and the box
  will align the child inside itself).
* A single child, but it isn't a [RenderBox]. Use the
  [RenderObjectWithChildMixin] mixin.
* A single list of children. Use the [ContainerRenderObjectMixin] mixin.
* A more complicated child model.

从上面我们可以了解到,带有子对象的情况有四种:

(1)子对象只有一个,并且是RenderBox的子类。如果当前视图需要根据子对象调整大小,则继承RenderProxyBox;如果子对象小于当前视图,且在当前视图内部对齐,则继承RenderShiftedBox(想一下Align会好理解一点);

(2)子对象只有一个,且非RenderBox子类,这种情况使用RenderObjectWithChildMixin;

(3)有多个子对象则使用ContainerRenderObjectMixin;

(4)更复杂的情况。

第四种情况是要用非链表的children结构时需要考虑的,比如children要用map或list等结构,这种情况需要继承RenderObject去实现一套绘制协议,我们这里暂且先不讨论。

而前三种情况其实注释里的描述不够明确,其实情况只有两种,第一是带有单一的child,第二是带有一个children列表,上面的第一第二两种情况其实可以合并为一种,为什么这么说呢?看下去吧~

2、单个子对象

(1)RenderProxyBox

这种情况其实就是当前容器没有跟大小相关的属性,size由子类决定,具体逻辑flutter已经在RenderProxyBoxMixin实现了,我们来看看:

  void performLayout() {
    if (child != null) {
      child.layout(constraints, parentUsesSize: true);
      size = child.size;
    } else {
      performResize();
    }
  }

逻辑非常简单,如果有child,则直接使用child的size;如果没有,就走performResize,而这里并没有实现performResize,即走RenderBox的默认实现,取约束的最小值:

  void performResize() {
    size = constraints.smallest;
    assert(size.isFinite);
  }

而绘制方法中,通过PaintingContext的paintChild方法,即可绘制child:

  @override
  void paint(PaintingContext context, Offset offset) {
    if (child != null)
      context.paintChild(child, offset);
  }
(2)RenderShiftedBox

这种情况则与RenderProxyBox相反,即当前容器有跟大小相关的属性,比如padding。接下来就以非常常见的Padding为例,看看RenderPadding的布局方法:

  @override
  void performLayout() {
    // 将padding的值按照语言方向解析
    _resolve();
    assert(_resolvedPadding != null);
    if (child == null) {
      // 如果没有child,就按照垂直、水平方向的padding值计算得出size
      size = constraints.constrain(Size(
        _resolvedPadding.left + _resolvedPadding.right,
        _resolvedPadding.top + _resolvedPadding.bottom,
      ));
      return;
    }
    // 如果有child,则将当前约束减去padding值以后,再传给child进行测量
    final BoxConstraints innerConstraints = constraints.deflate(_resolvedPadding);
    child.layout(innerConstraints, parentUsesSize: true);
    
    // 测量完毕以后,计算出坐标偏移量,提供给child绘制时使用
    // parentData是RenderObject的属性,提供给父布局使用,用来存取child在父布局中的一些信息,包括位置等
    final BoxParentData childParentData = child.parentData;
    childParentData.offset = Offset(_resolvedPadding.left, _resolvedPadding.top);
    
    // 最后得出大小是padding加上child的大小
    size = constraints.constrain(Size(
      _resolvedPadding.left + child.size.width + _resolvedPadding.right,
      _resolvedPadding.top + child.size.height + _resolvedPadding.bottom,
    ));
  }

可以看到,这里有三个关键步骤:第一,根据属性将约束减去需要额外占用的宽高,然后传给child进行测量;第二,测量完毕后计算出child需要用到的绘制偏移量;第三,根据属性和child的size得出总宽高。

另外,RenderShiftedBox的paint方法逻辑与RenderProxyBox稍微有点不同,会对offset进行处理:

  @override
  void paint(PaintingContext context, Offset offset) {
    if (child != null) {
      final BoxParentData childParentData = child.parentData;
      context.paintChild(child, childParentData.offset + offset);
    }
  }
(3)RenderObjectWithChildMixin

回到上面的问题,为什么说RenderBox和非RenderBox的单一子对象是一样的呢?其实,RenderProxyBox和RenderShiftedBox是专门为RenderBox的子类再封装了一层便于使用,它们本身还是with了RenderObjectWithChildMixin:

class RenderProxyBox extends RenderBox with RenderObjectWithChildMixin<RenderBox>, RenderProxyBoxMixin<RenderBox> {
  /// 略
}

abstract class RenderShiftedBox extends RenderBox with RenderObjectWithChildMixin<RenderBox> {
  /// 略
}

经过前面的分析,我们知道RenderProxyBox和RenderShiftedBox只负责测量和绘制,那么RenderObjectWithChildMixin是做什么的呢?借助Android Studio的Structure窗口,我们可以看到:

image

除去debug的方法以外,这个类方法并不多。以attach为例:

  @override
  void attach(PipelineOwner owner) {
    super.attach(owner);
    if (_child != null)
      _child.attach(owner);
  }

代码很少,就是在上层attach过来时,再attach自己的child,这里就涉及到渲染树的知识点,这又是另一个话题了,现在我也还没看到这里,后续我们再来分析这玩意儿~

一言蔽之,RenderObjectWithChildMixin实现了与渲染树相关的child的管理。

(4)SingleChildRenderObjectWidget

同样,定义完RenderBox以后,需要在一个Widget中进行创建,单个child的情况我们可以使用SingleChildRenderObjectWidget,与LeafRenderObjectWidget不同的地方在于需要在构造函数将child传入:

class CustomRenderWidget extends SingleChildRenderObjectWidget {
  CustomRenderWidget(Widget child) : super(child: child);
}

3、多个子对象

(1)ContainerRenderObjectMixin

相对于上面只有单个child的情况,多个子对象的情况稍微复杂一点,但也只是一点,其实区别不太大。同样,关于与渲染树相关的子对象管理,flutter也是提供了一个ContainerRenderObjectMixin,这里我们就不再分析它的原理了,只需要注意一个地方,当RenderBox被创建时,需要调一下addAll方法将children加入:

  RenderListBody({
    List<RenderBox> children,
    AxisDirection axisDirection = AxisDirection.down,
  }) : assert(axisDirection != null),
       _axisDirection = axisDirection {
    // 把children交给ContainerRenderObjectMixin管理
    addAll(children);
  }
(2)ContainerParentDataMixin

另外,ContainerDefaultsMixin指定了使用的ParentData必须是ContainerParentDataMixin的子类。ContainerParentDataMixin并不复杂,它的作用仅仅是实现了双向链表结构的ParentData:

mixin ContainerParentDataMixin<ChildType extends RenderObject> on ParentData {
  ChildType previousSibling;
  ChildType nextSibling;
}

指定了ParentData的类型后,还需要在RenderBox的setupParentData检查child使用的data类型是否符合,不符合则重新创建并替换:

  @override
  void setupParentData(RenderObject child) {
    super.setupParentData(child);
    if (child.parentData is! MultiChildLayoutParentData) {
      child.parentData = MultiChildLayoutParentData();
    }
  }
(3)案例源码分析

下面我们再以RenderStack为例,看看它的测量(函数写得有点长,但分段看挺容易理解的):

  @override
  void performLayout() {
    // 根据textDirection解析alignment
    _resolve();
    assert(_resolvedAlignment != null);
    _hasVisualOverflow = false;
    bool hasNonPositionedChildren = false;
    
    // 如果没有子对象,stack会充满父布局
    if (childCount == 0) {
      size = constraints.biggest;
      assert(size.isFinite);
      return;
    }

    double width = constraints.minWidth;
    double height = constraints.minHeight;

    // 根据fit属性调整约束
    BoxConstraints nonPositionedConstraints;
    assert(fit != null);
    switch (fit) {
      case StackFit.loose:
        nonPositionedConstraints = constraints.loosen();
        break;
      case StackFit.expand:
        nonPositionedConstraints = BoxConstraints.tight(constraints.biggest);
        break;
      case StackFit.passthrough:
        nonPositionedConstraints = constraints;
        break;
    }
    assert(nonPositionedConstraints != null);

    // 遍历所有没有通过Positioned指定位置或大小的子对象,进行布局
    RenderBox child = firstChild;
    while (child != null) {
      final StackParentData childParentData = child.parentData;

      if (!childParentData.isPositioned) {
        hasNonPositionedChildren = true;

        // 这种情况通过根据fit转换后的约束测量子对象
        child.layout(nonPositionedConstraints, parentUsesSize: true);

        // 测量完以后对比大小取最大值
        final Size childSize = child.size;
        width = math.max(width, childSize.width);
        height = math.max(height, childSize.height);
      }

      child = childParentData.nextSibling;
    }

    if (hasNonPositionedChildren) {
      // 如果存在没用Positioned指定位置或大小的子对象,则取这些子对象的最大size(上面测量后得到的)
      size = Size(width, height);
      assert(size.width == constraints.constrainWidth(width));
      assert(size.height == constraints.constrainHeight(height));
    } else {
      // 否则充满父布局
      size = constraints.biggest;
    }

    assert(size.isFinite);

    // 遍历计算约束、offset
    child = firstChild;
    while (child != null) {
      final StackParentData childParentData = child.parentData;

      if (!childParentData.isPositioned) {
      
        // 没指定位置或大小,则根据alignment来计算offset
        childParentData.offset = _resolvedAlignment.alongOffset(size - child.size);
        
      } else {
        BoxConstraints childConstraints = const BoxConstraints();

        if (childParentData.left != null && childParentData.right != null)
          // 指定了left和right,根据stack的宽度算出child的宽度
          childConstraints = childConstraints.tighten(width: size.width - childParentData.right - childParentData.left);
        else if (childParentData.width != null)
          // 这里直接指定了宽度
          childConstraints = childConstraints.tighten(width: childParentData.width);

        // 跟上面逻辑一样
        if (childParentData.top != null && childParentData.bottom != null)
          childConstraints = childConstraints.tighten(height: size.height - childParentData.bottom - childParentData.top);
        else if (childParentData.height != null)
          childConstraints = childConstraints.tighten(height: childParentData.height);

        // 测量child
        child.layout(childConstraints, parentUsesSize: true);

        // 计算offset
        double x;
        if (childParentData.left != null) {
          x = childParentData.left;
        } else if (childParentData.right != null) {
          x = size.width - childParentData.right - child.size.width;
        } else {
          x = _resolvedAlignment.alongOffset(size - child.size).dx;
        }

        if (x < 0.0 || x + child.size.width > size.width)
          // 标记溢出,在paint的时候会用
          _hasVisualOverflow = true;

        double y;
        if (childParentData.top != null) {
          y = childParentData.top;
        } else if (childParentData.bottom != null) {
          y = size.height - childParentData.bottom - child.size.height;
        } else {
          y = _resolvedAlignment.alongOffset(size - child.size).dy;
        }

        if (y < 0.0 || y + child.size.height > size.height)
          _hasVisualOverflow = true;

        childParentData.offset = Offset(x, y);
      }

      assert(child.parentData == childParentData);
      child = childParentData.nextSibling;
    }
  }

抽丝剥茧以后,不难理解,其实多个子对象和单个子对象本质上是一样的,提供子对象约束让它进行测量,然后根据测量结果决定自己的size,最后再计算子对象绘制的offset。就这样~

最后再看看绘制方法:

  @protected
  void paintStack(PaintingContext context, Offset offset) {
    // 其它情况则直接使用RenderBoxContainerDefaultsMixin提供的默认绘制方法
    defaultPaint(context, offset);
  }

  @override
  void paint(PaintingContext context, Offset offset) {
    // 处理方式为clip时,溢出部分裁减掉,_hasVisualOverflow在上面计算offset时进行了标记
    if (_overflow == Overflow.clip && _hasVisualOverflow) {
      context.pushClipRect(needsCompositing, offset, Offset.zero & size, paintStack);
    } else {
      paintStack(context, offset);
    }
  }

4、getXxxIntrinsicXxx和computeXxxIntrinsicXxx的作用、用法

细心的同学可能会发现,实现了performLayout的类中都重写了一系列compute开头的方法,另外也会有些地方调用了getMaxIntrinsicWidth等几个"get系列"的方法。从名字上看,这几个方法分别是用来计算和获取最大最小宽高的,但按照我们前面的说法,直接在performLayout或performResize中通过constrains计算宽高也可以,那么这几个方法有什么作用?跟我们前面的做法又有什么区别呢?别着急,接下来我们就来解开这些疑惑。

根据getMinIntrinsicWidth方法的注释,可以得出几个要点:

(1)getMinIntrinsicWidth用来获取能够++完整绘制++所有内容的最小宽度;

(2)这个方法是给父布局使用的,如果父布局调用了某个child的这个方法,当child调用markNeedsLayout时,父布局也会被通知刷新;

(3)这个方法的算法复杂的是O(N^2),所以非必要的情况不要用它;

(4)不要重写这个方法,有需要的话重写computeMinIntrinsicWidth。

结合这些说明,情况基本明确了。compute系列的方法是需要重写,并计算返回相应的大小;而get系列的方法则是提供给父布局使用,让父布局能够在child测量前就知道child的size。这么实现的原因是规避android原生那种measure两次的问题,详情可以看看闲鱼这篇文章:https://zhuanlan.zhihu.com/p/90195812

5、容器类控件的hitTest

相对于非容器类的控件,容器控件的命中测试需要额外考虑child的命中情况,结合上述内容,我们只需要实现hitTestChildren即可,不过需要注意一点,++这个方法接收的postion需要是相对于当前控件的(即原点在当前控件左上角),在对child进行命中测试前,我们需要把position转成原点在child左上角的相对坐标位置。++ HitTestResult类提供的一些方法会帮助我们完成这个转换。我们来看看RenderBoxContainerDefaultsMixin中的默认实现:

  bool defaultHitTestChildren(BoxHitTestResult result, { Offset position }) {
    ChildType child = lastChild;
    while (child != null) {
      final ParentDataType childParentData = child.parentData;
      
      // addWithPaintOffset会将根据offset将position转换成child的相对位置
      final bool isHit = result.addWithPaintOffset(
        offset: childParentData.offset,
        position: position,
        hitTest: (BoxHitTestResult result, Offset transformed) {
          // 这里的offset已经经过转换
          return child.hitTest(result, position: transformed);
        },
      );
      if (isHit)
        return true;
      child = childParentData.previousSibling;
    }
    return false;
  }

举个栗子,一个宽高为200的正方形容器中,装有一个宽高为100的小正方形,小正方形位于容器右下角:

image

这个时候childParentData中offset是(100,100),假设点击到正方形容器的左上角,那么容器的hitTestChildren方法拿到的position为(0,0),经过转换后,小正方形的hitTest方法中拿到的postion就应该是(-100, -100)。