深入理解事件分发 ViewGroup.mFirstTouchTarget的设计

ViewGroup事件派分过程中,mFirstTouchTarget起着相当重要的作用。

但对mFirstTouchTarget的作用是什么,大多数的文章都简单的描述为记录后续事件派分的目标,很少有具体分析这个机制的具体逻辑,更不说其他的一些问题,例如:

  1. 为什么要把mFirstTouchTarget设计成链表
  2. 记录目标的TouchTargetpointerIdBits又起到什么作用。

而这个机制又能引申出多点触控相关的问题,例如:
设ViewGroup VG中,有2个Button:A,B:

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

本文章在对mFirstTouchTarget进行分析的同时,对ViewGroupView的机制进行进一步的原理解析(例如resetCancelNextUpFlag具体作用等),相信读者有更多的收获。

  • 本文不对Accessibility服务相关处理进行分析,并在贴出的源码中移除相关逻辑。
  • 本文不对鼠标事件相关处理进行分析,并在贴出的源码中移除相关逻辑。

首先看定义:

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

mFirstTouchTarget是一个TouchTarget对象,通过注释的说明:"触摸目标的链接列表中的第一个触摸目标",可以得出:
mFirstTouchTarget是"触摸目标"链表的头部。

引申出一个问题:什么叫做"触摸目标":
"触摸目标"可以理解为触控点按下时,处理该触控点对应事件的目标控件。
简单来说,在ViewGroup.dispatchTouchEvent()遇到非拦截事件,且事件类型为ACTION_DOWNACTION_POINTER_DOWN,则会触发一个遍历子控件以查找"触摸目标"的流程。

通过后续的分析,这个概念可以相当容易理解。


再看看TouchTarget的设计,我们只需要关注它的关键成员:

private static final class TouchTarget {
        // The touched child view.
        public View child;

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

        // The next target in the target list.
        public TouchTarget next;
}
  1. View child
    被点击的子控件,即消耗事件的目标控件。

  2. int pointerIdBits
    "目标捕获的所有指针的指针ID的组合位掩码",光看注释难以理解,其实这里涉及到安卓所偏爱的位运算。
    为了区分多点触控时不同的触控点,每一个触控点都会携带一个pointerId
    pointerIdBits即是所有被目标控件消耗的触控点的pointerId的组合。即pointerIdBits包含一个或以上的pointerId数据。
    这个pointerIdBits的运算相关实现,将会在下面提到idBitsToAssign的时候说明。

  • 简单来说,就是如果记录pointerId为0,2,5时,pointerIdBits即为0010 0101,即:
    0对应0000 0001, 2对应0000 0100, 5对应0010 0000,然后通过或运算合并为0010 0101
  1. TouchTarget next
    记录下一个TouchTarget对象,由此组成链表。
  • 注意到TouchTarget包含obtainrecycle两个方法,用于缓存复用,这个同样在Message中实现,需要缓存复用的时候可以参考借鉴该方式,这也是安卓中常见的操作。

分析可得, TouchTarget的作用,是记录一个View及其对应分发的触控点列表pointerIdBits,且可以通过next与其他实例形成链表。

进一步分析,TouchTarget是对消耗事件的View以链表方式保存,且记录各个View对应的触控点列表,以实现后续的事件派分处理。

同时可以推理出:

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

然后进入到ViewGroup.dispatchTouchEvent()方法中看具体实现,去除无关逻辑的代码, 首先是:

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

该部分逻辑主要是重置状态,了解其中的内容我们需要先了解mFirstTouchTarget如何生成,所以暂不分析,只需要知道逻辑为:
ACTION_DOWN事件触发时,重置ViewGroup状态,且mFirstTouchTarget会被置空。

此时,mFirstTouchTarget = null


然后检测ViewGroup是否拦截事件:

// 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 {
    // There are no touch targets and this action is not an initial down
    // so this view group continues to intercept touches.
    intercepted = true;
}

