反思|Android 事件攔截機制的設計與實現

「反思」 系列是筆者一個新的嘗試,其起源與目錄請參考 這裏html

概述

完整的掌握 Android 事件分發體系並不是易事,其整個流程涉及到了 系統啓動流程SystemServer)、輸入管理(InputManager)、系統服務和UI的通訊ViewRootImpl + Window + WindowManagerService)、View層級的 事件分發機制 等等一系列的環節。android

事件攔截機制 是基於View層級 事件分發機制 的一個進階性的知識點,本文將對其進行更細緻化的講解。git

事件攔截機制 自己就相對比較獨立,所以本文不須要讀者有 事件分發機制 相關的預備知識,對後者感興趣的讀者能夠參考如下資料:github

反思 | Android 事件分發機制的設計與實現面試

本文總體結構以下圖:算法

從事件序列提及

一、什麼是事件序列

想要說清 事件分發機制事件攔截機制事件序列 是首先要理解的概念。markdown

什麼是事件序列?Google官方文檔中對其描述爲 The duration of the touch,顧名思義,咱們能夠將其理解爲 用戶一次完整的觸摸操做流程—— 舉例來講,用戶單擊按鈕、用戶滑動屏幕、用戶長按屏幕中某個UI元素等等,都屬於該範疇。數據結構

二、原因

爲何 事件序列 是一個很是重要的概念?app

上一篇文章 中,讀者已經瞭解事件分發的本質原理就是遞歸,對此簡單的實現方式是:每接收一個新的事件,都須要進行一次遞歸才能找到對應消費事件的View,並依次向上返回事件分發的結果。ide

以每一個觸摸事件做爲最基本的單元,都對View樹進行一次遍歷遞歸?這對性能的影響顯而易見,所以這種設計是有改進空間的。

如何針對這個問題進行改進?將 事件序列 做爲最基本的單元進行處理則更爲合適。

首先,設計者根據用戶的行爲對MotionEvent中添加了一個Action的屬性以描述該事件的行爲:

  • ACTION_DOWN:手指觸摸到屏幕的行爲
  • ACTION_MOVE:手指在屏幕上移動的行爲
  • ACTION_UP:手指離開屏幕的行爲
  • ...其它Action,好比ACTION_CANCEL...

咱們知道,針對用戶的一次觸摸操做,必然對應了一個 事件序列,從用戶手指接觸屏幕,到移動手指,再到擡起手指 ——單個事件序列必然包含ACTION_DOWNACTION_MOVE ... ACTION_MOVEACTION_UP 等多個事件,這其中ACTION_MOVE的數量不肯定,ACTION_DOWNACTION_UP的數量則爲1。

熟悉了 事件序列 的概念,設計者就能夠着手對現有代碼進行設計和改進,其思路以下:當接收到一個ACTION_DOWN時,意味着一次完整事件序列的開始,經過遞歸遍歷找到真正對事件進行消費的Child,並將其進行保存,這以後接收到ACTION_MOVEACTION_UP行爲時,則跳過遍歷遞歸的過程,將事件直接分發對應的消費者:

因而可知,事件序列事件分發 的知識體系中的確是很是重要的核心概念(甚至沒有之一),其最重要的意義是 足夠節省性能:用戶一次正常的觸摸行爲,其 事件序列 包含了若干個觸摸事件,這些事件並不是每次都經過遞歸算法去找到事件的消費者,由於這會消耗很是多的內存——當事件序列越複雜、或者View樹的層級嵌套越深,這種優點愈發明顯。

那麼,源碼的設計者是如何保證經過一次遞歸算法找到View樹中對應事件消費者的子View,其數據結構又是如何的呢?

認真思考,讀者不可貴出答案:鏈表

爲何是鏈表

爲何採用鏈表,有沒有更加簡單粗暴的實現方案?

固然,最符合直覺 的實現方式彷佛是:在經過遞歸完成第一次事件分發以後,將事件的消費者做爲成員保存在當前父View中:

不能否認,這樣的設計徹底能夠實現咱們須要的效果,但讀者仔細思考得知,這種設計最大的問題就是破壞了樹形結構的 內部自治性

最頂層View直接持有最下層某個View的引用合理嗎?答案是否認的。首先,這致使View層級依賴之間的混亂;其次,頂層View自己持有了最下層某個View的引用,則這之間若干個層級的Viewtarget屬性都毫無心義。

