深入进阶-从一次点击探寻Flutter事件分发原理

6,702 阅读12分钟

深入进阶-从一次点击探寻Flutter的事件分发原理

导语

一次需求中遇到了这样的场景,PageView中有三个页面,其中一个页面是TabBarView结构。结果出现了当滑动到TabBar的时候,外层PageView无法滑动(滑动冲突)。最终在stackoverflow上找到了这个问题的解法,过程中顺便将Flutter的手势与滑动机制总结了一番。这也是Flutter进阶必须掌握的一个知识点,相信我,这一定是全网最详细,易懂的总结!!

这个系列会分为三篇:

1、从一次点击探寻Flutter的事件分发原理

2、一张图理清Flutter的滑动原理

3、实战Flutter滑动原理

4、解决Flutter滑动冲突的两种思路

读完本文你将收获:Flutter是如何处理一次点击事件


引言

一般在App开发中,总会涉及到多种与用户交互的逻辑,例如用户点击某个按钮,或者双击。Flutter中GestureDetector提供一系列的回调,让我们轻易就能响应这些用户操作,那这看似简单Flutter是如何处理的呢?本文探究一次点击事件的响应过程,从源码流程带大家一一探索。

1、触摸事件传递

image-20201117142216857

首先我们知道,用户的任何交互行为,一定是在原生设备上进行。所以我们事件分发肯定是从Native侧传递到Flutter,下面一张图描述了这个过程(图片来自其他文章)

一次点击响应可以分解为两个事件,一个手指按下的Down事件,一个手指抬起的Up事件。以安卓为例,首先这两个事件依次从Java层传递到了C++,最终传递至Dart。在Dart部分,我们注意到经过zone.runUnaryGuarded方法之后会调用到window.onPointDataPacket方法处(window是Flutter中一个非常核心的概念,是作为一个与Native交互的源对象,后面单独介绍),查看GestureBinding初始化的过程得知这个方法会执行_handlePointerDataPacket

mixin GestureBinding on BindingBase implements HitTestable, HitTestDispatcher, HitTestTarget {
  @override
  void initInstances() {
    super.initInstances();
    _instance = this;
   //将_handlePointerDataPacket设置为 window.onPointerDataPacket回调
   window.onPointerDataPacket = _handlePointerDataPacket;
  }
}

GestureBinding# _handlePointerDataPacket(ui.PointerDataPacket packet)

//未处理的事件队列
final Queue<PointerEvent> _pendingPointerEvents = Queue<PointerEvent>();
//这里的packet是一个点的信息
void _handlePointerDataPacket(ui.PointerDataPacket packet) {
  // We convert pointer data to logical pixels so that e.g. the touch slop can be
  // defined in a device-independent manner.
  // 将data中的数据,映射到为逻辑像素 
  // window.devicePixelRatio 一个逻辑像素对应的设备像素,比如nex6:3.5
  _pendingPointerEvents.addAll(PointerEventConverter.expand(packet.data, window.devicePixelRatio));
  if (!locked)
    _flushPointerEventQueue();
}

这个方法首先会根据设备的属性将传递来数据映射到为逻辑像素后添加至队列,下一步调用_flushPointerEventQueue()


GestureBinding# _flushPointerEventQueue()

void _flushPointerEventQueue() {
  assert(!locked);
  while (_pendingPointerEvents.isNotEmpty)
    //直接调用_handlePointerEvent
    _handlePointerEvent(_pendingPointerEvents.removeFirst());
}

GestureBinding# _handlePointerEvent(PointerEvent event)

/// 此处的key是event.pointer,pointer是不会重复的,每个down事件的时候会去+1
final Map<int, HitTestResult> _hitTests = <int, HitTestResult>{};

