Flutter完整开发实战详解(十三、全面深入触摸和滑动原理)

本篇将带你深入了解 Flutter 中的手势事件传递、事件分发、事件冲突竞争,滑动流畅等等的原理,帮你构建一个完整的 Flutter 闭环手势知识体系,这也许是目前最全面的手势事件和滑动源码的深入文章了。

文章汇总地址:

Flutter 完整实战实战系列文章专栏

Flutter 番外的世界系列文章专栏

Flutter 中默认情况下,以 Android 为例,所有的事件都是起原生源于 io.flutter.view.FlutterView 这个 SurfaceView 的子类,整个触摸手势事件实质上经历了 JAVA => C++ => Dart 的一个流程,整个流程如下图所示,无论是 Android 还是 IOS ,原生层都只是将所有事件打包下发,比如在 Android 中,手势信息被打包成 ByteBuffer 进行传递,最后在 Dart 层的 _dispatchPointerDataPacket 方法中,通过 _unpackPointerDataPacket 方法解析成可用的 PointerDataPacket 对象使用。

image

那么具体在 Flutter 中是如何分发使用手势事件的呢?

1、事件流程

在前面的流程图中我们知道,在 Dart 层中手势事件都是从 _dispatchPointerDataPacket 开始的,之后会通过 Zone 判断环境回调,会执行 GestureBinding 这个胶水类中的 _handlePointerEvent 方法。(如果对 Zone 或者 GestureBinding 有疑问可以翻阅前面的篇章)

如下代码所示, GestureBinding_handlePointerEvent 方法中主要是 hitTestdispatchEvent通过 hitTest 碰撞,得到一个包含控件的待处理成员列表 HitTestResult,然后通过 dispatchEvent 分发事件并产生竞争,得到胜利者相应。

  void _handlePointerEvent(PointerEvent event) {
    assert(!locked);
    HitTestResult hitTestResult;
    if (event is PointerDownEvent || event is PointerSignalEvent) {
      hitTestResult = HitTestResult();
      ///开始碰撞测试了,会添加各个控件,得到一个需要处理的控件成员列表
      hitTest(hitTestResult, event.position);
      if (event is PointerDownEvent) {
        _hitTests[event.pointer] = hitTestResult;
      }
    } else if (event is PointerUpEvent || event is PointerCancelEvent) {
      ///复用机制,抬起和取消,不用hitTest,移除
      hitTestResult = _hitTests.remove(event.pointer);
    } else if (event.down) {
      ///复用机制,手指处于滑动中,不用hitTest
      hitTestResult = _hitTests[event.pointer];
    }
    if (hitTestResult != null ||
        event is PointerHoverEvent ||
        event is PointerAddedEvent ||
        event is PointerRemovedEvent) {
      ///开始分发事件
      dispatchEvent(event, hitTestResult);
    }
  }

了解了结果后,接下来深入分析这两个关键方法:

1.1 、hitTest

hitTest 方法主要为了得到一个 HitTestResult ,这个 HitTestResult 内有一个 List<HitTestEntry> 是用于分发和竞争事件的,而每个 HitTestEntry.target 都会存储每个控件的 RenderObject

因为 RenderObject 默认都实现了 HitTestTarget 接口,所以可以理解为: HitTestTarget 大部分时候都是 RenderObject ,而 HitTestResult 就是一个带着碰撞测试后的控件列表。

事实上 hitTestHitTestable 抽象类的方法,而 Flutter 中所有实现 HitTestable 的类有 GestureBindingRendererBinding ,它们都是 mixinsWidgetsFlutterBinding 这个入口类上,并且因为它们的 mixins 顺序的关系,所以 RendererBindinghitTest 会先被调用,之后才调用 GestureBindinghitTest

那么这两个 hitTest 又分别干了什么事呢?

1.2、RendererBinding.hitTest

RendererBinding.hitTest 中会执行 renderView.hitTest(result, position: position); ,如下代码所示,renderView.hitTest 方法内会执行 child.hitTest ,它将尝试将符合条件的 child 控件添加到 HitTestResult 里,最后把自己添加进去。

///RendererBinding

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

而查看 child.hitTest 方法源码,如下所示,RenderObjcet 中的hitTest ,会通过 _size.contains 判断自己是否属于响应区域,确认响应后执行 hitTestChildrenhitTestSelf ,尝试添加下级的 child 和自己添加进去,这样的递归就让我们自下而上的得到了一个 HitTestResult 的相应控件列表了,最底下的 Child 在最上面

  ///RenderObjcet
  
  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;
  }

