Flutter源码阅读(3)-Flutter的布局与hitTest

2,398 阅读12分钟

前言

在前面这两篇文章中,说了Flutter启动时是如何去构建Widget.Element,RenderObject节点树。

然后这篇文章中,会分析一下Flutter中的布局流程,以及点击hitTest的调用流程

基本的布局流程代码是在RenderObjcet这个类里处理,但是这是一个最基础的流程,不包含具体的坐标体系,大小等。移动开发中,通常是使用笛卡尔坐标。

RenderBox是继承了RenderObjcet,实现了基于笛卡尔坐标的布局。

本文从源码的角度分析Flutter中layout的基础流程,以及hitTest的调用流程。但是因为有些内容需要参考,可以参考

Widget,Element,RenderObject树的构建和更新流程

Flutter App的启动流程

RenderObject

基础

RenderObject可以理解为一个节点的信息,描述着节点的布局Layout,图层Layer和绘制Paint信息。

在文章说到,RenderObject是由Widget创建的。当构建Widget树的时候,也会一并创建RenderObject树。

如果一个Widget是跟UI信息有关的,基本基类都是RenderObjectWidget,对应的Element的基类都是RenderObjectElement,而且会对应有一个RenderObject。

请求布局更新

在Widget更新的时候,会调用RenderObjectElement的update方法

。update如下

 @override
  void update(covariant RenderObjectWidget newWidget) {
    super.update(newWidget);
   ...
    widget.updateRenderObject(this, renderObject);
   ...
    _dirty = false;
  }

当Wiget是一个RenderObjectWidget的时候,更新的时候会调用RenderObjectElement的update方法。update方法就会反过来调用RenderObjectWidget的updateRenderObject方法。

然后Widget在updateRenderObject处理RenderObject。如果需要更新布局的话,就调用RenerObject的markNeedsLayout方法去请求布局更新。markNeedsLayout的实现如下

void markNeedsLayout() {
   ...
    if (_relayoutBoundary != this) {
      //如果当前节点不是布局边界,也就是该节点的布局会影响到父布局
      //markParentNeedsLayout会向上递归调用markNeedsLayout()方法,直到父节点是布局边界为止
      markParentNeedsLayout();
    } else {
      _needsLayout = true;
     ...
       //owner是PipelineOwner,用来统一管理布局,图层,绘制
        owner!._nodesNeedingLayout.add(this);
        owner!.requestVisualUpdate();
      }
    }
  }

当调用markNeedsLayout的时候,不是马上就改动UI界面,而是把这个改动记录下来。当下次界面更新的时候,把所有的改动一次性修改

布局更新请求处理

像以前提到Widget的构建流程中BuildOwner一样,同样存在一个调度中心PipelineOwner。他是负责处理RenderObject树的布局,图层更新,和绘制流程。

当节点有布局Layout更新需求时,就会调用会markNeedsLayout()方法,把自身添加到PipelineOwner中的_nodesNeedingLayout中列表中,