void _handlePointerEvent(PointerEvent event) {
  HitTestResult hitTestResult;
  if (event is PointerDownEvent || event is PointerSignalEvent) {
    //down事件进行hitTest
    hitTestResult = HitTestResult();
    hitTest(hitTestResult, event.position);
    if (event is PointerDownEvent) {
      // dowmn事件的话对这个hitTest集合赋值
      _hitTests[event.pointer] = hitTestResult;
    }
  } else if (event is PointerUpEvent || event is PointerCancelEvent) {
    // up事件标识这次操作已经结束,所以移除
    hitTestResult = _hitTests.remove(event.pointer);
  } else if (event.down) {
    // move事件也应该被分发在down事件初始点击的区域  比如点击了列表中的A item这个时候开始滑动,那处理这个事件的始终只是列表和A item, 只是如果滑动的话事件是由列表进行处理
    hitTestResult = _hitTests[event.pointer];
  }
  if (hitTestResult != null ||
      event is PointerHoverEvent ||
      event is PointerAddedEvent ||
      event is PointerRemovedEvent) {
    dispatchEvent(event, hitTestResult);
  }
}

调用最终走到_handlePointerEvent(PointerEvent event),因为点击事件肯定是从Down事件开始,在PointerDownEvent的流程中先声明了一个HitTestResult()对象,之后调用 hitTest(hitTestResult, event.position)

总结:一次点击响应可以看做Down+up事件,dart首先将实际像素转换成物理像素,然后依次加入队列,对于down事件进行hitTest


2、HitTest收集响应控件与分发

RendererBinding#hitTest(HitTestResult result, Offset position)

///renderview:负责绘制的root节点
RenderView get renderView => _pipelineOwner.rootNode;
///绘制树的owner,负责绘制,布局,合成
PipelineOwner get pipelineOwner => _pipelineOwner;
@override
void hitTest(HitTestResult result, Offset position) {
  assert(renderView != null);
  renderView.hitTest(result, position: position);
  super.hitTest(result, position);
  =>
  GestureBinding#hitTest(HitTestResult result, Offset position) {
    result.add(HitTestEntry(this));
  }
}

这个hitTest方法被RendererBinding重写,里面调用了renderView的hitTest(result, position: position)。renderView是绘制树的根节点,是所有Widget的祖先。


RenderView#hitTest(BoxHitTestResult result, { @required Offset position })

  bool hitTest(HitTestResult result, { Offset position }) {
    if (child != null)
      (RenderBox)child.hitTest(BoxHitTestResult.wrap(result), position: position);
    result.add(HitTestEntry(this));
    return true;
  }

RenderBox#hitTest(BoxHitTestResult result, { @required Offset position })

///作用:给出指定position的所有绘制控件
///返回true,当这个控件或者他的子控件位于给定的position的时候,添加这个绘制的对象到给定的hitResult中 这样标志当前的控件已经吸收了这个点击事件,其他控件不响应
///返回false,表示这个事件交给在当前对象之后的控件处理,
///例如一个row里面,多个区域可以响应点击,只要如果第一块能响应点击的话,那后续就不用判断是否能响应了
///调用方需要将全局的坐标转换为RenderBox关联的坐标,Renderbox负责判断这个坐标是否包含在当前的范围里
///这个方法依赖于最新的layout而不是paint,因为判断区域只要布局即可 
bool hitTest(BoxHitTestResult result, { @required Offset position }) {
  if (_size.contains(position)) {
    // 对于每一个child调用自己的 hitTest,所以布局最深的子wiget放在最开始
    if (hitTestChildren(result, position: position) || hitTestSelf(position)) {
      result.add(BoxHitTestEntry(this, position));
      return true;
    }
  }
  return false;
}

