一文搞定面试 | 事件分发机制及冲突解决方案
事件分发简介
事件分发整体 需要从 应用层(IMS、InputChannel、WMS等方面)进行完整的剖析,本文将暂只介绍UI层面(ViewGroup->View)的部分源码,下文中后续对事件分发的指代也统一意为UI层面含义
,对应用层感兴趣的,可以移步这两篇文章,大佬之作、放心食用
同时借用江大佬文中的图,让大家好对ViewGroup之前的大体流程有个认知
开始之前,先介绍几个概念
- 事件序列:
对于用户的一次触摸操作,必然伴随
ACTION_DOWN
->...(可能没有)ACTION_MOVE
->ACTION_UP
事件行为按序构成, 而对于一个View,序列则为ACTION_DOWN
->...ACTION_MOVE
->ACTION_UP
\ACTION_CANCEL
(省略了多指事件) - 深度优先搜索:ViewTree是一个树形结构,为了正确合适地处理
MotionEvent
,采用DFS(Depth-First-Search)进行遍历。遍历过程中则伴有递归
&回溯
,此外为了减少遍历的时间复杂度,因此需要进行剪枝
- 分发和消费:其实
分发
则对应递归
,是向下遍历
的过程,为了找到合适的child view进行消费;而消费
则是回溯
,是向上反馈
的过程,决定了parent view是否可以选择进行消费。因此,消费是child view优先的。打个不太恰当的比喻,总当孩子说不吃时,父母才会去吃。当然父母也应该有自己的享受,这就对应了拦截
- 消费序列:对于一次事件序列的每次分发,如果每每进行完整的遍历,性能会有不小的损耗。因此引入
消费序列
进行剪枝
和记忆性搜索
,即当发生消费时,记录下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
,那同样不需要遍历,直接对它进行单一分支递归即可
这里也引申了两种事件冲突的解决方案:
- 外部拦截: 全权由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;
}
- 内部拦截:
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.onTouch
和onTouchEvent
的返回值,且一旦提前消费,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