likes
comments
collection
share

一文搞定面试 | 事件分发机制及冲突解决方案

作者站长头像
站长
· 阅读数 64

事件分发简介

事件分发整体 需要从 应用层(IMS、InputChannel、WMS等方面)进行完整的剖析,本文将暂只介绍UI层面(ViewGroup->View)的部分源码,下文中后续对事件分发的指代也统一意为UI层面含义,对应用层感兴趣的,可以移步这两篇文章,大佬之作、放心食用

同时借用江大佬文中的图,让大家好对ViewGroup之前的大体流程有个认知

一文搞定面试 | 事件分发机制及冲突解决方案

开始之前,先介绍几个概念

  1. 事件序列: 对于用户的一次触摸操作,必然伴随ACTION_DOWN->...(可能没有)ACTION_MOVE->ACTION_UP事件行为按序构成, 而对于一个View,序列则为ACTION_DOWN->...ACTION_MOVE->ACTION_UP \ ACTION_CANCEL(省略了多指事件)
  2. 深度优先搜索:ViewTree是一个树形结构,为了正确合适地处理MotionEvent,采用DFS(Depth-First-Search)进行遍历。遍历过程中则伴有递归&回溯,此外为了减少遍历的时间复杂度,因此需要进行剪枝

一文搞定面试 | 事件分发机制及冲突解决方案

  1. 分发和消费:其实分发则对应递归,是向下遍历的过程,为了找到合适的child view进行消费;而消费则是回溯,是向上反馈的过程,决定了parent view是否可以选择进行消费。因此,消费是child view优先的。打个不太恰当的比喻,总当孩子说不吃时,父母才会去吃。当然父母也应该有自己的享受,这就对应了拦截
  2. 消费序列:对于一次事件序列的每次分发,如果每每进行完整的遍历,性能会有不小的损耗。因此引入消费序列进行剪枝记忆性搜索,即当发生消费时,记录下child view,以便于在后续序列的分发中,能快速访问。因为树的每层都会记录TouchTarget,一如2中例图所示,与下层链接而成即为访问路径,因而称为序列
// ViewGroup中通过成员属性mFirstTouchTarget进行消费序列标定
class TouchTarget {
    // 主要属性为以下三个,其余类似于Message存在池化复用逻辑
    // The touched child view.
    public View child;

    // The combined bit mask of pointer ids for all pointers captured by the target.
    // 这个通常在多点触控中使用,需要结合actionIndex,在MotionEvent解析中可了解更多
    // int类型有32位,对于一个view可能有多指落下,即从最后一位开始,对对应位进行赋1
    // ...0101的情况,移位情况根据ev.getPointerId(ev.getActionIndex())
    public int pointerIdBits;

    // The next target in the target list.
    // 当多指触控时,在某一层的Parent中,可能有多个child view接受不同Pointer的信息
    // 在通常的单指操作时,可以不用考虑
    public TouchTarget next;
}

向下分发,询问是否消费

ViewGroup.dispatchTouchEvent发起,以ACTION_DOWN为序列起点,遍历child view当具有消费能力时,会通过dispatchTransformedTouchEvent(child)调用其dispatchTouchEvent进行深入递归

一文搞定面试 | 事件分发机制及冲突解决方案

@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
    // ……
    boolean handled = false;
    // ……
    // 1、序列起点
    // 2、判断是否拦截
    final boolean intercepted;
    
    // ……
    
    if (!canceled && !intercepted) {
        if (actionMasked == MotionEvent.ACTION_DOWN
            || (split && actionMasked == MotionEvent.ACTION_POINTER_DOWN)
            || actionMasked == MotionEvent.ACTION_HOVER_MOVE) {
            // 3、事件序列之初探寻消费序列的逻辑,即分发
        }
    }

    if (mFirstTouchTarget == null) {
        // 4、没有child view消费,此时自身即成为view节点,已然开始向上消费阶段
        // 对于上层parent来说,当然还在分发阶段。是一个逐步减少层级的过程
        // 同时,这也是除了拦截以外,第二个parent可以干预的时机
    } else {
        // 5、定向分发
        // 定向源于消费序列的形成,必然在down事件之后,以对child view路径进行快速访问
        // down事件时,虽然会走进来,但会通常标志位alreadyDispatchedToNewTouchTarget直接返回处理结果
    }
    
    // 6、序列终点
    
    return handled;
}