跟着会去调用PipelineOwner的requestVisualUpdate方法,这个方法会去注册一个回调,当帧信号发出的时候,就会调用这个回调。回调执行时候,会调用RenderBinding的drawFrame方法(关于这个RenderBinding以及调用流程,可以查看Flutter App的启动流程

这个方法如下

void drawFrame() {
    assert(renderView != null);
    pipelineOwner.flushLayout();
    pipelineOwner.flushCompositingBits();
    pipelineOwner.flushPaint();
    if (sendFramesToEngine) {
      renderView.compositeFrame(); // this sends the bits to the GPU
      pipelineOwner.flushSemantics(); // this also sends the semantics to the OS.
      _firstFrameSent = true;
    }
  }

可以看出,当GPU帧信号发出的时候,会调用PipelineOwner的flushLayout()方法去更新界面上的布局信息等,然后提交给GPU做渲染。

PS:本文重点讲述的是布局,加上图层和绘制的处理流程和布局的流程大致相似,所以这里重点讲得是flushLayout的过程。实现如下

void flushLayout() {
    ...
       while (_nodesNeedingLayout.isNotEmpty) {
        final List<RenderObject> dirtyNodes = _nodesNeedingLayout;
        _nodesNeedingLayout = <RenderObject>[];
        for (final RenderObject node in dirtyNodes..sort((RenderObject a, RenderObject b) => a.depth - b.depth)) {
          if (node._needsLayout && node.owner == this)
            node._layoutWithoutResize();
        }
      }
   ...
  }

这里会取出_nodesNeedingLayout,也就是所有需要更新布局的节点,对每个节点调用_layoutWithoutResize()方法。从这一步开始,就开始了节点的布局流程了。

布局流程

_layoutWithoutResize()方法,方法如下

void _layoutWithoutResize() {
    ...
      performLayout();
     ...
    _needsLayout = false;
    markNeedsPaint();
  }

可以看到,基本上就只是调用了 performLayout()和 markNeedsPaint()这两个方法

这里performLayout()就是负责去算出节点自身的位置和大小的。RenderObject中没有定义performLayout()的实现,具体得让子类去实现。

而且理所当然的是,当布局变化了,就需要重绘,所以这里有调用了一个 markNeedsPaint()标记节点需要重绘。

如果我们自定一个RenderObjct的子类,是需要实现performLayout()方法去实现的我们的布局方法的。如果有多个子节点。那么我们还需要调用子节点的 layout(Constraints constraints, { bool parentUsesSize = false }方法。我们会对子节点约束传入layout方法中,调用完子节点的layout方法后,我们就可以知道子节点所占用的大小。从而去设置该节点的布局

layout方法

这个layout方法是定义在RenderObject方法中的。如下

 void layout(Constraints constraints, { bool parentUsesSize = false }) {
   ...
    RenderObject? relayoutBoundary;//是否是布局边界,也就是说子节点布局改变会不会影响父布局
    if (!parentUsesSize || sizedByParent || constraints.isTight || parent is! RenderObject) {
      //如果满足以下的条件,则代表该节点是布局边界
      //1由父节点决定子节点的大小 
      //2父节点不需要用到子节点的大小 
      //3给定的约束能确定唯一的大小
      //4父节点不是一个RenderObject
      relayoutBoundary = this;
    } else {
      //否则的话,relayoutBoundary就等于父节点的布局边界relayoutBoundary
      relayoutBoundary = (parent! as RenderObject)._relayoutBoundary;
    }
    ...
    if (!_needsLayout && constraints == _constraints && relayoutBoundary == _relayoutBoundary) {
     ...
       //如果布局边界没有改变,约束没有改变,也没有标记为_needsLayout,则直接结束
      return;
    }
    //更新节点约束
    _constraints = constraints;
    if (_relayoutBoundary != null && relayoutBoundary != _relayoutBoundary) {
      //如果自身布局边界改变了,则清空所有的子节点的边界布局,并标记_needsLayout为true
      //这样当该节点layout发生变化的时候,子节点的layout也会发生变化
      visitChildren(_cleanChildRelayoutBoundary);
    }
   //更新_relayoutBoundary
    _relayoutBoundary = relayoutBoundary;
    ...
    if (sizedByParent) {
      ...
        //如果是父节点决定子节点的大小,则调用方法,
        //performResize是处理节点的大小
        //如果sizedByParent是true,则在performResize决定大小,不要在performLayout决定大小
        //performResize根据约束_constraints去决定大小
        performResize();
       ...
      ..
    }
   ...
    try {
      //调用performLayout()方法
      performLayout();
     ...
    } 
   ...
    _needsLayout = false;
    markNeedsPaint();
   ...
  }

layout方法主要做了以下这几个事情

  1. 处理布局边界_relayoutBoundary
  2. 如果sizedByParent是true,则调用performResize方法决定大小
  3. 调用performLayout方法
布局边界_relayoutBoundary_

_首先第一步这里是确定了布局边界_relayoutBoundary,这一点其实很重要,结合上面的markNeedsLayout方法来说,当调用markNeedsLayout方法的时候,就是根据 _relayoutBoundary去判断是否需要一直往上调用markNeedsLayout方法。调用markNeedsLayout越多,影响的节点就会越多,更新的UI速度就会越慢。所以从界面优化的角度上来说,增加 _relayoutBoundary 可以优化界面的流畅度。

具体可以通过下方的这个条件去入手

!parentUsesSize || sizedByParent || constraints.isTight || parent is! RenderObject

总的来说,就是减少Widget树的层级,以及尽量使用

  1. 不影响父节点的Widget。

  2. 由父节点决定大小的Widget

  3. 可以由约束确定唯一的大小的Widget。

这些需要看具体的Widget实现。

performResize

到了第二步,根据sizedByParent字段的值,判断是否调用performResize方法。如果sizedByParent为true,则代表节点的大小只有父节点调用layout时候提供的constraints有关系。那么就调用performResize()这个方法去确定节点的大小。一般来说,我们都是通过performLayout()方法去决定节点的大小。但是如果调用了performResize(),就不应该再在performLayout()去改变节点的大小

performLayout

到了第三步,我们可以看到,调用了performLayout()方法。结合前面的流程可以看出方法的调用如下

父节点performLayout -> 子节点layout -> 子节点performLayout -> 子子节点layout -> 子子节点performLayout -> .......

就是一个节点在布局的时候,如果存在子节点,就会调用子节点的layout方法并传入约束,子节点进行布局。然后一直重复这个过程,直到叶子节点为止

在查看Flutter的布局流程的水后,会经常在网上看到一张图。

约束

由父节点提供约束给子节点,子节点根据约束进行布局,然后返回给父节点去进行布局,完成布局流程。其实这就是第三步所说的这个过程。

至此,大概的布局流程就是这样,如下方图片所示

布局流程图

布局流程图

上方的这些布局流程都是在RenderObjct的基础上去展开的,但这只是定义了一个从上往下构建布局的基本流程。但是不涉及到具体的坐标系和节点大小。也就是说一个Widget显示在界面上的那个位置,占多少位置,光靠这个基础的布局流程是确定不了的。

Flutter中提供了一个基于笛卡尔积的布局方式RenderBox。RenderBox是继承于RenderObjct。在RednderObjct的布局流程上拓展了笛卡尔坐标,节点的大小和命中测试等。Flutter中大部分的RenderObject都是继承于RenderBox的。

如果你需要自定义坐标体系的布局,可以继承RenderObject。否则,继承RenderBox是一个最好的选择。

主要的布局RenderBox

大小和位置

box.dart中定义了BoxConstraints和BoxParentData。分别继承于Constraints和ParentData。RenderBox中的_constraints和parentData就是这两种类型。

BoxConstraints定义如下

class BoxConstraints extends Constraints {
  ...
  final double minWidth;//最小宽度
  final double maxWidth;//最大宽度
  final double minHeight;.//最小高度
  final double maxHeight;//最大高度
  ...
  }	

BoxParentData定义如下

class BoxParentData extends ParentData {
...
  Offset offset = Offset.zero;//基于笛卡尔积的起始点,
...
}

BoxConstraints确定了节点的大小,BoxParentData确定了节点的起始点。

每一个节点都接受了父子节点传递BoxConstraints和BoxParentData,然后按照上方的布局流程,那么节点的起始点和大小都能确定下来。

计算大小

RenderBox中提供了几个未实现的方法,子类需要提供实现

double computeMinIntrinsicWidth(double height) //算出最小宽度
double computeMaxIntrinsicWidth(double height) //算出最大宽度
double computeMinIntrinsicHeight(double width) //算出最小高度
double computeMaxIntrinsicHeight(double width) //算出最大高度
Size computeDryLayout(BoxConstraints constraints) //算出父节点给的约束下子节点的大小

通过这些办法,节点可以算出应该占用的尺寸。Flutter中是不建议直接调用这些方法的,而是需要通过调用以下方法获取

double getMinIntrinsicWidth(double height) //得到最小宽度
double getMaxIntrinsicWidth(double height) //得到最大宽度
double getMinIntrinsicHeight(double width) //得到最小高度
double getMaxIntrinsicHeight(double width) //得到最大高度
Size getDryLayout(BoxConstraints constraints) //得到父节点给的约束下子节点的大小

在前面的layout过程中,performLayout阶段会调用子节点的layout方法,然后就能确定子节点的大小。再通过子节点的getMinIntrinsicxxx或是getDryLayout方法去获取宽高,获取子节点的尺寸后就可以进行自身的布局。

顺带一提的是,xxxDryLayout方法是Flutter2.0以后才有的,这个方法是用来替代performResize方法的。也就是说如果一个节点的大小只有父节点的约束决定,那么不应该在performLayout方法中算出节点的大小,而应该在computeDryLayout计算出节点的大小。

而另外xxxDryLayout方法可以在不改变RenderObjct的其他状态的情况下,算出节点应该占用的大小。这里的DryLayout中的Dry就是相对普通layout方法而言的,从上面可知,layout方法是会改变边界布局,约束等。

hitTest

在布局完成后,界面UI也显示完整了,那么这时候用户点击了某个Widget,这个点击事件是怎么传递呢?这里以点击事件为例,说明事件传递的流程

上一篇文章提到,在App启动的时候会初始化一系列Binding,其中有一个是GestureBinding。当点击事件出现时,会调用GestureBinding的_handlePointerDataPacket方法,经过事件采用的操作最终会调用_handlePointerEventImmediately(PointerEvent event)方法,调用流程如下

image.png

_handlePointerEventImmediately如下

void _handlePointerEventImmediately(PointerEvent event) {
    HitTestResult? hitTestResult;
    if (event is PointerDownEvent || event is PointerSignalEvent || event is PointerHoverEvent) {
     ...
      hitTestResult = HitTestResult();//存储hitTest结果
      hitTest(hitTestResult, event.position);//进行hitTest
      if (event is PointerDownEvent) {
        _hitTests[event.pointer] = hitTestResult;
      }
      ...
    } else if (event is PointerUpEvent || event is PointerCancelEvent) {
      hitTestResult = _hitTests.remove(event.pointer);
    } else if (event.down) {
      ...
      hitTestResult = _hitTests[event.pointer];
    }
   ...
    }());
    if (hitTestResult != null ||
        event is PointerAddedEvent ||
        event is PointerRemovedEvent) {
      assert(event.position != null);
      dispatchEvent(event, hitTestResult);//分发事件
    }
  }

