一文理解Flutter的事件机制

1,065 阅读15分钟

与用户的事件交互是flutter在日常工作中主要处理的问题之一,Flutter是如何监听并分发事件,又是如何处理事件间的冲突?本文将从一个简单的菜单组件出发,以点击事件的处理为例,与读者一起探寻flutter的事件机制。对事件原理的掌握,有助于我们在各类多事件的组件编写时,更加得心应手地控制事件和处理事件冲突。

1.一个小问题

悬浮的菜单是我们在日常需求中遇到的最常见的组件之一,比如我们在下拉选择器中就会使用到该组件,下图是企业微信Mac中全局搜索(Flutter实现)的文件筛选中的下拉选择器

5.gif

其中就用到了【菜单】这一组件,它的基本结构一般由三个部分组成:

  • Overlay: 覆盖整个屏幕区域的,透明或半透明的遮罩层
  • ListView: 在悬浮层上装载列表项的容器
  • ListItem: 列表的具体项目,可进行点击交互

image.png 其基本交互逻辑是,用户点击按钮(或者其他触发事件),触发覆盖全屏幕的菜单浮层,浮层中在按钮下方固定一个可选择的菜单。点击除菜单以外的透明浮层区域,关闭浮层。点击菜单的选项,触发点击回调函数,关闭浮层。在菜单这个组件中,我们关注其中的两个基本事件: 1.列表项的点击事件,点击后触发回调函数给外层,告知选择了哪一个选项 2.悬浮层的点击事件,点击后关闭当前的悬浮层。 为了专注于探究事件,我们将其抽象为一个父元素(悬浮层)用手势监听器GestureDetector绑定点击事件2,和一个子元素(菜单列表)同样也用GestureDetector绑定点击事件1,具体代码如下所示。 我们期望的交互流程是这样的:当用户点击列表项目时,会同时触发事件1和事件2,完成菜单项目的选择并关闭菜单。

GestureDetector( //GestureDetector2
  onTapUp: (x)=>print("2"), // 监听父组件 tapUp 手势
  child: Container(
    width:200,
    height: 200,
    color: Colors.blueGrey,
    alignment: Alignment.center,
    child: GestureDetector( //GestureDetector1
      onTapUp: (x)=>print("1"), // 监听子组件 tapUp 手势
      child: Container(
        width: 50,
        height: 50,
        color: Colors.lightBlue,
      ),
    ),
  ),
);

image.png 但当你将这段代码执行后会发现,只有事件1被触发了,父元素的手势监听器似乎并没有起作用,这就意味着选择菜单选项之后,无法同时关闭悬浮层,当然你也可以在回调函数中增加关闭浮层这一步来解决这个问题,但浮层的操作往往相对比较独立,这有悖于高内聚的原则,并容易引发其他的严重bug。 如果你曾经参与过Web的开发,那么一定对Web中的事件冒泡和捕获机制略有耳闻,那么在flutter中有没有对应的机制呢?答案是肯定的,接下来我们就来详细分析flutter中的事件究竟如何实现监听和分发。

2.事件的监听与分发

在我们上面的代码举例中,我们用手势监听器GestureDetector来监听用户的点击事件,这是Flutter开发过程中最常见的Widget之一,但手势监听器其实也是基于Flutter中的监听器Listener来封装的,Listener是官方提供的原始指针Widget,可以用它来监听原始触摸或点击事件,下面是它的的构造函数定义:

// 触发新事件时,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);
  }
}

我们以前面的菜单组件为例,当在菜单选项的位置按下指针时,Flutter会对应用程序执行命中测试HitTest,以确定指针与屏幕接触的位置存在哪些组件Widget, 指针按下事件(以及该指针的后续事件)然后被分发到由命中测试发现的最内部的组件RenderView,然后从那里开始,事件会在组件树中向上冒泡,这些事件会从最内部的组件被分发到组件树根的路径上的所有组件,这和Web开发中浏览器的事件冒泡机制相似。 在Flutter中,组件只要通过了命中测试,就可以触发事件,整体的过程分为三步:

  • 命中测试:当手指按下时,触发PointerDownEvent事件,按照深度优先遍历当前渲染Render Object树,对每一个渲染对象进行“命中测试”hitTest,如果命中测试通过,则该渲染对象会被添加到一个hitTestResult列表当中。
  • 事件分发:命中测试完毕后,会遍历hitTestResult列表,调用每一个渲染对象的事件处理方法handleEvent来处理PointerDownEvent事件,该过程称为“事件分发”Event dispatch。随后当手指移动时,便会分发PointerMoveEvent事件。
  • 事件清理:当手指抬起PointerUpEvent或事件取消时PointerCancelEvent,会先对相应的事件进行分发,分发完毕后会清空HitTestResult列表。