1、6 序列始末

先看节点1和6,结合上文定义中对事件序列的解释,dispatchTouchEvent开头,如为down事件,即为一次新序列的开始。结尾如为up事件,即为一次完整序列的结束。其中主要对mFirstTouchTarget形成的消费序列进行重置、回收、异常处理

// 序列起点
final int action = ev.getAction();
final int actionMasked = action & MotionEvent.ACTION_MASK;

// 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.
    // 每一个down事件,均为一个新的开始,需要对尚存在的消费序列进行异常处理并回收
    // 当up事件结束时,则认为一个事件序列已经完成,会调用resetTouchState对消费序列进行清空
    // 所以当mFirstTouchTarget为null时,cancelAndClearTouchTargets并不会调用
    // 但存在特殊情况,如英文注释中APP切换、ANR、view被remove或其他状态变化引起的事件序列中断
    // 就需要进行重置,并发送cancel事件交由child view进行异常处理
    cancelAndClearTouchTargets(ev);
    resetTouchState();
}

// 序列终点
if (canceled
        || actionMasked == MotionEvent.ACTION_UP
        || actionMasked == MotionEvent.ACTION_HOVER_MOVE) {
    resetTouchState();
} else if (split && actionMasked == MotionEvent.ACTION_POINTER_UP) {
    final int actionIndex = ev.getActionIndex();
    final int idBitsToRemove = 1 << ev.getPointerId(actionIndex);
    removePointersFromTouchTargets(idBitsToRemove);
}

从这我们也得知了ACTION_CANCEL的由来,是用于处理事件序列中断的异常处理,因此收到此事件时,view可能需要进行如 终止动画、还原视图 等重置恢复操作

2 拦截判断

节点2是事件冲突处理的重头戏所在,也是消费序列进行剪枝作用的重要依据

final boolean intercepted;
// 两个拦截的重点,disallowIntercept和onInterceptTouchEvent
// 
// 调用parent.requestDisallowInterceptTouchEvent(true)可将mGroupFlags标记位通过异或运算进行赋值
// 即disallowIntercept将为true
if (actionMasked == MotionEvent.ACTION_DOWN
        || mFirstTouchTarget != null) {
    // down事件 或者 消费序列已经形成
    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;
}

记忆化搜索:child view如果连down事件都不要(即后续事件序列时mFirstTouchTarget为空),那之后肯定也就不用问了,节省了向下遍历。分发时也是如此,如果down时标定了mFirstTouchTarget,那同样不需要遍历,直接对它进行单一分支递归即可

这里也引申了两种事件冲突的解决方案:

  1. 外部拦截: 全权由ViewGroup进行拦截抉择,在需要拦截时拦截,如嵌套滑动-横向滑动list(如viewpager,但本身已处理了冲突)内套纵向滑动list时,在viewpager区域需要横向滑动切页,可以在move时,通过x&y的比较判断横纵向滑动,横向滑动则拦截,纵向滑动则不拦截,也可直接看ViewPager该方法的实现
//外部拦截法:父view    
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
    boolean intercepted = false;
    //父view拦截条件
    boolean parentCanIntercept;

    switch (ev.getActionMasked()) {
        case MotionEvent.ACTION_DOWN:
            intercepted = false;
            break;
        case MotionEvent.ACTION_MOVE:
            if (parentCanIntercept) {
                intercepted = true;
            } else {
                intercepted = false;
            }
            break;
        case MotionEvent.ACTION_UP:
            intercepted = false;
            break;
    }
    return intercepted;
}
  1. 内部拦截: down事件时不拦截,给child view分发到的机会,然后由child view在合适的时机调用parent.requestDisallowInterceptTouchEvent以归还消费权,但后续也将无缘本次事件序列。从使用上说,外部拦截思路更清晰,也更常用。但如果child view需要较多的判断来决定谁来执行的话,内部拦截会是更好的选择。
//父view          
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
    if (ev.getActionMasked() == MotionEvent.ACTION_DOWN) {
        return false;
    } else {
        return true;
    }
}