可以看到,这里最主要是两步

  1. hitTest 命中测试
  2. dispatchEvent 事件分发

hitTest 命中测试

因为Binding的mixin的设计,这里的hitTest方法会走到RenderBinding的hitTest方法中,如下

@override
  void hitTest(HitTestResult result, Offset position) {
    ...
    renderView.hitTest(result, position: position);
    //这里调用了super.hitTest,这个定义在GestureBing当中
    //会把Bingding也放入到hitTestResult中
    super.hitTest(result, position);
  }

这里会调用renderView.hitTest(result, position: position)方法。这里的renderView就是App启动的时候RenderObjct树的根节点。它是RenderView类型的,继承于RenderObject,mixin了RenderObjectWithChildMixin。其hitTest方法如下

bool hitTest(HitTestResult result, { required Offset position }) {
    if (child != null)
      child!.hitTest(BoxHitTestResult.wrap(result), position: position);
    result.add(HitTestEntry(this));
    return true;
  }

因为mixIn了RenderObjectWithChildMixin,所以当调用了子节点的hitTest方法的时候,会走到RenderBox的hitTest方法。如下

bool hitTest(BoxHitTestResult result, { required Offset position }) {
    ...
    if (_size!.contains(position)) {
      if (hitTestChildren(result, position: position) || hitTestSelf(position)) {
        result.add(BoxHitTestEntry(this, position));
        return true;
      }
    }
    return false;
  }

