Flutter完整開發實戰詳解(十三、全面深入觸控和滑動原理)

戀貓de小郭發表於2019-05-10

本篇將帶你深入瞭解 Flutter 中的手勢事件傳遞、事件分發、事件衝突競爭,滑動流暢等等的原理,幫你構建一個完整的 Flutter 閉環手勢知識體系,這也許是目前最全面的手勢事件和滑動原始碼的深入文章了。

前文:

Flutter 中預設情況下,以 Android 為例,所有的事件都是起原生源於 io.flutter.view.FlutterView 這個 SurfaceView 的子類,整個觸控手勢事件實質上經歷了 JAVA => C++ => Dart 的一個流程,整個流程如下圖所示,無論是 Android 還是 IOS ,原生層都只是將所有事件打包下發,比如在 Android 中,手勢資訊被打包成 ByteBuffer 進行傳遞,最後在 Dart 層的 _dispatchPointerDataPacket 方法中,通過 _unpackPointerDataPacket 方法解析成可用的 PointerDataPacket 物件使用。

Flutter完整開發實戰詳解(十三、全面深入觸控和滑動原理)

那麼具體在 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 才能響應和參與競爭。

Flutter完整開發實戰詳解(十三、全面深入觸控和滑動原理)

事實上 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

自此,第十三篇終於結束了!(///▽///)

資源推薦

完整開源專案推薦:
文章

《Flutter完整開發實戰詳解(一、Dart語言和Flutter基礎)》

《Flutter完整開發實戰詳解(二、 快速開發實戰篇)》

《Flutter完整開發實戰詳解(三、 打包與填坑篇)》

《Flutter完整開發實戰詳解(四、Redux、主題、國際化)》

《Flutter完整開發實戰詳解(五、 深入探索)》

《Flutter完整開發實戰詳解(六、 深入Widget原理)》

《Flutter完整開發實戰詳解(七、 深入佈局原理)》

《Flutter完整開發實戰詳解(八、 實用技巧與填坑)》

《Flutter完整開發實戰詳解(九、 深入繪製原理)》

《Flutter完整開發實戰詳解(十、 深入圖片載入流程)》

《Flutter完整開發實戰詳解(十一、全面深入理解Stream)》

《Flutter完整開發實戰詳解(十二、全面深入理解狀態管理設計)》

《跨平臺專案開源專案推薦》

《移動端跨平臺開發的深度解析》

我們還會再見嗎?

相關文章