大前端最重要的两个事情,界面渲染和事件分发。下面我们通过源码和一些实例聊一聊Flutter的手势事件从接收到响应的整个过程。
以下源代码基于
- Flutter 2.5.1
- Dart 2.14.2
原始事件获取
在大前端中,大部分平台或系统原始指针处理方式都是类似的,按下、移动、抬起,其它高级事件如双击、滚动都是基于此来实现的。
在Flutter中,接收事件的入口是从_dispatchPointerDataPacket开始的,通过绑定回调后面会调到_handlePointerDataPacket开始处理事件
void _handlePointerDataPacket(ui.PointerDataPacket packet) {
_pendingPointerEvents.addAll(PointerEventConverter.expand(packet.data, window.devicePixelRatio));
if (!locked)
_flushPointerEventQueue();
}
2
3
4
5
packet是从引擎层过来的手势数据,其中packet.data是一个数组,如果在电脑PC上只有鼠标操作时它的长度是1,在移动端上比如我们按下屏幕会同时触发hover和down事件,它的长度是2,它的类型是PointerData,它有一些重要属性
- change(PointerChange): 手势变化,如常见的
hover、down、move、up等 - kind(PointerDeviceKind): 触发手势的装置,如
touch(移动设备屏幕)、mouse(鼠标)等 - signalKind(PointerSignalKind): 除了手势外的响应方式,如鼠标滚轮滚动
PointerEventConverter.expand会将原始获取的手势数据转化为PointerEvent类,不同手势类型会继承它实现不同的类
- PointerCancelEvent: 手势取消,一般用于重置正在运行中的手势,可Native触发,也可以调用
cancelPointer触发,经常用于当跳转新页面后,将原来页面正在运行的手势重置(混合开发时,经常会碰到Flutter页跳转到Native后返回,Flutter页面卡顿,就是手势没有正确取消,可以看我这篇文章解决混合栈跳转导致Flutter页面事件卡死问题 - PointerDownEvent: 当手指或鼠标左键按下触发
- PointerMoveEvent: 当手指在屏幕上滑动或鼠标左键按下滑动触发
- PointerUpEvent: 当手指移出屏幕或鼠标左键抬起时触发
- PointerRemovedEvent: 当手势按下过程中移出到屏幕外触发
- PointerScrollEvent: 一般由鼠标滚轮触发
- PointerHoverEvent: 指针在屏幕上方时触发,常用于鼠标事件,触摸屏按下也会触发
- PointerEnterEvent: 指针进入某个区域时触发,常用于鼠标事件,由
PointerHoverEvent事件触发计算获得 - PointerExitEvent: 鼠标移出某个区域时触发,常用于鼠标事件,由
PointerHoverEvent事件触发计算获得
PointerEvent也有一些重要的属性
- embedderId(int): 平台唯一标识符
- pointer(int): 标识一次完整的手势,如手指按下、手指移动、手指抬起,当一次手势完成此标识会自增
- kind(PointerDeviceKind): 标识手势监听的设备,如手指、鼠标
- position(Offset): 指针相对于屏幕位置
- delta(Offset): 指针偏移量,目前只有
PointerHoverEvent、PointerMoveEvent有偏移值 - buttons(int): 标识鼠标、手写笔等按键事件,比如按下鼠标右键时,触发
PointerDownEvent同时buttons为kSecondaryButton,它是一个十六进制,Flutter定义了一些常量来标识,我以鼠标按键为例介绍一下- kPrimaryButton(0x01): 鼠标左键
- kSecondaryButton(0x02): 鼠标右键
- kTertiaryButton(0x04): 鼠标中键
- kBackMouseButton(0x08): 鼠标返回键
- kForwardMouseButton(0x08): 鼠标前进键
- down(bool): 是否按下屏幕,如
PointerDownEvent、PointerMoveEvent的down都为true
void _flushPointerEventQueue() {
while (_pendingPointerEvents.isNotEmpty)
handlePointerEvent(_pendingPointerEvents.removeFirst());
}
void handlePointerEvent(PointerEvent event) {
if (resamplingEnabled) {
_resampler.addOrDispatch(event);
_resampler.sample(samplingOffset, _samplingClock);
return;
}
_resampler.stop();
_handlePointerEventImmediately(event);
}
2
3
4
5
6
7
8
9
10
11
12
13
上面_flushPointerEventQueue很简单,就是循环遍历已经存入的指针列表,然后执行handlePointerEvent,resamplingEnabled为true的话表示在一些特殊设备上可以给_handlePointerEventImmediately添加一些延时来让手势更加顺畅。
void _handlePointerEventImmediately(PointerEvent event) {
HitTestResult? hitTestResult;
if (event is PointerDownEvent || event is PointerSignalEvent || event is PointerHoverEvent) {
assert(!_hitTests.containsKey(event.pointer));
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) {
hitTestResult = _hitTests[event.pointer];
}
//...
if (hitTestResult != null ||
event is PointerAddedEvent ||
event is PointerRemovedEvent) {
assert(event.position != null);
dispatchEvent(event, hitTestResult);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
_handlePointerEventImmediately过程有几个部分,当event is PointerDownEvent || event is PointerSignalEvent || event is PointerHoverEvent时,会进行有hitTest过程,同时如果event是PointerDownEvent时,会将获得的hitTest结果缓存到_hitTests中。hitTest通常叫作命中测试,它是对事件发生范围内的组件进行收集,PointerDownEvent、PointerSignalEvent、PointerHoverEvent这三种类型的手势都是需要接触到屏幕且涉及到指针的变化.
当event is PointerUpEvent || event is PointerCancelEvent时,代表一次手势的结束,所以它会将result从_hitTests中移除并赋值给hitTestResult,值得注意的是这里就是通过pointer属性来确定其对应的PointerDownEvent拿到的命中测试结果的。
当event.down为true时,目前只有一种手势会进入到这里,就是PointerMoveEvent,为什么同样都是在屏幕上操作,它不应该也需要进行hitTest吗。在手势中,一般地『手指按下』、『手指移动』、『手指抬起』被认为是一次完整的手势过程,手指移动虽然位置会改变,但是它所作用的区域被认为还是在按下的区域,所以只要把按下区域的result拿来用就行了。
hitTest(命中测试)
在Flutter中,有个类似前端的事件冒泡机制叫命中测试(Hit Test),它的作用是根据事件的位置收集所有在该位置上的Widget,它先从顶部节点开始对所有RenderObject(如果对RenderObject树不了解可以先看看我的另一篇文章)进行深度遍历,然后从最底层的节点开始向上将每个节点收集到一个为HitTestResult类型的result变量中,最后根据该result进行事件分发。
例子
我以一个例子带大家看看hitTest过程Flutter底层到底做了些什么。
import 'package:flutter/material.dart';
void main() {
runApp(MyHomePage(
key: UniqueKey(),
));
}
class MyHomePage extends StatelessWidget {
const MyHomePage({Key? key}) : super(key: key);
Widget build(BuildContext context) {
return ColoredBox(
color: Colors.white,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Listener(
onPointerDown: (p) {
print('down');
},
child: const ColoredBox(
color: Colors.green,
child: SizedBox(
width: 100,
height: 100,
),
),
),
Listener(
onPointerUp: (p) {
print('up');
},
child: const ColoredBox(
color: Colors.yellow,
child: SizedBox(
width: 100,
height: 100,
),
),
),
],
),
);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
当我点击上面绿色的方块时,命中测试会有以下过程

上面组件会生成如图中的RenderObject树,在RendBinding中会去调用RenderView的hitTest,RenderView会先对子组件进行收集,子组件是_RenderColorBox,在看_RenderColorBox的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;
}
2
3
4
5
6
7
8
9
它先简单判断点击位置(position)是否在当前节点范围中(需要注意的是传入的position已经是经过转化相对于当前节点的位置了),这是hitTest的关键,如果发生的事件不在组件范围,hitTest直接就失败了,就不用再遍历子组件了。
hitTestChildren
然后先调用hitTestChildren,它在这里很简单,作用是对子组件进行命中测试,如果子组件返回了true,说明子组件命中测试成功。
hitTestChildren默认返回false,因为可能当前组件没有子组件了。Flutter中比较典型的场景是当前组件存在一个子组件或多个子组件,当只有一个子组件时,大部分组件会使用RenderProxyBoxMixin这个mixin,比如SizedBox、Opacity、ColoredBox等,它的hitTestChildren是
bool hitTestChildren(BoxHitTestResult result, { required Offset position })
return child?.hitTest(result, position: position) ?? false;
}
2
3
很简单,它直接调用子组件的hitTest。当有多个子组件时,比如Column、Row、Stack、Wrap、ListBody等,它们都是自己重写了hitTestChildren,然后直接调用了RenderBoxContainerDefaultsMixin中的defaultHitTestChildren这个方法
bool defaultHitTestChildren(BoxHitTestResult result, { required Offset position }) {
ChildType? child = lastChild;
while (child != null) {
final ParentDataType childParentData = child.parentData! as ParentDataType;
final bool isHit = result.addWithPaintOffset(
offset: childParentData.offset,
position: position,
hitTest: (BoxHitTestResult result, Offset? transformed) {
assert(transformed == position - childParentData.offset);
return child!.hitTest(result, position: transformed!);
},
);
if (isHit)
return true;
child = childParentData.previousSibling;
}
return false;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
简单解释一下,它是从最后一个子组件开始,向前遍历,result.addWithPaintOffset是计算当前子节点的偏移位置,因为传入的position是相对于父节点的偏移位置。如果一个子组件命中测试返回true,那么该hitTestChildren就直接返回true,不再遍历前面的组件了,这也是如果两个重叠的Stack子组件,上面的组件能接收到事件,而下面的组件接收不到事件的原因。
如果我想让下面的组件也能接收到事件怎么办呢,这就是behavior属性该发挥作用了。
hitTestSelf
我们假设所有组件判断自己是否可通过命中测试都是依赖子组件,而看上面我们知道如果没有子组件,hitTestChildren会返回false,那就没有组件能通过命中测试,那么整个组件树hitTest都是返回false,hitTestSelf就是解决这个问题。
在hitTest过程中,如果hitTestSelf返回true就是告诉它,我是能接收事件命中的,让我来响应手势事件吧。在Flutter组件中有很多组件是直接将hitTestSelf返回true的,如MouseRegion、Texture、Image、EditableText等组件的RenderObject对应的hitTestSelf。有些组件是需要简单的判断,如Listener、ColoredBox组件使用的RenderProxyBoxWithHitTestBehavior这个mixin
bool hitTestSelf(Offset position) => behavior == HitTestBehavior.opaque;
它是通过behavior是否为HitTestBehavior.opaque来确定自己是否返回true。behavior这个东西又出现了,下面我们就来看看
behavior
hitTest过程如果通过其实就做了两个事情,我们将这两个事情取名为A和B,A是将自己添加到HitTestResult result中,这样我就能响应事件了,二是告诉父组件的hitTestChildren返回true。从上面RenderBox中的hitTest我们可以看到,这两个事情都是同时发生的,那么有没有可能A发生B不发生。答案就在RenderProxyBoxWithHitTestBehavior这个mixin中。
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)
result.add(BoxHitTestEntry(this, position));
}
return hitTarget;
}
bool hitTestSelf(Offset position) => behavior == HitTestBehavior.opaque;
2
3
4
5
6
7
8
9
10
其中behavior这个属性发挥了重要的作用,它的类型HitTestBehavior是一个枚举,有三种情况
- HitTestBehavior.deferToChild
deferToChild是默认行为,事件在组件范围内发生时,当前组件是否添加进命中测试由其子组件来决定。也就是子组件通过测试它才通过,子组件会影响当前组件的命中结果。
- HitTestBehavior.opaque
opaque表示当事件在组件范围内发生时,不管子组件能否通过命中测试,当前组件都能通过。看上面hitTestSelf部分,只要behavior为HitTestBehavior.opaque,hitTestSelf就返回true。
- HitTestBehavior.translucent
translucent就是比较神奇的存在了,当子组件没有通过命中测试返回了false,当前组件也能将自己添加到result中,但是却告诉了父组件当前组件没有通过命中测试,也就是它做了A,却没有做B。
它们分别有什么作用和区别呢,下面我们通过几个示例来看看HitTestBehavior几个属性的作用。
void main() {
runApp(MyHomePage());
}
class MyHomePage extends StatelessWidget {
const MyHomePage({Key? key}) : super(key: key);
Widget build(BuildContext context) {
return Center(
child: Listener(
onPointerDown: (_) {
print("down");
},
child: IgnorePointer(
child: ColoredBox(
color: Colors.red,
child: SizedBox(
width: 100,
height: 100,
),
),
),
),
);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
运行后会得到下面界面

简单解释一下这里IgnorePointer的作用,为了有点击区域的概念,我使用了ColoredBox和SizedBox,让界面中间出现一个红色区域,但ColoredBox它本身就能接收hitTest(hitTestSelf为true),为了不受它的影响,我套了一个IgnorePointer让Listener子组件不能通过命中测试。
上面Listener组件的behavior默认为HitTestBehavior.deferToChild,所以无论怎么点击红色区域,上面onPointerDown都不会有日志输出。
我们稍微改一下
Listener(
behavior: HitTestBehavior.opaque,
onPointerDown: (_) {
print("down");
},
//...
)
2
3
4
5
6
7
现在再来点击红色区域,发现输出了down的日志。在我们日常开发中,经常会遇到一个情况,我们使用Container组件没有设置颜色时,在它外层添加的GestureDetector点击范围可能只有在文字上,当我们给它设置了个透明色后就正常了,还是因为添加了颜色后,Container组件会嵌套一个ColoredBox,ColoredBox自己有通过命中测试的能力,所以就正常了,其实更好的做法是在GestureDetector上添加HitTestBehavior.opaque。
我们再将上面例子改一下
class MyHomePage extends StatelessWidget {
const MyHomePage({Key? key}) : super(key: key);
Widget build(BuildContext context) {
return Center(
child: Stack(
textDirection: TextDirection.ltr,
children: [
Listener(
behavior: HitTestBehavior.opaque,
onPointerDown: (_) {
print("down1");
},
child: IgnorePointer(
child: ColoredBox(
color: Colors.red,
child: SizedBox(
width: 100,
height: 100,
),
),
),
),
Positioned(
top: 50,
child: Listener(
behavior: HitTestBehavior.opaque,
onPointerDown: (_) {
print("down2");
},
child: IgnorePointer(
child: ColoredBox(
color: Colors.blue,
child: SizedBox(
width: 100,
height: 50,
),
),
),
),
),
],
),
);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47

下面蓝色方块是覆盖在红色方块上的,我们点击蓝色方块,会输出down2,但怎么都不会输出down1,如果我们将上面蓝色方块上的behavior设置成HitTestBehavior.translucent,将会输出
down2
down1
2
这样,不仅蓝色方块接收到了down事件,而且事件还传递到了下面的红色方块上,让红色方块也接收到了事件。至于为什么是先down2再down1这个顺序,我们看看事件分发过程。
事件分发
dispatchEvent
// rendering/binding
// from GestureBinding
void dispatchEvent(PointerEvent event, HitTestResult? hitTestResult) {
// 处理鼠标如`PointerExitEvent`、`PointerEnterEvent`等事件
_mouseTracker!.updateWithEvent(
event,
() => (hitTestResult == null || event is PointerMoveEvent) ? renderView.hitTestMouseTrackers(event.position) : hitTestResult,
);
super.dispatchEvent(event, hitTestResult);
}
// gesturing/binding
void dispatchEvent(PointerEvent event, HitTestResult? hitTestResult) {
if (hitTestResult == null) {
assert(event is PointerAddedEvent || event is PointerRemovedEvent);
try {
pointerRouter.route(event);
} catch (exception, stack) {
//...
}
return;
}
for (final HitTestEntry entry in hitTestResult.path) {
try {
entry.target.handleEvent(event.transformed(entry.transform), entry);
} catch (exception, stack) {
//...
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
当hitTestResult为null,会进行pointerRouter.route,pointerRouter可以接收framework其它任何位置提供的回调,为引擎其它地方提供一个可以接收事件通知的能力。
如果hitTestResult不为空,将会循环遍历命中测试拿到的result,执行其handleEvent方法。由于这里循环是数组从左往右执行,所以命中测试和事件分发是先进先出原则,这也就解释了为什么上面示例会先输出down2再输出down1。
handleEvent
handleEvent是HitTestTarget上的方法,RenderObject实现了HitTestTarget,所以所有的RenderObject类及子类上都可以通过实现handleEvent来接收到事件响应。
Flutter有很多组件自定义了handleEvent来处理比较复杂的事件,我们先先来看看Listener的handleEvent
void handleEvent(PointerEvent event, HitTestEntry entry) {
if (event is PointerDownEvent)
return onPointerDown?.call(event);
if (event is PointerMoveEvent)
return onPointerMove?.call(event);
if (event is PointerUpEvent)
return onPointerUp?.call(event);
if (event is PointerHoverEvent)
return onPointerHover?.call(event);
if (event is PointerCancelEvent)
return onPointerCancel?.call(event);
if (event is PointerSignalEvent)
return onPointerSignal?.call(event);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
它的实现很简单,就是根据事件类型直接返回给Listener传入的回调,所以Listener也可以叫做原始事件监听器,像我们常用的GestureDetector、Scrollable(ListView、PageView等都是基于它实现的)都是基于Listener实现的。
再看看GestureBinding上handleEvent的实现
// from HitTestTarget
void handleEvent(PointerEvent event, HitTestEntry entry) {
pointerRouter.route(event);
if (event is PointerDownEvent) {
gestureArena.close(event.pointer);
} else if (event is PointerUpEvent) {
gestureArena.sweep(event.pointer);
} else if (event is PointerSignalEvent) {
pointerSignalResolver.resolve(event);
}
}
2
3
4
5
6
7
8
9
10
11
GestureBinding是每次命中测试最后都会被添加到HitTestResult result中的,所以上面的handleEvent方法每次事件分发过程的最后都会执行一次,pointerRouter.route跟hitTestResult为null时一样。gestureArena提供一个手势竞争场,这个后面将GestureDetector组件时再细讲。pointerSignalResolver目前只是给鼠标滚轮事件使用的,鼠标滚轮事件没有竞争场概念,所以它提供一个能让鼠标事件能够竞争的机会,比如当多个如ListView嵌套时,当发生PointerSignalEvent事件时,所有ListView都会都滚动,这显然是不对的,pointerSignalResolver就能解决这个问题。
总结
在Flutter中,当手指或鼠标接触屏幕到对应组件收到事件响应有两个过程,命中测试和事件分发,命中测试用于收集那些可以收到事件的组件,事件分发用于执行对应组件的接收事件函数,这样组件就可以在接收到事件后处理对应的逻辑。Listener是一个非常原始的事件监听组件,我们很多复杂的手势都可以基于它来实现。我突然想到一个问题,当我触摸移动一段距离再抬起时,PointerDownEvent、PointerMoveEvent、PointerUpEvent会依次触发,按照Listener组件,这时会同时触发点击事件和拖动事件,这显然不太合理,按照常理这时只会触发拖动事件,为了解决这个问题,Flutter引入了手势竞争的概念,我们后面再继续分析。