Flutter点击事件处理机制

一次点击一般会有先后两个事件:PointerDownEvent和PointerUpEvent,分别表示按下down和抬起up(如果手指发生了滑动,还会有PointerMoveEvent事件)。本文分别分析按下和抬起两个事件的处理流程。

备注:本文以GestureDetector为示例进行点击事件处理流程的分析,基于Flutter 2.5.3。

PointerDownEvent(按下)事件的处理流程

GestureBindingGestureBindingHitTestResultRenderPointerListenerRenderPointerListenerTapGestureRecognizerTapGestureRecognizerhandlePointerEvent(PointerDownEvent)newHitTestResult命中测试 hitTest(RendererBinding).hitTestrenderView.hitTestchild!.hitTest深度优先遍历子树add(命中测试通过的RenderBox)add(HitTestEntry(RenderView.this))add(HitTestEntry(GestureBinding.this))点击down事件分发dispatchEvent获取命中测试的target列表handleEventonPointerDown?.call(event)对应RawGestureDetectorState._handlePointerDownaddPointer(event)addAllowedPointer(event)startTrackingPointer(pointer)pointerRouter.addRoute 加入到路由中gestureArena.add(pointer, this) 添加到事件竞技场handleEventpointerRouter.route(event)handleEvent正常按键按下的情况无操作。在触摸点移出按键区域等特殊情况会做清理操作。gestureArena.close阻止新事件竞争者加入只有一个竞争者才会执行scheduleMicrotaskGestureArenaManager删除pointer id对应的竞技场     acceptGesture(pointer)唯一的竞争者此时并不会立刻触发点击事件回调,而是等待up事件的到来。

命中测试 hitTest

一次点击事件发生后,会首先进行命中测试。

命中测试hitTest在PointerDownEvent事件的处理流程中进行,从RendererBinding.hitTest开始,然后调用GestureBinding.hitTest,再调用RenderView.hitTest,RenderView会直接调用子节点的hitTest。(其实RendererBinding和GestureBinding都是对应一个WidgetsFlutterBinding类实例,属于继承关系。)RenderView的子节点是RenderBox的子类,从此之后,参与命中测试的组件都是RenderBox或者其子类。hitTest的默认处理逻辑在RenderBox中实现:

1
2
3
4
5
6
7
8
9
10
11
abstract class RenderBox extends RenderObject {
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;
}
}

处理逻辑解读:

  • 先判断点击的坐标是否在控件范围内,如果在,则表示命中测试,否则就返回,不会再对子节点进行命中测试;
  • 执行hitTestChildren判断子节点是否命中测试,如果命中就加入到HitTestResult中;
  • 最后把自己加入到HitTestResult中。

因此,hitTest采用深度优先遍历算法,先将最内部的子节点加入到HitTestResult中,最后再加入自己。命中测试会把点击坐标位置的控件都加入到命中列表中,并没有区分是否是可点击控件。

hitTestChildren

RenderBox的hitTestChildren默认实现始终返回false,RenderBox子类需要自己来实现。对于RenderProxyBox,方法在RenderProxyBoxMixin中实现:

1
2
3
4
@override
bool hitTestChildren(BoxHitTestResult result, { required Offset position }) {
return child?.hitTest(result, position: position) ?? false;
}

代码解读:

hitTestChildren会直接调用子节点的hitTest,如果没有子节点就返回false,表示节点没有命中测试。

对于具有多个子节点的RenderObject,比如Column等,处理逻辑在RenderCustomMultiChildLayoutBox.hitTestChildren中,会使用倒序的方式调用child的hitTest。很多继承自RenderObject的子类都有自己的实现,比如RenderCustomPaint、RenderFittedBox、RenderFlex等。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
bool defaultHitTestChildren(BoxHitTestResult result, { required Offset position }) {
ChildType? child = lastChild; //采用倒序,优先命中测试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;
}

点击事件分发

命中测试完成后,就会执行down事件的分发,事件会分发给命中测试列表中的各个RenderBox,执行RenderBox.handleEvent函数。默认的handleEvent实现是空实现。GestureDetector会通过RenderPointerListener来处理按键。具体流程可以参考上面的时序图。

经过事件分发后,只有真正的点击事件处理组件才会被加入到事件路由和事件竞技场中。

PointerUpEvent(抬起)事件处理流程

GestureBindingGestureBindingRenderPointerListenerRenderPointerListenerTapGestureRecognizerTapGestureRecognizerhandlePointerEvent(PointerUpEvent)删除命中测试结果dispatchEventhandleEventonPointerUp?.call(event)在GestureDetector的场景,onPointerUp为nullhandleEventpointerRouter.routehandleEventhandlePrimaryPointer只有一个竞争者时才会执行handleTapUponTap 使用者接收到onTap事件_resetstopTrackingIfPointerNoLongerDownstopTrackingPointerpointerRouter.removeRoutegestureArena.sweep处理竞技逻辑alt[存在多个竞争者,则执行竞技流程][竞技胜出]acceptGesturehandleTapUponTap 使用者接收到onTap事件_reset[竞技失败]rejectGesture_reset

PointerUpEvent表示一次点击事件周期的结束,因此除了触发控件的点击事件回调外,还做了一些清理工作,比如删除事件路由,执行reset等。还有一项很重要的工作就是处理竞技逻辑,当点击范围存在多个可点击控件时,通过竞技逻辑来决定哪个空间来处理本次点击事件。

下面是竞技场的代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class GestureArenaManager {
void sweep(int pointer) {
final _GestureArena? state = _arenas[pointer]; //根据pointer id获取竞技场
...
_arenas.remove(pointer); //删除竞技场
if (state.members.isNotEmpty) {
// First member wins.
state.members.first.acceptGesture(pointer);
// Give all the other members the bad news.
for (int i = 1; i < state.members.length; i++)
state.members[i].rejectGesture(pointer);
}
}
}

竞技逻辑的代码实现非常简单,如果某次点击存在多个竞争者,则列表的第一个胜出,其他的都失败。所以,竞技的逻辑依赖竞争者的加入顺序,第一个加入的就会赢的胜利。这个顺序就是命中测试的顺序,可以参考hitTest一节的分析。

GestureDetector相关的类图

GestureDetectorRawGestureDetectorRawGestureDetectorState_handlePointerDown(event)GestureRecognizerOneSequenceGestureRecognizerPrimaryPointerGestureRecognizerBaseTapGestureRecognizeracceptGesture(int pointer)rejectGesture(int pointer)TapGestureRecognizerLongPressGestureRecognizerListeneronPointerDownSingleChildRenderObjectWidgetRenderPointerListenerhandleEvent(event, entry)RenderProxyBoxWithHitTestBehaviorbool hitTest(...)RenderProxyBoxMixinbool hitTestChildren(...)RenderProxyBoxGestureBindingGestureArenaManagerPointerRouter1nwith

参考资料

《Flutter实战·第二版》 8.3 Flutter事件机制
深入理解Flutter手势系统