直接看到用户处理是否拦截的逻辑:

  1. 只会在ACTION_DOWN事件时直接触发。
  2. 其他的事件会根据是否存在消耗ACTION_DOWN事件的目标控件(即是否有mFirstTouchTarget记录)而决定。

当不存在消耗ACTION_DOWN事件的目标控件时,后续事件的拦截标记intercepted将会越过用户处理表现为true,可以理解为ViewGroup退化成View,事件处理将交给super.dispatchTouchEvent()进行。

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

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


然后开始核心处理逻辑,先看2个关键的final局部变量:

// Check for cancelation.
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;
  1. boolean canceled
    该事件是否需要取消,由resetCancelNextUpFlag(this)或者事件本身决定,基本由事件本身决定。
    resetCancelNextUpFlag内部实际上是对PFLAG_CANCEL_NEXT_UP_EVENT进行操作。
    当控件持有PFLAG_CANCEL_NEXT_UP_EVENT标记时,则清除该标记并返回true,否则返回flase

  2. boolean split
    是否支持多点触控,此处默认基本为true

这里对涉及到的2个FLAG进行解析:

  1. View.PFLAG_CANCEL_NEXT_UP_EVENT:"指示视图是否临时分离"。
    字面理解为当前View是否与窗口处于分离状态。设置该标签的方法有:
    performButtonActionOnTouchDown():该方法在View.onTouchEvent()中调用,输入事件为鼠标右键的情况下触发,一般情况无需理会(一般不接入鼠标)。
    onStartTemporaryDetach():该方法在子控件与父控件"临时分离"时调用。
    .
    一般来说,原生控件中通常在RecycleView/ListView中可能会发生这样的情况。
    即控件执行轻量级临时分离,在触发onStartTemporaryDetach()后,又触发了控件的dispatchTouchEvent()。这时在流程中调用的resetCancelNextUpFlag()方法将会移除控件的PFLAG_CANCEL_NEXT_UP_EVENT标记,并标记当次事件canceled

  2. View.FLAG_SPLIT_MOTION_EVENTS:是否支持拆解MotionEvents拆解,即多点触控。
    该标记在Api>=11(Android 3.0)的后,ViewGroup在初始化时默认支持(initViewGroup()中)。
    也可以通过setMotionEventSplittingEnabled()手动管理。


继续分析,然后是2个变量:

TouchTarget newTouchTarget = null;
boolean alreadyDispatchedToNewTouchTarget = false;
  1. TouchTarget newTouchTarget
    当事件已经做出派分时,记录派分对应的控件。

  2. boolean alreadyDispatchedToNewTouchTarget
    记录事件是否已经做出派分。

用于过滤已派分的事件,避免事件重复派分。


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

