深入理解事件分发机制

324 阅读13分钟

Action、ActionMask、ActionIndex

getAction()返回值包含pointerIndex和事件类型,其中前8位代表pointerIndex,后8位代表事件类型

public final int getAction() {
    return nativeGetAction(mNativePtr);
}

public final int getActionMasked() {
    return nativeGetAction(mNativePtr) & ACTION_MASK;
}

public final int getActionIndex() {
    return (nativeGetAction(mNativePtr) & ACTION_POINTER_INDEX_MASK) >> ACTION_POINTER_INDEX_SHIFT;
}

引子

在安卓事件分发过程中,mFirstTouchTarget记录着后续事件分发的目标,但是对于如此核心的代码,你真的了解全部细节吗?

  1. 为什么要把mFirstTouchTarget设计成链表?
  2. TouchTarget中的pointerIdBits又起到了什么作用?

思考一下

假设ViewGroup(VG)中有两个Button:A、B:

  1. 按下A,再按下A(多点触控),为什么释放后A的点击事件只会触发一次?
  2. 按下A,按下VG(空白区域),为什么先释放A,却无法触发A的点击事件,继续释放VG,又会触发A的点击事件
  3. 按下VG(空白区域),为什么点击A、B无响应

正文

TouchTarget

// First touch target in the linked list of touch targets.
@UnsupportedAppUsage
private TouchTarget mFirstTouchTarget;

从定义来看,mFirstTouchTarget是触摸目标链表的头节点,但是什么是“触摸目标”呢?

触摸目标:描述触摸控件以及该控件捕获的点击事件。在ViewGroup.dispatchTouchEvent()遇到非拦截、非取消事件,且事件类型为ACTION_DOWN或ACTION_POINTER_DOWN,则会触发一个遍历子控件以查找“触摸目标”的流程

private static final class TouchTarget {
    private static final int MAX_RECYCLED = 32;
    private static final Object sRecycleLock = new Object[0];
    private static TouchTarget sRecycleBin;
    private static int sRecycledCount;

    public static final int ALL_POINTER_IDS = -1; // all ones

    // The touched child view.
    public View child;

    // The combined bit mask of pointer ids for all pointers captured by the target.
    public int pointerIdBits;

    public TouchTarget next;

    private TouchTarget() {
    }

    public static TouchTarget obtain(@NonNull View child, int pointerIdBits)

    public void recycle()
}

其中:

  1. View child:为被点击的子控件,即消耗事件的目标控件
  2. int pointerIdBits:目标捕获的所有事件ID的组合位掩码
  3. TouchTarget next:目标控件列表中的下一个目标
  4. obtain和recycle用于对象的分配和回收,类似Message对象池

TouchTarget是对消耗事件的View以链表方式保存,且记录各个View对应的触控点列表pointerIdBits,以实现后续的事件派分处理,同时可以推理出:

  • 非多点触控:mFirstTouchTarget链表退化成单个TouchTarget对象
  • 多点触控,目标相同:同样为单个TouchTarget对象,只是pointerIdBits保存了多个pointerId信息
  • 多点触控,目标不同:mFirstTouchTarget成为链表

ViewGroup.dispatchTouchEvent

一、首先重置状态,在ACTION_DOWN事件触发时,重置ViewGroup状态,mFirstTouchTarget被置空,此时mFirstTouchTarget = null

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

二、检测ViewGroup是否拦截事件

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);
    } else {
        intercepted = false;
    }
} else {
    intercepted = true;
}
  1. ACTION_DOWN事件直接走拦截判断逻辑
  2. 其他事件会根据是否存在消耗ACTION_DOWN事件的目标控件(mFirstTouchTarget)而决定
  3. 当不存在消耗ACTION_DOWN事件的目标控件或者事件已经被拦截时,后续事件的拦截标记intercepted将会越过用户处理表现为true,可以理解为ViewGroup退化成View
  4. 只要有一个子View不允许父View拦截,父View则无法拦截

这里可以回答开篇的第三个问题,当点击VG空白位置时,由于不存在消耗ACTION_DOWN的子控件,导致mFirstTouchTarget为空。任何后续事件的派分,都会由于拦截标记intercepted=true而被拦截,包括多点触控ACTION_POINTER_DOWN事件