而RenderView的hitTest(BoxHitTestResult result, { @required Offset position })最终调用RenderBox的hitTest(BoxHitTestResult result, { @required Offset position })hitTestChildrenhitTestSelf是两个抽象方法(因为Widget可能有一个child或者多个child),查看具体实现发现其实逻辑和这儿差不多,也是先判断自己是否在这次点击的Postion范围内,然后递归调用子Widget的hitTest。观察这个方法结构,我们知道,如果一个Widget越深,则越先被添加进HitTestResult中。这样的流程执行下来,HitTestResult就得到了这次点击事件坐标上所有能响应的控件集合。需要注意,GestureBinding中最后把自己添加到Result的结尾

//GestureBinding中最后把自己添加到result中
@override // from HitTestable
void hitTest(HitTestResult result, Offset position) {
  result.add(HitTestEntry(this));
}


GestureBinding#void dispatchEvent(PointerEvent event, HitTestResult hitTestResult)

_handlePointerEvent() 
if (hitTestResult != null ||
      event is PointerHoverEvent ||
      event is PointerAddedEvent ||
      event is PointerRemovedEvent) {
    dispatchEvent(event, hitTestResult);
  }
/// Dispatch an event to a hit test result's path.
/// 分发hit事件到每一个可以响应的widget中
@override // from HitTestDispatcher
void dispatchEvent(PointerEvent event, HitTestResult hitTestResult) {
  ///循环调用每个空间的handleEvent
  for (HitTestEntry entry in hitTestResult.path) {
    try {
      entry.target.handleEvent(event.transformed(entry.transform), entry);
    } 
  }
}

回到_handlePointerEvent中,最后在hitTestResult不为空的情况下,进行事件分发 dispatchEvent(event, hitTestResult)。循环调用集合中每一个对象的handleEvent(event.transformed(entry.transform), entry)方法,但并不是所有的控件的都会处理 handleEvent ,大部分时候只有RenderPointerListener会处理。查询引用关系,他被嵌套在RawGestureDetector中。事实上Flutter中几乎所有的手势处理都是这个类的包装(如图,InkWell的结构,在最里层返回的是一个RenderPointerListener),handleEvent会根据不同的事件类型,回调到RawGestureDetector的相关手势处理中。

结论:在Down事件的时候,hitTest根据点击的position通过rendview获取一个可以响应事件的object集合,并且在集合最后 GestureBinding将自己添加到队尾。之后通过dispatchEvent 事件进行分发,但并不是所有的控件的 RenderObject 子类都会处理 handleEvent ,大部分时候,RenderPointerListener 处理 handleEvent 事件,这个控件被嵌套在RawGestureDetector中,handleEvent会根据不同的事件类型回调到RawGestureDetector的相关手势处理。这里有个问题,可以看出每一个RawGestureDetector都能handleEvent,那如果点击区域里有多个RawGestureDetector控件,那这次的点击究竟应该由谁响应?


3、手势竞争

在查看冲突原理之前,需要先了解一下手势处理的基本概念:

  • GestureRecognizer :手势识别器基类,基本上 RenderPointerListener 中需要处理的手势事件,都会分发到它对应的 GestureRecognizer,并经过它处理和竞技后再分发出去,常见有 :OneSequenceGestureRecognizerMultiTapGestureRecognizerVerticalDragGestureRecognizerTapGestureRecognizer 等等。

  • GestureArenaManagerr :手势竞技管理,它管理了整个“战争”的过程,原则上竞技胜出的条件是 :第一个竞技获胜的成员或最后一个不被拒绝的成员。

  • GestureArenaEntry :提供手势事件竞技信息的实体,内封装参与事件竞技的成员。

  • GestureArenaMember:参与竞技的成员抽象对象,内部有 acceptGesturerejectGesture 方法,它代表手势竞技的成员,默认 GestureRecognizer 都实现了它,所有竞技的成员可以理解为就是 GestureRecognizer 之间的竞争。

  • _GestureArenaGestureArenaManager 内的竞技场,内部持参与竞技的 members 列表,官方对这个竞技场的解释是: 如果一个手势试图在竞技场开放时(isOpen=true)获胜,它将成为一个带有“渴望获胜”的属性的对象。当竞技场关闭(isOpen=false)时,竞技场将寻找一个“渴望获胜”的对象成为新的参与者,如果这时候刚好只有一个,那这一个参与者将成为这次竞技场胜利的存在。