//子view
@Override
public boolean dispatchTouchEvent(MotionEvent event) {
    //父view拦截条件
    boolean parentCanIntercept;

    switch (event.getActionMasked()) {
        case MotionEvent.ACTION_DOWN:
            getParent().requestDisallowInterceptTouchEvent(true);
            break;
        case MotionEvent.ACTION_MOVE:
            getParent().requestDisallowInterceptTouchEvent(!parentCanIntercept);
            break;
        case MotionEvent.ACTION_UP:
            break;
    }
    return super.dispatchTouchEvent(event);
}

3 消费序列的探寻

down作为事件序列的开端,是形成消费序列的重要节点。后续消费序列可能会有变动,但只是路径变短,即被上层拦截而下发ACTION_CANCEL终结,并清空其下已有消费路径

// 三个判断均为down事件,分别为 单指down、多指down、鼠标移动
if (actionMasked == MotionEvent.ACTION_DOWN
        || (split && actionMasked == MotionEvent.ACTION_POINTER_DOWN)
        || actionMasked == MotionEvent.ACTION_HOVER_MOVE) {
    // 这里是多指分发的重要点
    // actionIndex指代手指index
    // pointerId根据actionIndex生成,代表触控位id,为0~31
    final int actionIndex = ev.getActionIndex(); // always 0 for down
    final int idBitsToAssign = split ? 1 << ev.getPointerId(actionIndex)
            : TouchTarget.ALL_POINTER_IDS;

    // Clean up earlier touch targets for this pointer id in case they
    // have become out of sync.
    removePointersFromTouchTargets(idBitsToAssign);

    final int childrenCount = mChildrenCount;
    if (newTouchTarget == null && childrenCount != 0) {
        // 在多指触控中,获取对应手指的x\y需要通过,getX(actionIndex)来取得
        final float x =
                isMouseEvent ? ev.getXCursorPosition() : ev.getX(actionIndex);
        final float y =
                isMouseEvent ? ev.getYCursorPosition() : ev.getY(actionIndex);

        // buildTouchDispatchChildList将child根据Z轴升序排列
        final ArrayList<View> preorderedList = buildTouchDispatchChildList();
        final boolean customOrder = preorderedList == null
                && isChildrenDrawingOrderEnabled();
        final View[] children = mChildren;
        
        // 这里从后遍历,根据1中Z轴升序排列,所以越在上层的优先处理
        for (int i = childrenCount - 1; i >= 0; i--) {
            // ……
            
            // n.canReceivePointerEvents和isTransformedTouchPointInView
            // 有任一返回false,即无消费能力
            // canReceivePointerEvents当 不可见 且 没有动画时返回false
            // isTransformedTouchPointInView判断x,y是否在childView中
            // 简单来说,就是判断该child有无消费能力
            if (!child.canReceivePointerEvents()
                    || !isTransformedTouchPointInView(x, y, child, null)) {
                ev.setTargetAccessibilityFocus(false);
                continue;
            }

            // 这里是多指的判断,如果该次手指触控命中的child,在之前的pointer中已经标定了
            // 那就取出来,对pointerIdBits进行赋值,前面idBitsToAssign已进行移位过
            // 而分发时,也会根据idBitsToAssign对多指进行拆分,以分发对应pointer
            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);
            // dispatchTransformedTouchEvent返回为true时,意味着child view选择消费
            if (dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign)) {
                // Child wants to receive touch within its bounds.
                mLastTouchDownTime = ev.getDownTime();
                // ……省略一部分代码
                // addTouchTarget将此child设置为mFirstTouchTarget头结点,即消费终点已有
                // 接下来就是逐步向上回溯时形成完整的消费序列
                newTouchTarget = addTouchTarget(child, idBitsToAssign);
                // 这个标记位,使得在5的定向分发时,得以避免重复
                alreadyDispatchedToNewTouchTarget = true;
                break;
            }

            // ……
        }
        // ……
    }
    // ……
}

4 二次分发

为什么会有二次分发(也仅发生在down事件),因为down事件时,child view可能均不选择消费(也可能被拦截了),即没有形成消费序列。那parent自身将被看作view叶子节点,进行消费选择。dispatchTransformedTouchEvent中传null,即会调用super即View.java中的dispatchTouchEvent,即相当于走入了消费逻辑中(可在消费的部分细看逻辑),但对于其parent仍在分发阶段,所以称之二次分发

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