顺便复习一下拦截处理onInterceptTouchEvent()和requestDisallowInterceptTouchEvent():如果子类调用了requestDisallowInterceptTouchEvent(true)时,ViewGroup会越过用户设置的拦截逻辑onInterceptTouchEvent(),表现为优先使子控件处理事件

final boolean canceled = resetCancelNextUpFlag(this) || actionMasked == MotionEvent.ACTION_CANCEL;

// Update list of touch targets for pointer down, if needed.
final boolean split = (mGroupFlags & FLAG_SPLIT_MOTION_EVENTS) != 0;
private static boolean resetCancelNextUpFlag(@NonNull View view) {
    if ((view.mPrivateFlags & PFLAG_CANCEL_NEXT_UP_EVENT) != 0) {
        view.mPrivateFlags &= ~PFLAG_CANCEL_NEXT_UP_EVENT;
        return true;
    }
    return false;
}
  1. boolean canceled:该事件是否需要取消,由resetCancelNextUpFlag(this)或者事件本身决定,基本由事件本身决定。resetCancelNextUpFlag内部实际上是对PFLAG_CANCEL_NEXT_UP_EVENT进行操作,当控件持有PFLAG_CANCEL_NEXT_UP_EVENT标记时,则清除该标记并返回true,否则返回false。一般来说,原生控件中通常在RecycleView/ListView中可能会发生这样的情况,即控件执行轻量级临时分离,在触发onStartTemporaryDetach后,又触发了控件的dispatchTouchEvent
  2. boolean split:是否支持多点触控,此处默认基本为true,FLAG_SPLIT_MOTION_EVENTS标记在Api11后默认支持,也可以通过setMotionEventSplittingEnabled手动管理
TouchTarget newTouchTarget = null;
boolean alreadyDispatchedToNewTouchTarget = false;
  1. TouchTarget newTouchTarget:当事件已经做出派分时,记录派分对应的控件
  2. boolean alreadyDispatchedToNewTouchTarget:记录事件是否已经做出派分,用于过滤已派分的事件,避免重复派分

假如事件未被标记为取消或者拦截时,将会进行核心的遍历逻辑,该逻辑中将会尝试查找消耗事件的newTouchTarget