if (!canceled && !intercepted) {
    >

逻辑中,会先对ACTION_DOWNACTION_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计算,又是安卓各种神奇位运算的一个实例。
    逻辑为1 << ev.getPointerId(actionIndex),即对0000 0001左移,位数为PointerId值,一般情况PointerId从0开始,每次+1。
    即把PointerId记录通过位进行保存:0对应0000 0001,2对应0000 0100,5对应0010 0000等。


接下来就是调用removePointersFromTouchTargets检查是否有记录的PointID

>>
// Clean up earlier touch targets for this pointer id in case they
// have become out of sync.
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时执行【遍历】,以下部分忽略子控件布局排序机制的源码,视为customOrder = false的情况:

>>
final int childrenCount = mChildrenCount;
if (newTouchTarget == null && childrenCount != 0) {
    final float x = ev.getX(actionIndex);
    final float y = ev.getY(actionIndex);

    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,及ViewViewGroup中的位置数据[LTRB])

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

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

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

getTouchTarget方法用于查找当前的mFirstTouchTarget是否存在对应View的记录。
以上逻辑为:
如果mFirstTouchTarget已经存在对应ViewTouchTarget,则可以直接把idBitsToAssign添加到TouchTarget中,并跳出【遍历】。

  • 这里则可以回答文章开篇的问题3:
    ACTION_DOWN被A消耗,ACTION_POINTER_DOWN也被A消耗,此时相当于A是2个触控点的目标元素。
    当释放任意一个触控点时,对应的事件是ACTION_POINTER_UP而不是ACTION_UP,导致不产生点击事件。
    (原理为dispatchTransformedTouchEvent逻辑中,传入的id为A对应的pointerIdBits,此时应为0000 0011,然后会进入if(newPointerIdBits == oldPointerIdBits)的逻辑,该部分逻辑不会通过event.split拆解事件,则为ACTION_POINTER_UP

假如是ACTION_DOWN的情况,mFirstTouchTarget必然为空,则继续以下流程:

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

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

通过addTouchTarget(),生成一个新的TouchTarget(包裹着消化事件的View),并添加到mFirstTouchTarget头部,并使newTouchTarget指向生成的TouchTarget
alreadyDispatchedToNewTouchTarget标记为true

以上完成了【遍历】的逻辑。


>逻辑(即处理ACTION_DOWNACTION_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记录到该元素。

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

这时候就处理完>的逻辑,完成ACTION_DOWNACTION_POINTER_DOWN引起的目标查找。

以上部分可能导致的结果有:

/ mFirstTouchTarget newTouchTarget alreadyDispatchedToNewTouchTarget
ACTION_DOWN 无目标 null null false
ACTION_DOWN 有目标 TouchTarget(0) new TouchTarget(0) true
ACTION_POINTER_DOWN 无目标 TouchTarget(0) TouchTarget(0) false
ACTION_POINTER_DOWN 有目标(已存在) 不变 find TouchTarget false
ACTION_POINTER_DOWN 有目标(不存在) TouchTarget(n) new TouchTarget(n) true

可以总结到如下性质:

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

接下来就是派分非ACTION_DOWN类型的事件的处理,该部分处理主要是根据上述的3个对象进行。

  1. ACTION_DOWN没有派分目标:
 if (mFirstTouchTarget == null) {
    // No touch targets so treat this as an ordinary view.
    handled = dispatchTransformedTouchEvent(ev, canceled, null, TouchTarget.ALL_POINTER_IDS);
}

此处dispatchTransformedTouchEvent传入的View参数为null,视为ViewGroup,即派分到自身。

  1. 遍历mFirstTouchTarget进行派分:
// 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) {
        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;
}
  1. alreadyDispatchedToNewTouchTarget && target == newTouchTarget
    用到了alreadyDispatchedToNewTouchTarget标记,用于过滤新建TouchTarget时已消耗事件的情况,避免重复派分。

  2. final boolean cancelChild = resetCancelNextUpFlag(target.child) || intercepted
    intercepted标记的作用区域,主要处理ACTION_DOWN类型事件的目标控件,的后续事件派分被拦截的情况(ACTION_DOWN无目标时将会直接导致mFirstTouchTarget == null)。
    .
    当拦截时,需要对目标控件传入一个ACTION_CANCEl事件以通知目标控件当次事件派分被拦截需要进行取消操作。并在后续处理中将cancelChild的目标控件从mFirstTouchTarget中移除。
    .
    当不拦截时,派分事件到mFirstTouchTarget链表中的所有目标控件。(由于dispatchTransformedTouchEvent存在触控点ID判断和事件分割,所以实际上只有链表部分的目标控件会收到事件派分)

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


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

  1. ACTION_UP:说明这是最后一个触控点抬起,通过resetTouchState()完全清理派分目标和状态。

  2. ACTION_POINTER_UP:移除触控点对应的TouchTarget内的pointerIdBits记录,当移除后pointerIdBits = 0(即没有其他触控点记录),则把该TouchTargetmFirstTouchTarget中移除。


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

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

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


阅读该机制的收获其实远不止了解到mFirstTouchTarget的作用,更是能对ViewGroup如何拦截/派分事件进行更深入的理解,当遇到一些较复杂的情况下,也能更加轻松的应对。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,185评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,445评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,684评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,564评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,681评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,874评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,025评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,761评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,217评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,545评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,694评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,351评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,988评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,778评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,007评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,427评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,580评论 2 349

推荐阅读更多精彩内容