这里的hitTest调用hitTestChildren和hitTestSelf方法。这两个方法默认返回false,应该交由具体的子类实现。

hitTestChildren方法用于处理判断子节点是否命中测试,hitTestSelf判断节点本身是否响应命中测试。如果命中,就往命中测试结果中添加该节点。

一般而言,hitTestChildren方法中一般都会调用子节点的hitTest方法,通过

hitTest -> hitTestChildren -> hitTest -> hitTestChildren -> .... 

这个流程,会把所有符合命中测试的结果都存到GestureBinding的_handlePointerEventImmediately方法中的hitTestResult中,也就是说,在

dispatchEvent 事件分发

得到hitTestResult以后,就执行dispatchEvent方法,如下

void dispatchEvent(PointerEvent event, HitTestResult? hitTestResult) {
  ...
    //便利result
    for (final HitTestEntry entry in hitTestResult.path) {
      ...
        //事情处理与分发
        entry.target.handleEvent(event.transformed(entry.transform), entry);
     ...
    }
  }

因为这里涉及很多的事件分发的处理,边幅较大,所以不在这里讨论。

hitTest流程图

hitTest流程图

总结

这里主要分析了布局流程,但是没有详细的具体例子(不然文章篇幅暴涨),但是读者可以阅读源码的时候可以结合具体的例子去看,这里推荐看Stack的实现,因为这个Widget的布局计算相对简单。