if (!canceled && !intercepted) {
    >

逻辑中,会对ACTION_DOWN、ACTION_POINTER_DOWN的情况进行处理:

>
if (actionMasked == MotionEvent.ACTION_DOWN
        || (split && actionMasked == MotionEvent.ACTION_POINTER_DOWN)
        || actionMasked == MotionEvent.ACTION_HOVER_MOVE) {
    final int actionIndex = ev.getActionIndex(); // always 0 for down
    final int idBitsToAssign = split ? 1 << ev.getPointerId(actionIndex)
            : TouchTarget.ALL_POINTER_IDS;
    >>
  1. int actionIndex:触控点下标,表明这是第几个触控点
  2. int idBitsToAssign:位分配ID,通过触控点的PointerId计算,又是安卓各种神奇位运算的一个实例

接下来调用removePointersFromTouchTargets来检查并清除是否有之前记录的相同的pointerId

>>
removePointersFromTouchTargets(idBitsToAssign);
private void removePointersFromTouchTargets(int pointerIdBits) {
    TouchTarget predecessor = null;
    TouchTarget target = mFirstTouchTarget;
    while (target != null) {
        final TouchTarget next = target.next;
        if ((target.pointerIdBits & pointerIdBits) != 0) {
            target.pointerIdBits &= ~pointerIdBits;
            if (target.pointerIdBits == 0) {
                if (predecessor == null) {
                    mFirstTouchTarget = next;
                } else {
                    predecessor.next = next;
                }
                target.recycle();
                target = next;
                continue;
            }
        }
        predecessor = target;
        target = next;
    }
}

假如mFirstTouchTarget不为空,检查mFirstTouchTarget链表,检索是否存在记录了该触控点的TouchTarget,存在时,则移除该触控点记录;移除后,如TouchTarget不存在其他的触控点记录,则从链表中移除

当控件的子控件数量大于0时执行遍历,以下部分忽略“子控件布局排序机制”的源码

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

continue中断逻辑:

  1. canReceivePointerEvents:判断控件是否可以接受事件,当控件可见性为VISIBLE或者正在执行动画时,返回true
  2. isTransformedTouchPointInView:判断View是否包含事件的坐标,计算过程中通过transformPointToViewLocal()计算当前的真实坐标(其中包括了滚动量mScroll,及View在ViewGroup中的位置数据)

假如当前遍历的View不可接受事件,或点击坐标不在其中,则跳过当前遍历的View

>>>>
protected boolean canReceivePointerEvents() {
    return (mViewFlags & VISIBILITY_MASK) == VISIBLE || getAnimation() != null;
}

protected boolean isTransformedTouchPointInView(float x, float y, View child, PointF outLocalPoint) {
    final float[] point = getTempPoint();
    point[0] = x;
    point[1] = y;
    transformPointToViewLocal(point, child);
    final boolean isInView = child.pointInView(point[0], point[1]);
    if (isInView && outLocalPoint != null) {
        outLocalPoint.set(point[0], point[1]);
    }
    return isInView;
}

当View可接受事件且点击坐标在该View空间内时,执行下一步:

只要View消费过DOWN/POINTER_DOWN事件,后续落入该View区域的其他POINTER_DOWN事件也会被默认消费,只有第一次消费的时候才会关心dispatchTransformedTouchEvent返回true还是false

mFirstTouchTarget还有个关键作用,记录事件由谁来消费(只关注DOWN/POINTER_DOWN事件,不关注其他事件类型)

>>>
newTouchTarget = getTouchTarget(child);
if (newTouchTarget != null) {
    newTouchTarget.pointerIdBits |= idBitsToAssign;
    break;
}

如果mFirstTouchTarget已经存在对应View的TouchTarget,则可以直接把idBitsToAssign添加到TouchTarget中,并跳出【遍历】

这里可以回答开篇的第一个问题,ACTION_DOWN被A消耗,ACTION_POINTER_DOWN也被A消耗,此时相当于A是2个触控点的目标元素。当释放任意一个触控点时,对应的事件是ACTION_POINTER_UP而不是ACTION_UP(涉及到dispatchTransformedTouchEvent事件拆解),导致不产生点击事件

假如mFirstTouchTarget为空(ACTION_DOWN事件或者新的ACTION_POINTER_DOWN事件没有落入已经存在的TouchTarget中),则继续以下流程:

>>>
if (dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign)) {
    newTouchTarget = addTouchTarget(child, idBitsToAssign);
    alreadyDispatchedToNewTouchTarget = true;
    break;
}
private TouchTarget addTouchTarget(@NonNull View child, int pointerIdBits) {
    final TouchTarget target = TouchTarget.obtain(child, pointerIdBits);
    target.next = mFirstTouchTarget;
    mFirstTouchTarget = target;
    return target;
}

此处涉及到一个关键方法dispatchTransformedTouchEvent,主要用于调用View.dispatchTouchEvent()以执行View的事件分发流程,但由于篇幅问题,此处先直接说明作用:

  • 当传入参数View child为空时:视为传入View为ViewGroup本身
  • 当传入参数boolean cancel为true时:将MotionEvent的Action设置为ACTION_CANCEL分发到传入的View
  • 上文提及isTransformedTouchPointInView()中进行了坐标偏移处理,同样,该方法中也有相同的操作,只是偏移值直接保存到了MotionEvent中,并在调用完View.dispatchTouchEvent还原。该方法中,对MotionEvent进行了拆解,获取对应触摸点的MotionEvent,拆解参考的是传入的位分配ID

如果传入的View消耗了该事件,dispatchTransformedTouchEvent将会返回true,然后执行以下逻辑后跳出遍历

  1. 通过addTouchTarget(),生成一个新的TouchTarget,并添加到mFirstTouchTarget头部,并使newTouchTarget指向生成的TouchTarget
  2. alreadyDispatchedToNewTouchTarget标记为true

在>逻辑(即处理ACTION_DOWN、ACTION_POINTER_DOWN)中最后一部分的逻辑为:

>
if (newTouchTarget == null && mFirstTouchTarget != null) {
    // Did not find a child to receive the event.
    // Assign the pointer to the least recently added target.
    newTouchTarget = mFirstTouchTarget;
    while (newTouchTarget.next != null) {
        newTouchTarget = newTouchTarget.next;
    }
    newTouchTarget.pointerIdBits |= idBitsToAssign;
}

假如当前的newTouchTarget等于空,即无法找到消耗ACTION_POINTER_DOWN事件的View,但mFirstTouchTarget不为空,则:使newTouchTarget指向mFirstTouchTarget链表最后的元素(一般即为消耗ACTION_DOWN的控件),并把当次ACTION_POINTER_DOWN事件的PointID记录到该元素

这里则可以回答文章开篇的第二个问题: 此处原理和问题一一样,只是添加的条件发生变化:ACTION_DOWN被A消耗,则mFirstTouchTarget的末尾元素为A,后续没有被消耗的ACTION_POINTER_DOWN事件都会传入A中,此时相当于A是2个触控点的目标元素

这时候就处理完>的逻辑,完成ACTION_DOWN,ACTION_POINTER_DOWN引起的目标查找。总结如下:

  • 标记位alreadyDispatchedToNewTouchTarget只会在新建TouchTarget时设置true
  • ACTION_DOWN无法找到目标时会导致后续所有的派分都直接传到ViewGroup本身
  • ACTION_POINTER_DOWN无法找到目标时视为ACTION_DOWN目标接收派分

Dispatch to TouchTarget

if (mFirstTouchTarget == null) {
    handled = dispatchTransformedTouchEvent(ev, canceled, null, TouchTarget.ALL_POINTER_IDS);
} 

ACTION_DOWN没有派分目标:此处dispatchTransformedTouchEvent传入的View参数为null,视为ViewGroup,即派分到自身,执行View.dispatchTouchEvent方法

else {
    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;
            }
            if (cancelChild) {
                if (predecessor == null) {
                    mFirstTouchTarget = next;
                } else {
                    predecessor.next = next;
                }
                target.recycle();
                target = next;
                continue;
            }
        }
        predecessor = target;
        target = next;
    }
}
  • alreadyDispatchedToNewTouchTarget && target == newTouchTarget:用到了alreadyDispatchedToNewTouchTarget标记,用于过滤新建TouchTarget时已消耗事件的情况,避免重复派分
  • final boolean cancelChild = resetCancelNextUpFlag(target.child) || intercepted: intercepted标记的作用区域,主要处理ACTION_DOWN类型事件的目标控件的后续事件派分被拦截的情况
    • 当拦截时,需要对目标控件传入一个ACTION_CANCEl事件以通知目标控件当次事件派分被拦截需要进行取消操作。并在后续处理中将cancelChild的目标控件从mFirstTouchTarget中移除
    • 当不拦截时,派分事件到mFirstTouchTarget链表中的所有目标控件,由于dispatchTransformedTouchEvent存在触控点ID判断和事件分割,所以实际上只有链表部分的目标控件会收到事件派分

