從果推因 ---- Android的事件的分發與攔截

緣由

偶然看到了下面這幾篇“逆視角”分析思考的文章,覺得還是挺有意思的,距離上次好好看事件分發源碼也有幾年了,想著也換個角度重新思考梳理下對Andrroid視圖層級事件處理的理解。
反思|Android 事件分發機制的設計與實現
反思|Android 事件攔截機制的設計與實現

首先帶幾個問題

ViewTree

如上圖,Android的視圖結構可以本質上構成了一顆N叉樹,每個節點都是View的子類(View or ViewGroup)
ViewGroup節點持有View[] mChildren記錄該節點的所有子節點,整體構成了一個N叉樹。

  1. 為何選擇遞歸實現事件分發?
  2. FrameLayout內放置兩個重疊的Button點擊如何響應?Why?
  3. DOWN事件作為事件序列的開始,既然首次遞歸遍歷找到了消費DOWN事件的View,為何不直接記錄該View的引用,后續直接將后續事件直接將事件直送該View?

比如ViewG接收了DOWN事件,若是記錄ViewG那么下次可以直接從Activity或rootView直接下發MOVE/UP給ViewG;而不需要再次rootView -> A ->D ->viewG

  1. 為何自定義View一般不修改dispatchTouchEvent函數?
  2. 為何解決滑動沖突自定義View不應攔截Down事件?
  3. Cancel事件用處?

為何選擇遞歸

首先ViewTree這種天然的樹形結構是非常符合遞歸算法,在樹形結構上使用遞歸非常簡單簡潔(二叉樹的前中后序的遞歸遍歷,三五行代碼搞定);
其次符合實際情況,View嵌套層級越深,即ViewTree越下層的View反而顯示在屏幕的上層,越接近用戶所見,遞歸的深度優先DFS滿足這種讓底層的View可以先獲得事件的處理機會,達到用戶“所觸即所得”的效果。

dispatchTouchEvent遞歸大概流程

