Flutter事件处理流程
Flutter事件处理流程主要分为两部。
- 命中测试:当手指按下时,触发PointerDownEvent事件,按照深度优先遍历当前渲染(render object)树,对每一个渲染对象进行命中测试(hitTest),如果命中测试通过,则该渲染对象会被添加到一个HitTestResult列表中。
- 事件分发:命中测试完成后,会遍历HitTestResult列表,调用每一个渲染对象的事件处理方法(handleEvent)来处理PointerDownEvent事件,该过程称为事件分发(event dispatch)。随后当手指移动时,便会分发PointerMoveEvent事件。
- 事件清理:当手指抬起(PointerUpEvent)或事件取消时(PointerCancelEvent),会先对相应的事件进行分发,分发完毕后会清空HitTestResult列表。
注意:
命中测试是在PiinterDownEvent事件触发时进行的,一个完整的事件流是down->move->up(cancel)。 如果父子组件都监听了同一个事件,则子组件会比父组件优先响应事件。这是因为命中测试过程是按照深度优先规则遍历的,所以子渲染对象会比父渲染对象先加入HitTestResult列表,又因为在事件分发时是从前到后遍历HitTestResult列表的,所以子组件比父组件会更先被调用handleEvent。
// 触发新事件时,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);
}
}
命中测试详解
- 命中测试的起点 一个对象是否可以响应事件,取决于在其对命中测试过程中是否被添加到了HitTestResult列表,如果没有被添加进去,则后续的事件分发将不会分发给自己。命中测试过程:当发生用户事件时,Flutter会从根节点(RenderView)开始调用它hitTest()。
@override
void hitTest(HitTestResult result, Offset position) {
//从根节点开始进行命中测试
renderView.hitTest(result, position: position);
// 会调用 GestureBinding 中的 hitTest()方法,我们将在下一节中介绍。
super.hitTest(result, position);
}
核心代码只有两行,整体是命中测试分两步:
第一步:renderView是RenderView对应的RenderObject对象,RenderObject对象的hitTest方法主要功能是:从该节点出发,按照深度优先的顺序递归遍历子树(渲染树)上的每一个节点,并对它们进行命中测试。这个过程称为渲染树命中测试。
注意:命中测试逻辑在RenderObject中,并非在Widget或Element中。
第二步:渲染树命中测试完毕后,会调用GestureBinding的hitTest方法,该方法主要用户处理手势。 2. 渲染树命中测试过程 渲染树命中测试流程就是父节点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即可,如果一个渲染对象有多个子节点,则命中测试逻辑为:如果任意一个子节点通过了命中测试或者当前节点强行声明自己通过了命中测试,则当前节点会通过命中测试。
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,当然这样做并不好,在自定义组件时应尽可能避免。但是有些需要自定义命中测试流程的场景下可能就需要打破这种默契。
整体逻辑就是:
- 先判断事件的触发位置是否位于组件范围内,如果不是则不会通过命中测试,此时hitTest返回false,如果是则进入第二步
- 会先调用hitTestChildren()判断是否子节点通过命中测试,如果是,则将当前节点添加到HitTestResult列表,此时hitTest返回Ture,即只要有子节点通过了命中测试,那么它的父节点(当前节点)也会通过命中测试。
- 如果没有子节点通过命中测试,则会取hitTestSelf方法的返回值,如果返回值为True,则当前节点通过命中测试,反之则否。
如果当前节点有子节点通过了命中测试或者当前节点自己通过了命中测试,则将当前节点添加到HitTestResult中,又因为hitTestChildren()中会递归用子组件的hitTest()方法,所以组件树的命中测试顺序深度优先,即如果通过命中测试,子组件会比父组件优先被加入HitTestResult中。
@protected
bool hitTestChildren(HitTestResult result, { Offset position }) => false;
@protected
bool 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,则终止遍历,直接返回true
if (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返回true导致父节点hitTestChildren也会返回true,最终会导致父节点的hitTest返回Ture,父节点被添加到HitTestResult中。
当子节点的hitTest返回false时,继续遍历该子节点前面的兄弟节点,对它们进行命中测试,如果所有的子节点都返回false时,则父节点会调用自身的hitTestSelf方法,如果该方法也返回false,则父节点会被认为没有通过命中测试。
思考:
- 为什么要指定中断机制? 因为一般情况下兄弟节点占用的布局空间是不重合的,因此当用户点击坐标位置只会有一个节点,所以一旦找到后(通过命中测试,hitTest返回true),就没有必要再继续判断其他兄弟节点。但是如果在布局中使用了Stack,则兄弟节点的布局空间会重叠,如果想让位于底部的组件也能响应事件,就需要有一种机制,能确保:即使找到了一个节点,也不应该终止遍历,也就是所有的子组件的hitTest方法都必须返回false!为此,Flutter通过HitTestBehavior来定制这个过程。
- 为什么兄弟节点的遍历需要倒序? 同1中所述,兄弟节点一般不会重叠,而一旦发生重叠的话,往往是后面的组件会在前面的组件之上,点击时应该是后面的组件会响应事件,而前面被遮挡的组件不能响应,所以命中测试应该优先对后面的节点进行测试,因为一旦通过测试,就不会再继续遍历,如果按照正向遍历,则会出现被遮住的组件能响应事件,而位于上面的组件反而不能,这不符合预期。
总结:
- 如果不重写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方法就可以处理事件了。
HitTestBehavior
先实现一个能够监听PointDownEvent的组件。
class PointerDownListener extends SingleChildRenderObjectWidget {
PointerDownListener({Key? key, this.onPointerDown, Widget? child})
: super(key: key, child: child);
final PointerDownEventListener? onPointerDown;
@override
RenderObject createRenderObject(BuildContext context) =>
RenderPointerDownListener()..onPointerDown = onPointerDown;
@override
void updateRenderObject(
BuildContext context, RenderPointerDownListener renderObject) {
renderObject.onPointerDown = onPointerDown;
}
}
class RenderPointerDownListener extends RenderProxyBox {
PointerDownEventListener? onPointerDown;
@override
bool hitTestSelf(Offset position) => true; //始终通过命中测试
@override
void 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);
@override
Widget build(BuildContext context) {
return PointerDownListener(
child: Text('Click me'),
onPointerDown: (e) => print('down'),
);
}
}
Listener的实现和PointerDownListener的实现原理差不多,有两点不同:
- Listener监听的事件类型更多一些
- Listener的hitTestSelf并不是一直都是返回true
Listener组件有一个behavior参数,这里介绍一下。发现它渲染对象RenderPointerListener继承了RenderProxyBoxWithHitTestBehavior类:
abstract class RenderProxyBoxWithHitTestBehavior extends RenderProxyBox {
//[behavior] 的默认值为 [HitTestBehavior.deferToChild].
RenderProxyBoxWithHitTestBehavior({
this.behavior = HitTestBehavior.deferToChild,
RenderBox? child,
}) : super(child);
HitTestBehavior behavior;
@override
bool 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) //1
result.add(BoxHitTestEntry(this, position)); // 通过命中测试
}
return hitTarget;
}
@override
bool hitTestSelf(Offset position) => behavior == HitTestBehavior.opaque; //2
}
可以看到behavior在hitTest和hitTestSelf中会使用,它的取值会影响Listener的命中测试结果。
//在命中测试过程中 Listener 组件如何表现。
enum HitTestBehavior {
// 组件是否通过命中测试取决于子组件是否通过命中测试
deferToChild,
// 组件必然会通过命中测试,同时其 hitTest 返回值始终为 true
opaque,
// 组件必然会通过命中测试,但其 hitTest 返回值可能为 true 也可能为 false
translucent,
}
behavior三个值的作用:
- behavior为deferToChild时,hitTestSelf返回false,当前组件是否能通过命中测试完全取节育hitTestChildren的返回值。也就是说需要有一个子节点通过命中测试,则当前组件便会通过命中测试。
- behavior为opaque时,hitTestSelf返回true,hitTarget值始终为true,当前组件通过命中测试。
- behavior为translucent时,hitTestSelf返回false,hitTarget值此时取决于hitTestChildren的返回值,但是无论hitTarget值是什么,当前节点都会被添加到HitTestResult中。
注意:behavior为opaque和translucent时,当前组件都会通过命中测试,它们的区别是hitTest返回值hitTarget可能不同,所以它们的区别就看hitTest的返回值会影响什么。
实例:
class WaterMaskTest extends StatelessWidget {
const WaterMaskTest({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Stack(
children: [
wChild(1, Colors.white, 200),
Container(
width: double.infinity,
height: double.infinity,
color: Colors.purple.withOpacity(0.3),//为了显示下层视图,添加透明背景色
),
],
);
}
Widget wChild(int index, color, double size) {
return Listener(
onPointerDown: (e) => print(index),
child: Container(
width: size,
height: size,
color: Colors.grey,
),
);
}
}
- 点击时Stack有两个组件,这时会先对第二个组件(上层的透明色组件)进行点击测试。
- 上层组件通过命中测试后就会导致Stack的hitTestChildren直接返回,所以Stack的第一个组件(灰色组件)将不会参与命中测试,因此就不会响应事件。
解决方法就是让第一个子组件也能参与命中测试,此时可以将第二个子组件的hitTest返回false即可。使用IgnorePointer包裹就行。修改后发现点击事件可以响应了。
如果想让Stack的所有子组件都响应事件,应该如何处理?
如果将Listener的behavior属性指定为opaque或translucent呢?结果还是一样,因为只要Container的hitTest会返回true,最终Listener的hitTestChildren就会返回true,第一个组件就不会再进行命中测试。直接粗暴的方式就是将其他的组件都用IgnorePointer包裹。但是这样会显的很low。
可以通过自定义组件实现:
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;
@override
RenderObject createRenderObject(BuildContext context) {
return RenderHitTestBlocker(up: up, down: down, self: self);
}
@override
void 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;
@override
bool 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));
}
由于默认up为true所以命中测试会返回false
return !up && pass;
}
@override
bool hitTestSelf(Offset position) => self;
}
接下来直接使用HitTestBlocker替换IgnorePointer:
@override
Widget 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)),
],
);
}
可以看到输出台会同时输出1和2:
- HitTestBlocker的hitTest会返回false,这样可以保证stack的所有子节点都能参与命中测试
- HitTestBlocker的hiTest中又会调用hitTestChildren,所以HitTestBlicker的后代节点有机会参与命中测试。
HitTestBlicker是一个非常灵活的类,可以拦截命中测试的各个阶段,通过HitTestBlicker完全可以实现IgnorePointer和AbsorbPointer的功能,比如当HitTestBlocker的up和down都会true时,功能和IgnorePointer相同。
手势存在的情况
将上面代码Listener换为GestureDetector:
class GestureHitTestBlockerTest extends StatelessWidget {
const GestureHitTestBlockerTest({Key? key}) : super(key: key);
@override
Widget 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 换为 GestureDetector
onTap: () => print('$index'),
child: Container(
width: size,
height: size,
color: Colors.grey,
),
);
}
}
代码只会输出2,Stack的两个子组件都会参与且通过命中测试,但是GestureDetector会在事件分发阶段来决定是否响应事件(而不是命中测试阶段),GestureDetector有一套单独的处理手势冲突的机制。
总结
- 组件只有通过命中测试才能响应事件
- 一个组件是否通过命中测试取决于hitTestChildren()||hitTestSelf()的值
- 组件树中组件的命中测试顺序是深度优先
- 组件子节点命中测试的顺序是倒序,并且一旦有一个子节点的hitTest返回true,就会终止遍历,后续子节点将没机会参与命中测试。
- 大多数情况下Listener的HitTestBehavior为opaque或translucent效果是相同的,只有当其子节点的hitTest返回为false时才会有区别
- HitTestBlocker是一个灵活的组件,可以通过它干预命中测试的各个阶段