深入理解事件分发 ViewGroup.mFirstTouchTa
在ViewGroup
事件派分过程中,mFirstTouchTarget
起着相当重要的作用。
但对mFirstTouchTarget
的作用是什么,大多数的文章都简单的描述为记录后续事件派分的目标,很少有具体分析这个机制的具体逻辑,更不说其他的一些问题,例如:
- 为什么要把
mFirstTouchTarget
设计成链表 - 记录目标的
TouchTarget
的pointerIdBits
又起到什么作用。
而这个机制又能引申出多点触控相关的问题,例如:
设ViewGroup VG中,有2个Button:A,B:
- 按下A,再按下A(多点触控),为什么释放后A的点击事件只会触发一次。
- 按下A,按下VG(空白区域),为什么先释放A,却无法触发A的点击事件,继续释放VG,又会触发A的点击事件。
- 按下VG(空白区域),为什么点击A,B无响应。
本文章在对mFirstTouchTarget
进行分析的同时,对ViewGroup
和View
的机制进行进一步的原理解析(例如resetCancelNextUpFlag
具体作用等),相信读者有更多的收获。
- 本文不对
Accessibility
服务相关处理进行分析,并在贴出的源码中移除相关逻辑。 - 本文不对
鼠标事件
相关处理进行分析,并在贴出的源码中移除相关逻辑。
首先看定义:
// First touch target in the linked list of touch targets.
private TouchTarget mFirstTouchTarget;
mFirstTouchTarget
是一个TouchTarget
对象,通过注释的说明:"触摸目标的链接列表中的第一个触摸目标",可以得出:
mFirstTouchTarget
是"触摸目标"链表的头部。
引申出一个问题:什么叫做"触摸目标":
"触摸目标"可以理解为触控点按下时,处理该触控点对应事件的目标控件。
简单来说,在ViewGroup.dispatchTouchEvent()
遇到非拦截事件,且事件类型为ACTION_DOWN
或ACTION_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;
}
-
View
child
:
被点击的子控件,即消耗事件的目标控件。 -
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
。
- TouchTarget
next
:
记录下一个TouchTarget
对象,由此组成链表。
- 注意到
TouchTarget
包含obtain
和recycle
两个方法,用于缓存复用,这个同样在Message
中实现,需要缓存复用的时候可以参考借鉴该方式,这也是安卓中常见的操作。
分析可得, TouchTarget
的作用,是记录一个View
及其对应分发的触控点列表pointerIdBits
,且可以通过next
与其他实例形成链表。
进一步分析,TouchTarget
是对消耗事件的View
以链表方式保存,且记录各个View
对应的触控点列表,以实现后续的事件派分处理。
同时可以推理出:
- 非多点触控:
mFirstTouchTarget
链表退化成单个TouchTarget
对象。 - 多点触控,目标相同:同样为单个
TouchTarget
对象,只是pointerIdBits
保存了多个pointerId
信息。 - 多点触控,目标不同:
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;
}
直接看到用户处理是否拦截的逻辑:
- 只会在
ACTION_DOWN
事件时直接触发。 - 其他的事件会根据是否存在消耗
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;
-
boolean
canceled
:
该事件是否需要取消,由resetCancelNextUpFlag(this)
或者事件本身决定,基本由事件本身决定。
resetCancelNextUpFlag
内部实际上是对PFLAG_CANCEL_NEXT_UP_EVENT
进行操作。
当控件持有PFLAG_CANCEL_NEXT_UP_EVENT
标记时,则清除该标记并返回true
,否则返回flase
。 -
boolean
split
:
是否支持多点触控,此处默认基本为true
。
这里对涉及到的2个FLAG
进行解析:
-
View.PFLAG_CANCEL_NEXT_UP_EVENT
:"指示视图是否临时分离"。
字面理解为当前View是否与窗口处于分离状态。设置该标签的方法有:
performButtonActionOnTouchDown()
:该方法在View.onTouchEvent()
中调用,输入事件为鼠标右键的情况下触发,一般情况无需理会(一般不接入鼠标)。
onStartTemporaryDetach()
:该方法在子控件与父控件"临时分离"时调用。
.
一般来说,原生控件中通常在RecycleView
/ListView
中可能会发生这样的情况。
即控件执行轻量级临时分离,在触发onStartTemporaryDetach()
后,又触发了控件的dispatchTouchEvent()
。这时在流程中调用的resetCancelNextUpFlag()
方法将会移除控件的PFLAG_CANCEL_NEXT_UP_EVENT
标记,并标记当次事件canceled
。 -
View.FLAG_SPLIT_MOTION_EVENTS
:是否支持拆解MotionEvents拆解,即多点触控。
该标记在Api>=11(Android 3.0)的后,ViewGroup
在初始化时默认支持(initViewGroup()
中)。
也可以通过setMotionEventSplittingEnabled()
手动管理。
继续分析,然后是2个变量:
TouchTarget newTouchTarget = null;
boolean alreadyDispatchedToNewTouchTarget = false;
-
TouchTarget
newTouchTarget
:
当事件已经做出派分时,记录派分对应的控件。 -
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;
>>
-
int
actionIndex
:
触控点下标,表明这是第几个触控点。 -
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
中断:
-
canReceivePointerEvents()
:
判断控件是否可以接受事件,当控件可见性为VISIBLE
或者正在执行动画时,返回true
。 -
isTransformedTouchPointInView()
:
判断View
是否包含事件的坐标,计算过程中通过transformPointToViewLocal()
计算当前的真实坐标(其中包括了滚动量mScroll
,及View
在ViewGroup
中的位置数据[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
已经存在对应View
的TouchTarget
,则可以直接把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
为空时:视为传入View
为ViewGroup
本身;
当传入参数boolean cancel
为flase
时:将MotionEvent
的Action
设置为ACTION_CANCEL
分发到传入的View
。
上文提及isTransformedTouchPointInView()
中进行了坐标偏移处理,同样,该方法中也有相同的操作,只是偏移值直接保存到了MotionEvent
中,并在调用完View.dispatchTouchEvent
还原。
该方法中,对MotionEvent
进行了拆解,获取对应触摸点的MotionEvent
,拆解参考的是传入的位分配ID。
假如传入的View
消耗了该事件,dispatchTransformedTouchEvent
将会返回true
,然后执行以下逻辑后跳出【遍历】。
通过addTouchTarget()
,生成一个新的TouchTarget
(包裹着消化事件的View
),并添加到mFirstTouchTarget
头部,并使newTouchTarget
指向生成的TouchTarget
。
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
记录到该元素。
- 这里则可以回答文章开篇的问题4:
此处原理和问题3一样,只是添加的条件发生变化 -ACTION_DOWN
被A消耗,则mFirstTouchTarget
的末尾元素为A,后续没有被消耗的ACTION_POINTER_DOWN
事件都会传入A中,此时相当于A是2个触控点的目标元素。
这时候就处理完>
的逻辑,完成ACTION_DOWN
,ACTION_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 |
可以总结到如下性质:
- 标记位
alreadyDispatchedToNewTouchTarget
只会在新建TouchTarget
时设置true
。 -
ACTION_DOWN
无法找到目标时会导致后续所有的派分都直接传到ViewGroup
本身。 -
ACTION_POINTER_DOWN
无法找到目标时视为ACTION_DOWN
目标接收派分。
接下来就是派分非ACTION_DOWN
类型的事件的处理,该部分处理主要是根据上述的3个对象进行。
-
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
,即派分到自身。
- 遍历
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;
}
-
alreadyDispatchedToNewTouchTarget && target == newTouchTarget
:
用到了alreadyDispatchedToNewTouchTarget
标记,用于过滤新建TouchTarget
时已消耗事件的情况,避免重复派分。 -
final boolean cancelChild = resetCancelNextUpFlag(target.child) || intercepted
:
intercepted
标记的作用区域,主要处理ACTION_DOWN
类型事件的目标控件,的后续事件派分被拦截的情况(ACTION_DOWN
无目标时将会直接导致mFirstTouchTarget == null
)。
.
当拦截时,需要对目标控件传入一个ACTION_CANCEl
事件以通知目标控件当次事件派分被拦截需要进行取消操作。并在后续处理中将cancelChild
的目标控件从mFirstTouchTarget
中移除。
.
当不拦截时,派分事件到mFirstTouchTarget
链表中的所有目标控件。(由于dispatchTransformedTouchEvent
存在触控点ID判断和事件分割,所以实际上只有链表部分的目标控件会收到事件派分)
以上,完成了后续事件对mFirstTouchTarget
的派分。
剩余的部分,是对ACTION_UP
类型事件进行清理:
-
ACTION_UP
:说明这是最后一个触控点抬起,通过resetTouchState()
完全清理派分目标和状态。 -
ACTION_POINTER_UP
:移除触控点对应的TouchTarget
内的pointerIdBits
记录,当移除后pointerIdBits = 0
(即没有其他触控点记录),则把该TouchTarget
从mFirstTouchTarget
中移除。
最后可以回答开篇问题的1和2:
mFirstTouchTarget
设计成链表的作用,是用于记录多点触控情况下,多目标控件的派分逻辑。
pointerIdBits
的作用,是配合mFirstTouchTarget
,使多点触控时,同个目标可以对多个触控点进行合理的处理逻辑。
阅读该机制的收获其实远不止了解到mFirstTouchTarget
的作用,更是能对ViewGroup
如何拦截/派分事件进行更深入的理解,当遇到一些较复杂的情况下,也能更加轻松的应对。