ViewGroup dispatchTouchEvent:

    //省略大部分代碼,看看關鍵步驟
    public boolean dispatchTouchEvent(MotionEvent ev) {
        
        boolean handled = false;
        if (onFilterTouchEventForSecurity(ev)) {
            final int action = ev.getAction();
            final int actionMasked = action & MotionEvent.ACTION_MASK;

            //綜合requestDisallowInterceptTouchEvent及自身的onInterceptTouchEvent判斷是否攔截事件
            // Check for interception.
            final boolean intercepted;
            if (actionMasked == MotionEvent.ACTION_DOWN
                    || mFirstTouchTarget != null) {
                //注意這里的判斷,只有Down或者mFirstTouchTarget不為空才會進行攔截判斷;
                final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;
                if (!disallowIntercept) {
                    intercepted = onInterceptTouchEvent(ev); //調用了onInterceptTouchEvent ---> 函數return true代表攔截事件
                    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;
            }
            .......
            ......
                    final int childrenCount = mChildrenCount;
                    if (newTouchTarget == null && childrenCount != 0) {
                        final float x = ev.getX(actionIndex);
                        final float y = ev.getY(actionIndex);
                        // Find a child that can receive the event.
                        // Scan children from front to back.
                       
                        final View[] children = mChildren;
                        //根據children index倒敘遍歷所有child view
                        //尋找真正需要消費事件的child view
                        for (int i = childrenCount - 1; i >= 0; i--) {
                            final int childIndex = getAndVerifyPreorderedIndex(
                                    childrenCount, i, customOrder);
                            //根據touchEvent的坐標x,y篩掉不在觸點范圍內的childView
                            if (!canViewReceivePointerEvents(child)
                                    || !isTransformedTouchPointInView(x, y, child, null)) {
                                ev.setTargetAccessibilityFocus(false);
                                continue;
                            }
                        ....... 
                        .......
                        //調用dispatchTransformedTouchEvent會觸發調用child的dispatchTouchEvent(這里的返回值代表了該child子樹內是否有View消費了事件)
                        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); //注意這個函數,記錄了該child是消費事件的子child
                                alreadyDispatchedToNewTouchTarget = true;
                                break; //如果該child包含目標view直接退出遍歷循環
                            }
                            
        //通過該函數實現遞歸調用所有觸點內child的dispatchTouchEvent
       private boolean dispatchTransformedTouchEvent(MotionEvent event, boolean cancel,
            View child, int desiredPointerIdBits) {
        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());
            }
            handled = child.dispatchTouchEvent(transformedEvent);
        }

View dispatchTouchEvent:

          //noinspection SimplifiableIfStatement
            ListenerInfo li = mListenerInfo;
            if (li != null && li.mOnTouchListener != null
                    && (mViewFlags & ENABLED_MASK) == ENABLED
                    && li.mOnTouchListener.onTouch(this, event)) {
                result = true;
            }

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

忽略掉細節核心邏輯:ViewGroup會遍歷它的所有直接children(根據touch坐標過濾掉不包含觸點坐標的child),轉換坐標之后繼續遞歸調用child的dispatchTouchEvent函數;最終遞歸到targetView的dispatchTouchEvent,然后返回默認false或者mOnTouchListener、onTouchEvent的返回值。

稍微畫個圖看下dispatchTouchEvent調用的順序:

image.png

通過幾個核心的邏輯,可以看出整個遞歸調其實相當于偽廣度優先BFS 和 深度優先DFS結合來遍歷了這顆ViewTree(根據touch坐標值優化了不需要范圍的節點);若是視圖層級很深,布局復雜會導致遍歷開銷的大幅增加

優化遞歸的開銷

通過上述分析,這個遞歸調用鏈遍歷尋找targetView的過程開銷比較大,所以從這里入手可以考慮優化:

把事件分成DOWNMOVEUP&CANCEL序列,DOWN作為完整Touch事件的開始節點,只要跟蹤到Down事件分發到targetView的路線,后面的MOVE/UP就不需要遍歷直達target。

       //addTouchTarget這個函數,記錄了該child是消費事件的子child
       newTouchTarget = addTouchTarget(child, idBitsToAssign); 
  
    private TouchTarget addTouchTarget(@NonNull View child, int pointerIdBits) {
        final TouchTarget target = TouchTarget.obtain(child, pointerIdBits);
        target.next = mFirstTouchTarget;
        mFirstTouchTarget = target;
        return target;
    }

在遍歷ViewGroup尋找消費事件的子View過程中,Android是設計了一個TouchTarget的數據結構一個單向鏈表,每次遍歷ViewGroup的所有children,若是該child(或者child的子view)消費了事件那么會將該child存入mFirstTouchTarget鏈表。

為啥mFirstTouchTarget只存ViewGroup的直接childView

回到問題3:rootView -> A ->D ->viewG這條路徑:

rootView 的 mFirstTouchTarget指向的是A
A的 mFirstTouchTarget指向的是D
D的 mFirstTouchTarget指向的是ViewG

是不是有點迷糊,如果rootView的mFirstTouchTarget直接指向ViewG不是更省事?并且明顯這個mFirstTouchTarget只會存在一個值,搞個鏈表(循環在找到第一個消費事件的child時就break跳出了)?

  1. 這里若是直接從rootView->ViewG會廢掉onInterceptTouchEvent機制;
  2. 頂層rootView直接持有最底層的View,打破了樹形結構父節點僅依賴直接子節點的設計,ViewGroup的設計原則也被破壞。
  3. 設計成鏈表應該是為了多指觸控考慮的

mFirstTouchTarget機制使得僅Down事件花費較大循環+遞歸尋找targetView,后續依靠每個ViewGroup的mFirstTouchTarget值省掉了viewTree的橫向循環,但是遞歸函數調用的深度依然是樹形結構的層數。

    //直接通過mFirstTouchTarget分發事件
   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;
          }
          if (cancelChild) {
              if (predecessor == null) {
                  mFirstTouchTarget = next;
              } else {
                  predecessor.next = next;
              }
              target.recycle();
              target = next;
              continue;
          }
      }

FrameLayout內放置兩個重疊的Button點擊如何響應?Why?

上面分析過,在ViewGroup橫向遍歷子View時找到第一個消費事件的child就會記錄在mFirstTouchTarget然后跳出循環。所以這種情況肯定只有一個button會響應。

考慮下面這么個布局,點擊事件直覺應該是只有button1響應:

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">
    <com.android.test.testapplication.lib.CustomFrameLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent" >
        <Button
            android:id="@+id/button"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="Button"
            android:onClick="onClick"
            android:layout_gravity="center"/>
    </com.android.test.testapplication.lib.CustomFrameLayout>
    <com.android.test.testapplication.lib.CustomFrameLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent" >
        <Button
            android:id="@+id/button1"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="Button1"
            android:onClick="onClick"
            android:layout_gravity="center"/>
    </com.android.test.testapplication.lib.CustomFrameLayout>
    <com.android.test.testapplication.lib.CustomFrameLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent" >
    </com.android.test.testapplication.lib.CustomFrameLayout>
