一、面试场景
面试官:讲讲 Android 的事件分发机制?
菜鸟:基本会遵从 Activity => ViewGroup => View 的顺序进行事件分发,然后通过调用 onTouchEvent() 方法进行事件的处理。我们在项目中一般会对 MotionEvent.ACTION_DOWN,MotionEvent.ACTION_UP,MotionEvent.ACTION_MOVE,MotionEvent.ACTION_CANCEL 分情况进行操作。
面试官:有去查看源码中的事件拦截方法吗?或者说在进行事件分发的时候如何让正常的分发方式进行拦截?
菜鸟:我知道有个拦截事件的方法叫...叫,onInterceptEvent()?应该是,不过由于平时项目较多,确实没时间去关注太多源码。
面试官:厄,那你觉得在一个列表中,同时对父 View 和子 View 设置点击方法,优先响应哪个?为什么会这样?
菜鸟:肯定是优先响应子 View 的,至于为什么这样,平时知道这个结论,所以没去太深入研究
面试官:哦,这样子呀。那谈谈你对滑动冲突的理解,平时是如何处理滑动冲突的
菜鸟:滑动冲突就是父View和子View在处理MOVE事件上出现了冲突,但是由于平时遇到的比较少,所以并没有自己处理过滑动冲突的经验。
面试官:嗯,你的情况我基本了解了。您先回家等我们的结果吧。
我们可能经常会遇到上面的这种情况,面试官希望了解我们知识的深入情况,或者说是平时学习欲望到底怎样。可很不幸的是,我搞 模拟面试 以来,80% 的小伙伴都属于开发能力不错,可对类似事件分发这样的基础问题一概不知。究其原因,除去忙以外,大多数小伙伴还是觉得平时开发也用不上什么,即使用到了,直接 百度一下便能得到正确答案。这其中除了我们平时面向百度编程的习惯外,更重要的是没有去真正的理解这个事件分发机制的原理,更没有去扣源码的习惯,也没有自定义View的经验。于是尴尬的面试场景就来了。
而实际上要回答上面的几个问题,只要我们弄明白Android的事件分发机制就能够回答出80%了。
二、事件分发
1、事件分发的对象是谁
答案:点击事件(Touch
事件)
而Touch事件又分为如下四种
事件 |
简介 |
ACTION_DOWN |
手指 初次接触到屏幕 时触发 |
ACTION_MOVE |
手指 在屏幕上滑动时触发,会多次触发 |
ACTION_UP |
手指 离开屏幕 时触发 |
ACTION_CANCEL |
事件 被上层拦截 时触发 |
一般情况下,事件列都是以DOWN事件开始、UP事件结束,中间有无数的MOVE事件,如下图:
即当一个点击事件(MotionEvent )产生后,系统需把这个事件传递给一个具体的 View 去处理。
而我们可以将事件传递这个过程分成三层:Activity、ViewGroup、View。
同时这个过程中,事件每经过一层都会触发如下三个方法。
- dispatchTouchEvent()
- onTouchEvent()
- onInterceptTouchEvent()
类型 |
相关方法 |
Activity |
ViewGroup |
View |
事件分发 |
dispatchTouchEvent |
√ |
√ |
√ |
事件拦截 |
onInterceptTouchEvent |
X |
√ |
X |
事件消费 |
onTouchEvent |
√ |
X |
√ |
2、Activity事件分发
没源码没真相,首先我们来对源码进行分析
public boolean dispatchTouchEvent(MotionEvent ev) {
if (ev.getAction() == MotionEvent.ACTION_DOWN) {//事件分发都是从用户按下那一刻,产生DOWN事件开始的
onUserInteraction();//一般都是用来做屏保功能的,但是平时开发中我们基本不会重写该方法
}
if (getWindow().superDispatchTouchEvent(ev)) {//将事件传递给PhoneWindow,再有PhoneWindow通过DecodView分发给ViewGroup
//如果Activity下的控件消费了事件则直接返回,停止事件传递
return true;
}
return onTouchEvent(ev);//没有控件消费事件则直接调用onTouchEvent
}
3、ViewGroup事件分发
上面的源码我们说ViewGroup的事件是从Activity通过PhoneWindow再通过DecodeView传递的。
直接调用了 DecorView 的 superDispatchTouchEvent() 方法。DecorView 继承于 FrameLayout,作为顶层 View,是所有界面的父类。而 FrameLayout 作为 ViewGroup 的子类,所以直接调用了 ViewGroup 的 dispatchTouchEvent()。
而ViewGroup对事件分发的所有处理逻辑都在dispatchTouchEvent方法中,所以dispatchTouchEvent方法是我们必须要看的源码之一。
首先我们来看知识点:如图中红色框的内容,定义了一个标志位来判断该ViewGroup是否拦截事件,其中就调用了 onInterceptTouchEvent(ev) 对 intercept 进行赋值。大多数情况下,onInterceptTouchEvent() 返回值为 false,但我们完全可以通过重写 onInterceptTouchEvent(ev) 来改变它的返回值来完成对事件的拦截。
当ViewGroup不对事件进行拦截的时候,就需要将事件分发到子View了,但是并不是所有的子View都要分发这个事件,而是通过如红框中所示根据用户的点击区域找到对应的子View。当找到在点击区域内的子View剩下的就是将事件分发给View了。
那么这个dispatchTransformedTouchEvent又干了什么呢?
除了cancel事件还有其他的几个事件呀,其他事件怎么处理呢?我们继续往下看:
这里需要注意啦,这里说的子View本身可能也是一个ViewGroup。这就是著名的责任链模式。
3、View事件分发
ViewGroup 说到底还是一个 View,而且ViewGroup包含着其他的子View,并可能将事件分发给这些子View。所以View 的 dispatchTouchEvent()方法同样是我们需要关注的重点
红框中的三个条件,第一个我就不用说了。
- (mViewFlags & ENABLED_MASK) == ENABLED
该条件是判断当前点击的控件是否为 enable,但由于基本 View 都是 enable 的,所以这个条件基本都返回 true。而handleScrollBarDragging()则是判断当前View是否正在处理拖拽。
- mOnTouchListener.onTouch(this, event)
即我们调用 setOnTouchListener() 时必须覆盖的方法 onTouch() 的返回值。
- 当上面两个判断执行后只要事件没有被消费,即result依旧为false。就会继续调用该View自身的onTouchEvent()方法。
从上述的分析,终于知道OnTouchListener的onTouch() 方法优先级高于View自身的 onTouchEvent(event) 方法是怎么来的了吧。
三、事件拦截
都在说事件拦截是ViewGroup独有的特性,因为它有一个抽象的onInterceptTouchEvent()方法供开发者去实现拦截功能。但如果自己做过滑动冲突的朋友应该明白,实际上事件拦截是分为两种的:父控件拦截、子控件拦截。这里我们就简单的说一下这两种拦截的方式。
1、父控件拦截
既然是父控件,那必然是ViewGroup了。所以重写onInterceptTouchEvent来实现拦截是必然的了。那么我们就跟着源码分析一下,重写这个onInterceptTouchEvent方法后,是如何实现拦截的?并且是否只要拦截一个事件其他事件同样不会往下传递了呢?
2、子控件拦截
有人就奇怪了,View并没有onInterceptTouchEvent的方法,如何实现拦截呢?这里说的子控件拦截实质上是说子控件请求父类不要拦截事件,将事件分发到子View,由子View来消费该事件。
当子View想要获取事件,但是事件又被父控件把持着不下发的时候就需要子View调用ViewGroup的requestDisallowInterceptTouchEvent(true)来请求事件(实际上就是设置一下mGroupFlags这个标志位的值)。但是,是否只要子View调用了这个方法就必然能够获取到事件呢?比如父控件的onInterceptTouchEvent()方法直接返回true的情况下。是否能够通过这种方法请求到事件下发呢?答案是不能!答案就在每次Down事件时,ViewGroup都会重置这个标志位,这就导致了子View发起的请求无效了。
那么如何解决这个问题呢?既然在Down事件的时候都会被重置标志,那么如果跳过这个事件,让Down事件正常下发,然后再设置不就可以了吗?所以父控件只要拦截除了Down事件之外的事件就行了,而Down事件继续分发到子View。子View接收到Down事件后再调用requestDisallowInterceptTouchEvent(true)方法请求其他事件的下发。如果子View不需要某个事件了再调用requestDisallowInterceptTouchEvent(false)将事件拦截的权利重新交给父控件。
实际上我们在后续解决滑动冲突的时候遵循的就是这两种方式。
四、事件消费
我们都知道ViewGroup实质上也是继承自View,它同样有着View的属性。而在事件分发过程中,不管该事件是继续向子View下发还是拦截下来自己处理,最终都会执行View的dispatchTouchEvent()方法。
而事件的消费就从View的dispatchTouchEvent()开始。
public boolean onTouchEvent(MotionEvent event) {
.......
// 若该控件可点击,则进入switch判断中
if (clickable || (viewFlags & TOOLTIP) == TOOLTIP) {
switch (action) {
// a. 若当前的事件 = 抬起View(主要分析)
case MotionEvent.ACTION_UP:
...// 经过种种判断,此处省略
// Only perform take click actions if we were in the pressed state
if (!focusTaken) {
// Use a Runnable and post this rather than calling
// performClick directly. This lets other visual state
// of the view update before click actions start.
if (mPerformClick == null) {
mPerformClick = new PerformClick();
}
// 执行performClickInternal() ->>分析1
if (!post(mPerformClick)) {
performClickInternal();
}
}
}
// b. 若当前的事件 = 按下View
case MotionEvent.ACTION_DOWN:
....
if (!clickable) {
//执行长按事件
checkForLongClick(
ViewConfiguration.getLongPressTimeout(),
x,
y,
TOUCH_GESTURE_CLASSIFIED__CLASSIFICATION__LONG_PRESS);
break;
}
// c. 若当前的事件 = 结束事件(非人为原因)
case MotionEvent.ACTION_CANCEL:
......
break;
// d. 若当前的事件 = 滑动View
case MotionEvent.ACTION_MOVE:
......
break;
}
// 若该控件可点击,就一定返回true
return true;
}
// 若该控件不可点击,就一定返回false
return false;
}
分析一
private boolean performClickInternal() {
// Must notify autofill manager before performing the click actions to avoid scenarios where
// the app has a click listener that changes the state of views the autofill service might
// be interested on.
notifyAutofillManagerOnClick();
//分析二:执行performClick
return performClick();
}
分析二
public boolean performClick() {
...
if (li != null && li.mOnClickListener != null) {
playSoundEffect(SoundEffectConstants.CLICK);
// 只要我们通过setOnClickListener()为控件View注册1个点击事件
// 那么就会给mOnClickListener变量赋值(即不为空)
// 则会往下回调onClick() & performClick()返回true
li.mOnClickListener.onClick(this);
result = true;
} else {
result = false;
}
.....
return result;
}
我们可以看到当View的dispatchTouchEvent返回true即表示该View消费该事件。