更能將樹結構應用淋漓盡致的方式是構建一個鏈表:

每一個View節點都持有事件的下一級消費者,當同一事件序列後續的觸摸事件抵達時,再也不須要進行消耗性能的DFS算法,而是直接交給下一級的子View,子View則直接交給下下一級的子View,直到事件到達真正的消費者:

和鏈表的定義相似,設計者設計了TouchTarget類,同時爲每個ViewGroup都聲明這樣一個成員,做爲鏈表的一個結點,以描述當前事件序列的傳遞方向:

public abstract class ViewGroup extends View {

  // 鏈表的下一級結點
  private TouchTarget mFirstTouchTarget;

  private static final class TouchTarget {
      // 描述接下來的觸摸事件由哪個子View接收並分發
      public View child;
  }
}
複製代碼

那麼這個鏈表是怎麼構建的呢?正如上文所說,當接收到一個ACTION_DOWN時,意味着一次完整事件序列的開始,經過遞歸遍歷找到真正對事件進行消費的Child

讀者需認真揣摩 事件序列 的相關概念,由於這個知識點貫穿了整個 事件分發機制 流程,能夠說是很是核心的知識點;同時,掌握它也是下文快速掌握 事件攔截機制 的關鍵。

事件攔截機制

大多數Android開發者對 事件攔截機制 都不會陌生,讀者應該都有了解,ViewGroup層級額外設計了onInterceptTouchEvent()函數並向外暴露給開發者,以達到讓ViewGroup再也不將觸摸事件交給View處理,而是自身決定是否消費事件,並將結果反饋給上層級的ViewGroup

一、原因

爲何設計出這樣一種攔截機制?其實這是有必要的,以常規的ScrollView對應的滑動頁面爲例,當用戶拋出了一個列表的滑動操做,這時,對應的觸摸事件序列是否還有必要交給ScrollView的子View進行處理?

答案是否認的,當ScrollView接收到滑動操做時,理所固然,本次滑動操做相關事件都再也不須要交給子View,而是直接交給ScrollView去處理滑動操做。

讀者一樣須要明白,並不是全部事件序列都會被攔截——當用戶點擊ScrollView中的某個按鈕時,設計者又指望此次的點擊操對應的系列事件可以被ScrollView分發給子Button去處理,這樣開發者最終可以在按鈕自己的OnClickListener中觀察到此次點擊事件,並進行對應的業務操做。

所以,對於不一樣類型的ViewGroup,開發者須要在不一樣的場景下,作出是否攔截事件的決定,這種 父控件根據自己職責去攔截指定場景的事件序列 的行爲,咱們稱之爲 事件攔截機制

二、攔截函數:onInterceptTouchEvent()

那麼開發者如何作,才能保證 不一樣場景的事件被合理的向下分發或直接攔截 呢?設計者據此提供了 onInterceptTouchEvent() 攔截函數:

public abstract class ViewGroup extends View {

  public boolean onInterceptTouchEvent(MotionEvent ev) {
      // ...
      return false;
  }
}
複製代碼

其定義是,當觸摸事件到來時,事件首先做爲參數傳入onInterceptTouchEvent函數中,開發者自定義onInterceptTouchEvent內部邏輯,以決定是否對該事件進行攔截,並將boolean類型的結果進行返回。當返回值爲true時,該事件序列接下來全部的事件都會被當前的ViewGroup攔截;一般狀況下,ViewGroup的該函數默認返回false,即不對事件進行攔截。

以上文爲例,咱們能夠對ScrollView添加相似以下策略——當用戶發起一個 點擊事件 的操做時,onInterceptTouchEvent返回false,將事件交給下游的子控件去決定消費與否;而當用戶 滑動屏幕 時,則將事件序列進行攔截:

public class ScrollView extends ViewGroup {

  public boolean onInterceptTouchEvent(MotionEvent ev) {
      // 這裏模擬一個抽象的函數代替實際的業務邏輯
      // 實際源碼中,這裏是根據對觸摸事件序列的複雜判斷,得出操做是不是滑動事件
      if (isUserScrollAction(ev)) {
        return true;
      } else {
        return false;
      }
  }
}
複製代碼

