探索View的事件分發機制

weixin_33907511發表於2017-11-05

可能你遇到過這樣的情形,從github上down下來一個開源專案的demo跑到好好的,可是一用到自己專案中就出現各種問題,例如滑動衝突問題,可是不知道從何下手解決?瞭解View的分發機制也許可以幫助你。在自定義有互動View中,事件分發處於一個比較重的地位,也是面試的常客。

在開始之前呢先囉嗦一點題外話,我們在平時學習工作中經常會遇到一些問題,特別是作為開發人員,通常的做法是google、baidu一些資料,看看別人有沒有遇到過類似的問題,借鑑他們的處理方案。這的確是一個有效的方法,可是我們都堅信一點,那就是不管在生活上還是在工作上,總有的時候沒有人可以給你參考,你需要獨立思考並作出選擇,所以養成獨立思考的習慣也很重要。

通常來說要去探究一件事,總要有一些線索才行,你比如說警察破案,他要勘察案發現場,蒐集一些證據,然後沿著線索一步步偵破案件。那我們現在要分析View的事件分發機制,如何去找這個所謂的線索,事實上程式的“作案”過程會被完整地記錄了,那就是方法棧。你是不是聯想到了平時程式異常時打出來的異常棧,就是它,現在就案情重演一次,看看它的“作案”過程:

btn.setOnTouchListener(new View.OnTouchListener() {
    @Override
    public boolean onTouch(View v, MotionEvent event) {
        //throw new RuntimeException("Touch");
        Thread.dumpStack();
        return false;
    }
});

這裡選擇給一個Button設定OnTouchListener,然後在onTouch方法中通過Thread.dumpStack()列印出方法的呼叫棧,當然你也可以丟擲一個異常,或者進入debug模式來檢視。點一下這個Button列印出了下面這一段內容:

3631399-a9a1e0989616d519.png
事件傳遞的方法呼叫棧

這一段內容記雖然比較長,但是不復雜。它展示了點選事件在整個Android系統中的傳遞過程,從ZygoteInit.main方法到OnTouchListener.onTouch方法。從嚴格意義上來講,這不算是完整的過程,為啥?通常點選事件是從點選螢幕開始,當點選手機螢幕後,驅動程式是如何處理並把這個事件交給Android系統這個過程是沒有體現的。

這次我並不打算分析完整個過程,因為其實在應用層,事件的輸入通常是在應用的介面上,而我們編寫介面基本是開始於Activity,所以從Activity開始分析往下分析,就是下面這一段:

3631399-0f1edf08f276628b.png
start_from_activity.png

是不是看起來內容少了很多,心理負擔一下子就輕了不少。可以看到事件的傳遞過程是Activity->PhoneWindow->DecorView->ViewGroup->View。下面就沿著這條線索摸索摸索,首先是Activity的dispatchTouchEvent方法:

public boolean dispatchTouchEvent(MotionEvent ev) {
    if (ev.getAction() == MotionEvent.ACTION_DOWN) {
        //這個方法是空的實現,用法可以看看它的註釋
        onUserInteraction();
    }
    //將事件傳遞到Window中
    if (getWindow().superDispatchTouchEvent(ev)) {
        return true;
    }
    
    //如果子View都不消耗該事件,那Activity的onTouchEvent()方法就會被呼叫
    return onTouchEvent(ev);
}

可以看到,Activity將事件傳遞給了Window,如果Window的superDispatchTouchEvent()方法返回true,那Activity的onTouchEvent就不會被呼叫了。

接著就到Window了,這個Window是個抽象類,我們要找的是它的實現類,通過前面的方法呼叫棧知道它是PhoneWindow,看看它的superDispatchTouchEvent方法:

@Override
public boolean superDispatchTouchEvent(MotionEvent event) {
    return mDecor.superDispatchTouchEvent(event);
}

Window又將事件傳遞給了DecorView,可見這個Window充當了Activity和DecorView之間的連線紐帶。