private boolean dispatchTransformedTouchEvent(MotionEvent event, boolean cancel,
            View child, int desiredPointerIdBits) {
    final boolean handled;
    if (cancel || oldAction == MotionEvent.ACTION_CANCEL) {
        event.setAction(MotionEvent.ACTION_CANCEL);
        if (child == null) {
            // 自身作为child
            handled = super.dispatchTouchEvent(event);
        } else {
            // child递归
            handled = child.dispatchTouchEvent(event);
        }
        event.setAction(oldAction);
        return handled;
    }
    // ……
    transformedEvent.recycle();
    return handled;
}

5 定向分发

定向分发是记忆化搜索的重要一环,其中有对down形成消费序列时分发的规避,也有上层拦截的cancel分发,也有多指触控的next遍历

TouchTarget predecessor = null;
TouchTarget target = mFirstTouchTarget;
while (target != null) {
    final TouchTarget next = target.next;
    if (alreadyDispatchedToNewTouchTarget && target == newTouchTarget) {
        // 这里就是对down时分发的规避
        handled = true;
    } else {
        // 这里是ACTION_CANCEL的另一个点,intercepted
        // cancel当然也是会被逐级传递下去的
        final boolean cancelChild = resetCancelNextUpFlag(target.child)
                || intercepted;
        if (dispatchTransformedTouchEvent(ev, cancelChild,
                target.child, target.pointerIdBits)) {
            handled = true;
        }
        if (cancelChild) {
            // 被拦截的,被移出链表结点,就直接recycle回收了
            if (predecessor == null) {
                mFirstTouchTarget = next;
            } else {
                predecessor.next = next;
            }
            target.recycle();
            target = next;
            continue;
        }
    }
    predecessor = target;
    target = next;
}

向上消费,反馈消费情况

到这里,想必大家已经对分发的逻辑已经了然于心。消费的逻辑链比较简单,与其说是消费,不如说是事件处理更为妥帖。 一文搞定面试 | 事件分发机制及冲突解决方案

从图和代码中均可看出,消费的主要依据就是OnTouchListener.onTouchonTouchEvent的返回值,且一旦提前消费,onTouchEvent连执行的机会都没有,跟着看后面的代码,这也将导致click事件无法响应

public boolean dispatchTouchEvent(MotionEvent event) {
    // ……
    boolean result = false;

    // ……

    if (onFilterTouchEventForSecurity(event)) {
        if ((mViewFlags & ENABLED_MASK) == ENABLED && handleScrollBarDragging(event)) {
            result = true;
        }
        //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;
        }
    }
    // ……
    return result;
}

这段代码总结一下就是,只要view是clickable的,那就会消费事件,哪怕是disable置灰,只不过会提前退出处理逻辑

public boolean onTouchEvent(MotionEvent event) {
    // ……

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

    if ((viewFlags & ENABLED_MASK) == DISABLED
            && (mPrivateFlags4 & PFLAG4_ALLOW_CLICK_WHEN_DISABLED) == 0) {
        if (action == MotionEvent.ACTION_UP && (mPrivateFlags & PFLAG_PRESSED) != 0) {
            setPressed(false);
        }
        mPrivateFlags3 &= ~PFLAG3_FINGER_DOWN;
        // A disabled view that is clickable still consumes the touch
        // events, it just doesn't respond to them.
        return clickable;
    }
    if (mTouchDelegate != null) {
        if (mTouchDelegate.onTouchEvent(event)) {
            // 这里还有个触摸代理,也可以进行提前消费,相当于onTouchListenter了
            return true;
        }
    }

    if (clickable || (viewFlags & TOOLTIP) == TOOLTIP) {
        switch (action) {
            case MotionEvent.ACTION_UP:
                // ……
                if (mPerformClick == null) {
                    mPerformClick = new PerformClick();
                }
                // post这里用handler发送的返回值check点击是否可以响应
                if (!post(mPerformClick)) {
                    // 这里也就是通常setOnClickListenter触发回调的地方了,注意看下面附的代码
                    performClickInternal();
                }
                // ……
                break;

            // ……
        }

        return true;
    }

    return false;
}


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;
}

灵魂发问

部分问题取自以下博客,欢迎大家评论补充

1. 一个View clickable is false的话,dispatchTouchEvent和onTouchEvent会如何表现?如果为true,又会如何表现?

clickable的与否决定了View是否消费事件,不重写返回值的话,那么false时,只能收到ACTION_DOWN,反之能看到完整的事件序列。因为为true时,进行消费形成了消费序列,在上层不特殊拦截的情况下,事件后续序列均会进行定向分发。如为false,即down时的探寻就决定了

