zoukankan      html  css  js  c++  java
  • flutter的RenderBox使用说明书&原理浅析

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

    一、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 = constraints.biggest;
      }
    
      @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( size.width - childParentData.right - childParentData.left);
            else if (childParentData.width != null)
              // 这里直接指定了宽度
              childConstraints = childConstraints.tighten( 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)。

  • 相关阅读:
    python解压缩rar,zip文件的正确姿势
    tensorflow1.x及tensorflow2.x不同版本实现验证码识别
    qt5.6.3下使用firebird
    Python3.6下的Requests登录及利用Cookies登录
    c++实现全密码生成
    android中利用HttpURLConnection进行Get、Post和Session读取页面。
    Freebsd10.3 Nginx多版本PHP
    Freebsd10.3(FreeBSD11 Beta1)使用手记
    Eclipse导入的User Libarary
    MySQL zip版本安装
  • 原文地址:https://www.cnblogs.com/joahyau/p/12931941.html
Copyright © 2011-2022 走看看