1.3、GestureBinding.hitTest

最后 GestureBinding.hitTest 方法不过最后把 GestureBinding 自己也添加到 HitTestResult 里,最后因为后面我们的流程还会需要回到 GestureBinding 中去处理。

1.4、dispatchEvent

dispatchEvent 中主要是对事件进行分发,并且通过上述添加进去的 target.handleEvent 处理事件,如下代码所示,在存在碰撞结果的时候,是会通过循环对每个控件内部的handleEvent 进行执行。

  @override // from HitTestDispatcher
  void dispatchEvent(PointerEvent event, HitTestResult hitTestResult) {
     ///如果没有碰撞结果,那么通过 `pointerRouter.route` 将事件分发到全局处理。
    if (hitTestResult == null) {
      try {
        pointerRouter.route(event);
      } catch (exception, stack) {
      return;
    }
    ///上面我们知道 HitTestEntry 中的 target 是一系自下而上的控件
    ///还有 renderView 和 GestureBinding
    ///循环执行每一个的 handleEvent 方法
    for (HitTestEntry entry in hitTestResult.path) {
      try {
        entry.target.handleEvent(event, entry);
      } catch (exception, stack) {
      }
    }
  }

事实上并不是所有的控件的 RenderObject 子类都会处理 handleEvent ,大部分时候,只有带有 RenderPointerListener (RenderObject) / Listener (Widget) 的才会处理 handleEvent 事件,并且从上述源码可以看出,handleEvent 的执行是不会被拦截打断的。

那么问题来了,如果同一个区域内有多个控件都实现了 handleEvent 时,那最后事件应该交给谁消耗呢?

更具体为一个场景问题就是:比如一个列表页面内,存在上下滑动和 Item 点击时,Flutter 要怎么分配手势事件? 这就涉及到事件的竞争了。

核心要来了,高能预警!!!

2、事件竞争

Flutter 在设计事件竞争的时候,定义了一个很有趣的概念:通过一个竞技场,各个控件参与竞争,直接胜利的或者活到最后的第一位,你就获胜得到了胜利。 那么为了分析接下来的“战争”,我们需要先看几个概念:

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

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

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

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

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

好了,知道这些概念之后我们开始分析流程,我们知道 GestureBindingdispatchEvent 时会先判断是否有 HitTestResult 是否有结果,一般情况下是存在的,所以直接执行循环 entry.target.handleEvent

2.1、PointerDownEvent

循环执行过程中,我们知道 entry.target.handleEvent 会触发RenderPointerListenerhandleEvent ,而事件流程中第一个事件一般都会是 PointerDownEvent

PointerDownEvent 的流程在事件竞技流程中相当关键,因为它会触发 GestureRecognizer.addPointer

GestureRecognizer 只有通过 addPointer 方法将 PointerDownEvent 事件和自己绑定,并添加到 GestureBindingPointerRouter 事件路由和 GestureArenaManager 事件竞技中,后续的事件这个控件的 GestureRecognizer 才能响应和参与竞争。

image

事实上 Down 事件在 Flutter 中一般都是用来做添加判断的,如果存在竞争时,大部分时候是不会直接出结果的,而 Move 事件在不同 GestureRecognizer 中会表现不同,而 UP 事件之后,一般会强制得到一个结果。

所以我们知道了事件在 GestureBinding 开始分发的时候,在 PointerDownEvent 时需要响应事件的 GestureRecognizer 们,会调用 addPointer 将自己添加到竞争中。之后流程中如果没有特殊情况,一般会执行到参与竞争成员列表的 last,也就是 GestureBinding 自己这个 handleEvent 。

如下代码所示,走到 GestureBindinghandleEvent ,在 Down 事件的流程中,一般 pointerRouter.route 不会怎么处理逻辑,然后就是 gestureArena.close 关闭竞技场了,尝试得到胜利者。

  @override // from HitTestTarget
  void handleEvent(PointerEvent event, HitTestEntry entry) {
     /// 导航事件去触发  `GestureRecognizer` 的 handleEvent
     /// 一般 PointerDownEvent 在 route 执行中不怎么处理。
    pointerRouter.route(event);
    
    ///gestureArena 就是 GestureArenaManager
    if (event is PointerDownEvent) {
    
        ///关闭这个 Down 事件的竞技,尝试得到胜利
      /// 如果没有的话就留到 MOVE 或者 UP。
      gestureArena.close(event.pointer);
      
    } else if (event is PointerUpEvent) {
        ///已经到 UP 了,强行得到结果。
      gestureArena.sweep(event.pointer);
      
    } else if (event is PointerSignalEvent) {
      pointerSignalResolver.resolve(event);
    }
  }

让我们看 GestureArenaManagerclose 方法,下面代码我们可以看到,如果前面 Down 事件中没有通过 addPointer 添加成员到 _arenas 中,那会连参加的机会都没有,而进入 _tryToResolveArena 之后,如果 state.members.length == 1 ,说明只有一个成员了,那就不竞争了,直接它就是胜利者,直接响应后续所有事件。 那么如果是多个的话,就需要后续的竞争了。

  void close(int pointer) {
    /// 拿到我们上面 addPointer 时添加的成员封装
    final _GestureArena state = _arenas[pointer];
    if (state == null)
      return; // This arena either never existed or has been resolved.
    state.isOpen = false;
    ///开始打起来吧
    _tryToResolveArena(pointer, 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);
    }
  }

