作为Android最重要的机制之一,事件分发一直是一个老生常谈的话题,那么我们今天就来仔细研究一下Android中的事件分发机制。
本文的要点如下:
- 事件分发概述
- 事件分发的流程
- Activity
- ViewGroup
- View
- 总体流程
- 一些问题
- 总结
事件分发概述
说到事件分发,那么我们就一定要明确,一个问题:事件分发的对象是谁?
从名字也能看出来,当然是事件咯。没错,当用户触摸屏幕时(View或ViewGroup派生的控件),将产生点击事件(Touch事件)。Touch事件相关细节(发生触摸的位置(X,Y)、时间、历史记录、手势动作等)被封装成MotionEvent对象。
那么,Touch事件有几种呢?
主要发生的Touch事件有如下四种:
- MotionEvent.ACTION_DOWN:按下View(所有事件的开始)
- MotionEvent.ACTION_MOVE:滑动View
- MotionEvent.ACTION_UP:抬起View(与DOWN对应)
- MotionEvent.ACTION_CANCEL:非人为原因结束本次事件
其中前三种是正常情况下一个Touch事件列所包含的步骤。
事件列:从手指接触屏幕至手指离开屏幕,这个过程产生的一系列事件。任何事件列都是以DOWN事件开始,UP事件结束,中间有无数的MOVE事件。
明白了事件是什么,我们再来看看事件分发。将点击事件(MotionEvent)向某个View进行传递并最终得到处理的过程即为事件分发。那么事件都能发给谁呢?
对于View,ViewGroup和Activity都能处理Touch事件,它们之间处理的先后顺序和方法有所不同。一个点击事件产生后,传递顺序大致为:Activity(Window)-> ViewGroup -> View。
事件分发的流程
对事件分发有了一个感性的认知后,我们来仔细的研究一下事件分发的流程。
事件分发过程主要涉及到dispatchTouchEvent() 、onInterceptTouchEvent()和onTouchEvent()这三个方法。
首先是Activity
当手指触摸到屏幕时,屏幕硬件会获取到触摸事件,从底层产生中断上报。再通过native层调用Java层InputEventReceiver中的dispatchInputEvent方法。经过层层调用,最终交由Activity的dispatchTouchEvent方法来处理。
好我们具体来看一看这个方法:
public boolean dispatchTouchEvent(MotionEvent ev) {
if (ev.getAction() == MotionEvent.ACTION_DOWN) {
//onUserInteraction为空方法,每当Key,Touch,Trackball事件分发到当前Activity就会被调用。
//如果你想当你的Activity在运行的时候,能够得知用户正在与你的设备交互,你可以override该方法。
onUserInteraction();
}
//若getWindow().superDispatchTouchEvent(ev)的返回true
//则Activity.dispatchTouchEvent()就返回true,则方法结束
if (getWindow().superDispatchTouchEvent(ev)) {
return true;
}
//没有返回则继续往下调用Activity.onTouchEvent
return onTouchEvent(ev);
}
其中关键是getWindow().superDispatchTouchEvent(ev)方法,getWindow() 方法会获取Window类的对象,而Window类是抽象类,其唯一实现类是PhoneWindow类。来看具体实现:
@Override
public boolean superDispatchTouchEvent(MotionEvent event) {
return mDecor.superDispatchTouchEvent(event);
// mDecor 为顶层View(DecorView)的实例对象
}
DecorView类是PhoneWindow类的一个内部类,DecorView继承自FrameLayout,是所有界面的父类,我们又知道FrameLayout是ViewGroup的子类,因此DecorView的间接父类为ViewGroup。
public boolean superDispatchTouchEvent(MotionEvent event) {
return super.dispatchTouchEvent(event);
// 调用ViewGroup的dispatchTouchEvent()
}
暂时不管ViewGroup的dispatchTouchEvent(),先来看Activity本身的onTouchEvent()。
public boolean onTouchEvent(MotionEvent event) {
if (mWindow.shouldCloseOnTouch(this, event)) {
finish();
return true;
}
return false;
}
可以看到,里面逻辑很简单,就是用shouldCloseOnTouch方法对事件进行判断,根据返回值决定是否消费事件。
举个例子:在开发过程中,我们有时会通过Activity实现弹窗效果,实现很简单,在AndroidMenifest.xml中将对应的Activity增加android:theme="@android:style/Theme.Dialog"属性即可(也可以自定义弹窗的样式)。对于弹窗,点击其周围的空白区域,正常情况下弹窗都会自动消失。就是Activity中的onTouchEvent产生的作用。即shouldCloseOnTouch判断触摸点在边界外,那么就会finish(),因此对话框Activity就会关闭。
接着来看ViewGroup
从上面Activity事件分发机制可知,ViewGroup事件分发机制从dispatchTouchEvent()开始。看过源码的都知道ViewGroup的dispatchTouchEvent有200多行,我们在这里就不贴源码了,主要看看其工作流程。
整体的工作流程可以简化为以下三步:
- 判断自身是否需要(询问 onInterceptTouchEvent 是否拦截),如果需要,调用自己的 onTouchEvent。
- 自身不需要或者不确定,则询问 ChildView ,一般来说是调用手指触摸位置的 ChildView。
- 如果子 ChildView 不需要则调用自身的 onTouchEvent。
接下来我们看看每一步的具体工作:
ViewGroup每次事件分发时,都先判断disallowIntercept是否为true,然后调用onInterceptTouchEvent()询问是否拦截事件:
if (disallowIntercept || !onInterceptTouchEvent(ev)) { ...... }
disallowIntercept为false则代表禁用事件拦截功能,可以通过requestDisallowInterceptTouchEvent()修改。
onInterceptTouchEvent()中返回false代表不拦截事件,返回true则会拦截事件,即事件不会向下层view传递。
如果要向下层传递,那么问题就来了,该把事件传递给哪个子View呢?
for (int i = count - 1; i >= 0; i--) {
final View child = children[i];
......判断event的坐标是否包含在child中、子view是否可以处理touch事件等
}
可以看到,源码中其实是遍历了所有的子View,根据坐标从而找到当前被点击的View。那么就出现了一个问题,如果两个子View有重叠,那么应该给谁呢?
这个问题的答案就在上面的源码中,int i = count - 1,可以发现,遍历是从后往前的,即后面的View如果能处理就不会发给前面的View。那么View的顺序是怎么定的呢?是加载的先后。为什么要先给后加载的View呢?因为View绘制时,后加载的View会覆盖掉先加载的View,显示在最上面的是最后加载的,因此当 ChildView 重叠时,一般会分配给显示在最上面的 ChildView。(当然了,前提是最上面的ChildView可以处理touch事件)
如果所有的ChildView都不接收事件或者是覆写的onInterceptTouchEvent()返回了true(即拦截事件),则会调用:
super.dispatchTouchEvent(ev);
我们知道,ViewGroup的父类为View,那么就会调用View类的dispatchTouchEvent()(也就是把此ViewGroup当作一个View来看,调用其dispatchTouchEvent方法)。
最终事件都来到了View类
同样,从上面ViewGroup事件分发机制可以知道,View事件分发机制是从dispatchTouchEvent()开始的。
那么问题就来了,ViewGroup 有 dispatchTouchEvent 也就算了,毕竟人家有一堆 ChildView 需要管理,但为啥 View 也有?
其实很简单,我们都知道 View 可以注册很多事件监听器,单击事件(onClick)、长按事件(onLongClick)、触摸事件(onTouch),并且View自身也有 onTouchEvent 方法,到底该由哪个监听器来响应呢?这就是dispatchTouchEvent的工作了。
那么问题就又来了,dispatchTouchEvent中View 事件相关的各个方法调用顺序是怎样的?我们可以先抛开源码不看,思考一下:
单击事件(onClickListener) 需要两个两个事件(ACTION_DOWN 和 ACTION_UP )才能触发,如果先分配给onClick判断,等它判断完,用户手指已经离开屏幕,黄花菜都凉了,肯定会使得 View 无法响应其他事件,所以应该最后调用。
长按事件(onLongClickListener) 也是需要长时间等待才能出结果,肯定不能排到前面,但因为不需要ACTION_UP,应该排在 onClick 前面。(onLongClickListener > onClickListener)。
触摸事件(onTouchListener) 如果用户注册了触摸事件,说明用户要自己处理触摸事件了,这个应该排在最前面。
View自身处理(onTouchEvent) 算是提供了一种默认的触摸事件的处理方式,如果用户已经设置了处理方式,那也就不需要了,所以应该排在 onTouchListener 后面。
再来看看源码:
public boolean dispatchTouchEvent(MotionEvent event) {
if (mOnTouchListener != null && (mViewFlags & ENABLED_MASK) == ENABLED &&
mOnTouchListener.onTouch(this, event)) {
return true;
}
return onTouchEvent(event);
}
似乎和我们想的不一样,OnClick 和 OnLongClick不见了。其实实际的原理是一样的,只不过OnClick 和 OnLongClick 的处理被放到了onTouchEvent中。
再来看看onTouchEvent:
public boolean onTouchEvent(MotionEvent event) {
if (((viewFlags & CLICKABLE) == CLICKABLE ||
(viewFlags & LONG_CLICKABLE) == LONG_CLICKABLE)) {
switch{
//......用case判断具体该用哪个处理方式
}
// 若该控件可点击,就一定返回true
return true;
}
// 若该控件不可点击,就一定返回false
return false;
}
其实关键不是switch判断,而是return true和return false。可以看出,只要控件可以点击,那么就一定会return true,即一定会消费事件,这个返回值和switch里面的判断是一点关系也没有的,也就是说:
- 不论 View 自身是否注册点击事件,只要 View 是可点击的就会消费事件。
- 事件是否被消费由返回值决定,true 表示消费,false 表示不消费,与是否使用了事件无关。
总体流程
最后我们用一张图来回顾一下事件分发的整体流程:
一些问题
一个事件列应该被同一View消费
显然,View中onClick事件需要同时接收到ACTION_DOWN和ACTION_UP才能触发,如果分配给了不同的 View,那么onClick 将无法被正确触发。
因此,安卓为了保证一个事件列都是被一个 View 消费的,对第一次的事件( ACTION_DOWN )进行了特殊判断,View 只有消费了 ACTION_DOWN 事件,才能接收到后续的事件(可点击控件会默认消费所有事件),并且会将后续所有事件传递过来,不会再传递给其他 View,除非上层 View 进行了拦截。
如果上层 View 拦截了当前正在处理的事件,会收到一个 ACTION_CANCEL,表示当前事件已经结束,后续事件不会再传递过来。
View的滑动冲突的解决
常见的滑动冲突有两种:
- 外层与内层滑动方向不一致,外层ViewGroup是可以横向滑动的,内层View是可以竖向滑动的(类似ViewPager,每个页面里面是ListView)
- 外层与内层滑动方向一致,外层ViewGroup是可以竖向滑动的,内层View同样也是竖向滑动的(类似ScrollView包裹ListView)
这些情况下,就会产生滑动冲突,即到底应该执行哪个的滑动方法呢?
当然,还可以更多层的嵌套,不过原理都是一样的,一层一层处理即可。
(eg:UC浏览器、新浪微博等)
这里可能有些人会说,ViewPager带ListView并没有出现滑动冲突啊,我用过都没问题啊。那是因为ViewPager已经为我们处理了滑动冲突!如果我们自己定义一个水平滑动的ViewGroup内部再使用ListView,那么是一定需要处理滑动冲突的。
那么该如何解决呢?
针对上面第一种场景,由于外部与内部的滑动方向不一致,那么我们可以根据当前滑动方向,水平还是垂直来判断这个事件到底该交给谁来处理。至于如何获得滑动方向,我们可以得到滑动过程中的两个点的坐标。一般情况下根据水平和竖直方向滑动的距离差就可以判断方向,当然也可以根据滑动路径的斜率、或者水平和竖直方向滑动速度差来判断。
第二种场景,由于外部与内部的滑动方向一致,就只能根据业务逻辑来判断了。以微博热搜为例,当热搜标签栏滚动到顶部时,热搜微博才能滚动。
讲了半天都是理论,那么实际怎么实现呢?
常用的也就两种方法:
外部拦截法:指点击事件都先经过父容器的拦截处理,如果父容器需要此事件就拦截,否则就不拦截。具体方法:需要重写父容器的onInterceptTouchEvent方法,在内部做出相应的拦截。
内部拦截法:指父容器不拦截任何事件,而将所有的事件都传递给子容器,如果子容器需要此事件就直接消耗,否则就交由父容器进行处理。具体方法:需要配合requestDisallowInterceptTouchEvent方法
总结
1. 事件分发原理: 责任链模式,事件层层传递,直到被消费。
2. Touch事件的传递顺序大致为Activity(Window)-> ViewGroup -> View。
3. 事件分发过程主要涉及到dispatchTouchEvent() 、onInterceptTouchEvent()和onTouchEvent()这三个方法,其中dispatchTouchEvent 主要用于分发事件,具体由onTouchEvent处理,onInterceptTouchEvent则是负责在ViewGroup中拦截事件。
4. ViewGroup 中有多个ChildView时,将事件分配给包含点击位置且能够点击的最后加载的ChildView。
5. 一个事件列应该被同一View消费
6. 如果当前正在处理的事件被上层 View 拦截,会收到一个 ACTION_CANCEL,后续事件不会再传递过来。
7. 滑动冲突可以用外部拦截法或内部拦截法解决