Flutter 事件处理流程主要分两步,为了聚焦核心流程,我们以用户触摸事件为例来说明:
需要注意:
下面我们从代码层面看一些整个事件处理流程:
// 触发新事件时,flutter 会调用此方法void _handlePointerEventImmediately(PointerEvent event) {HitTestResult? hitTestResult;if (event is PointerDownEvent ) {hitTestResult = HitTestResult();// 发起命中测试hitTest(hitTestResult, event.position);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) { // PointerMoveEvent//直接获取命中测试的结果hitTestResult = _hitTests[event.pointer];}// 事件分发if (hitTestResult != null) {dispatchEvent(event, hitTestResult);}}
上面代码只是核心代码,完整的代码位于GestureBinding 实现中。下面我们分别来介绍一些命中测试和事件分发过程。
一个对象是否可以响应事件,取决于在其对命中测试过程中是否被添加到了 HitTestResult 列表 ,如果没有被添加进去,则后续的事件分发将不会分发给自己。下面我们看一下命中测试的过程:当发生用户事件时,Flutter 会从根节点(RenderView
)开始调用它hitTest()
。
@overridevoid hitTest(HitTestResult result, Offset position) {//从根节点开始进行命中测试renderView.hitTest(result, position: position);// 会调用 GestureBinding 中的 hitTest()方法,我们将在下一节中介绍。super.hitTest(result, position);}
上面代码位于 RenderBinding 中,核心代码只有两行,整体是命中测试分两步,我们来解释一下:
第一步: renderView 是 RenderView 对应的 RenderObject 对象, RenderObject 对象的 hitTest 方法主要功能是:从该节点出发,按照深度优先的顺序递归遍历子树(渲染树)上的每一个节点并对它们进行命中测试。这个过程称为“渲染树命中测试”。
注意,为了表述方便,“渲染树命中测试”,也可以表述为组件树或节点树命中测试,只是我们需要知道,命中测试的逻辑都在 RenderObject 中,而并非在 Widget或 Element 中。
第二步:渲染树命中测试完毕后,会调用 GestureBinding 的 hitTest 方法,该方法主要用于处理手势,我们会在后面介绍。
渲染树的命中测试流程就是父节点 hitTest 方法中不断调用子节点 hitTest 方法的递归过程。下面是RenderView
的hitTest()
源码:
// 发起命中测试,position 为事件触发的坐标(如果有的话)。bool hitTest(HitTestResult result, { Offset position }) {if (child != null)child.hitTest(result, position: position); //递归对子树进行命中测试//根节点会始终被添加到HitTestResult列表中result.add(HitTestEntry(this));return true;}
因为 RenderView 只有一个孩子,所以直接调用child.hitTest 即可。如果一个渲染对象有多个子节点,则命中测试逻辑为:如果任意一个子节点通过了命中测试或者当前节点“强行声明”自己通过了命中测试,则当前节点会通过命中测试。我们以RenderBox
为例,看看它的hitTest()
实现:
bool hitTest(HitTestResult result, { @required Offset position }) {...if (_size.contains(position)) { // 判断事件的触发位置是否位于组件范围内if (hitTestChildren(result, position: position) || hitTestSelf(position)) {result.add(BoxHitTestEntry(this, position));return true;}}return false;}
上面代码中:
hitTestChildren()
功能是判断是否有子节点通过了命中测试,如果有,则会将子组件添加到 HitTestResult 中同时返回 true;如果没有则直接返回false。该方法中会递归调用子组件的 hitTest 方法。hitTestSelf()
决定自身是否通过命中测试,如果节点需要确保自身一定能响应事件可以重写此函数并返回true ,相当于“强行声明”自己通过了命中测试。需要注意,节点通过命中测试的标志是它被添加到 HitTestResult 列表中,而不是它 hitTest 的返回值,虽然大所数情况下节点通过命中测试就会返回 true,但是由于开发者在自定义组件时是可以重写 hitTest 的,所以有可能会在在通过命中测试时返回 false,或者未通过命中测试时返回 true,当然这样做并不好,我们在自定义组件时应该尽可能避免,但是在有些需要自定义命中测试流程的场景下可能就需要打破这种默契,比如我们将在本节后面实现的 HitTestBlocker 组件。
所以整体逻辑就是:
hitTestChildren()
判断是否有子节点通过命中测试,如果是,则将当前节点添加到 HitTestResult 列表,此时 hitTest 返回 true。即只要有子节点通过了命中测试,那么它的父节点(当前节点)也会通过命中测试。如果当前节点有子节点通过了命中测试或者当前节点自己通过了命中测试,则将当前节点添加到 HitTestResult 中。又因为 hitTestChildren()
中会递归调用子组件的 hitTest 方法,所以组件树的命中测试顺序深度优先的,即如果通过命中测试,子组件会比父组件会先被加入HitTestResult 中。
我们看看这两个方法默认实现如下:
@protectedbool hitTestChildren(HitTestResult result, { Offset position }) => false;@protectedbool hitTestSelf(Offset position) => false;
如果组件包含多个子组件,就必须重写 hitTestChildren()
方法,该方法中应该调用每一个子组件的 hitTest 方法,比如我们看看 RenderBoxContainerDefaultsMixin 中的实现:
// 子类的 hitTestChildren() 中会直接调用此方法bool defaultHitTestChildren(BoxHitTestResult result, { required Offset position }) {// 遍历所有子组件(子节点从后向前遍历)ChildType? child = lastChild;while (child != null) {final ParentDataType childParentData = child.parentData! as ParentDataType;// isHit 为当前子节点调用hitTest() 的返回值final bool isHit = result.addWithPaintOffset(offset: childParentData.offset,position: position,//调用子组件的 hitTest方法,hitTest: (BoxHitTestResult result, Offset? transformed) {return child!.hitTest(result, position: transformed!);},);// 一旦有一个子节点的 hitTest() 方法返回 true,则终止遍历,直接返回trueif (isHit) return true;child = childParentData.previousSibling;}return false;}bool addWithPaintOffset({required Offset? offset,required Offset position,required BoxHitTest hitTest,}) {...// 省略无关代码final bool isHit = hitTest(this, transformedPosition);return isHit; // 返回 hitTest 的执行结果}
我们可以看到上面代码的主要逻辑是遍历调用子组件的 hitTest() 方法,同时提供了一种中断机制:即遍历过程中只要有子节点的 hitTest() 返回了 true 时:
当子节点的 hitTest() 返回了 false 时,继续遍历该子节点前面的兄弟节点,对它们进行命中测试,如果所有子节点都返回 false 时,则父节点会调用自身的 hitTestSelf 方法,如果该方法也返回 false,则父节点就会被认为没有通过命中测试。
下面思考两个问题:
我们回到 hitTestChildren 上,如果不重写 hitTestChildren,则默认直接返回 false,这也就意味着后代节点将无法参与命中测试,相当于事件被拦截了,这也正是 IgnorePointer 和 AbsorbPointer 可以拦截事件下发的原理。
如果 hitTestSelf 返回 true,则无论子节点中是否有通过命中测试的节点,当前节点自身都会被添加到 HitTestResult 中。而 IgnorePointer 和 AbsorbPointer 的区别就是,前者的 hitTestSelf 返回了 false,而后者返回了 true。
命中测试完成后,所有通过命中测试的节点都被添加到了 HitTestResult 中。
事件分发过程很简单,即遍历HitTestResult,调用每一个节点的 handleEvent 方法:
// 事件分发void dispatchEvent(PointerEvent event, HitTestResult? hitTestResult) {...for (final HitTestEntry entry in hitTestResult.path) {entry.target.handleEvent(event.transformed(entry.transform), entry);}}
所以组件只需要重写 handleEvent 方法就可以处理事件了。
我们先来实现一个能够监听 PointerDownEvent 的组件:
class PointerDownListener extends SingleChildRenderObjectWidget {PointerDownListener({Key? key, this.onPointerDown, Widget? child}): super(key: key, child: child);final PointerDownEventListener? onPointerDown;@overrideRenderObject createRenderObject(BuildContext context) =>RenderPointerDownListener()..onPointerDown = onPointerDown;@overridevoid updateRenderObject(BuildContext context, RenderPointerDownListener renderObject) {renderObject.onPointerDown = onPointerDown;}}class RenderPointerDownListener extends RenderProxyBox {PointerDownEventListener? onPointerDown;@overridebool hitTestSelf(Offset position) => true; //始终通过命中测试@overridevoid handleEvent(PointerEvent event, covariant HitTestEntry entry) {//事件分发时处理事件if (event is PointerDownEvent) onPointerDown?.call(event);}}
因为我们让 hitTestSelf 的返回值始终为 true,所以无论子节点是否通过命中测试,PointerDownListener 都会通过,所以后续分发事件时 handleEvent 就会被调用,我们在里面判断事件类型为 PointerDownEvent 时触发回调即可,测试代码如下:
class PointerDownListenerRoute extends StatelessWidget {const PointerDownListenerRoute({Key? key}) : super(key: key);@overrideWidget build(BuildContext context) {return PointerDownListener(child: Text('Click me'),onPointerDown: (e) => print('down'),);}}
点击文本后控制台就会打印 'down'。
Listener 的实现和 PointerDownListener 的实现原理差不多,有两点不同:
这里需要重点说一下第二点。 Listener 组件有一个 behavior 参数,我们之前并没有介绍,下面我们仔细介绍一下。通过查看 Listener 源码,发现它的渲染对象 RenderPointerListener 继承了 RenderProxyBoxWithHitTestBehavior 类:
abstract class RenderProxyBoxWithHitTestBehavior extends RenderProxyBox {//[behavior] 的默认值为 [HitTestBehavior.deferToChild].RenderProxyBoxWithHitTestBehavior({this.behavior = HitTestBehavior.deferToChild,RenderBox? child,}) : super(child);HitTestBehavior behavior;@overridebool hitTest(BoxHitTestResult result, { required Offset position }) {bool hitTarget = false;if (size.contains(position)) {hitTarget = hitTestChildren(result, position: position) || hitTestSelf(position);if (hitTarget || behavior == HitTestBehavior.translucent) //1result.add(BoxHitTestEntry(this, position)); // 通过命中测试}return hitTarget;}@overridebool hitTestSelf(Offset position) => behavior == HitTestBehavior.opaque; //2}
我们看到 behavior 在 hitTest 和 hitTestSelf 中会使用,它的取值会影响 Listener 的命中测试结果。我们先看看 behavior 都有哪些取值:
//在命中测试过程中 Listener 组件如何表现。enum HitTestBehavior {// 组件是否通过命中测试取决于子组件是否通过命中测试deferToChild,// 组件必然会通过命中测试,同时其 hitTest 返回值始终为 trueopaque,// 组件必然会通过命中测试,但其 hitTest 返回值可能为 true 也可能为 falsetranslucent,}
它有三个取值,我们结合 hitTest 实现来分析一下不同取值的作用:
注意,behavior 为 opaque 和 translucent 时当前组件都会通过命中测试,它们的区别是 hitTest() 的返回值(hitTarget )可能不同,所以它们的区别就看 hitTest() 的返回值会影响什么,这个我们已经在上面详细介绍过了,下面我们通过一个实例来理解一下。
效果如图8-6所示:
实现思路是,在页面的最顶层覆盖一个水印遮罩,我们可以通过 Stack 来实现,将水印组件作为最后一个孩子传给Stack:
class WaterMaskTest extends StatelessWidget {const WaterMaskTest({Key? key}) : super(key: key);@overrideWidget build(BuildContext context) {return Stack(children: [wChild(1, Colors.white, 200),WaterMark(painter: TextWaterMarkPainter(text: 'wendux', rotate: -20),),],);}Widget wChild(int index, color, double size) {return Listener(onPointerDown: (e) => print(index),child: Container(width: size,height: size,color: Colors.grey,),);}}
WaterMark 是实现水印的组件,具体逻辑我们将在后面自定义组件一章中介绍,现在只需知道 WaterMark 中使用了 DecoratedBox。效果是实现了,但是我们点击 Stack 的第一个子组件(灰色矩形区域)时发现控制台没有任何输出,这是不符合预期的,原因是水印组件在最顶层,事件被它 “拦住了”,我们分析一下这个过程:
原因找到了,解决的方法就是想办法让第一个子组件也能参与命中测试,这样的话,我们就得想办法让第二个子组件的 hitTest 返回 false 即可。因此我们可以用 IgnorePointer 包裹一下 WaterMask即可。
IgnorePointer(child: WaterMark(...))
修改后,重新运行,发现第一个子组件可以响应事件了。
如果我们想让 Stack 的所有子组件都响应事件,应该如何实现呢?当然,这很可能是一个伪需求,现实的场景中几乎不会遇到,但考虑这个问题可以加深我们对 Flutter 事件处理流程的理解。
class StackEventTest extends StatelessWidget {const StackEventTest({Key? key}) : super(key: key);@overrideWidget build(BuildContext context) {return Stack(children: [wChild(1),wChild(2),],);}Widget wChild(int index) {return Listener(onPointerDown: (e) => print(index),child: Container(width: 100,height: 100,color: Colors.grey,),);}}
运行后,点击灰色框,读者猜猜控制台会打印什么?
答案是只会打印一个 '2',原因是,Stack 先遍历第二个子节点 Listener ,因为 Container 的 hitTest 会返回 true( 实际上 Container 是一个组合组件,本示例中,Container最终会生成一个ColoredBox,而参与命中测试的是ColoredBox对应的 RenderObject),所以 Listener 的 hitTestChildren 会返回 true,最终 Listener 的hitTest 也会返回true,所以第一个子节点将不会收到事件。
那如果我们将 Listener 的 behavior 属性指定为 opaque 或 translucent 呢?其实结果还是一样的,因为只要 Container 的 hitTest 会返回 true, 最终Listener 的 hitTestChildren 就会返回 true,第一个节点就不会再进行命中测试。 那 opaque 和 translucent 能体现出差异的具体场景有什么呢?理论上只有 Listener 的子节点 hitTest 返回 false 时两者才有区别,但是 Flutter 中有UI 的组件都会在用户点击到它之上时,它的hitTest基本都会返回 true ,因此很难找到具体场景,但是为了测试它们的区别,我们可以强行制造一个场景,比如下面代码:
class HitTestBehaviorTest extends StatelessWidget {const HitTestBehaviorTest({Key? key}) : super(key: key);@overrideWidget build(BuildContext context) {return Stack(children: [wChild(1),wChild(2),],);}Widget wChild(int index) {return Listener(//behavior: HitTestBehavior.opaque, // 放开此行,点击只会输出 2behavior: HitTestBehavior.translucent, // 放开此行,点击会同时输出 2 和 1onPointerDown: (e) => print(index),child: SizedBox.expand(),);}}
SizedBox 没有子元素,当它被点击时,它的 hitTest 就会返回 false,此时 Listener 的 behavior 设置为 opaque 和translucent 就会有区别(见注释)。
因为实际场景中几乎不会出现上面这样的类似的 case,所以如果想让 Stack 的所有子组件都响应事件就必须保证 Stack 的所有孩子的 hitTest 返回 false ,虽然用 IgnorePointer 包裹所有子组件就可以做到这一点,但是 IgnorePointer 也同时不会再对子组件进行命中测试,这意味着它的子组件树也将不能响应事件,比如下面的代码运行后,点击灰色区域将不会有任何输出:
class AllChildrenCanResponseEvent extends StatelessWidget {const AllChildrenCanResponseEvent({Key? key}) : super(key: key);@overrideWidget build(BuildContext context) {return Stack(children: [IgnorePointer(child: wChild(1, 200)),IgnorePointer(child: wChild(2, 200)),],);}Widget wChild(int index, double size) {return Listener(onPointerDown: (e) => print(index),child: Container(width: size,height: size,color: Colors.grey,),);}}
虽然我们在子节点中通过监听了 Container 的事件,但是子节点是在 IgnorePointer 中的,所以子节点是没有机会参与命中测试的,所以不会响应任何事件。看来没有现成的组件可以满足要求,那我们就自己动手实现一个组件然后来定制它的 hitTest 来满足我们的要求即可。
下面我们定义一个可以拦截 hitTest 各个过程的 HitTestBlocker 组件。
class HitTestBlocker extends SingleChildRenderObjectWidget {HitTestBlocker({Key? key,this.up = true,this.down = false,this.self = false,Widget? child,}) : super(key: key, child: child);/// up 为 true 时 , `hitTest()` 将会一直返回 false.final bool up;/// down 为 true 时, 将不会调用 `hitTestChildren()`.final bool down;/// `hitTestSelf` 的返回值final bool self;@overrideRenderObject createRenderObject(BuildContext context) {return RenderHitTestBlocker(up: up, down: down, self: self);}@overridevoid updateRenderObject(BuildContext context, RenderHitTestBlocker renderObject) {renderObject..up = up..down = down..self = self;}}class RenderHitTestBlocker extends RenderProxyBox {RenderHitTestBlocker({this.up = true, this.down = true, this.self = true});bool up;bool down;bool self;@overridebool hitTest(BoxHitTestResult result, {required Offset position}) {bool hitTestDownResult = false;if (!down) {hitTestDownResult = hitTestChildren(result, position: position);}bool pass =hitTestSelf(position) || (hitTestDownResult && size.contains(position));if (pass) {result.add(BoxHitTestEntry(this, position));}return !up && pass;}@overridebool hitTestSelf(Offset position) => self;}
代码很简单,但需要读者好好根据事先理解一下。我们用 HitTestBlocker 直接替换 IgnorePointer 就可以实现所有子组件都可以响应事件了,代码如下:
@overrideWidget build(BuildContext context) {return Stack(children: [// IgnorePointer(child: wChild(1, 200)),// IgnorePointer(child: wChild(2, 200)),HitTestBlocker(child: wChild(1, 200)),HitTestBlocker(child: wChild(2, 200)),],);}
点击后,控制台会同时输出 2 和 1,原理也很简单 :
HitTestBlocker 是一个非常灵活的类,它可以拦截命中测试的各个阶段,通过 HitTestBlocker 完全可以实现IgnorePointer 和 AbsorbPointer 的功能, 比如当 HitTestBlocker 的 up 和 down 都为 true 时,功能和 IgnorePointer 相同。
我们稍微修改一下上面的代码, 将 Listener 换为 GestureDetector, 代码如下:
class GestureHitTestBlockerTest extends StatelessWidget {const GestureHitTestBlockerTest({Key? key}) : super(key: key);@overrideWidget build(BuildContext context) {return Stack(children: [HitTestBlocker(child: wChild(1, 200)),HitTestBlocker(child: wChild(2, 200)),],);}Widget wChild(int index, double size) {return GestureDetector( // 将 Listener 换为 GestureDetectoronTap: () => print('$index'),child: Container(width: size,height: size,color: Colors.grey,),);}}
可以猜一下点击后会输出什么?答案是只会输出 2 !这是因为虽然 Stack 的两个子组件都会参与且通过命中测试,但是 GestureDetector 会在事件分发阶段来决定是否响应事件(而不是命中测试阶段),GestureDetector 有一套单独的处理手势冲突的机制,这个我们将在下一节中介绍。