View事件分發機制分析

磊少工作室_CTO發表於2019-01-11

View 事件分發是很重要的知識點,只有理解其中的原理 在寫程式碼過程中更精準的處理程式碼邏輯,控制好 api 的呼叫時機。本文通過閱讀SDK 28的原始碼,在這裡做一次輸出,深入理解下。

目錄

一、例項引申

二、事件分發原理

    1. Activity
    1. ViewGroup
    1. View

三、總結

一、例項引申

public class MainActivity extends Activity {
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        (Button)findViewById(R.id.btn).setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                Log.d("MainActivity", "click btn");
            }
        });
    }
}
複製程式碼
# activity_main.xml

<RelativeLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">
        <Button
            android:id="@+id/btn"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"/>
</RelativeLayout>
複製程式碼

以上是最簡單的點選按鈕點選事件,對我們應用層開發來講就是點選了一個Button,然後回撥到了 listener 中的onClick 方法,但其背後的原理要從觸控到螢幕開始講起。

二、事件分發原理

1. Activity

觸控事件首先會達到 Activity 中的 dispatchTouchEvent 方法內,如果你問我觸控螢幕後是怎麼到達 Activity 的,這個問題 I don't know!也並不是本文談論的範圍。

public boolean dispatchTouchEvent(MotionEvent ev) {
    if (ev.getAction() == MotionEvent.ACTION_DOWN) {
        onUserInteraction();
    }
    if (getWindow().superDispatchTouchEvent(ev)) {
        return true;
    }
    return onTouchEvent(ev);
}
複製程式碼

這裡有必要解釋一下 MotionEvent 這個物件,這是觸控事件發生後,系統將觸控事件動作(ACTION_DOWN、ACTION_MOVE、ACTION_UP、ACTION_CANCEL)、觸控座標點、多指觸控等資訊儲存到此物件中,以便傳輸時操作。而我們觸控螢幕時是一序列的事件,會有按壓然後不停的移動,最後會抬起,這些動作和座標點都是會變化的,也就是說會產生down + 很多 move + up/cancel 事件,多指觸控比較複雜不在本文討論範圍內。

onUserInteraction 方法是 Activity 內的一個空實現,如果想在觸控螢幕的最初期做一些操作,可以重寫此方法。對於 View 事件分發必須要有一個「消費」的概念,觸控事件到底是在哪一步、哪一個元件裡被消費了。在這裡,若 getWindow().superDispatchTouchEvent(ev) 返回 true 代表事件被某個元件消費了,此時直接返回 true 結束,如果事件沒被消費,那麼就繼續走到 onTouchEvent 方法,Activity 的 onTouchEvent 基本上都會返回 false, 表示沒有消費。

直接跟到 getWindow().superDispatchTouchEvent(ev) 方法,在 Android 系統中 Window 抽象類唯一的實現類就是 PhoneWindow, 而 PhoneWindow 內部呼叫了 DecorView.superDispatchTouchEvent(event), 此方法內又呼叫了 super.dispatchTouchEvent(event), 也就是調到 ViewGroup 的 dispatchTouchEvent 方法。

ps: DecorView 就是所有一個頁面(也就是setContentView後)的最頂層View。

至此觸控事件從 Activity 傳遞到了 ViewGroup 中,這裡把 Window 和 DecorView 的呼叫過程都寫在 Activity 範疇內,因為這個流程是很簡單的,沒必要分開。

下圖是Activity事件分發呼叫流程圖解:

Activity事件分發

2. ViewGroup

ViewGroup 中有三個關鍵方法:

  • dispatchTouchEvent 用於觸控事件一開始傳遞到 ViewGroup 時呼叫,
  • onInterceptTouchEvent 用於攔截觸控事件,決定是否自己來消費事件。
  • onTouchEvent 用於消費觸控事件。

看原始碼有些細節是真的看不懂,但是那些細節又不是特別重要,那麼就略過好了。。只看重要的呼叫流程。 由於 dispatchTouchEvent 方法內容很多,因此分幾塊去看。首先是 ViewGroup 是否需要攔截的部分。

