Android Touch事件分發處理機制詳解

一、前言

Android應用的開發過程不可能不涉及到Touch事件的處理,簡單地如設置OnClickListener、OnLongClickListener等監聽器處理View的點擊事件,復雜地如在自定義View中通過重寫onTouchEvent來捕獲用戶交互事件以定制出各種效果,在使用的過程中或多或少會遇到一些奇怪的Bug,讓你對Touch事件“從哪來,到哪去”產生迷之疑惑,經過多少次徘徊之后終于決定系統的分析下源碼,本文就給大家分享下我的收獲。

二、MotionEvent

MotionEvent作為Touch事件的載體,采用時間片來管理Touch事件所有相關行為的數據,本文這樣理解時間片這個概念:

  • 縱向上,MotionEvent(一般為Move事件)會在一段時間內多次采樣合成為一個事件進行處理,由于每一次采樣均對應一份采樣得到的Touch數據,因此該事件中就包含了多份Touch數據,并將最近采樣的數據作為當前數據,其他數據存儲為歷史數據,采取這種批量的處理方式主要出于效率的考慮。
  • 橫向上,一個時間片對應特定時間點(可通過getEventTime獲取)的采樣數據數據,該采樣點中可能包含多個觸摸點,MotionEvent中采用Pointer才標記每一個采樣點,每一個Pointer的激活周期為從Down事件至Up或Cancel事件,在激活周期內會分配一個在不同MotionEvent中保持唯一的PointerId,但是在不同MotionEvent種Pointer的排序會不斷調整,因此Pointer在不同MotionEvent中對應的PointerIndex也會不斷變化,根據PointerId可以找到該Pointer在某一MotionEvent種的PointerIndex,根據PointerIndex則可以獲取該Pointer在MotionEvent中相關Touch數據:

rawX:相對于屏幕坐標系的原始X坐標,通過getRawX獲取
rawY:相對于屏幕坐標系的原始Y坐標,通過getRawY獲取
x:相對于事件處理主體坐標系的X坐標,通過getX(int index)獲取
y:相對于事件處理主體坐標系的Y坐標,通過getY(int index)獲取
size:按壓區域的大小,通過getSize(int index)獲取
pressure:按壓的壓力,通過getPressure(int index)獲取
orientation:按壓時屏幕的方向,通過getOrientation(int index)獲取
touchMajor:按壓橢圓區域長邊長,通過getTouchMajor(int index)獲取
touchMinor:按壓橢圓區域短邊長,通過getTouchMinor(int index)獲取

通常MotionEvent會將觸發當前事件的Pointer作為主要Pointer,其PointerIndex為0,而MotionEvent通過提供getX()這類不帶index參數的接口以更方便的操作主要Pointer的數據。
了解了MotionEvent的組成結構之后,接下來就可以分析MotionEvent包含的事件類型了,MotionEvent通過getAction接口來獲取事件Action,而Action中低8位地址存儲的是事件類型(對于觸摸事件來說,主要包括Down、Move、Up、Cancel、PointerDown、PointerUp),高8位地址存儲的是PointerId(當事件類型為PointerDown、PointerUp時)。通常來說事件會以Down開始,以Up或Cancel結束,各事件所承擔的角色以及各自的特點在分析事件分發與處理的過程時再詳細說明。
另外,MotionEvent中的Flag需要說明一下:

FLAG_WINDOW_IS_OBSCURED:是否被透明View遮擋
FLAG_TAINTED:事件是否出現不一致
FLAG_TARGET_ACCESSIBILITY_FOCUS:事件是否需要先觸發輔助功能View

三、ViewGroup.dispatchEvent

1. 事件分發邏輯

本文僅分析Touch事件在Framework中Java層的傳遞,因此從事件傳遞到Activity開始分析。當Touch事件傳遞給Activity時,會調用Activity.dispatchTouchEvent(MotionEvent),Activity會將事件傳遞給其Window進行處理,實際會調用PhoneWindow.superDispatchTouchEvent(MotionEvent),PhoneWindow會將該事件傳遞給Android中View層級中的頂層View(即DecorView)進行處理:

public boolean dispatchTouchEvent(MotionEvent ev) {
    final Callback cb = getCallback();
    return cb != null && !isDestroyed() && mFeatureId < 0 ? cb.dispatchTouchEvent(ev)
            : super.dispatchTouchEvent(ev);
    }

在Window未設置Callback的情況下,會調用父類的dispatchTouchEvent,DecorView繼承自FrameLayout,然后FrameLayout并未實現dispatchEvent,因此最終調用ViewGroup.dispatchTouchEvent,也就是Touch事件分發的核心邏輯所在,前文中提到MotionEvent中事件類型主要包括Down、Move、Up、Cancel、PointerDown、PointerUp,而dispatchTouchEvent根據事件的不同類型會做不同處理,因此這里分別進行分析:

Down事件處理

非異常情況下,Touch事件的事件周期總是以Down事件開始的,因此Down事件在整個事件分發邏輯中起關鍵作用,將決定了后續Move、Up及Cancel事件的處理主體,先看一張Down事件分發的流程圖:

Down事件處理邏輯

從流程圖中可以看到,Down事件的分發邏輯主要目的在于尋找到能處理該Touch事件的View控件(該View為以當前ViewGroup為Root節點的View層級中的View,利用尋找到的View創建事件處理Target),整個處理邏輯主要包含以下幾步:

  • 清空異常及已有狀態
    • 給所有之前選擇出的Target發送Cancel事件,確保之前Target能收到完整的事件周期;
    • 清除已有Target,復位所有標志位(如PFLAG_CANCEL_NEXT_UP_EVENT、FLAG_DISALLOW_INTERCEPT等)
  • 調用onInterceptTouchEvent以確定當前ViewGroup是否攔截該Down事件
    • 若攔截,此事件不會向下傳遞給子View,而調用super.dispatchEvent判斷該ViewGroup本身是否需要消費事件
    • 若不攔截,則會遍歷所有子View尋找是否有子View需要消費該事件
      • 若有子View需要消費該事件,則設定該事件的處理Target為該子View
      • 若無子View需要消費該事件,則調用super.dispatchEvent判斷該ViewGroup本身是否需要處理該事件
  • 若事件處理Target不為空或該ViewGroup消費該事件,則返回true;否則返回false。返回值將決定該ViewGroup的上級ViewGroup是否需要繼續詢問其他子View是否需要消費該事件;對于處于頂層的DecorView來說,其返回值會決定包含該DecorView的Activity是否需要調用Activity.onTouchEvent進行處理。

Move、Up、Cancel事件處理

完成Down事件的分發邏輯后,就確定了該Down事件后續Move、Up及Cancel事件的處理主體(注意:這里并沒有確定PointerDown事件的處理主體,關于PointerDown事件的分發邏輯稍后分析),先通過一張流程圖來感受下Move、Up、Cancel事件的分發邏輯:

Move、Up、Cancel事件的處理邏輯

從流程圖可以看出,對于Move、Up、Cancel事件的分發步驟如下:

  • 判斷在Down事件的處理中是否找到可處理該事件的Target:
    • 存在Target,則調用onInterceptTouchEvent以確定當前ViewGroup是否攔截該事件:
      • 攔截,直接調用super.dispatchEvent判斷該ViewGroup本身是否需要消費事件
      • 不攔截,傳遞該事件至所有已有Target
    • 不存在Target,直接調用super.dispatchEvent判斷該ViewGroup本身是否需要消費事件
  • 若事件為Up或Cancel,表明一個完整事件周期結束,則清除已有Target,復位被置位的標志位(如PFLAG_CANCEL_NEXT_UP_EVENT、FLAG_DISALLOW_INTERCEPT等)

PointerDown事件處理

PointerDown事件是在支持多Pointer(調用setMotionEventSplittingEnabled將FLAG_SPLIT_MOTION_EVENTS置位)的環境下,當有新的Pointer按下時產生的,該事件處理的特殊性在于會重新遍歷View層級,尋找可以處理新Pointer事件的Target,具體流程參考Down事件的分發邏輯;遍歷結束若仍沒有找到處理該事件的Target,則會將新Pointer的處理權設置給已有Target中最早被添加的Target。完成Target的尋找之后,會將該事件通過dispatchTransformedTouchEvent傳遞至所有已有Target進行處理,可以通過下面流程圖,對PointerDown事件的處理有一個更全局的認識:

PointerDown事件處理邏輯

PointerUp事件處理

相對于Up事件來說,對于PointerUp事件的處理區別在于當傳遞至所有已有Target結束之后并不能標記以Down事件起始的整個事件周期結束,僅能標記其關聯Pointer(以PointerDown事件起始)的事件周期結束,因此不會清除所有狀態,而僅會從已有Target中移除掉與該Pointer相關的部分。

2. 關鍵函數分析

onInterceptTouchEvent

在ViewGroup進行事件分發的過程中,會調用該函數來確定是否需要攔截事件,當該函數返回true時該事件將會被攔截,即不會進行正常的View層級傳遞,而是直接由該ViewGroup來處理,而攔截后的操作需要根據攔截事件的類型不同而不同:

  • Down事件:該事件及后續所有事件均不會傳遞至其子View,而該事件會由該ViewGroup嘗試消費:
    • 若消費,則后續所有事件均有該ViewGroup消費
    • 若不消費,則后續事件均不會傳遞至以該ViewGroup為root的View層級
  • PointerDown事件:
    • 若存在處理了Down事件的Target,則傳遞Cancel事件給所有已有Target;
    • 該事件不會傳遞至子View,而直接由ViewGroup接手處理;
  • Move、Up、PointerUp、Cancel事件:若存在處理了Down事件的Target,則會先傳遞Cancel事件給所有已有Target,而該事件及后續事件均由該ViewGroup接手消費(不管是否實際消費)

dispatchTransformedTouchEvent(MotionEvent event, boolean cancel, View child, int desiredPointerIdBits)

