从果推因 ---- 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响应:



    
        
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

你可能感兴趣的:(从果推因 ---- Android的事件的分发与拦截)