事件序列 在這個過程當中再次起到了 相當重要 的做用。針對單獨一個觸摸事件——例如 ACTION_DOWNACTION_MOVE而言,咱們都沒法肯定這是不是咱們但願攔截的操做。而當咱們獲取到 事件序列 中連續若干個事件後,咱們則能夠根據手勢操做的方向和距離(判斷是不是滑動)、觸摸屏幕的時間(判斷是點擊事件仍是長按事件)對用戶的此次行爲進行定義,最終決定是否進行攔截。

——這意味着,當ScrollView接收到最初的ACTION_DOWN事件時,父控件並無當即對事件進行攔截,而是交給了子Button去消費;而當接收了若干個ACTION_MOVE事件時,ScrollViewonInterceptTouchEvent()函數中判斷得出 本次觸摸行爲方向朝下,是滑動事件,而後該函數返回true,致使本次和接下來的觸摸事件都會被攔截。

等等!到了這裏,讀者彷佛推斷出了一個怪異的結論: 針對一個完整事件序列的向下分發過程而言,觸摸事件的消費者並不必定只有一個角色——這彷佛不太符合直覺。

但事實的確如此。

既然一個完整的 事件序列 其事件可能會交給不一樣的角色,這是否意味着極端狀況下,用戶的一次 滑動行爲 不但會觸發了父控件自己的 滑動 效果,用戶也會同時接收到Button子控件的 點擊 效果?

目前爲止的設計中確實存在這個缺陷,所以接下來咱們須要增長新的邏輯單元去彌補這個問題,ACTION_CANCEL閃亮登場。

三、ACTION_CANCEL:彌補與終結

終於來到了ACTION_CANCEL的舞臺,報幕員對這名演員的介紹是兩個單詞:彌補終結

如今咱們但願,當Button的父控件ScrollView對滑動操做進行了攔截時,Button的點擊事件再也不會被響應。

正常的邏輯處理中,Button須要在接收到ACTION_UP時,判斷整個事件序列持續的時間,若是符合一系列單擊操做的前置定義(好比touchable = trueclickable = true等等),就直接交給單擊事件的監聽器View.OnClickListener去處理。

咱們能夠將ACTION_UP視爲 事件序列 中的 終止事件,但很明顯,這個邏輯在 事件攔截機制 中並不適用,由於當父控件對事件進行了攔截後,接下來整個序列中全部的事件都轉交給了父控件,子控件再也接受不到任何事件,包括ACTION_UP

咱們老是但願善始善終(好比期待面試結果的及時反饋),事件分發機制 中也是同樣,當子控件事件被父控件攔截,子控件也須要一個 終止事件 的通知以做出對應的行爲。

所以,設計者額外提供了ACTION_CANCEL事件,以通知當前的View做出對 事件被攔截 以後的收尾工做,好比取消點擊事件或長按事件相關判斷邏輯中的計時器(若是有的話,下同),或者對當前控件滑動距離計算的重置等等,避免了「既發生父控件滑動」又「觸發子控件點擊」的尷尬場景。

如今,當父控件攔截了觸摸事件後,子控件當即接收到一個額外的ACTION_CANCEL做爲彌補,並草草進行了相關的收尾工做,以後的業務邏輯則通通交給了父控件去處理。

事件攔截機制 到此彷佛告一段落,讀者認真思考,這樣的邏輯處理目前已是完美的了麼?

攔截機制與反攔截機制

父控件:「我無效你的效果。」 子控件:「我無效你的無效。」

一、壓迫與抗爭

音樂播放器的進度條控件SeekBar提出了嚴重抗議。

SeekBarScrollView搭配使用時,前者愕然發現,做爲子控件,其最引覺得豪的技能——滑動調整音頻進度的功能徹底被廢掉了。

這是固然的,ScrollView接收到滑動事件時,會很天然的將接下來相關的全部事件都進行攔截,而做爲子控件的SeekBar連湯都喝不上。

這是不合理的設計,父控件的權利實在太大了,而子控件對此徹底一籌莫展。所以設計者爲ViewGroup設計了一個另一個API——requestDisallowInterceptTouchEvent(boolean)

該函數的做用是,命令指定的ViewGroup 是否 再也不針對事件序列進行攔截 ,而是正常將事件交給子控件去處理是否消費事件。

SeekBar爲例,其徹底能夠這樣設計:

public abstract class AbsSeekBar extends ProgressBar {