public boolean dispatchTouchEvent(MotionEvent ev) {
    ...
    
    final int action = ev.getAction();
    final int actionMasked = action & MotionEvent.ACTION_MASK;

    // Handle an initial down.
    // 當一個ACTION_DOWN事件來的時候代表使用者剛點選了螢幕,
    // 此時將之前的觸控狀態、手勢都還原,重新開始一個事件序列。
    if (actionMasked == MotionEvent.ACTION_DOWN) {
        // Throw away all previous state when starting a new touch gesture.
        // The framework may have dropped the up or cancel event for the previous gesture
        // due to an app switch, ANR, or some other state change.
        cancelAndClearTouchTargets(ev);
        resetTouchState();
    }

    // Check for interception.
    // 此標誌位代表自己是否要攔截這個事件
    final boolean intercepted;
    if (actionMasked == MotionEvent.ACTION_DOWN
            || mFirstTouchTarget != null) {
        // 通過 mGroupFlags 標誌位得到是否允許我這個ViewGroup攔截事件
        final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;
        if (!disallowIntercept) {
            // 基本上onInterceptTouchEvent都會返回false,代表不攔截,
            // 除非自定義ViewGroup,重寫此方法是解決滑動衝突的重要手段
            intercepted = onInterceptTouchEvent(ev);
            ev.setAction(action); // restore action in case it was changed
        } else {
            intercepted = false;
        }
    } else {
        // There are no touch targets and this action is not an initial down
        // so this view group continues to intercept touches.
        intercepted = true;
    }
    
    ...
}
複製程式碼

上述一段原始碼做了一些註釋,解釋了其流程的邏輯。這裡有幾個重要變數需要解釋的。

mFirstTouchTarget:此物件是一個單連結串列結構,儲存這一系列的事件(ACTION_DOWN、ACTION_MOVE...、ACTION_UP)發生時所涉及到的子View,因此觸控事件 ACTION_DOWN 發生後如果這個物件還是為null,那麼就表示 ViewGroup 沒有將事件傳遞到子View。

mGroupFlags:mGroupFlags 可以理解為很多個標誌位的組合。mGroupFlags & FLAG_DISALLOW_INTERCEPT != 0 表示這個標誌位組合內有「不允許攔截事件」這個標誌位(類似於Map中找一個Key是否存在)。對於位運算本人一直很疑惑,雖說這些不一定都需要看懂,但是這些判斷邏輯的標誌位看不懂就很難受。。反正在看位運算的時候千萬不要按一貫的邏輯在腦海裡把數值轉換成十進位制的,就用二進位制去理解,這裡推薦一篇位操作文章。

    ...
    
    final View[] children = mChildren;
    for (int i = childrenCount - 1; i >= 0; i--) {
        ...
        
        if (!canViewReceivePointerEvents(child)
            || !isTransformedTouchPointInView(x, y, child, null)) {
            ev.setTargetAccessibilityFocus(false);
            continue;
        }
        
        ...

        if (dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign)) {
            // Child wants to receive touch within its bounds.
            // if 程式碼塊內主要儲存了一些變數,設定標誌位
            // 記錄找到的子View,以便之後的事件序列可以直接使用目標View
            ...
            break;
        }
    }
    
    ...
}
複製程式碼

這裡省略了很多雜七雜八的程式碼,關鍵還是在於遍歷 ViewGroup 的所有子 View, 通過 isTransformedTouchPointInView 方法找到點選時座標落在哪個子 View 上,跟進 dispatchTransformedTouchEvent 看看:

private boolean dispatchTransformedTouchEvent(MotionEvent event, boolean cancel,
        View child, int desiredPointerIdBits) {
    ...
    
    final boolean handled;
    // If the number of pointers is the same and we don't need to perform any fancy
    // irreversible transformations, then we can reuse the motion event for this
    // dispatch as long as we are careful to revert any changes we make.
    // Otherwise we need to make a copy.
    final MotionEvent transformedEvent;
    if (newPointerIdBits == oldPointerIdBits) {
        if (child == null || child.hasIdentityMatrix()) {
            if (child == null) {
                // View來處理事件
                handled = super.dispatchTouchEvent(event);
            } else {
                final float offsetX = mScrollX - child.mLeft;
                final float offsetY = mScrollY - child.mTop;
                // 這裡就是將觸控點的座標轉換成子檢視的座標
                event.offsetLocation(offsetX, offsetY);
                // 子檢視處理事件
                handled = child.dispatchTouchEvent(event);
                // 又將觸控座標還原,之前轉換的座標只適合那個子檢視
                event.offsetLocation(-offsetX, -offsetY);
            }
            return handled;
        }
        transformedEvent = MotionEvent.obtain(event);
    } else {
        transformedEvent = event.split(newPointerIdBits);
    }
    
    ...
    return handled;
}
複製程式碼

