「反思」 系列是筆者一個新的嘗試,其起源與目錄請參考 這裏 。html
完整的掌握 Android
事件分發體系並不是易事,其整個流程涉及到了 系統啓動流程(SystemServer
)、輸入管理(InputManager
)、系統服務和UI的通訊(ViewRootImpl
+ Window
+ WindowManagerService
)、View
層級的 事件分發機制 等等一系列的環節。android
事件攔截機制 是基於View
層級 事件分發機制 的一個進階性的知識點,本文將對其進行更細緻化的講解。git
事件攔截機制 自己就相對比較獨立,所以本文不須要讀者有 事件分發機制 相關的預備知識,對後者感興趣的讀者能夠參考如下資料:github
本文總體結構以下圖:算法
想要說清 事件分發機制 和 事件攔截機制,事件序列 是首先要理解的概念。markdown
什麼是事件序列?Google
官方文檔中對其描述爲 The duration of the touch
,顧名思義,咱們能夠將其理解爲 用戶一次完整的觸摸操做流程—— 舉例來講,用戶單擊按鈕、用戶滑動屏幕、用戶長按屏幕中某個UI元素等等,都屬於該範疇。數據結構
爲何 事件序列 是一個很是重要的概念?app
上一篇文章 中,讀者已經瞭解事件分發的本質原理就是遞歸,對此簡單的實現方式是:每接收一個新的事件,都須要進行一次遞歸才能找到對應消費事件的View
,並依次向上返回事件分發的結果。ide
以每一個觸摸事件做爲最基本的單元,都對View
樹進行一次遍歷遞歸?這對性能的影響顯而易見,所以這種設計是有改進空間的。
如何針對這個問題進行改進?將 事件序列 做爲最基本的單元進行處理則更爲合適。
首先,設計者根據用戶的行爲對MotionEvent
中添加了一個Action
的屬性以描述該事件的行爲:
ACTION_DOWN
:手指觸摸到屏幕的行爲ACTION_MOVE
:手指在屏幕上移動的行爲ACTION_UP
:手指離開屏幕的行爲ACTION_CANCEL
...咱們知道,針對用戶的一次觸摸操做,必然對應了一個 事件序列,從用戶手指接觸屏幕,到移動手指,再到擡起手指 ——單個事件序列必然包含ACTION_DOWN
、ACTION_MOVE
... ACTION_MOVE
、ACTION_UP
等多個事件,這其中ACTION_MOVE
的數量不肯定,ACTION_DOWN
和ACTION_UP
的數量則爲1。
熟悉了 事件序列 的概念,設計者就能夠着手對現有代碼進行設計和改進,其思路以下:當接收到一個ACTION_DOWN
時,意味着一次完整事件序列的開始,經過遞歸遍歷找到真正對事件進行消費的Child
,並將其進行保存,這以後接收到ACTION_MOVE
和ACTION_UP
行爲時,則跳過遍歷遞歸的過程,將事件直接分發對應的消費者:
因而可知,事件序列 在 事件分發 的知識體系中的確是很是重要的核心概念(甚至沒有之一),其最重要的意義是 足夠節省性能:用戶一次正常的觸摸行爲,其 事件序列 包含了若干個觸摸事件,這些事件並不是每次都經過遞歸算法去找到事件的消費者,由於這會消耗很是多的內存——當事件序列越複雜、或者View
樹的層級嵌套越深,這種優點愈發明顯。
那麼,源碼的設計者是如何保證經過一次遞歸算法找到View
樹中對應事件消費者的子View
,其數據結構又是如何的呢?
認真思考,讀者不可貴出答案:鏈表。
爲何採用鏈表,有沒有更加簡單粗暴的實現方案?
固然,最符合直覺 的實現方式彷佛是:在經過遞歸完成第一次事件分發以後,將事件的消費者做爲成員保存在當前父View
中:
不能否認,這樣的設計徹底能夠實現咱們須要的效果,但讀者仔細思考得知,這種設計最大的問題就是破壞了樹形結構的 內部自治性。
最頂層View
直接持有最下層某個View
的引用合理嗎?答案是否認的。首先,這致使View
層級依賴之間的混亂;其次,頂層View
自己持有了最下層某個View
的引用,則這之間若干個層級的View
的target
屬性都毫無心義。
更能將樹結構應用淋漓盡致的方式是構建一個鏈表:
每一個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()
攔截函數:
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_DOWN
或ACTION_MOVE
而言,咱們都沒法肯定這是不是咱們但願攔截的操做。而當咱們獲取到 事件序列 中連續若干個事件後,咱們則能夠根據手勢操做的方向和距離(判斷是不是滑動)、觸摸屏幕的時間(判斷是點擊事件仍是長按事件)對用戶的此次行爲進行定義,最終決定是否進行攔截。
——這意味着,當ScrollView
接收到最初的ACTION_DOWN
事件時,父控件並無當即對事件進行攔截,而是交給了子Button
去消費;而當接收了若干個ACTION_MOVE
事件時,ScrollView
的onInterceptTouchEvent()
函數中判斷得出 本次觸摸行爲方向朝下,是滑動事件,而後該函數返回true
,致使本次和接下來的觸摸事件都會被攔截。
等等!到了這裏,讀者彷佛推斷出了一個怪異的結論: 針對一個完整事件序列的向下分發過程而言,觸摸事件的消費者並不必定只有一個角色——這彷佛不太符合直覺。
但事實的確如此。
既然一個完整的 事件序列 其事件可能會交給不一樣的角色,這是否意味着極端狀況下,用戶的一次 滑動行爲 不但會觸發了父控件自己的 滑動 效果,用戶也會同時接收到Button
子控件的 點擊 效果?
目前爲止的設計中確實存在這個缺陷,所以接下來咱們須要增長新的邏輯單元去彌補這個問題,ACTION_CANCEL
閃亮登場。
終於來到了ACTION_CANCEL
的舞臺,報幕員對這名演員的介紹是兩個單詞:彌補 和 終結。
如今咱們但願,當Button
的父控件ScrollView
對滑動操做進行了攔截時,Button
的點擊事件再也不會被響應。
正常的邏輯處理中,Button
須要在接收到ACTION_UP
時,判斷整個事件序列持續的時間,若是符合一系列單擊操做的前置定義(好比touchable = true
或clickable = true
等等),就直接交給單擊事件的監聽器View.OnClickListener
去處理。
咱們能夠將ACTION_UP
視爲 事件序列 中的 終止事件,但很明顯,這個邏輯在 事件攔截機制 中並不適用,由於當父控件對事件進行了攔截後,接下來整個序列中全部的事件都轉交給了父控件,子控件再也接受不到任何事件,包括ACTION_UP
。
咱們老是但願善始善終(好比期待面試結果的及時反饋),事件分發機制 中也是同樣,當子控件事件被父控件攔截,子控件也須要一個 終止事件 的通知以做出對應的行爲。
所以,設計者額外提供了ACTION_CANCEL
事件,以通知當前的View
做出對 事件被攔截 以後的收尾工做,好比取消點擊事件或長按事件相關判斷邏輯中的計時器(若是有的話,下同),或者對當前控件滑動距離計算的重置等等,避免了「既發生父控件滑動」又「觸發子控件點擊」的尷尬場景。
如今,當父控件攔截了觸摸事件後,子控件當即接收到一個額外的ACTION_CANCEL
做爲彌補,並草草進行了相關的收尾工做,以後的業務邏輯則通通交給了父控件去處理。
事件攔截機制 到此彷佛告一段落,讀者認真思考,這樣的邏輯處理目前已是完美的了麼?
父控件:「我無效你的效果。」 子控件:「我無效你的無效。」
音樂播放器的進度條控件SeekBar
提出了嚴重抗議。
當SeekBar
與ScrollView
搭配使用時,前者愕然發現,做爲子控件,其最引覺得豪的技能——滑動調整音頻進度的功能徹底被廢掉了。
這是固然的,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
老是能夠被父控件接收到並進行攔截處理,所以,開發者絕大多數狀況下不能在 ViewGroup
的 onInterceptTouchEvent()
中,直接對ACTION_DOWN
事件返回true
,由於這將會致使父控件攔截了整個 事件序列 ,子控件連ACTION_DOWN
都接受不到,反攔截機制完全失效。
事件攔截機制 是一個很是重要的基礎知識點,而 事件序列 又是其中最核心的概念,不管是 事件分發 仍是 事件攔截,搞懂了 事件序列 的意義,其它邏輯概念的理解都再也不困難。
這一篇文章就能讓我理解Android事件攔截機制嗎?
固然不能,在撰寫本文的過程當中,筆者最終刪除了若干更細節知識點的講解,好比:
mFirstTouchTarget
發生了怎樣的變化?(事件傳遞鏈表的更新操做)等等,這些細節一樣十分重要,它們是填充 事件攔截機制 完總體系的血與肉,建議讀者結合本文與下列相關資料,開啓一次更細緻的探究之旅。
Hello,我是 卻把清梅嗅,女兒奴,Android,分享者 & 見證者,觀衆途徑序列1,樂於分享的開發者。歡迎關注個人 博客 或者 GitHub。
若是您以爲文章對您有價值,歡迎 ❤️,或經過下方打賞功能,督促我寫出更好的文章 :)