以上,完成了后续事件对mFirstTouchTarget的派分

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

剩余的部分,是对ACTION_UP类型事件进行清理:

  • ACTION_UP:说明这是最后一个触控点抬起,通过resetTouchState()完全清理派分目标和状态
  • ACTION_POINTER_UP:移除触控点对应的TouchTarget内的pointerIdBits记录,当移除后pointerIdBits = 0(即没有其他触控点记录),则把该TouchTarget从mFirstTouchTarget中移除

最后可以回答开篇问题的1和2:

  1. mFirstTouchTarget设计成链表的作用,是用于记录多点触控情况下,多目标控件的派分逻辑

  2. pointerIdBits的作用,是配合mFirstTouchTarget,使多点触控时,同个目标可以对多个触控点进行合理的处理逻辑

View.dispatchTouchEvent

public boolean dispatchTouchEvent(MotionEvent event) {
    boolean result = false;
    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;
}
  • 1、如果设置了onTouchListener,首先执行onTouch,如果返回false,继续执行onTouchEvent
  • 2、在onTouchEvent中,如果View是clickable的,一定返回true
    • 执行touchDelegate
    • 执行onClick、onLongClick逻辑

ACTION_CANCEL触发时机

/**
 * Constant for {@link #getActionMasked}: The current gesture has been aborted. 
 * You will not receive any more points in it. You should treat this as 
 * an up event, but not perform any action that you normally would. 
 */
public static final int ACTION_CANCEL = 3;

ACTION_CANCEL意味着当前的手势被中止了,你不会再收到任何事件了,你可以把它当做一个ACTION_UP事件,但是不要执行正常情况下的逻辑