image.png 需要注意:

  • 命中测试是在 PointerDownEvent 事件触发时进行的,一个完成的事件流是 down > move > up (cancel)。
  • 如果父子组件都监听了同一个事件,则子组件会比父组件先响应事件。这是因为命中测试过程是按照深度优先规则遍历的,所以子渲染对象会比父渲染对象先加入 hitTestResult 列表,又因为在事件分发时是从前到后遍历 hitTestResult 列表的,所以子组件比父组件会更先被调用 handleEvent

2.1 命中测试流程

渲染树的命中测试流程就是父节点 hitTest 方法中不断调用子节点 hitTest 方法的递归过程。下面是RenderViewHitTest()源码:

// 发起命中测试,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为例,看看它的hitText实现:

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,当然这样做并不好,我们在自定义组件时应该尽可能避免。 所以整体逻辑就是:

  1. 先判断事件的触发位置是否位于组件范围内,如果不是则不会通过命中测试,此时 hitTest 返回 false,如果是则到第二步。
  2. 会先调用hitTestChildren()判断是否有子节点通过命中测试,如果是,则将当前节点添加到 hitTestResult 列表,此时 HitTest 返回 true。即只要有子节点通过了命中测试,那么它的父节点(当前节点)也会通过命中测试。
  3. 如果没有子节点通过命中测试,则会取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 返回 true,父节点被添加到 HitTestResult 中。

当子节点的 hitTest() 返回了 false 时,继续遍历该子节点前面的兄弟节点,对它们进行命中测试,如果所有子节点都返回 false 时,则父节点会调用自身的 hitTestSelf 方法,如果该方法也返回 false,则父节点就会被认为没有通过命中测试。 如果 hitTestSelf 返回 true,则无论子节点中是否有通过命中测试的节点,当前节点自身都会被添加到hitTestResult中。flutter中的IgnorePointerAbsorbPointer正是在这里实现了其拦截事件或者一定触发事件的功能,二者的区别就是,IgnorePointerhitTestSelf 返回了 false,而AbsorbPointer返回了 true。 命中测试完成后,所有通过命中测试的节点都被添加到了 hitTestResult 中。

  • 为什么要制定这个children的中断呢?因为一般情况下兄弟节点占用的布局空间是不重合的,因此当用户点击的坐标位置只会有一个节点,所以一旦找到它后(通过了命中测试,hitTest返回true),就没有必要再判断其他兄弟节点了。在下图中我们可以看到,当用户点击了child2,找到了child2就没必要去遍历下面的其他child了。但是也有例外情况,比如在 Stack 布局中,兄弟组件的布局空间会重叠,如果我们想让位于底部的组件也能响应事件,就得有一种机制,能让我们确保:即使找到了一个节点,也不应该终止遍历,也就是说所有的子组件的 hitTest 方法都必须返回 false!为此,Flutter 中通过HitTestBehavior 来定制这个过程。

image.png

  • 为什么兄弟节点的遍历要倒序?同 1 中所述,兄弟节点一般不会重叠,而一旦发生重叠的话,往往是后面的组件会在前面组件之上,如同我们在下图所示,点击时应该是后面的组件会响应事件,即child3先响应,而不是child1,所以命中测试应该优先对后面的节点进行测试,因为一旦通过测试,就不会再继续遍历了。如果我们按照正向遍历,则会出现被遮住的组件能响应事件,而位于上面的组件反而不能,这明显不符合预期。

image.png

2.2 事件分发阶段

在事件监听阶段我们已经将事件推入了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方法就可以处理事件了。 不过话又说回来,flutter的官方文档中其实是不建议我们直接使用Listener来处理事件的,因为这是最原始的指针监听组件,在分发上的处理较为简单。所以官方在此基础之上封装了GestureDetector组件,用于监听和分发各类手势事件,也就是我们最开始使用的手势监听器。 GestureDetector 是一个StatelessWidget, 包含了 RawGestureDetector,我们看一下它的 build 方法实现:

@override
Widget build(BuildContext context) {
  ... // 省略无关代码
  Widget result = Listener(
    onPointerDown: _handlePointerDown,
    behavior: widget.behavior ?? _defaultBehavior,
    child: widget.child,
  );
}  
 
void _handlePointerDown(PointerDownEvent event) {
  for (final GestureRecognizer recognizer in _recognizers!.values)
    recognizer.addPointer(event);
}

其中最主要的部分就是手势识别器GestureRecognizer,在flutter中有许许多多的手势识别器,我们以最常见的点击手势识别器 TapGestureRecognize为例,来看看他如何实现事件的分发,由于 TapGestureRecognizer 有多层继承关系,这里列出一个简化版:

class CustomTapGestureRecognizer1 extends TapGestureRecognizer {

  void addPointer(PointerDownEvent event) {
    //会将 handleEvent 回调添加到 pointerRouter 中
    GestureBinding.instance!.pointerRouter.addRoute(event.pointer, handleEvent);
  }
  
  @override
  void handleEvent(PointerEvent event) {
    //会进行手势识别,并决定是是调用 acceptGesture 还是 rejectGesture,
  }
  
  @override
  void acceptGesture(int pointer) {
    // 竞争胜出会调用
  }

  @override
  void rejectGesture(int pointer) {
    // 竞争失败会调用
  }
}

可以看到当 PointerDownEvent 事件触发时,会调用 TapGestureRecognizeraddPointer,在 addPointer 中会将 handleEvent 方法添加到 pointerRouter 中保存起来。这样一来当手势发生变化时只需要在 pointerRouter中取出 GestureRecognizerhandleEvent 方法进行手势识别即可。 正常情况下应该是手势直接作用的对象应该来处理手势,所以一个简单的原则就是同一个手势应该只有一个手势识别器生效,为此,手势识别才映入了手势竞技场Arena的概念,简单来讲:

  1. 每一个手势识别器GestureRecognizer都是一个“竞争者”GestureArenaMember,当发生指针事件时,他们都要在“竞技场”去竞争本次事件的处理权,默认情况最终只有一个“竞争者”会胜出(win)。
  2. GestureRecognizerhandleEvent 中会识别手势,如果手势发生了某个手势,竞争者可以宣布自己是否胜出,一旦有一个竞争者胜出,竞技场管理者GestureArenaManager就会通知其他竞争者失败。
  3. 胜出者的 acceptGesture 会被调用,其余的 rejectGesture 将会被调用。
@override 
void handleEvent(PointerEvent event, HitTestEntry entry) {
  // 会调用在 pointerRouter 中添加的 GestureRecognizer 的 handleEvent
  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);
  }
}

gestureArenaGestureArenaManager 类实例,负责管理竞技场。 上面关键的代码就是第一行,调用之前在 pointerRouter 中添加的 GestureRecognizerhandleEvent,不同 GestureRecognizerhandleEvent 会识别不同的手势,然后它会和 gestureArena 交互(如果当前的 GestureRecognizer 胜出,需要 gestureArena 去通知其他竞争者它们失败了),最终,如果当前GestureRecognizer 胜出,则最终它的 acceptGesture 会被调用,如果失败则其rejectGesture 将会被调用,因为这部分代码不同的 GestureRecognizer 会不同,知道做了什么就行,读者有兴趣可以自行查看源码。

3.问题的解决

现在我们回到最开始的问题,现在我们知道,当我们点击子组件只会触发子组件的事件1而不会触发父组件的事件2,是因为手指抬起后,GestureDetector1GestureDetector2 会发生竞争,判定获胜的规则是“子组件优先”,所以 GestureDetector1 获胜,因为只能有一个“竞争者”胜出,所以 GestureDetector 2 将被忽略。这个例子中想要解决冲突的方法有两种。 1.将 GestureDetector 换为 Listener 即可,因为Listener并没有手势识别这一套事件分发的逻辑,并且在Listener中也可以自定义分发的逻辑。 2.使用RawGestureDetector,将重写后的gesture传入其中,从而实现父元素的事件也可以被触发,代码如下:

RawGestureDetector(
    gestures: {
      AllowMultipleGestureRecognizer: GestureRecognizerFactoryWithHandlers<AllowMultipleGestureRecognizer>(
        () => AllowMultipleGestureRecognizer(),
        (AllowMultipleGestureRecognizer instance) {
          instance.onTap = () => print('2');
        },
      )
    },
    behavior: HitTestBehavior.opaque,
    //Parent Container
    child: Container(
      width: 200,
      height: 200,
      color: Colors.blueGrey,
      child: GestureDetector(
        onTap: () {
          print('1');
        },
        child: Center(
          child: Container(
            width: 50,
            height: 50,
            color: Colors.lightBlue,
          ),
        ),
      ),
    )),
	
// 这里重写了拒绝的方法,让被拒绝的事件可以被再次分发
class AllowMultipleGestureRecognizer extends TapGestureRecognizer {
  @override
  void rejectGesture(int pointer) {
    acceptGesture(pointer);
  }
}

通过以上介绍,我们对flutter中的事件机制已经有了较为详细的了解,包括命中测试、事件分发、手势竞技等机制,读者对这些机制有了进一步的认识之后,相信能够更加从容地处理各类复杂的事件嵌套与冲突,更快速的定位和解决相关问题。