//DecorView#superDispatchTouchEvent
public boolean superDispatchTouchEvent(MotionEvent event) {
    return super.dispatchTouchEvent(event);
}

DecorView的superDispatchTouchEvent方法呼叫了父類的dispatchTouchEvent,而DecorView的直接父類是FrameLayout,而FrameLayout的dispatchTouchEvent方法是從ViewGroup繼承下來的,所以事件就傳遞到了ViewGroup中,這點從方法棧也可以看出來。

ViewGroup的dispatchTouchEvent方法程式碼是比較多的,也是View事件分發的核心,搞清楚它基本也就搞清楚了View的事件分發過程,下面分段來看看這個方法的實現:

// Handle an initial 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();
}

從這段程式碼實現可以看到在DOWN事件的時候會重置一些狀態資訊。

// Check for interception.
final boolean intercepted;
if (actionMasked == MotionEvent.ACTION_DOWN
        || mFirstTouchTarget != null) {
    final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;
    if (!disallowIntercept) {
        intercepted = onInterceptTouchEvent(ev);
        ev.setAction(action); // restore action in case it was changed
    } else {
        intercepted = false;
    }
} else {
    //從這個else條件可以看出,只要不是DOWN事件或者mFirstTouchTarget為null, intercepted直接賦值為true,也就是預設攔截。這個DOWN很好理解,但是這個mFirstTouchTarget是什麼現在還不知道
    
    // There are no touch targets and this action is not an initial down
    // so this view group continues to intercept touches.
    intercepted = true;
}

從這一段程式碼可以看到在DOWN事件的時候,會呼叫onInterceptTouchEvent()方法來詢問是否要攔截該事件,並通過intercepted來標記,後面的程式碼會根據這個標記來選擇不同的處理方式,首先看看intercepted為false,即事件會向下傳遞給子View:

// Find a child that can receive the event.
// Scan children from front to back.
final ArrayList<View> preorderedList = buildTouchDispatchChildList();
final boolean customOrder = preorderedList == null
        && isChildrenDrawingOrderEnabled();
final View[] children = mChildren;

//遍歷所有的子View
for (int i = childrenCount - 1; i >= 0; i--) {
    final int childIndex = getAndVerifyPreorderedIndex(
            childrenCount, i, customOrder);
    final View child = getAndVerifyPreorderedView(
            preorderedList, children, childIndex);

    // If there is a view that has accessibility focus we want it
    // to get the event first and if not handled we will perform a
    // normal dispatch. We may do a double iteration but this is
    // safer given the timeframe.
    if (childWithAccessibilityFocus != null) {
        if (childWithAccessibilityFocus != child) {
            continue;
        }
        childWithAccessibilityFocus = null;
        i = childrenCount - 1;
    }
    
    //判斷子View是否可以接收點選事件,點選事件的x,y座標是否在子View內部
    if (!canViewReceivePointerEvents(child)
            || !isTransformedTouchPointInView(x, y, child, null)) {
        ev.setTargetAccessibilityFocus(false);
        continue;
    }
    
    newTouchTarget = getTouchTarget(child);
    if (newTouchTarget != null) {
        // Child is already receiving touch within its bounds.
        // Give it the new pointer in addition to the ones it is handling.
        newTouchTarget.pointerIdBits |= idBitsToAssign;
        break;
    }

    resetCancelNextUpFlag(child);
    if (dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign)) {
        // Child wants to receive touch within its bounds.
        mLastTouchDownTime = ev.getDownTime();
        if (preorderedList != null) {
            // childIndex points into presorted list, find original index
            for (int j = 0; j < childrenCount; j++) {
                if (children[childIndex] == mChildren[j]) {
                    mLastTouchDownIndex = j;
                    break;
                }
            }
        } else {
            mLastTouchDownIndex = childIndex;
        }
        mLastTouchDownX = ev.getX();
        mLastTouchDownY = ev.getY();
        newTouchTarget = addTouchTarget(child, idBitsToAssign);
        alreadyDispatchedToNewTouchTarget = true;
        break;
    }

    // The accessibility focus didn't handle the event, so clear
    // the flag and do a normal dispatch to all children.
    ev.setTargetAccessibilityFocus(false);
}