有四种情况会触发ACTION_CANCEL:

if (mFirstTouchTarget == null) {
    ...
} 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) {
            ... 
        } else { 
            // 判断一:此时cancelChild == true 
            final boolean cancelChild = resetCancelNextUpFlag(target.child) || intercepted;
            // 判断二:给child发送cancel事件 
            if (dispatchTransformedTouchEvent(ev, cancelChild, target.child, target.pointerIdBits)) {
                handled = true;
            }
            ...
        }
        ...
    }
}
  • 在子View处理事件的过程中,父View对事件拦截
  • 子View被设置了PFLAG_CANCEL_NEXT_UP_EVENT标记时
  • ACTION_DOWN初始化操作(cancelAndClearTouchTarget)
  • 子View被父View移除,且View存在于mFirstTouchTarget中时

滑出子View区域会发生什么?

滑出view后依然可以收到ACTION_MOVEACTION_UP事件。

为什么有人会认为滑出view后会收到ACTION_CANCEL呢?

我想是因为滑出view后,view的onClick()不会触发了,所以有人就以为是触发了ACTION_CANCEL

那么为什么滑出view后不会触发onClick呢?再来看看View的源码:

在view的onTouchEvent()中:

case MotionEvent.ACTION_MOVE:
    // 判断是否超出view的边界
    if (!pointInView(x, y, mTouchSlop)) {
        removeLongPressCallback();
        if ((mPrivateFlags & PRESSED) != 0) {
            // 这里改变状态为 not PRESSED
            mPrivateFlags &= ~PRESSED;
        }
    }
    break;
    
case MotionEvent.ACTION_UP:
    removeLongPressCallback();
    boolean prepressed = (mPrivateFlags & PFLAG_PREPRESSED) != 0;
    // 可以看到当move出view范围后,这里走不进去了
    if ((mPrivateFlags & PFLAG_PRESSED) != 0 || prepressed) {
        ...
        performClick();
        ...
    }
    mIgnoreNextUpEvent = false;
    break;

1,在ACTION_MOVE中会判断事件的位置是否超出view的边界,如果超出边界则将mPrivateFlags置为not PRESSED状态。

2,在ACTION_UP中判断只有当mPrivateFlags包含PRESSED状态时才会执行performClick()等。

因此滑出view后不会执行onClick()

onLongClick

如果同时设置了onClickListener和onLongClickListener,onLongClickListener返回true的时候,不会触发onClickListener的回调,返回false的时候两个回调都会触发

onLongClickListener的返回值最终会复制给mHasPerformedLongPress变量,而且在View的onTouchEvent()方法中判断了这个变量

if (!mHasPerformedLongPress && !mIgnoreNextUpEvent) {
    removeLongPressCallback();
    if (!focusTaken) {
        if (mPerformClick == null) {
            mPerformClick = new PerformClick();
        }
        if (!post(mPerformClick)) {
            performClick();
        }
    }
}

onLongClick回调通过postDelayed(CheckForLongPress)实现,具体逻辑在checkForLongClick方法中

    private void checkForLongClick(int delayOffset, float x, float y) {
        if ((mViewFlags & LONG_CLICKABLE) == LONG_CLICKABLE ||
            (mViewFlags & TOOLTIP) == TOOLTIP) {
            mHasPerformedLongPress = false;

            if (mPendingCheckForLongPress == null) {
                mPendingCheckForLongPress = new CheckForLongPress();
            }
            mPendingCheckForLongPress.setAnchor(x, y);
            mPendingCheckForLongPress.rememberWindowAttachCount();
            mPendingCheckForLongPress.rememberPressedState();
            postDelayed(mPendingCheckForLongPress,
                    ViewConfiguration.getLongPressTimeout() - delayOffset);
        }
    }

其中,在ACTION_DOWN事件的时候肯定是调用了checkForLongClick()的

再次查看onTouchEvent()中ACTION_UP下的代码:

if (!mHasPerformedLongPress && !mIgnoreNextUpEvent) {
    // This is a tap, so remove the longpress check
    removeLongPressCallback(); //移除长按的callback

    if (!focusTaken) {

        if (mPerformClick == null) {
            mPerformClick = new PerformClick();
        }
        if (!post(mPerformClick)) {
            performClick();
        }
    }
}

参考文献