Flutter源码阅读(3)-Flutter的布局与hitTest
前言
在前面这两篇文章中,说了Flutter启动时是如何去构建Widget.Element,RenderObject节点树。
然后这篇文章中,会分析一下Flutter中的布局流程,以及点击hitTest的调用流程
基本的布局流程代码是在RenderObjcet这个类里处理,但是这是一个最基础的流程,不包含具体的坐标体系,大小等。移动开发中,通常是使用笛卡尔坐标。
RenderBox是继承了RenderObjcet,实现了基于笛卡尔坐标的布局。
本文从源码的角度分析Flutter中layout的基础流程,以及hitTest的调用流程。但是因为有些内容需要参考,可以参考
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中列表中,
这个方法如下
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方法主要做了以下这几个事情
- 处理布局边界_relayoutBoundary
- 如果sizedByParent是true,则调用performResize方法决定大小
- 调用performLayout方法
布局边界_relayoutBoundary_
_首先第一步这里是确定了布局边界_relayoutBoundary,这一点其实很重要,结合上面的markNeedsLayout方法来说,当调用markNeedsLayout方法的时候,就是根据 _relayoutBoundary去判断是否需要一直往上调用markNeedsLayout方法。调用markNeedsLayout越多,影响的节点就会越多,更新的UI速度就会越慢。所以从界面优化的角度上来说,增加 _relayoutBoundary 可以优化界面的流畅度。
具体可以通过下方的这个条件去入手
!parentUsesSize || sizedByParent || constraints.isTight || parent is! RenderObject
总的来说,就是减少Widget树的层级,以及尽量使用
-
不影响父节点的Widget。
-
由父节点决定大小的Widget
-
可以由约束确定唯一的大小的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
大小和位置
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)方法,调用流程如下
_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);//分发事件
}
}
可以看到,这里最主要是两步
- hitTest 命中测试
- 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流程图
总结
这里主要分析了布局流程,但是没有详细的具体例子(不然文章篇幅暴涨),但是读者可以阅读源码的时候可以结合具体的例子去看,这里推荐看Stack的实现,因为这个Widget的布局计算相对简单。
转载自:https://juejin.cn/post/6983604022770925582