這裡的英文註釋解釋的很清晰,這個方法的主要作用就是將觸控事件轉換成子View相對父容器的座標,並過濾一些不相關的觸控點(由於不討論多點觸控所以不必糾結),如果沒有子檢視,那麼就會傳到 View 的 dispatchTouchEvent 方法(要知道 ViewGroup 就是繼承自 View)。最後返回的 handled 代表是否被處理了,也就是事件是否被消費了。

以上幾塊程式碼在 ViewGroup.dispatchTouchEvent 方法中是針對 ACTION_DOWN 這個動作所做的處理,因此還需要做其他動作的處理,其實完全是類似的,只是操作更簡單了:

...

if (mFirstTouchTarget == null) {
    // No touch targets so treat this as an ordinary view.
    handled = dispatchTransformedTouchEvent(ev, canceled, null,
            TouchTarget.ALL_POINTER_IDS);
} else {
    // Dispatch to touch targets, excluding the new touch target if we already
    // dispatched to it.  Cancel touch targets if necessary.
    TouchTarget predecessor = null;
    TouchTarget target = mFirstTouchTarget;
    while (target != null) {
        final TouchTarget next = target.next;
        if (alreadyDispatchedToNewTouchTarget && target == newTouchTarget) {
            handled = true;
        } else {
            final boolean cancelChild = resetCancelNextUpFlag(target.child)
                    || intercepted;
            if (dispatchTransformedTouchEvent(ev, cancelChild,
                    target.child, target.pointerIdBits)) {
                handled = true;
            }
            
            ...
        }

    }
}

...
複製程式碼

程式碼大致意思就是如果沒有找到對應的子 View 即 mFirstTouchTarget = null, 那麼交給 View.dispatchTouchEvent 處理;如果之前的 ACTION_DOWN 動作已經找到了子 View,那麼就繼續給它處理。

ViewGroup.onInterceptTouchEvent 方法,這個方法預設基本不做什麼事,一般會返回 false;但它是解決滑動衝突的關鍵方法,遇到滑動衝突時,需要重寫此方法。

ViewGroup 的 onTouchEvent 完全是繼承了 View 的 onTouchEvent 方法,因此處理方式和 View 完全相同,此方法在 View 小節分析。

ViewGroup事件分發呼叫流程圖解:

ViewGroup事件分發

3. View

View 中有兩個關鍵方法:

  • dispatchTouchEvent 用於觸控事件傳遞到 View 時觸發。
  • onTouchEvent 用於消費觸控事件。
/**
 * Pass the touch screen motion event down to the target view, or this
 * view if it is the target.
 *
 * @param event The motion event to be dispatched.
 * @return True if the event was handled by the view, false otherwise.
 */
public boolean dispatchTouchEvent(MotionEvent event) {
    ...
    
        if (li != null && li.mOnTouchListener != null
                && (mViewFlags & ENABLED_MASK) == ENABLED
                && li.mOnTouchListener.onTouch(this, event)) {
            result = true;
        }

        if (!result && onTouchEvent(event)) {
            result = true;
        }

    ...
    return result;
}
複製程式碼

首先判斷 OnTouchListener 是否為空,再判斷這個 View 是否可以用(即setEnable屬性,預設都是true),然後呼叫 OnTouchListener.onTouch 方法執行我們自定義的觸控操作,如果此方法返回 true, 則代表事件被消費,接下來不需要執行 onTouchEvent; 如果我們使其返回 false, 那麼可以繼續傳遞給 onTouchEvent 去消費。跟進 View.onTouchEvent 看看:

public boolean onTouchEvent(MotionEvent event) {

    final boolean clickable = ((viewFlags & CLICKABLE) == CLICKABLE
            || (viewFlags & LONG_CLICKABLE) == LONG_CLICKABLE)
            || (viewFlags & CONTEXT_CLICKABLE) == CONTEXT_CLICKABLE;

    if (clickable || (viewFlags & TOOLTIP) == TOOLTIP) {
        switch (action) {
            case MotionEvent.ACTION_UP:
                    ...
                    
                    // Use a Runnable and post this rather than calling
                    // performClick directly. This lets other visual state
                    // of the view update before click actions start.
                    if (mPerformClick == null) {
                        mPerformClick = new PerformClick();
                    }
                    if (!post(mPerformClick)) {
                        performClick();
                    }
                    
                    ...
        }

        return true;
    }

    return false;
}
複製程式碼