2.2 开始竞争

那竞争呢?接下来我们以 TapGestureRecognizer 为例子,如果控件区域内存在两个 TapGestureRecognizer ,那么在 PointerDownEvent 流程是不会产生胜利者的,这时候如果没有 MOVE 打断的话,到了 UP 事件时,就会执行 gestureArena.sweep(event.pointer); 强行选取一个。

而选择的方式也是很简单,就是 state.members.first ,从我们之前 hitTest 的结果上理解的话,就是控件树的最里面 Child 了。 这样胜利的 member 会通过 members.first.acceptGesture(pointer) 回调到 TapGestureRecognizer.acceptGesture 中,设置 _wonArenaForPrimaryPointer 为 ture 标志为胜利区域,然后执行
_checkDown_checkUp 发出事件响应触发给这个控件。

而这里有个有意思的就是 ,Down 流程的 acceptGesture 中的 _checkUp 因为没有 _finalPosition 此时是不会被执行的,_finalPosition 会在 handlePrimaryPointer 方法中,获得_finalPosition 并判断 _wonArenaForPrimaryPointer 标志为,再次执行 _checkUp 才会成功。

handlePrimaryPointer 是在 UP 流程中 pointerRouter.route 触发 TapGestureRecognizerhandleEvent 触发的。

那么问题来了,_checkDown_checkUp 时在 UP 事件一次性被执行,那么如果我长按住的话,_checkDown 不是没办法正确回调了?

当然不会,在 TapGestureRecognizer 中有一个 didExceedDeadline 的机制,在前面 Down 流程中,addPointerTapGestureRecognizer 会创建一个定时器,这个定时器的时间时 kPressTimeout = 100毫秒如果我们长按住的话,就会等待到触发 didExceedDeadline 去执行 _checkDown 发出 onTabDown 事件了。

_checkDown 执行发送过程中,会有一个标志为 _sentTapDown 判断是否已经发送过,如果发送过了也不会在重发,之后回到原本流程去竞争,手指抬起后得到胜利者相应,同时在 _checkUp 之后 _sentTapDown 标识为会被重置。

这也可以分析点击下的几种场景:

普通按下:
  • 1、区域内只有一个 TapGestureRecognizer :Down 事件时直接在竞技场 close 时就得到竞出胜利者,调用 acceptGesture 执行 _checkUp,到 Up 事件的时候通过 handlePrimaryPointer 执行 _checkUp,结束。

  • 2、区域内有多个 TapGestureRecognizer :Down 事件时在竞技场 close 不会竞出胜利者,在 Up 事件的时候,会在 route 过程通过handlePrimaryPointer 设置好 _finalPosition,之后经过竞技场 sweep 选取排在第一个位置的为胜利者,调用 acceptGesture,执行 _checkDown_checkUp

长按之后抬起:

1、区域内只有一个 TapGestureRecognizer :除了 Down 事件是在 didExceedDeadline 时发出 _checkDown 外其他和上面基本没区别。

  • 2、区域内有多个 TapGestureRecognizer :Down 事件时在竞技场 close 时不会竞出胜利者,但是会触发定时器 didExceedDeadline,先发出 _checkDown,之后再经过 sweep 选取第一个座位胜利者,调用 acceptGesture,触发 _checkUp