這段程式碼也比較簡單,遍歷所有的子View,首先判斷子View是否能接收點選事件(View的可見性為VISIBLE或者view.getAnimation() != null),接著判斷事件的x,y座標是否在View的內部。如果能滿足這兩個條件,那事件就可以傳給該子View處理了。其中dispatchTransformedTouchEvent()方法實際上是呼叫了子View的dispatchTouchEvent()方法,如下:

View#dispatchTouchEvent

// Perform any necessary transformations and dispatch.
if (child == null) {
    handled = super.dispatchTouchEvent(transformedEvent);
} else {
    final float offsetX = mScrollX - child.mLeft;
    final float offsetY = mScrollY - child.mTop;
    transformedEvent.offsetLocation(offsetX, offsetY);
    if (! child.hasIdentityMatrix()) {
        transformedEvent.transform(child.getInverseMatrix());
    }
    //這裡傳入的child不是null,所以走這個分支
    handled = child.dispatchTouchEvent(transformedEvent);
}

這裡呼叫dispatchTransformedTouchEvent()傳入的child不是null,所以走的是else分支。注意,如果子View的dispatchTouchEvent()方法返回true, 那麼 mFirstTouchTarget 就會被賦值並跳出遍歷子View的迴圈,如下:

//addTouchTarget內部會對 mFirstTouchTarget進行賦值操作
newTouchTarget = addTouchTarget(child, idBitsToAssign);
alreadyDispatchedToNewTouchTarget = true;
break;

addTouchTarget()內部會為mFirstTouchTarget賦值

private TouchTarget addTouchTarget(@NonNull View child, int pointerIdBits) {
    final TouchTarget target = TouchTarget.obtain(child, pointerIdBits);
    target.next = mFirstTouchTarget;
    mFirstTouchTarget = target;
    return target;
}

還記的前面關於攔截是的條件嗎?

if (actionMasked == MotionEvent.ACTION_DOWN|| mFirstTouchTarget != null)

這裡就解答了前面的疑問,ViewGroup不攔截DOWN事件,且子View的dispatchTouchEvent()方法返回true時mFirstTouchTarget被賦值。前面我們也提到DOWN事件時會重置一些狀態資訊,這個mFirstTouchTarget會被置為null。也就是說一旦子View的dispatchTouchEvent()方法返回true,那同一個事件序列中,mFirstTouchTarget != null這個條件就都成立。

如果遍歷完所有子View,mFirstTouchTarget為null(有兩種情況,子View的dispatchTouchEvent()方法都返回false;或者事件被攔截了,即intercepted為true),則會走下面的邏輯:

// Dispatch to touch targets.
if (mFirstTouchTarget == null) {
    // No touch targets so treat this as an ordinary view.
    handled = dispatchTransformedTouchEvent(ev, canceled, null,
            TouchTarget.ALL_POINTER_IDS);
}

注意這裡的dispatchTransformedTouchEvent()方法的child是null。

// Perform any necessary transformations and dispatch.
if (child == null) {
    //child為null,走的就是這個分支
    handled = super.dispatchTouchEvent(transformedEvent);
} else {
    final float offsetX = mScrollX - child.mLeft;
    final float offsetY = mScrollY - child.mTop;
    transformedEvent.offsetLocation(offsetX, offsetY);
    if (! child.hasIdentityMatrix()) {
        transformedEvent.transform(child.getInverseMatrix());
    }

    handled = child.dispatchTouchEvent(transformedEvent);
}