其中最關鍵的部分就是我們最常用的 click 事件,一些長按等事件的邏輯這裡就不再分析。 通過屬性判斷 View 是否可點選,並且在手指抬起時即 ACTION_UP 執行 performClick 方法,其內部就是判斷使用者是否設定了 OnClickListener 監聽器,如果有則呼叫 onClick 方法。

public boolean performClick() {
    final boolean result;
    final ListenerInfo li = mListenerInfo;
    if (li != null && li.mOnClickListener != null) {
        playSoundEffect(SoundEffectConstants.CLICK);
        li.mOnClickListener.onClick(this);
        result = true;
    } else {
        result = false;
    }
    return result;
}
複製程式碼

View 的事件分發大致流程就是這樣了。其中處理的優先順序是:

  • 如果使用者設定了 OnTouchListener, 那麼就會呼叫 onTouch 方法,並且如果 onTouch 方法返回true, 那麼就不會執行 onTouchEvent 了,也就不會執行 onClick 了;
  • 如果來到 onTouchEvent 方法,那麼就有機會去執行 OnClickListener.onClick 方法,除非你執行了長按之類的操作;
  • 最後回撥到 onClick;
  • onTouch -> onTouchEvent -> onClick

View 事件分發呼叫流程圖解:

View事件分發

三、總結

事件分發

觸控事件會經過以下幾個元件:Activity、Window、DecorView、ViewGroup、View。

  • 當使用者點選螢幕時,觸控事件 MotionEvent 最先傳遞到 Activity.dispatchTouchEvent 方法,然後傳遞到 PhoneWindow.superDispatchTouchEvent 方法,緊接著傳到 DecorView.dispatchTouchEvent 方法,然後直接呼叫了父類 ViewGroup.dispatchTouchEvent 方法。
  • 在 ViewGroup 的 dispatchTouchEvent 中主要做了以下幾件事:當 ACTION_DOWN 事件來的時候,判斷現在的 ViewGroup 是否攔截這個事件,而 onInterceptTouchEvent 方法一般返回 false; 同樣地,針對 ACTION_DOWN 事件,會遍歷一遍 ViewGroup 的所有子 View, 點選如果落在某個子 View 上,那麼就將觸控事件傳遞給子 View 的 dispatchTouchEvent 方法,如果沒有找到子 View 那就直接交給父類 View.dispatchTouchEvent 處理事件;當 ACTION_MOVE 或 ACTION_UP 等事件來的時候,依然會傳給子 View 或 父類 View 實現的 dispatchTouchEvent, 只是這個過程不用再攔截了,只要 down 的時候攔截了,那麼都會交由此 View 攔截,除非呼叫了 requestDisallowInterceptTouchEvent;
  • 最後事件會來到 View, dispatchTouchEvent 主要去找是否有 OnTouchListener 監聽,如果有則呼叫 onTouch 方法,並根據此方法的返回值決定是否執行 onTouchEvent 方法,onTouchEvent 方法內部會判斷是否有 OnClickListener 監聽,如果有則呼叫 onClick。
  • 如果事件達到了子 View,而子 View 並沒有去消費它,那麼這個事件會拋到上一層,如果每層的父檢視都不消費事件,那麼最後會交給 Activity 執行 onTouchEvent 方法。

事件分發的理解是通過《Android開發藝術探索》(好書) + View事件分發(好文)。

理解事件分發機制的原理後,突然發現,原始碼的設計都是很巧妙的,有些業務場景我們也可以採用這種從上到下委託的方式去設計程式碼不是嗎?因此看原始碼能提高自身的程式碼質量,這點是毋庸置疑的。後來搜尋了下,這就是責任鏈模式啊。。

其實原始碼中的註釋非常詳細、清晰,比我們平時接觸的業務程式碼不知道清晰多少倍,但有一點讓大多數人望而卻步,那就是英語。英語對程式設計來說太重要了,因此本人現在已經重新開始學習英語了。。看不懂的註釋就配合著翻譯強行去看。

相關文章