基础知识
View位置参数
- Layout坐标:(
left
,top
)、(right
,bottom
)描述View相对于父View的位置,分别代表view左上角与右下角相对于父view的坐标(在开发者模式中打开「显示布局边界」可以看到此坐标围成的框)。 - 绝对坐标:(
x
,y
)描述的是View相对于屏幕坐标系的位置,代表View左上角相对于屏幕的位置。 - 偏移坐标:(
translationX
,translationY
)描述的是View相对于初始位置的偏移量,修改此属性不会改变View的Layout坐标,但是会改变view的绝对坐标。
触摸事件
手指在屏幕上滑动的过程中会产生一系列滑动事件,这些事件被封装为一个个MotionEvent对象,通过MotionEvent我们可以获取到以下数据:事件类型action
、相对于View的事件发生位置(x
, y
)、相对于屏幕的事件发生位置(rawX
, rawY
)。
常见的事件类型有以下三种:
ACTION_DOWN
: 手指按下时触发,每次按下只触发一次。ACTION_MOVE
: 手指在屏幕上滑动时触发,滑动时会产生多个此事件。ACTION_UP
: 手指移开屏幕时触发,每次移开只触发一次。
通常情况下,一次完整的滑动过程会以ACTION_DOWN
事件开始、以ACTION_UP
事件结束,中间包含0~N个ACTION_MOVE
事件,我们将这样一组点击事件称为一条事件序列。
TouchSlop
TouchSlop是系统设定的滑动最小判定距离,当滑动的距离小于这个值时,可以不认为这是一次滑动(具体还是取决于你怎么使用这个值)。这是一个系统常量,其值的大小取决于设备。使用ViewConfiguration.get(context).getScaledTouchSlop()
可以获取到这个值。
系统层级的分发
事件的分发起源于系统层的InputManager
,InputManager
运行在native层,负责与硬件通信并接收其输入的事件。以触摸事件为例,用户触摸时,屏幕首先会捕获到触摸事件,并将其交给InputManager
,随后InputManager
会将事件传递给ViewRootImpl
的内部类成员WindowInputEventReceiver
,由该成员将事件添加到一个输入事件队列中,以等待后续处理。
当事件进入队列后,在ViewRootImpl
中会有一条InputStage
责任链逐一处理队列中的事件,这条责任链由InputStage
的子类对象构成,链条上每个节点都代表着一类事件的处理阶段。比如ViewPostImeInputStage
就代表着视图输入处理阶段,主要处理按键、轨迹球、屏幕触摸等事件,而我们后文中要讲的UI层级的触摸事件分发就在此阶段。
Activity层级的分发
在ViewPostImeInputStage
中,触摸事件MotionEvent会通过processPointerEvent
方法分发给DecorView
,DecorView就是ViewRootImpl
中的mView
成员。
当事件到达DecorView后,DecorView会通过mWindow
成员,将事件交给Activity的dispatchTouchEvent
方法,Activity首先会通过Window的superDispatchTouchEvent
方法再次将事件传回给DecorView,以进行View层级的事件分发,若在View层级事件没有被处理掉,superDispatchTouchEvent
就会返回false
,此时将会调用Activity的onTouchEvent
来处理事件。
View层级的事件分发
当Window将事件再次传回给DecorView,自此DecorView将作为View树的根节点(一个ViewGroup)来处理事件,由此进入View层级的事件分发。在View层级的事件分发过程中,有三个关键方法:
三个关键方法
-
dispatchTouchEvent(MotionEvent): Boolean
此方法用于事件分发,根据View扮演的角色不同表现出不同行为:- 作为View时,负责将事件分发给自身处理,具体如处理滚动条的拖动、调用自身的
onTouchEvent
方法处理事件等。 - 作为ViewGroup时,负责判断事件是否应该交给谁处理(自己or子View),并且调用目标View的
dispatchTouchEvent
方法,进一步分发事件。
该方法会返回一个boolean值,用来表示事件是否已经被处理。
- 作为View时,负责将事件分发给自身处理,具体如处理滚动条的拖动、调用自身的
-
onInterceptedTouchEvent(MotionEvent): Boolean
此方法用来决定是否要拦截当前的事件,在ViewGroup的dispatchTouchEvent
方法中调用,返回true表示要拦截此事件,这会让这个事件及其所在事件序列的后续事件都交由当前View处理。 -
onTouchEvent(MotionEvent): Boolean
用于处理事件的方法,由View的dispatchTouchEvent
方法调用,返回值表示当前View是否处理了此事件。
核心分发过程
整个View层级分发过程中,有两类角色参与事件分发,一是View,二是ViewGroup。前者是事件最终被处理的地方,后者则用于将事件分发给对应的View处理。需要注意的是ViewGroup也是View,所以它自己也可以作为View的角色来对事件进行最终处理。
我们知道,Android中的View是组成树状结构展示给用户的,这颗树由若干的ViewGroup、View类型的节点组成,而事件分发的本质就是在这颗树中找到合适View来处理事件,所以,我们可以简单的将View层级的事件分发看做是一个多叉树的搜索问题,只是在这之上增加了“亿“点点细节。
因为View树的根节点和中间节点一定是ViewGroup,所以我们首先来看一下ViewGroup是如何处理事件分发的,以下是ViewGroup事件分发的伪代码:
// 伪代码
public boolean dispatchTouchEvent(MotionEvent event) {
boolean consumed = false;
// 1. 遍历子view,找到合适的子view调用它的dispatchTouchEvent
for (int i = mChildrenCount - 1; i >= 0; i--) {
View child = getChildAt(i);
// 子view不在这个事件的范围内,则跳过此view
if (!childCanDealThisEvent(child)) continue;
// 因为多态机制,child总能调用到正确的dispatchTouchEvent方法
if (child.dispatchTouchEvent(event)) {
// 2. 如果子View成功消费了此事件,就跳出循环
consumed = true;
break;
}
}
// 3. 如果没有子View消费此事件,就自己尝试处理事件
if (!consumed) {
// 此时ViewGroup作为View的角色,也就是调用了View的dispatchTouchEvent
consumed = super.dispatchTouchEvent(event);
}
// 4. 将处理结果返回给调用者
return consumed;
}
复制代码
可以看到,ViewGroup是通过遍历并调用子View的dispatchTouchEvent
方法来分发事件的,利用多态机制,当子View是ViewGroup时,便会递归的重复这一过程,以此事件就能够在View树上逐层传递,直到找到最终需要处理此事件的View。如果你熟悉数据结构,就会发现,这其实就是树的DFS(深度优先搜索)算法。
接下来看看View的处理流程:
// 伪代码
public boolean dispatchTouchEvent(MotionEvent event) {
boolean result = false;
// 1. 如果设置了TouchListener,则先调用listener的onTouch方法
if (mOnTouchListener != null && mOnTouchListener.onTouch(event)) {
// 如果listener的onTouch返回了true,则表示此事件已经被消费
result = true;
}
// 2. 如果事件还未被消费,则调用onTouchEvent方法
if (!result && onTouchEvent(event)) {
result = true;
}
// 3. 将处理结果返回给调用者
return result;
}
复制代码
以上便是View层事件分发的核心流程,整体调用过程如下图,当然相比真正实现中间还省略了很多细节,比如对事件处理的优化细节、以及事件拦截机制。
优化分发过程
前面我们提到,手指滑过屏幕,会产生一系列事件,也就是说,这些事件会在短时间内产生很多个,如果为每一个事件的分发都对View树进行一次DFS,那么这将会非常浪费资源。
其实很多时候,对于按下—移动—抬起
这样一组操作,通常都由同一个View处理,因此设计者将这样一组操作产生的事件称为 事件序列, 对于同一个事件序列中的事件,都交给同一个View处理,以此便能减少不必要的遍历。那么,View树是如何在分发事件时记住View的呢?
当ViewGroup接收到ACTION_DOWN
事件时,会进行完整的事件分发流程,当发现某个子View成功的消费了事件时,便将这个子View保存到一个mFirstTouchTarget
成员中;对于View树中下一层的ViewGroup,也会这样将消费事件的子View保存起来,从而在View树中留下一条通向目标View的路径。
当ViewGroup接收到事件序列的其它事件时,就只需要从mFirstTouchTarget
中取出下一个需要访问的View,然后调用它的dispatchTouchEvent
将事件传递给它就好了。以下是伪代码实现:
// 伪代码
public boolean dispatchTouchEvent(MotionEvent event) {
TouchTarget newTouchTarget = null;
if (isDownEvent(event)) {
// 如果是down事件,就遍历子view,递归寻找能处理事件的子View,否则跳过
for (int i = mChildrenCount - 1; i >= 0; i--) {
View child = getChildAt(i);
if (!childCanDealThisEvent(child))
continue;
if (child.dispatchTouchEvent(event)) {
// 如果有子View消费了此事件,赋值TouchTarget并跳出循环
newTouchTarget.child = child;
break;
}
}
mFirstTouchTarget = newTouchTarget;
}
if (mFirstTouchTarget == null) {
// 此时ViewGroup作为View的角色,也就是调用了View的dispatchTouchEvent
consumed = super.dispatchTouchEvent(event);
} else {
if (mFirstTouchTarget != newTouchTarget) {
// 当除了Down事件之外的其它事件到来时,通过TouchTarget直接调用
// 目标view的dispatchTouchEvent,完成事件分发。
consumed = mFirstTouchTarget.child.dispatchTouchEvent(event);
} else {
// Down事件到来时会遍历子View执行dispatchTouchEvent以找到处理事件的目标View,
// 处理事件的目标View,从而赋值mFirstTouchTarget,此时dispatchTouchEvent
// 已经执行过了,因此无需再次执行。
consumed = true;
}
}
return consumed;
}
复制代码
事件拦截机制
回顾一下我们前面已经实现了的事件分发流程,我们会发现,当事件在ViewGroup中分发时,总是会先尝试将事件交给子View来处理,直到确认没有子View消费此事件时,才尝试自己处理。为什么要这样设计呢?
仔细观察一下应用界面,我们会发现,那些呈现在屏幕最前面,最常响应我们触控的View,通常是View树中最下层的节点。所以,为了能让事件尽快传递给显示在最前面的View,ViewGroup会优先将事件交给子View来处理。
同样的道理还运用在ViewGroup中对子view的遍历方式:从
index
最大的view开始到index
最小。因为像FrameLayout
这类子View可以重叠的ViewGroup,通常index
大的子View,也是显示在最前面的View。
将事件优先交给子View来处理这个设计,这在大多数情况下是没有什么问题的。然而,在一些特殊的场景下,这样的设计便有些不足了。
试想一个ScrollView
,其内部有许多的子View。当我们想滚动ScrollView
以显示其它子View时,按照上面的设计,需要子View对事件进行判定完了,才轮得到ScrollView
处理,并且,如果有任一子View消费了此事件,那么ScrollView
便没有机会处理事件了。换句话说,ViewGroup能否处理事件,得靠子View做决定。这好吗?这很不好!子View没有义务知道父View要不要处理事件,强行这样做只会增加它们之间的耦合。
因此,设计者为ViewGroup设计了事件拦截机制,用于ViewGroup将事件分发给子View之前判断自己是否要处理此事件。在该机制中,ViewGroup可以通过重写onInterceptTouchEvent
方法,决定自己是否要拦截并处理本次事件。
以下是加入了拦截机制后的事件分发伪代码:
// 伪代码
public boolean dispatchTouchEvent(MotionEvent event) {
// 根据onInterceptTouchEvent返回值判断是否要拦截此次事件。
boolean intercept = onInterceptTouchEvent(event);
TouchTarget newTouchTarget = null;
if (!intercept) {
// 如果不拦截则遍历子View分发事件
if (isDownEvent(event)) {
...
mFirstTouchTarget = newTouchTarget;
}
}
if (intercept || mFirstTouchTarget == null) {
// 决定拦截事件时或没有子View消费此事件时尝试自己处理事件。
consumed = super.dispatchTouchEvent(event);
} else if (mFirstTouchTarget != newTouchTarget) {
consumed = mFirstTouchTarget.child.dispatchTouchEvent(event);
}
return consumed;
}
复制代码
上述代码还存在一些问题:每次接收到事件都会去判断是否需要拦截,这似乎不太性能友好。参考之前的事件序列优化方式,我们可以将拦截方法的调用时机再限制一下:
// 伪代码
public boolean dispatchTouchEvent(MotionEvent event) {
boolean intercept = false;
if (isDownEvent(event) || mFirstTouchTarget != null) {
// 如果是DOWN事件时执行拦截判断,如果拦截就将mFirstTouchTarget置null,
// 当此事件序列的其它事件到来时,mFirstTouchTarget为null,就不会触发拦截判断了。
// 另外,如果mFirstTouchTarget不为null时,表示事件已有确定的子View消费,
// 此时也需要执行拦截判断。
if (onInterceptTouchEvent(event)) {
intercept = true;
mFirstTouchTarget = null;
} else {
intercept = false;
}
} else {
intercept = true;
}
...
}
复制代码
为什么已经有确定的子View消费事件了还需要进行拦截判断呢?其实在通常情况下,ViewGroup仅凭借一个DOWN
事件是无法确定是否需要进行拦截的,比如ScrollView
想进行上下滑动,那么至少得遇到一个MOVE
事件才能确定是否拦截。因此,在决定是否拦截之前,可能需要多次调用onInterceptTouchEvent
。
这样看来,子View在处理事件时是可能会被父View“截胡”的。为了“弥补”事件被抢对子View造成的伤害, 营造ViewGroup的良好人设, ViewGroup需要在拦截事件后,为已经设置为mFirstTouchTarget
的View奉上一个CANCEL
事件,当子View收到这份“心意”后,就可以开始“自我治疗”(取消点击事件、终止用于判断长按事件而设置的计时器等等),从而安心的放弃被拦截的事件了。
以下是添加了取消机制的事件拦截:
// 伪代码
public boolean dispatchTouchEvent(MotionEvent event) {
boolean intercept = false;
if (isDownEvent(event) || mFirstTouchTarget != null) {
intercept = onInterceptTouchEvent(event);
} else {
intercept = true;
}
// 遍历子View分发事件
...
if (mFirstTouchTarget == null) {
consumed = super.dispatchTouchEvent(event);
} else {
if (intercept) {
// 如果是事件序列中途拦截了事件,则将当前事件设置为cancel事件,
// 分发给mFirstTouchTarget,以告知子View,此事件序列已被拦截。
event.setAction(MotionEvent.ACTION_CANCEL);
mFirstTouchTarget.child.dispatchTouchEvent(event);
mFirstTouchTarget = null;
} else if(mFirstTouchTarget != newTouchTarget) {
consumed = mFirstTouchTarget.child.dispatchTouchEvent(event);
} else {
consumed = true;
}
}
return consumed;
}
复制代码
反拦截
前面拦截机制中提到,为了避免子View霸占事件,需要设计一个onInterceptTouchEvent
方法来帮助父View抢夺事件处理权。但是在这种机制下,要保证父子View能正常协作,会非常依赖父View:父View需要知道子View并且还得在它需要某些事件的时候,放弃拦截,从而让事件可以传递到子View。
比如一个横向滚动的ScrollView
内部有一个 SeekBar
,当我们需要拖动SeekBar
时,ScrollView
就必须要判断出是在拖动SeekBar
,而不是滚动自己。
大部分情况下,我们可以通过覆写父View的onInterceptTouchEvent
方法来处理此类冲突,但也有时候父View可能并不在我们的控制之中,难道此时就只能束手无策了吗?并不,在ViewGroup中还有一个名为requestDisallowInterceptTouchEvent
的方法,通过该方法子View可以主动要求父View不拦截事件,因此我们还得对事件拦截部分再做一些调整,以兼容requestDisallowInterceptTouchEvent
:
// 伪代码
private boolean isDisallowIntercept = false;
public boolean dispatchTouchEvent(MotionEvent event) {
boolean intercept = false;
if (isDownEvent(event) || mFirstTouchTarget != null) {
if(isDisallowIntercept) {
// 子View通过调用父View的requestDisallowInterceptTouchEvent
// 设置isDisallowIntercept,以禁用父View进行事件拦截。
intercept = false;
} else {
intercept = onInterceptTouchEvent(event);
}
} else {
intercept = true;
}
...
}
public void requestDisallowInterceptTouchEvent(boolean disallow) {
isDisallowIntercept = disallow;
}
复制代码
当然,这个方法的能提供的权限其实小的可怜,因为它还必须配合父View的onInterceptTouchEvent
来使用:在父View的onInterceptTouchEvent
中,不能够拦截DOWN
事件,因为一旦父View拦截了DOWN
事件,处理后续事件时就不会进入isDisallowIntercept
的判断了。
为什么要这样设计呢?其实父View和子View对事件的处理的权利争夺是一个相互制衡的过程,任何一方的权利都不能过大,否则就会不平衡。从最开始子View的权利过大,到使用onInterceptTouchEvent
为父View夺回控制权,再到requestDisallowInterceptTouchEvent
限制父View的权利,就这样一步步平衡着父View与子View,使它们得以良好协作得处理事件,同时也为事件处理增添了灵活性。
结语
本文大致的梳理了一下事件分发的整体流程,并且在View层事件分发上尝试以流程图、伪代码的形式逐步演化整个分发过程,但最终相比真实的事件分发过程,无疑缺少了诸多细节,甚至某些环节的代码与实际相去甚远,不过思路应该是一致的。最后,食用本文的最佳姿势是结合源码一起,伪代码中所包含的思想都能在源码中找到对应的部分。