可以看到當child為null是呼叫的super.dispatchTouchEvent()方法,這個super就是View。看到這你可能會問,前面為什麼不直接往下分析View的dispatchTouchEvent()方法的實現?當然可以,只是這樣方法棧就會變深,而我們記憶是有限的,一味地深入會讓自己無法自拔,這點在閱讀原始碼的時候要注意。現在大局觀已經明確,再去分析它的實現就比較清晰了。

public boolean dispatchTouchEvent(MotionEvent event) {
    ...
    boolean result = false;
    ...
    if (onFilterTouchEventForSecurity(event)) {
        //點選ScrollBar
        if ((mViewFlags & ENABLED_MASK) == ENABLED && handleScrollBarDragging(event)) {
            result = true;
        }
        //noinspection SimplifiableIfStatement
        ListenerInfo li = mListenerInfo; 
        
        //li.mOnTouchListener就是通過setOnTouchListener設定的OnTouchListener
        if (li != null && li.mOnTouchListener != null
                && (mViewFlags & ENABLED_MASK) == ENABLED
                && li.mOnTouchListener.onTouch(this, event)) {
            result = true;
        }
        //如果 mOnTouchListener.onTouch()返回true,onTouchEvent()就不會再被呼叫了
        if (!result && onTouchEvent(event)) {
            result = true;
        }
    }
    ...
    return result;
}

從View的dispatchTouchEvent()的實現可以看到,如果有呼叫setOnTouchListener()設定了OnTouchListener的話,就會先呼叫OnTouchListener的onTouch()方法,若onTouch()返回false,再呼叫View的onTouchEvent()方法;若返回true,則直接返回了,這將導致View的onTouchEvent()不會被呼叫。另外在onTouchEvent()內部會呼叫OnClickListener.onClick()方法:

public boolean onTouchEvent(MotionEvent event) {
    ...
    if (clickable || (viewFlags & TOOLTIP) == TOOLTIP) {
        switch (action) {
            case MotionEvent.ACTION_UP:
                ...
                if (!post(mPerformClick)) {
                    //這個方法的實現會呼叫OnClickListener.onClick()方法
                    performClick();
                }
                ...
        }
    }
}

在UP事件時呼叫performClick()內部會呼叫OnClickListener.onClick(),所以父View不能攔截UP事件,否則點選事件就不會被呼叫。

public boolean performClick() {
    final boolean result;
    final ListenerInfo li = mListenerInfo;
    if (li != null && li.mOnClickListener != null) {
        playSoundEffect(SoundEffectConstants.CLICK);
        //onClick()被呼叫
        li.mOnClickListener.onClick(this);
        result = true;
    } else {
        result = false;
    }
    sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_CLICKED);
    notifyEnterOrExitForAutoFillIfNeeded(true);
    return result;
}

從前面的分析可以看到我們平時常用的onClick()方法的優先順序是最低的,呼叫順序為:OnTouchListener.onTouch()->onTouchEvent()->OnClickListener.onClick(),而且onTouch()返回true會中斷後續的呼叫。

前面的分析過程中發現,主要的邏輯是下面三個方法:

public boolean dispatchTouchEvent(MotionEvent ev)

用來進行事件分發,當事件傳遞到當前面View,dispatchTouchEvent()方法會被呼叫,它的返回值受onTouchEvent()方法的返回值和子View的dispatchTouchEvent()返回值的影響,表示是否消耗事件。

public boolean onInterceptTouchEvent(MotionEvent ev)

這個方法的返回值表示是否攔截事件,如果返回true,同一個事件序列中不會再被呼叫,同一個事件序列指的是down->move...move->up。

public boolean onTouchEvent(MotionEvent event)

在dispatchTouchEvent()內部呼叫,用於處理事件,返回值表示是否消耗該事件。如果返回false,同一事件序列中,當前View(不包括ViewGroup)將無法再次接收到。

這三個方法的關係大致可以理解為下面的虛擬碼:

public boolean dispatchTouchEvent(MotionEvent ev) {
    boolean consume;
    if (onInterceptTouchEvent(ev)) {
        consume = onTouchEvent(ev);
    } else {
        consume = child.dispatchTouchEvent(ev);
    }
    return consume;
}

大致的意思是當事件傳遞給ViewGroup時,ViewGroup的dispatchTouchEvent()方法會被呼叫,接著呼叫它的onInterceptTouchEvent()方法詢問是否攔截該事件,如攔截則該事件交給這個ViewGroup處理,即它的onTouchEvent()方法被呼叫;如不攔截,則傳遞給子View,如此反覆直到事件被處理掉。下面給出一些前面分析得到的結論:

(1)當一個View決定攔截某個事件,即onInterceptTouchEvent()返回true,那麼同一個事件序列的所有事件都直接交給它處理而不會再呼叫onInterceptTouchEvent()來詢問是否攔截;換句話說就是一個事件序列只能被一個View攔截並消耗。
(2)某個View一旦開始處理事件,如果它不消耗調DOWN事件,即onTouchEvent()返回false,那同一個事件序列的其他事件就不會再交給它處理,事件將重新交由它的父View處理,即父View的onTouchEvent()會被呼叫。
(3)如果不消耗DOWN以外的事件,同一個事件序列還是會傳給當前View,沒有消耗的事件會交給Activity的onTouchEvent()來處理。
(4)View的onTouchEvent()預設都消耗事件,即返回true,除非它是不可點選的,即下面的clickable為false。

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

可以看到只要是CLICKABLE、LONG_CLICKABLE、CONTEXT_CLICKABLE這三個有一個為true它就是可點選的,而與ennable無關,換句話說就是如果View的enable為true也是會消耗事件的。

(5)事件總是先傳給父View,然後在傳給子View,在子View中可以通過呼叫父View的requestDisallowInterceptTouchEvent()來干預事件的攔截過程,在處理滑動衝突可以利用這一點。

@Override
public void requestDisallowInterceptTouchEvent(boolean disallowIntercept) {

    if (disallowIntercept == ((mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0)) {
        // We're already in this state, assume our ancestors are too
        return;
    }

    if (disallowIntercept) {
        mGroupFlags |= FLAG_DISALLOW_INTERCEPT;
    } else {
        mGroupFlags &= ~FLAG_DISALLOW_INTERCEPT;
    }

    // Pass it up to our parent
    if (mParent != null) {
        mParent.requestDisallowInterceptTouchEvent(disallowIntercept);
    }
}

在這個方法內部修改了mGroupFlags的值,現在再回頭看看ViewGroup攔截事件的邏輯:

ViewGroup#dispatchTouchEvent

if (actionMasked == MotionEvent.ACTION_DOWN
        || mFirstTouchTarget != null) {
    //這個值會受到干預,進而影響是intercepted的值,也就是干預攔截   
    final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;
    if (!disallowIntercept) {
        intercepted = onInterceptTouchEvent(ev);
        ev.setAction(action); // restore action in case it was changed
    } else {
        intercepted = false;
    }
} else {
    intercepted = true;
}

可以看到如果disallowIntercept為true,intercepted直接置為false。另外DOWN事件是不能干預的,因為在DONW事件時呼叫會resetTouchState()來重置狀態資訊,mGroupFlags會被重置。

private void resetTouchState() {
    clearTouchTargets();
    resetCancelNextUpFlag(this);
    mGroupFlags &= ~FLAG_DISALLOW_INTERCEPT;
    mNestedScrollAxes = SCROLL_AXIS_NONE;
}

這些結論看似輕描淡寫,要真正理解就必須要仔細看看原始碼才行,如果你能為這些結論說出有力的論據,那說明你掌握的也差不多了。後面我會運用這些論據來自定義一個具體的ViewGroup,讓這些結論變得有用。

3631399-8f9eb7a73f20c8b6.jpg
關注微信公眾號,第一時間接收推送!

相關文章