通俗的理解,当有多个控件可响应事件时,这些GestureRecognizer会作为一个个GestureArenaMember被放置到_GestureArena中进行竞争。


上面我们提到了dispatchEvent(event, hitTestResult)中,会依次调用每一个参与者的handleEvent这个方法依据不同的事件类型触发RawGestureDetector中的不同处理。一次点击从Dowm事件开始,Up事件结束,依次HitResult中的对应回调。

RenderPointerListener#handleEvent(PointerEvent event, HitTestEntry entry)

根据图中调用时序可知,HitTestResult中的每一个RawGestureDetector会将自己添加到GestureArenaManager中。不要忘了GestureBinding被添加在HitTestResult的最后。


GestureBinding#handleEvent(PointerEvent event, HitTestEntry entry)

@override // from HitTestTarget
void handleEvent(PointerEvent event, HitTestEntry entry) {
 /// 导航事件去触发GestureRecognizer的handleEvent
 /// 一般 PointerDownEvent 在 route 执行中不怎么处理。
 ///gestureArena 就是 GestureArenaManager
  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);
  }
}

GestureBinding对于PointerDownEvent事件去 gestureArena.close(event.pointer),PointerUpEvent事件gestureArena.sweep(event.pointer)


GestureArenaManager#void close(int pointer)

/// Prevents new members from entering the arena.
/// 阻止新成员进入竞技
/// Called after the framework has finished dispatching the pointer down event.
/// 在完成事件分发后调用
void close(int pointer) {
	/// 拿到上面 addPointer 时添加的成员封装
  final _GestureArena state = _arenas[pointer];
  ///关闭竞技场
  state.isOpen = false;
	///尝试打一架
  _tryToResolveArena(pointer, state);
}

GestureArenaManager# _tryToResolveArena(int pointer, _GestureArena state)

void _tryToResolveArena(int pointer, _GestureArena state) {
  if (state.members.length == 1) {
 		///只有一个竞技成员的话,直接交给他处理
    scheduleMicrotask(() => _resolveByDefault(pointer, state));
  } else if (state.members.isEmpty) {
    _arenas.remove(pointer);
  } else if (state.eagerWinner != null) {
    _resolveInFavorOf(pointer, state, state.eagerWinner);
  }
}

查跟踪上面的流程可以得知,down事件最后会驱动竞技场的关闭(因为此时参加手势竞争的控件已经确定了)。如果只有一个控件响应手势的情况下这个控件直接获得胜利,触发它的acceptGesture流程。如果控件区域内存在多个 TapGestureRecognizer ,那么在 PointerDownEvent 流程是不会产生胜利者的。以单单击为例,整个过程不会产生Move。到了 UP 事件时,就会执行 gestureArena.sweep(event.pointer) 强行选取一个。


GestureArenaManager#sweep(int pointer)

/// Forces resolution of the arena, giving the win to the first member.
/// 迫使竞技场得出一个决胜者
/// Sweep is typically after all the other processing for a [PointerUpEvent]
/// have taken place. It ensures that multiple passive gestures do not cause a
/// stalemate that prevents the user from interacting with the app.
/// sweep通常是在[PointerUpEvent]发生之后。它确保了竞争不会造成卡顿,从而阻止用户与应用程序交互。
/// See also:
void sweep(int pointer) {
  ///获取竞争的对象
  final _GestureArena state = _arenas[pointer];
  if (state.isHeld) {
    state.hasPendingSweep = true;
    return; // This arena is being held for a long-lived member.
  }
  _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);
  }
}

sweep中流程很简单,就是让竞争者中的第一位直接获取胜利,其他的拒绝响应。而竞争者中的第一个,就是就是Widget树中,最深的手势响应者。