在將事件傳遞給Target進行處理之前會調用該函數對MotionEvent進行處理:

  • cancel為true,會將事件Action轉換成ACTION_CACEL
    • child為null:調用ViewGroup.super.dispatchTouchEvent
    • child不為null:調用child.dispatchTouchEvent
  • cancel為false
    • Down、Move、Up事件
      • child為null:調用ViewGroup.super.dispatchTouchEvent
      • child不為null:轉換事件的X、Y坐標至child的坐標系,調用child.dispatchTouchEvent
    • PointerDown、PointerUp事件:調用MotionEvent.split分離并創建出該Pointer的事件
      • child為null:調用ViewGroup.super.dispatchTouchEvent
      • child不為null:轉換事件的X、Y坐標至child的坐標系,調用child.dispatchTouchEvent

MotionEvent.split(int idBits)

  • 參數idBits:需要分離的pointerId,MotionEvent的Pointer最大為32,因此用整數可唯一標記任一事件中的任一Pointer的Id;
    • 分割步驟:
      • 遍歷當前事件中所有Pointer,找出與idBits中id相對應的Pointer
      • 計算出新pointerCount
      • 計算出新pointerIndex:對于Action_Pointer_Down或Action_Pointer_Up事件,事件中需存在pointerIndex;
      • 轉化Action:
        • Action_Pointer_Down或Action_Pointer_Up事件類型:
          • 無新pointerIndex,表明該事件中按下的pointer并不在idBits的興趣范圍,因此轉換為ACTION_MOVE事件
          • 新pointerCount等于1,表明新事件中僅有一個Pointer,因此Action中事件類型由轉換Action_Pointer_Down為ACTION_DOWN或由Action_Pointer_Up轉換為ACTION_UP
          • 其他情況表明新事件中有多個Pointer,因此不轉換Action中事件類型,但由原事件類型和新pointerIndex合成為新的Action
        • 其他事件無需轉換
      • 創建新事件
      • 添加Batch中的History Event
      • 返回分割得到的新事件

四、View.dispatchEvent

判斷一個View控件是否消費一個事件,是由View.dispatchEvent的返回值來決定的,而View.dispatchEvent用于尋找事件的最終消費者,話不多說,還是通過一張流程圖來個直觀感受:

事件消費流程

從流程圖中可以看出,View會根據ouch事件對Scroll狀態進行調整,并尋找該事件的最終處理器:

  • 首先傳遞給mTouchListener.onTouch
    • 返回true,消費掉該事件
    • 返回false,未消費該事件,則繼續傳遞給View.onTouchEvent
      • 返回true,消費掉該事件
      • 返回false,未消費該事件

View.dispatchEvent將向其直接ViewGroup返回是否消費掉該事件,返回值將決定上級ViewGroup是否需要繼續詢問其他子View是否需要消費該事件。這就是View中分發事件的邏輯,真是簡單粗暴!

五、View.onTouchEvent

從View.dispatchEvent的分析中可以發現當未對View設置mTouchListener或mTouchListener未消費掉該事件時,Touch事件最終將由View.onTouchEvent來決定是否消費,自定義View可以重寫該方法實現自身的邏輯,此處僅分析View中的通用處理邏輯:

  • View處于Disabled狀態,若可點擊(Clickable、LongClickable、ContextClickable某一項為true)則消費掉改事件,但不執行任何具體邏輯;
  • View處于enable狀態下
    • 若存在touchDelegate(可用于調整View的可點擊區域),則將事件轉發給touchDelegate
    • 不存在touchDelegate:
      • 可點擊:
        • Action_Down:
          • 若在Scroll容器內,則設置為PrePressed狀態,并延遲判定該事件是否點擊事件;
          • 若不在Scroll容器,設置為Pressed狀態,并延遲判定該事件是否為長按事件;
        • Action_Move:若移出該View區域,則取消點擊及長按判定,并設置為非Pressed狀態;
        • Action_Cancel:取消點擊長按判定,設置為非Pressed狀態,清除其他狀態;
        • Action_Up:
          • 若為PrePressed狀態,則設置為Pressed狀態
          • 若為Pressed狀態:
            • 未執行長按邏輯:移除長按判定,執行點擊邏輯
            • 已執行長按邏輯:無需執行點擊邏輯
          • 設置為非Pressed狀態,移除當前時間點之前的點擊及長按判定
      • 不可點擊:不消費該事件

從上述分析可以很開心地發現熟悉的onClick及onLongClick事件的產生邏輯,若是之前沒看過類似的文章,應該會有原來如此的感覺吧,哈哈~~

六、后記

至此,Touch事件的分發與處理流程算是走通了,個人看完整個源碼之后有種豁然開朗的感覺,能很清晰的分析向“為什么事件有時候傳到某個View有時候卻不傳?”、“有時候只傳前面幾個事件后面卻不傳了?”等問題,也希望本文的分析能讓你更清晰地感知Android中Touch事件的傳遞流程,如果發現文中有何錯誤,希望不吝賜教!

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容