Android基础之事件分发

作为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多行,我们在这里就不贴源码了,主要看看其工作流程。
整体的工作流程可以简化为以下三步:

  1. 判断自身是否需要(询问 onInterceptTouchEvent 是否拦截),如果需要,调用自己的 onTouchEvent。
  2. 自身不需要或者不确定,则询问 ChildView ,一般来说是调用手指触摸位置的 ChildView。
  3. 如果子 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里面的判断是一点关系也没有的,也就是说:

  1. 不论 View 自身是否注册点击事件,只要 View 是可点击的就会消费事件
  2. 事件是否被消费由返回值决定,true 表示消费,false 表示不消费,与是否使用了事件无关

总体流程

最后我们用一张图来回顾一下事件分发的整体流程:


一些问题

一个事件列应该被同一View消费

显然,View中onClick事件需要同时接收到ACTION_DOWN和ACTION_UP才能触发,如果分配给了不同的 View,那么onClick 将无法被正确触发。
因此,安卓为了保证一个事件列都是被一个 View 消费的,对第一次的事件( ACTION_DOWN )进行了特殊判断,View 只有消费了 ACTION_DOWN 事件,才能接收到后续的事件(可点击控件会默认消费所有事件),并且会将后续所有事件传递过来,不会再传递给其他 View,除非上层 View 进行了拦截。
如果上层 View 拦截了当前正在处理的事件,会收到一个 ACTION_CANCEL,表示当前事件已经结束,后续事件不会再传递过来。

View的滑动冲突的解决

常见的滑动冲突有两种:

  1. 外层与内层滑动方向不一致,外层ViewGroup是可以横向滑动的,内层View是可以竖向滑动的(类似ViewPager,每个页面里面是ListView)
  2. 外层与内层滑动方向一致,外层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. 滑动冲突可以用外部拦截法或内部拦截法解决

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,015评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,262评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,727评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,986评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,363评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,610评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,871评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,582评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,297评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,551评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,053评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,385评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,035评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,079评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,841评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,648评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,550评论 2 270

推荐阅读更多精彩内容