</FrameLayout>
image.png

可以看到結果確實也是只有Button1響應,而且看輸出也印證了橫向循環是依據childView添加index倒敘來判斷是否消費事件,并且由于childAt 1消費了事件,childAt 0的View一點機會都沒有。

那么同樣的布局,有沒有什么方法讓下方的Button獲得響應機會?

  //三個自定義FrameLayout的父view也換成自定義View,開啟isChildrenDrawingOrderEnabled & 重寫getChildDrawingOrder
  override fun onFinishInflate() {
        isChildrenDrawingOrderEnabled = true
        super.onFinishInflate()
    }
    override fun getChildDrawingOrder(childCount: Int, i: Int): Int {
        return childCount-1-i
    }

image.png

這下看到直接從childAt 0 開始遍歷分發事件,其實是利用了ChildDrawingOrder,查看橫向遍歷源碼可知道如果開啟了isChildrenDrawingOrderEnabled = true;遍歷時會做一個映射把實際index通過getChildDrawingOrder-->為自定義的順序;childCount-1-i //強行把末尾的index換成了childAt 0的View

為何自定義View一般不修改dispatchTouchEvent函數?

其實整個遞歸過程核心就是dispatchTouchEvent函數,onInterceptTouchEventonTouchEvent 用于輔助。

onInterceptTouchEvent提供事件攔截機制,是滑動沖突的解決方案;
onTouchEvent通常情況是真正的事件消費者,返回值會被dispatchTouchEvent調用棧層層上報一直到頂層View

默認情況整個事件分發的V字形結構:ViewGroup 遞歸調用child的dispatchTouchEvent,函數入棧過程形成了事件往底層View分發的路線;最終最底層的targetView onTouchEvent的返回值,dispatchTouchEvent函數層層返回出棧:

遞歸函數調用棧

其中targetViewG的onTouchEvent返回值影響函數棧內各個dispatchTouchEvent后續行為,返回true則其直系parentView的dispatchTouchEvent函數將會給mFirstTouchTarget賦值,后續也逐層上報true,使得完整消費路徑上mFirstTouchTarget都得到賦值。

返回false導致直系parentView的mFirstTouchTarget==null,觸發直系parentView調用super.dispatchTouchEvent也就是View的dispatchTouchEvent(其實幾乎就相當于觸發parentView自己的onTouchEvent)

            // Dispatch to touch targets.
            if (mFirstTouchTarget == null) {
                // No touch targets so treat this as an ordinary view.
                //下面這個函數傳入null值會觸發super.dispatchTouchEvent
                handled = dispatchTransformedTouchEvent(ev, canceled, null,
                        TouchTarget.ALL_POINTER_IDS);
            } 

如果修改了dispatchTouchEvent的實現相當于打斷了這條遞歸調用鏈,你得自己接上后續的調用(可能工作包括:轉換坐標調用child的dispatch、自行調用onIntercept和onTouchEvent函數,甚至還需要維護對子view cancel事件的分發等)

最直觀的修改,自定義一個Button,直接把dispatchTouchEvent return true你的Button將不會響應任何點擊事件包括點擊背景變化效果都沒了!

為什么滑動沖突一般不應攔截Down事件?

前面分析了一個事件完整序列是以Down事件開始,為了減少消耗根據Down事件來追蹤事件的消費路徑,后續的Move、Cancel事件才有完整的路徑依據用于分發;如果一開始就攔截down事件,其(mFirstTouchTarget == null直接調用super.dispatch)導致該ViewGroup所有的childView什么事件都收不到。除非本意就是為了disable掉所有的子View接受事件。

為什么需要Action Cancel

上面提到滑動沖突方案里面,需要放開Down事件的通行,那么子View接受Down事件響應了pressed狀態,而后續Move、Up事件被parentView攔截消費掉了;那么就沒有機會取消這個pressed狀態。
所以需要在事件被攔截之后或者說改變事件消費主體之后,應該要給之前的消費者一個Cancel通知;有始有終才不是“渣TouchEvent”。
通過Cancel事件可以作為清理標志,用于清理恢復狀態,比如把之前設置的touchTarget置為null等。

事件序列中onInterceptTouchEvent會執行幾次?

完整事件總是從Down開始,之前也是利用僅在Down事件的去遍歷尋找初始mFirstTouchTarget;以便優化后續事件不需要再次遍歷。
同樣的理由是否存在于onInterceptTouchEvent?