那么问题又来了,你有没有疑问,如果有区域两个 TapGestureRecognizer ,长按的时候因为都触发了 didExceedDeadline 执行 _checkDown 吗?

答案是:会的!因为定时器都触发了 didExceedDeadline,所以 _checkDown 都会被执行,从而都发出了 onTapDown 事件。但是后续竞争后,只会执行一个 _checkUp ,所有只会有一个控件响应 onTap

竞技失败:

在竞技场竞争失败的成员会被移出竞技场,移除后就没办法参加后面事件的竞技了 ,比如 TapGestureRecognizer 在接受到 PointerMoveEvent 事件时就会直接 rejected , 并触发 rejectGesture ,之后定时器会被关闭,并且触发 onTapCancel ,然后重置标志位.

总结下:

Down 事件时通过 addPointer 加入了 GestureRecognizer 竞技场的区域,在没移除的情况下,事件可以参加后续事件的竞技,在某个事件阶段移除的话,之后的事件序列也会无法接受。事件的竞争如果没有胜利者,在 UP 流程中会强制指定第一个为胜利者。

2.3 滑动事件

滑动事件也是需要在 Down 流程中 addPointer ,然后 MOVE 流程中,通过在 PointerRouter.route 之后执行 DragGestureRecognizer.handleEvent

image.png

PointerMoveEvent 事件的 DragGestureRecognizer.handleEvent 里,会通过在 _hasSufficientPendingDragDeltaToAccept判断是否符合条件,如:

bool get _hasSufficientPendingDragDeltaToAccept => _pendingDragOffset.dy.abs() > kTouchSlop;

如果符合条件就直接执行 resolve(GestureDisposition.accepted); ,将流程回到竞技场里,然后执行 acceptGesture ,然后触发onStartonUpdate

回到我们前面的上下滑动可点击列表,是不是很明确了:如果是点击的话,没有产生 MOVE 事件,所以 DragGestureRecognizer 没有被接受,而Item 作为 Child 第一位,所以响应点击。如果有 MOVE 事件, DragGestureRecognizer 会被 acceptGesture,而点击 GestureRecognizer 会被移除事件竞争,也就没有后续 UP 事件了。

那这个 onUpdate 是怎么让节目动起来的?

我们以 ListView 为例子,通过源码可以知道, onUpdate 最后会调用到 Scrollable_handleDragUpdate ,这时候会执行 Drag.update

image.png

通过源码我们知道 ListViewDrag 实现其实是 ScrollDragController, 它在 Scrollable 中是和 ScrollPositionWithSingleContext 关联的在一起的。那么 ScrollPositionWithSingleContext 又是什么?

ScrollPositionWithSingleContext 其实就是这个滑动的关键,它其实就是 ScrollPosition 的子类,而 ScrollPosition 又是 ViewportOffset 的子类,而 ViewportOffset 又是一个 ChangeNotifier,出现如下关系:

继承关系:ScrollPositionWithSingleContext : ScrollPosition : ViewportOffset : ChangeNotifier

所以 ViewportOffset 就是滑动的关键点。上面我们知道响应区域 DragGestureRecognizer 胜利之后执行 Drag.update ,最终会调用到 ScrollPositionWithSingleContextapplyUserOffset,导致内部确定位置的 pixels 发生改变,并执行父类 ChangeNotifier 的方法notifyListeners 通知更新。

而在 ListView 内部 RenderViewportBase 中,这个 ViewportOffset 是通过 _offset.addListener(markNeedsLayout); 绑定的,so ,触摸滑动导致 Drag.update ,最终会执行到 RenderViewportBase 中的 markNeedsLayout 触发页面更新。

至于 markNeedsLayout 如何更新界面和滚动列表,这里暂不详细描述了,给个图感受下:

image.png

自此,第十三篇终于结束了!(///▽///)

资源推荐

完整开源项目推荐:
我们还会再见吗?
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,294评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,493评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,790评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,595评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,718评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,906评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,053评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,797评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,250评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,570评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,711评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,388评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,018评论 3 316
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,796评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,023评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,461评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,595评论 2 350

推荐阅读更多精彩内容