GSYFlutterBook/Flutter-13.md

327 lines
20 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

本篇将带你深入了解 Flutter 中的手势事件传递、事件分发、事件冲突竞争,滑动流畅等等的原理,帮你构建一个完整的 Flutter 闭环手势知识体系,这也许是目前最全面的手势事件和滑动源码的深入文章了。
## 文章汇总地址:
> [Flutter 完整实战实战系列文章专栏](https://juejin.im/collection/5db25bcff265da06a19a304e)
>
> [Flutter 番外的世界系列文章专栏](https://juejin.im/collection/5db25d706fb9a069f422c374)
Flutter 中默认情况下,以 Android 为例,所有的事件都是起原生源于 `io.flutter.view.FlutterView` 这个 `SurfaceView` 的子类,整个触摸手势事件实质上经历了 **JAVA => C++ => Dart** 的一个流程,整个流程如下图所示,无论是 Android 还是 IOS ,原生层都只是将所有事件打包下发,比如在 Android 中,手势信息被打包成 `ByteBuffer` 进行传递,最后在 Dart 层的 `_dispatchPointerDataPacket` 方法中,通过 `_unpackPointerDataPacket` 方法解析成可用的 `PointerDataPacket` 对象使用。
![](http://img.cdn.guoshuyu.cn/20190604_Flutter-13/image1)
**那么具体在 Flutter 中是如何分发使用手势事件的呢?**
## 1、事件流程
在前面的流程图中我们知道,在 Dart 层中手势事件都是从 `_dispatchPointerDataPacket` 开始的,之后会通过 `Zone` 判断环境回调,会执行 `GestureBinding` 这个胶水类中的 `_handlePointerEvent` 方法。*(如果对 `Zone` 或者 `GestureBinding` 有疑问可以翻阅前面的篇章)*
如下代码所示, `GestureBinding``_handlePointerEvent` 方法中主要是 `hitTest``dispatchEvent` **通过 `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` 就是一个带着碰撞测试后的控件列表。**
事实上 `hitTest``HitTestable` 抽象类的方法,而 Flutter 中所有实现 `HitTestable` 的类有 **`GestureBinding``RendererBinding`** ,它们都是 `mixins``WidgetsFlutterBinding` 这个入口类上,并且因为它们的 `mixins` 顺序的关系,所以 **`RendererBinding``hitTest` 会先被调用,之后才调用 `GestureBinding``hitTest` 。**
那么这两个 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` 判断自己是否属于响应区域,确认响应后执行 `hitTestChildren``hitTestSelf` ,尝试添加下级的 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`,并经过它处理和竞技后再分发出去,常见有 `OneSequenceGestureRecognizer` 、 `MultiTapGestureRecognizer` 、`VerticalDragGestureRecognizer` 、`TapGestureRecognizer` 等等。
- **`GestureArenaManagerr`** :手势竞技管理,它管理了整个“战争”的过程,原则上竞技胜出的条件是 **第一个竞技获胜的成员或最后一个不被拒绝的成员。**
- **`GestureArenaEntry`** :提供手势事件竞技信息的实体,内封装参与事件竞技的成员。
- **`GestureArenaMember`**:参与竞技的成员抽象对象,内部有 `acceptGesture``rejectGesture` 方法,它代表手势竞技的成员,默认 `GestureRecognizer` 都实现了它,**所有竞技的成员可以理解为就是 `GestureRecognizer` 之间的竞争。**
- **`_GestureArena`**`GestureArenaManager` 内的竞技场,内部持参与竞技的 `members` 列表,官方对这个竞技场的解释是: **如果一个手势试图在竞技场开放时(isOpen=true)获胜,它将成为一个带有“渴望获胜”的属性的对象。当竞技场关闭(isOpen=false)时,竞技场将寻找一个“渴望获胜”的对象成为新的参与者,如果这时候刚好只有一个,那这一个参与者将成为这次竞技场胜利的青睐存在。**
好了,知道这些概念之后我们开始分析流程,我们知道 `GestureBinding``dispatchEvent` 时会先判断是否有 `HitTestResult` 是否有结果,一般情况下是存在的,所以直接执行循环 `entry.target.handleEvent`
#### 2.1、PointerDownEvent
循环执行过程中,我们知道 `entry.target.handleEvent` 会触发`RenderPointerListener` 的 `handleEvent` ,而事件流程中第一个事件一般都会是 `PointerDownEvent`
> `PointerDownEvent` 的流程在事件竞技流程中相当关键,因为它会触发 `GestureRecognizer.addPointer`。
**`GestureRecognizer` 只有通过 `addPointer` 方法将 `PointerDownEvent` 事件和自己绑定,并添加到 `GestureBinding``PointerRouter` 事件路由和 `GestureArenaManager` 事件竞技中,后续的事件这个控件的 `GestureRecognizer` 才能响应和参与竞争。**
![](http://img.cdn.guoshuyu.cn/20190604_Flutter-13/image2)
> 事实上 **Down** 事件在 Flutter 中一般都是用来做添加判断的,如果存在竞争时,大部分时候是不会直接出结果的,而 **Move** 事件在不同 `GestureRecognizer` 中会表现不同,而 **UP** 事件之后,一般会强制得到一个结果。
所以我们知道了**事件在 `GestureBinding` 开始分发的时候,在 `PointerDownEvent` 时需要响应事件的 `GestureRecognizer` 们,会调用 `addPointer` 将自己添加到竞争中。之后流程中如果没有特殊情况,一般会执行到参与竞争成员列表的 last也就是 `GestureBinding` 自己这个 handleEvent 。**
如下代码所示,走到 `GestureBinding``handleEvent` ,在 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);
}
}
```
让我们看 `GestureArenaManager``close` 方法,下面代码我们可以看到,如果前面 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` 触发 `TapGestureRecognizer` 的 `handleEvent` 触发的。
**那么问题来了,`_checkDown ` 和 `_checkUp ` 时在 UP 事件一次性被执行,那么如果我长按住的话,`_checkDown` 不是没办法正确回调了?**
当然不会,在 `TapGestureRecognizer` 中有一个 `didExceedDeadline` 的机制,在前面 Down 流程中,**在 `addPointer``TapGestureRecognizer` 会创建一个定时器**,这个定时器的时间时 ` 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](http://img.cdn.guoshuyu.cn/20190604_Flutter-13/image3)
`PointerMoveEvent` 事件的 `DragGestureRecognizer.handleEvent` 里,会通过在 `_hasSufficientPendingDragDeltaToAccept `判断是否符合条件,如:
```
bool get _hasSufficientPendingDragDeltaToAccept => _pendingDragOffset.dy.abs() > kTouchSlop;
```
如果符合条件就直接执行 `resolve(GestureDisposition.accepted);` ,将流程回到竞技场里,然后执行 `acceptGesture` ,然后触发`onStart` 和 `onUpdate`
回到我们前面的上下滑动可点击列表,是不是很明确了:**如果是点击的话,没有产生 MOVE 事件,所以 `DragGestureRecognizer` 没有被接受而Item 作为 Child 第一位,所以响应点击。如果有 MOVE 事件, `DragGestureRecognizer` 会被 `acceptGesture`,而点击 `GestureRecognizer` 会被移除事件竞争,也就没有后续 UP 事件了。**
那这个 `onUpdate` 是怎么让节目动起来的?
我们以 `ListView` 为例子,通过源码可以知道, `onUpdate` 最后会调用到 `Scrollable``_handleDragUpdate` ,这时候会执行 `Drag.update`
![image.png](http://img.cdn.guoshuyu.cn/20190604_Flutter-13/image4)
通过源码我们知道 `ListView``Drag` 实现其实是 `ScrollDragController`, 它在 `Scrollable` 中是和 `ScrollPositionWithSingleContext` 关联的在一起的。那么 `ScrollPositionWithSingleContext` 又是什么?
`ScrollPositionWithSingleContext` 其实就是这个滑动的关键,它其实就是 `ScrollPosition` 的子类,而 `ScrollPosition` 又是 `ViewportOffset` 的子类,而 `ViewportOffset` 又是一个 `ChangeNotifier`,出现如下关系:
> 继承关系:**ScrollPositionWithSingleContext : ScrollPosition : ViewportOffset : ChangeNotifier**
所以 **ViewportOffset** 就是滑动的关键点。上面我们知道响应区域 `DragGestureRecognizer` 胜利之后执行 `Drag.update` ,最终会调用到 `ScrollPositionWithSingleContext``applyUserOffset`,导致内部确定位置的 `pixels` 发生改变,并执行父类 `ChangeNotifier ` 的方法`notifyListeners` 通知更新。
而在 `ListView` 内部 `RenderViewportBase` 中,这个 `ViewportOffset` 是通过 `_offset.addListener(markNeedsLayout);` 绑定的so **触摸滑动导致 `Drag.update` ,最终会执行到 `RenderViewportBase` 中的 `markNeedsLayout` 触发页面更新。**
至于 `markNeedsLayout` 如何更新界面和滚动列表,这里暂不详细描述了,给个图感受下:
![image.png](http://img.cdn.guoshuyu.cn/20190604_Flutter-13/image5)
>自此,第十三篇终于结束了!(///▽///)
### 资源推荐
* 本文Demo https://github.com/CarGuo/state_manager_demo
* Github [https://github.com/CarGuo/](https://github.com/CarGuo)
* **开源 Flutter 完整项目https://github.com/CarGuo/GSYGithubAppFlutter**
* **开源 Flutter 多案例学习型项目: https://github.com/CarGuo/GSYFlutterDemo**
* **开源 Fluttre 实战电子书项目https://github.com/CarGuo/GSYFlutterBook**
##### 完整开源项目推荐:
* [GSYGithubApp Flutter](https://github.com/CarGuo/GSYGithubAppFlutter )
* [GSYGithubApp React Native](https://github.com/CarGuo/GSYGithubApp )
* [GSYGithubAppWeex](https://github.com/CarGuo/GSYGithubAppWeex)
![我们还会再见吗?](http://img.cdn.guoshuyu.cn/20190604_Flutter-13/image6)