因為ViewGroup攔截事件之后,肯定是交由super.dispatchToucheEvent -->onTouchEvent處理;不會再去到其子View,故而可以在攔截事件之后將mFirstTouchTarget置為null; 那么后續事件(非Down)即可跳過onInterceptTouchEvent判斷。

            // Check for interception. 
            final boolean intercepted;
            if (actionMasked == MotionEvent.ACTION_DOWN
                    || mFirstTouchTarget != null) {
                //判斷onInterceptTouchEvent條件:down 或者 touchTarget不為空
                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 {
                // There are no touch targets and this action is not an initial down
                // so this view group continues to intercept touches.
                intercepted = true;
            }
            
            //mFirstTouchTarget == null直接進入super.dispatch
            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) {
                    //首次進入攔截邏輯touchTarget != null
                    final TouchTarget next = target.next;
                    if (alreadyDispatchedToNewTouchTarget && target == newTouchTarget) {
                        handled = true;
                    } else {
                        final boolean cancelChild = resetCancelNextUpFlag(target.child)
                                || intercepted; // intercepted=true --> cancelChild ==true
                        if (dispatchTransformedTouchEvent(ev, cancelChild,
                                target.child, target.pointerIdBits)) {
                            // 分發Action Cancel事件到touchTarget
                            handled = true;
                        }
                        if (cancelChild) {
                            if (predecessor == null) {
                                //最終action cancel分發循環完成 --> mFirstTouchTarget=null
                                mFirstTouchTarget = next;
                            } else {
                                predecessor.next = next;
                            }
                            target.recycle();
                            target = next;
                            continue;
                        }
                    }
                    predecessor = target;
                    target = next;
                }
            }

通過代碼印證首次攔截事件,touchTarget != null,遍歷touchTarget鏈表分發ActionCancel事件,最終將mFirstTouchTarget=null;后續的事件就不會再觸發interceptTouchEvent函數。從而也可知道,interceptTouchEvent函數在一次事件序列中只會觸發一次。

父View攔截Move返回true之后本次序列不再觸發

childView調用禁止父View攔截不撤銷,為何沒影響?

Android提供了攔截機制用以解決滑動沖突,相當于給父View開了特權攔截子View的事件;那么同樣的子View理應也有拒絕父View攔截的權利:

攔截事件機制提供了滾動父View和子View點擊事件的沖突解決;
那么ViewPager和里面的進度條,滑動和滑動的沖突如何解決?
viewParent.requestDisallowInterceptTouchEvent(true)允許子View申請禁止父View攔截事件。

問題:如果子View忘記requestDisallowInterceptTouchEvent(false),那么其父View攔截事件如何解除禁令?
這里也是利用的事件序列的起點:DOWN事件,在dispatch函數起始就判斷收到DOWN事件就做了全面狀態重置,解除了禁令。

            // Handle an initial down.
            //在ViewGroup的dispatch函數,一開始就判斷了收到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();
            }

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

onTouchListener & onTouchEvent

這個看一眼View dispatchTouchEvent就可以知道: touchListener先于onTouchEvent調用,并且touchListener的返回值決定了是否執行onTouchEvent。
touchListener優先于touchEvent函數,touchListener消費事件返回true消費事件之后,onTouchEvent將不觸發。而onClick、onLongClick的響應會受到影響,click事件都是在onTouchEvent處理的。

            //先調用了touchListener回調然后根據其返回值決定是否調用onTouchEvent
            ListenerInfo li = mListenerInfo;
            if (li != null && li.mOnTouchListener != null
                    && (mViewFlags & ENABLED_MASK) == ENABLED
                    && li.mOnTouchListener.onTouch(this, event)) {
                result = true;
            }
            //短路特性,如果result=true, onTouchEvent將不會調用
            if (!result && onTouchEvent(event)) {
                result = true;
            }

onClick & onLongClick主要依據是在onTouchEvent里面down和up事件判斷。
Down事件是肯定都需要的,click僅需要判斷up事件到來即可滿足click條件;onLongClick則需要超過500ms未接受Up or Cancel事件才滿足longClick。 同時onLongClick的返回值代表響應longClick之后是否還需要響應click,長按之后還是會觸發一個up事件的!

擴展思考

前面的結論好似都指向了一個TouchEvent僅能被一個View消費,那么現在的嵌套滑動怎么玩?

參考

https://juejin.im/post/5d3140c951882565dd5a66ef

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