4、响应点击

BaseTapGestureRecognizer#acceptGesture(int pointer)

@override
void acceptGesture(int pointer) {
  ///标志自己已经获得了手势的竞争
  super.acceptGesture(pointer);
  if (pointer == primaryPointer) {
    _checkDown();
    _wonArenaForPrimaryPointer = true;
    _checkUp();
  }
}
void _checkDown() {
   ///如果已经处理过了,就不会再次处理!!
   if (_sentTapDown) {
     return;
   }
   ///交给子控件处理down事件
   handleTapDown(down: _down);
   _sentTapDown = true;
}

acceptGesture中,会先进行down事件的消费


BaseTapGestureRecognizer#_checkUp()

void _checkUp() {
  ///_up为空或者不是手势竞争的胜利者,则直接返回
  if (!_wonArenaForPrimaryPointer || _up == null) {
    return;
  }
  handleTapUp(down: _down, up: _up);
  _reset();
}

这里如果_up事件为空的话也不会执行 handleTapUp(),这个_up的会在PointUpEvent的时候调用handlePrimaryPointer赋值,所以即使当竞技场,只有一个竞技者的时候在Down事件也不会被识别为一个完整的点击动作。

TapGestureRecognizer#handleTapUp({PointerDownEvent down, PointerUpEvent up})

void handleTapUp({PointerDownEvent down, PointerUpEvent up}) {
  final TapUpDetails details = TapUpDetails(
    globalPosition: up.position,
    localPosition: up.localPosition,
  );
  switch (down.buttons) {
    case kPrimaryButton:
      if (onTapUp != null)
        invokeCallback<void>('onTapUp', () => onTapUp(details));
      if (onTap != null)
        invokeCallback<void>('onTap', onTap);
      break;
    case kSecondaryButton:
      if (onSecondaryTapUp != null)
        invokeCallback<void>('onSecondaryTapUp',
          () => onSecondaryTapUp(details));
      break;
    default:
  }
}

最后在handleTapUp中先执行onTapUp,后执行onTap,完成一次点击事件的识别。

总结

以一次点击事件来看,Flutter的事件分发流程为:

1、事件从Native层通过C++传递到Dart层,通过映射为逻辑像素后在GestureBinding中进行处理

2、无论什么手势一定是从Down事件开始,在Down阶段,HitTest从负责绘制树的根节点开始,递归将可以响应事件的控件添加至HitTestResult中,GesureBinding将自己添加到列表最后,对result中的每一个对象进行事件分发。

3、并非所有控件都会handleEvent ,主要是RawGestureDetector会进行处理。在Down事件中,所有的竞争者被添加到_GestureArena进行竞争,最后回到GestureBinding关闭竞技场。这时如果区域中只有一个RawGestureDetector,则在Down事件阶段这个控件直接获得胜利,进行acceptGesture,但这时并不会触发onTap,等到Up事件之后,触发onTapUp,后执行onTap

4、如果区域内有多个RawGestureDetector,在Down 事件时竞技场 close 不会竞出胜利者。Up 事件的时候,竞技场 sweep 选取排在第一个位置的为胜利者进行acceptGesture

这个过程只是以点击为例,例如滑动,肯定在Move阶段就产生了胜利者,不会等到Up事件。文章只是提供一个全流程和大家一起分析Flutter的事件分发机制~这是一切手势交互的基础流程。手势总结下来无非几种单击,双击,长按,滑动。拆解下来,可能是在down,up,或者move事件中做了特殊判断。熟悉了整体流程,在解决问题的时候就不至于无从下手了。

最后

这篇文章是作为滑动冲突的第一篇,也是事件分发中最基础的知识点,下一篇将会和大家以PageView为例探究Scrollable的嵌套结构,最详细的嵌套结构图,看完对Scrollable结构了然于心。最后求个点赞吧QAQ。