   // ...代碼大幅簡化,具體邏輯請參考源碼...
   @Override
   public boolean onTouchEvent(MotionEvent event) {
        switch (event.getAction()) {
          // 當接收到ACTION_DOWN事件時,命令父控件不能攔截事件序列
          case MotionEvent.ACTION_DOWN:
              mParent.requestDisallowInterceptTouchEvent(true);
              break;
          // ...
        }
   }
}
複製代碼

如今,即便ScrollView內部持有對 滑動操做 相關的攔截機制,但SeekBar依然能夠經過更高等級的API對其進行壓制,從而跳過父控件相關的攔截,並本身消費滑動事件——最終用戶獲得了他但願獲得的操做體驗(滑動調節播放進度)。

二、更深刻性思考

上一小節的敘述自己是存在瑕疵的,一般來講,調節進度的SeekBar處理的是橫向滑動,而ScrollView處理的則是豎向滑動,本質上二者邏輯並不衝突。

這樣描述,只是爲了讓讀者可以更容易的理解 反攔截機制 對應的requestDisallowInterceptTouchEvent()函數設計的目的及意義,對此讀者沒必要深究——固然,讀者也可自定義實現一個橫向滑動的HorizontalScrollView,以獲得上一小節中滑動衝突的效果,本文不贅述。

另一點須要思考的是,當子控件調用了父控件的requestDisallowInterceptTouchEvent(true)函數無效化了父控件的攔截機制以後,父控件攔截機制的無效化須要一直存在嗎

答案是否認的,正確的方式是應該在某個時間點 對父控件攔截機制進行重啓——即調用requestDisallowInterceptTouchEvent(false),這樣才能保證在觸摸到其它子控件時,父控件依然可以對 事件攔截機制 進行正常的運轉。

那麼這個重置的時間點如何把握,在子控件接收到ACTION_UP時調用嗎?

在子控件 事件序列的終止事件中重置狀態,這聽起來不錯,可是須要注意的是,攔截機制被無效化的狀態是存在父控件ViewGroup中的,所以換個思路,更好的時機會不會實際上是隱藏在ViewGroup中的呢?

三、更好的時機

設計者最終將重置的時機放在了父控件 事件序列的起始事件——ACTION_DOWN的處理邏輯中。

public abstract class ViewGroup extends View {

  @Override
  public boolean dispatchTouchEvent(MotionEvent ev) {
      // ...
      if (actionMasked == MotionEvent.ACTION_DOWN) {
         // 1.這個函數內部將事件攔截功能的開關進行了重置
         resetTouchState();
      }
      // ...2.繼續處理事件攔截和事件分發
  }
}
複製代碼

這確實是重置攔截機制的更好時機,既保證了其它子控件的觸摸事件不會被以前的反攔截機制所影響,同時也維護了ViewGroup內部自己的自治性。

這也證實了 事件序列 中的起始事件 ACTION_DOWN 老是能夠被父控件接收到並進行攔截處理,所以,開發者絕大多數狀況下不能在 ViewGrouponInterceptTouchEvent() 中,直接對ACTION_DOWN事件返回true,由於這將會致使父控件攔截了整個 事件序列 ,子控件連ACTION_DOWN都接受不到,反攔截機制完全失效。

總結

事件攔截機制 是一個很是重要的基礎知識點,而 事件序列 又是其中最核心的概念,不管是 事件分發 仍是 事件攔截,搞懂了 事件序列 的意義,其它邏輯概念的理解都再也不困難。

參考 & 額外的話

這一篇文章就能讓我理解Android事件攔截機制嗎?

固然不能,在撰寫本文的過程當中,筆者最終刪除了若干更細節知識點的講解,好比:

  • 父控件攔截了事件後,其內部的mFirstTouchTarget發生了怎樣的變化?(事件傳遞鏈表的更新操做)
  • 事件的攔截機制經常用於解決開發中的哪些問題?(解決滑動衝突)

等等,這些細節一樣十分重要,它們是填充 事件攔截機制 完總體系的血與肉,建議讀者結合本文與下列相關資料,開啓一次更細緻的探究之旅。

關於我

Hello,我是 卻把清梅嗅,女兒奴,Android,分享者 & 見證者,觀衆途徑序列1,樂於分享的開發者。歡迎關注個人 博客 或者 GitHub

若是您以爲文章對您有價值,歡迎 ❤️,或經過下方打賞功能,督促我寫出更好的文章 :)

相關文章
相關標籤/搜索