2.多指触控如何把对应事件分发给对应的View

前面讲到过,多指触控在探寻时,如果是已标定的View,在其TouchTarget.pointerIdBits上进行位标记。分发时dispatchTransformedTouchEvent(ev, cancelChild,target.child, target.pointerIdBits),然后根据event的位信息和pointer位信息进行比较,不同则拆分后再行分发。如果是新的View,则在mFirstTouchTarget的基础上,通过next形成链表结构,探寻时通过addTouchTarget组链,定向分发时则交替遍历next

private boolean dispatchTransformedTouchEvent(MotionEvent event, boolean cancel,
        View child, int desiredPointerIdBits) {
        
    // Calculate the number of pointers to deliver.
    final int oldPointerIdBits = event.getPointerIdBits();
    final int newPointerIdBits = oldPointerIdBits & desiredPointerIdBits;

    // 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) {
        // ……
    } else {
        transformedEvent = event.split(newPointerIdBits);
    }
    // ……
    child.dispatchTouchEvent(transformedEvent);
}

3.描述下开发中实际应用如何运用事件分发

做的类似于如图的一个RecyclerView嵌套ViewPager+RecyclerView并需要TabLayout吸顶的效果 一文搞定面试 | 事件分发机制及冲突解决方案 因为这个场景的嵌套滑动冲突点在于内外两个RecyclerView,需要在未吸顶时,全由外部承接,吸顶时由内部承接。当时应用的是外部拦截,即在onInterceptTouchEvent,判断触摸点是否在 标记View内(定义一个标记接口,需要嵌套的则实现接口),如果在则不拦截,否则即默认super拦截处理。

但这样仍然存在一个问题,即过度消费:快吸顶时的快速滑动会卡顿,就是全被parent消费了。因此同时引入了NestedScrolling嵌套滑动机制,当吸顶时,将剩余的滑动child.doScrollConsumed。而内部过多的滑动,就会通过onNestedScrollInternal传递给parent。以此来实现滑动&吸顶的丝滑体验

MotionEvent浅析

MotionEvent及Android中很多场景,均采用了位运算来节省标记位、状态位的内存空间,同时也大面积了使用了Message.obtain\recycle池化回收复用思路。

public final class MotionEvent extends InputEvent implements Parcelable {
    // 前三个方法,均调用了底层的nativeGetAction
    // 即action为32位,其中分为低8位的事件行为,和高位8位(对于低16位来说)的pointer index
    public final int getAction() {
        return nativeGetAction(mNativePtr);
    }

    public static final int ACTION_MASK             = 0xff;
    // ACTION_MASK代表着低8位,十六进制f,转化为二进制为1111,两个f即需要8位
    public final int getActionMasked() {
        return nativeGetAction(mNativePtr) & ACTION_MASK;
    }
    
    public static final int ACTION_POINTER_INDEX_MASK  = 0xff00;
    public static final int ACTION_POINTER_INDEX_SHIFT = 8;
    // ACTION_POINTER_INDEX_MASK是高8位,&运算取值后,在右移去掉低位0,得到最终的手指序号
    public final int getActionIndex() {
        return (nativeGetAction(mNativePtr) & ACTION_POINTER_INDEX_MASK)
                >> ACTION_POINTER_INDEX_SHIFT;
    }
    
    // 多指触控时,提供的能力
    public final int getPointerCount() {
        return nativeGetPointerCount(mNativePtr);
    }

    public final int getPointerId(int pointerIndex) {
        return nativeGetPointerId(mNativePtr, pointerIndex);
    }
    
    public final int findPointerIndex(int pointerId) {
        return nativeFindPointerIndex(mNativePtr, pointerId);
    }
    
    public final float getX(int pointerIndex) {
        return nativeGetAxisValue(mNativePtr, AXIS_X, pointerIndex, HISTORY_CURRENT);
    }

下面两张图取自其他大佬博客,希望可以帮助快速建立起多指的概念。对多指触控感兴趣的,建议写个双指图片缩放的demo练练手

一文搞定面试 | 事件分发机制及冲突解决方案

一文搞定面试 | 事件分发机制及冲突解决方案

参考资料

转载自:https://juejin.cn/post/7228169924216045605
评论
请登录