Android中的事件传递与事件处理机制

0.223字数 4571阅读 2742

概述

当Android系统捕获到用户的各种输入事件后,如何准确地传递给真正需要这个事件的控件呢?Android系统给我们提供了一整套完善的事件传递、处理机制,来帮助开发者完成准确的事件传递与处理。

预备知识

  1. Android应用中Activity的视图层级结构
    每个Activity都是通过PhoneWindow来呈现View的,PhoneWindow中最顶层View是mDecor(DecorView的对象),当我们在Activity中调用setContentView()设置布局时会调用PhoneWindow的setContentView()方法生成DecorView对象mDecor。mDecor只有一个子元素为LinearLayout,而LinearLayout下包含两个FrameLayout,上面那个FrameLayout为标题栏显示界面,包含一个id为android.R.id.title的TextView,而下面那个FrameLayout的id为android.R.id.content,我们通过setContentView()设置进来的布局界面就会放在这个FrameLayout中。mDecor的视图层级结构如下图所示:
    mDecor的视图层级结构

    因此,我们可以通过
    ((ViewGroup)getWindow().getDecorView().findViewById(android.R.id.content)).getChildAt(0)
    这种方式获取到Activity所设置的View。
  2. MotionEvent事件
    在手指接触屏幕后所产生的一系列事件中,典型的事件类型有如下几种:
ACTION_DOWN  ----- 手指刚接触屏幕
ACTION_MOVE   ----- 手指在屏幕上移动
ACTION_UP        ----- 手指从屏幕上松开的一瞬间

正常情况下,一次手指触碰屏幕的行为会触发一系列触摸事件,考虑如下几种情况:

点击屏幕后立即松开,事件顺序为 ACTION_DOWN -> ACTION_UP
点击屏幕后滑动一会再松开,事件顺序为 ACTION_DOWN -> ACTION_MOVE ->...-> ACTION_MOVE -> ACTION_UP

上面两种情况是典型的事件序列,同时通过MotionEvent对象我们可以得到点击事件发生的x和y坐标。系统提供了两组方法:getX/getY 和 getRawX/getRawY。她们的区别很简单,getX/getY返回的是相对于当前View左上角的x和y坐标,而getRawX/getRawY返回的是相对于手机屏幕左上角的x和y坐标。

触摸事件传递、处理机制

  1. 当一个触摸事件产生后,它的传递过程顺序如下:Activity -> Window -> DecorView,即事件总是先传递给Activity,Activity再传递给Window,最后Window再传递给顶层View DecorView;然后在不被拦截的情况下,触摸事件会被传递到触摸位置对应的最底层View。传递完成后就要处理触摸事件了,处理顺序是从最底层View向Activity进行的。

  2. ViewGroup传递触摸事件的过程 :先通过方法dispatchTouchEvent实现事件的分发,然后通过onInterceptTouchEvent方法实现事件的拦截;
    ViewGroup处理触摸事件的过程:通过方法onTouchEvent实现事件的处理。
    Activity或者非ViewGroup类型的View 传递触摸事件的过程:通过方法dispatchTouchEvent实现事件的分发。
    Activity或者非ViewGroup类型的View 处理触摸事件的过程:通过方法onTouchEvent实现事件的处理。
    下面我们介绍一下上面提到的三个方法:

public boolean dispatchTouchEvent(MotionEvent ev)
用来进行事件的分发。如果事件能够传递给当前View,那么此方法一定会被调用,返回结果受到当前View的
onTouchEvent和下级View的dispatchTouchEvent方法的影响,表示是否消耗当前事件。
public boolean onInterceptTouchEvent(MotionEvent ev) 
在上述方法内部被调用,用来判断是否拦截某个事件,如果当前View拦截了某个事件,那么在同一个事件序列中,
此方法不会被再次调用,返回结果表示是否拦截当前事件。
public boolean onTouchEvent(MotionEvent ev)
在dispatchTouchEvent方法中被调用,用来处理点击事件,返回结果表示是否消耗当前事件,如果不消耗,
则在同一事件序列中,当前View无法再次接受到事件。
  1. Activity对事件的传递、处理过程
    当一个触摸事件发生的时候,触摸事件最先传递给Activity,由Activity的dispatchTouchEvent来进行触摸事件传递。Activity的dispatchTouchEvent方法:
public boolean dispatchTouchEvent(MotionEvent ev) {
    if (ev.getAction() == MotionEvent.ACTION_DOWN) {
        onUserInteraction();
    }
    if (getWindow().superDispatchTouchEvent(ev)) {
        return true;
    }
    return onTouchEvent(ev);
}

首先事件传递给Activity所附属的Window进行分发,如果返回true,即消耗了事件,整个事件循环就结束了,返回false意味着事件没人处理,即mDecor及其所有子孙View的onTouchEvent都返回了false,那么Activity的onTouchEvent()就会被调用。

  1. Window对事件的传递过程
    上面代码中通过Activity.getWindow()可以获取到Activity所附属的Window对象,但是通过阅读源码可以知道Window是抽象类,而Window.superDispatchTouchEvent()也是一个抽象方法,具体的实现类是什么?通过阅读相关文档和源码可知是Window的唯一实现类是PhoneWindow。查看PhoneWindow的superDispatchTouchEvent方法:
@Override
public boolean superDispatchTouchEvent(MotionEvent event) {
    return mDecor.superDispatchTouchEvent(event);
}

可以看到PhoneWindow将事件直接传递给mDecor处理,DecorView中superDispatchTouchEvent方法源码如下:

public boolean superDispatchTouchEvent(MotionEvent event) {
    return super.dispatchTouchEvent(event);
}

DecorView继承自FrameLayout,所以mDecor对触摸事件的传递、处理过程与上面说到的ViewGroup一样。

  1. 图解触摸事件传递、处理过程
    为了能够方便理解事件分发的流程,我们设计一个实例,实例布局如图所示:
    实例布局图

    如果所有的View都不处理事件(onTouchEvent方法返回false),整个事件分发流程对应如图所示:

    如果把ViewGroupA或者ViewGroupB的onInterceptTouchEvent()方法返回true,即拦截事件,事件分发过程如下所示:


    现在应该对触摸事件的分发、拦截比较清楚了,下面我们来看看触摸事件的处理过程。若把MyView的onTouchEvent方法返回true,即消耗了事件,此时事件不会再传递给ViewGroupB的onTouchEvent()方法。类似的,如果ViewGroupB处理了事件,即onTouchEvent()返回true,事件也不会传递给ViewGroupA的onTouchEvent()方法。事件分发关系图如下所示:


    上面只是简单的分析了触摸事件传递、处理机制,下面给出一些结论(如果想要知道这些结论是如何得到的,可以参考 Android 开发艺术探讨 3.4.2节),根据这些结论可以更好的理解整个触摸事件传递、处理机制,如下所示:
    1> 同一个事件序列是指从手指接触屏幕的那一刻起,到手指离开屏幕的那一刻结束,在这个过程中所产生的一系列事件,这个事件序列以down事件开始,中间含有数量不定的move事件,最终以up事件结束。
    2> 在正常情况下,一个事件序列只能被一个View拦截且消耗。因为一旦一个View拦截了某类事件,那么同一个事件序列内的所有事件都会直接交给它处理,因此同一个事件序列中的事件不能分别由两个View同时处理,但是通过特殊手段可以做到,比如一个View将本该自己处理的事件通过onTouchEvent()强行传递给其他View处理。
    3> 某个View一旦决定拦截,那么这一个事件序列都只能由它处理(如果事件序列能够传递给它的话),并且它的onInterceptTouchEvent()不会再被调用。也就是说当一个View决定拦截一个事件后,那么系统会把同一个事件序列内的其他事件都直接交给它来处理,因此就不用再调用这个View的onInterceptTouchEvent()去询问它是否要拦截了。
    4> 某个View一旦开始处理事件,如果它不消耗ACTION_DOWN事件(onTouchEvent()返回了false),那么同一事件序列中的其他事件都不会再交给它来处理,并且事件将重新交由它的父元素去处理,即父元素的onTouchEvent()会被调用。
    5> 如果View不消耗除ACTION_DOWN以外的其他事件,那么这个触摸事件会消失,此时父元素的onTouchEvent()并不会被调用,并且当前View可以持续收到后续的事件,最终这些消失的点击事件会传递给Activity处理。
    6> ViewGroup默认不拦截任何事件,即Android源码中ViewGroup的onInterceptTouchEvent()方法默认返回false。
    7> View没有onInterceptTouchEvent方法,一旦有触摸事件传递给它,那么它的onTouchEvent方法就会被调用。
    8> View的onTouchEvent的返回值取决于它是否可点击的(clickable和longClickable属性),如果这两个属性都为false的话,onTouchEvent就会返回false,其余情况都返回true。View的longClickable属性默认为flase,clickable属性要分情况,比如Button的clickable属性默认为true,而TextView的clickable属性默认为false。注意,setOnClickListener()会自动将View的clickable属性设为true
    9> View的enable属性不影响onTouchEvent()的返回值。哪怕一个View是disable状态的,只要它的clickable或者longClickable有一个为true,那么它的onTouchEvent就会返回true。
    10> onClick会发生的前提是当前View是可点击的,并且它收到了down和up事件
    11> 事件传递过程是由外向内的,即事件总是先传递给父元素,然后再由父元素传递给子View,通过requestDisallowInterceptTouchEvent方法可以在子View中干预父View的事件传递过程,但是ACTION_DOWN事件除外。

滑动冲突

对于Android开发者,相信对滑动冲突大家一定不陌生,下面就来介绍滑动冲突和解决滑动冲突(利用上面介绍的事件传递、处理的机制解决)。

  1. 常见的滑动冲突场景
    常见的滑动冲突的场景可以分为如下三种:
    场景1 --- 外部滑动方向和内部滑动方向不一致
    场景2 --- 外部滑动方向和内部滑动方向一致
    场景3 --- 上面两种情况的嵌套


    滑动冲突的场景
  2. 滑动冲突的处理规则
    对于场景1,它的处理规则是:当用户左右滑动时,需要让外部的View拦截触摸事件,当用户上下滑动时,需要让内部View拦截触摸事件。
    对于场景2,它没有既定的处理规则,因为它要根据具体的业务来制定处理规则,即当处于某种状态下时需要外部View拦截触摸事件,而处于另外一种状态时需要内部View拦截触摸事件。
    对于场景3,与场景2相同,必须根据具体业务制定处理规则。
  3. 滑动冲突的解决方案
    对于3种常见的滑动冲突场景,本节将会一一分析各种场景并给出具体的解决方案。无论多复杂的滑动冲突,它们之间的区别仅仅是滑动冲突处理规则不同,所以我们可以抛开滑动冲突处理规则,找到一种不依赖具体的滑动冲突处理规则的通用解决方案,然后根据不同的滑动冲突场景和业务来修改有关滑动冲突处理规则的逻辑即可。
    针对滑动冲突,这里给出两种解决滑动冲突的方案:外部拦截法和内部拦截法。
    1> 外部拦截法
    所谓外部拦截法是指所有的触摸事件都会先经过经过父容器的传递,从而父容器在需要此触摸事件的时候就可以拦截此触摸事件,否者就传递给子View。这样就可以解决滑动冲突的问题,这种方法比较符合触摸事件的传递、处理机制。外部拦截法需要重写父容器的onInterceptTouchEvent方法,在该方法中根据滑动冲突处理规则做相应的拦截即可,这种方法的典型代码如下:
@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
    boolean intercepted = false;
    int x = (int) event.getX();
    int y = (int) event.getY();

    switch (event.getAction()) {
    case MotionEvent.ACTION_DOWN: {
        intercepted = false;
        break;
    }
    case MotionEvent.ACTION_MOVE: {
        if (父容器需要当前触摸事件) {
            intercepted = true;
        } else {
            intercepted = false;
        }
        break;
    }
    case MotionEvent.ACTION_UP: {
        intercepted = false;
        break;
    }
    default:
        break;
    }
    mLastXIntercept = x;
    mLastYIntercept = y;
    return intercepted;
}

上述代码是外部拦截法的典型逻辑,针对不同的滑动冲突场景,只需要修改父容器需要当前触摸事件这个滑动冲突处理规则即可,其它均不用修改并且不能修改。这里对上述代码再来解释一下,在onInterceptTouchEvent方法中,首先是ACTION_DOWN类型的触摸事件,父容器必须返回false,即不拦截ACTION_DOWN类型的触摸事件,这是因为一旦父容器拦截了ACTION_DOWN类型的触摸事件,那么后续处于同一个事件序列的ACTION_MOVE和ACTION_UP类型的触摸事件就会直接交给父容器处理,这个时候事件就没法再传递给子元素了;其次是ACTION_MOVE类型的触摸事件,这个类型的触摸事件可以根据需求来决定是否拦截,如果父容器需要拦截就返回true,否者返回false(与滑动冲突处理规则有关);最后是ACTION_UP类型的触摸事件,这里必须返回false,考虑一张情况,假设事件交由子元素处理,如果父容器在ACTION_UP类型的触摸事件时返回了true,就会导致子元素无法接收到ACTION_UP类型的触摸事件,这个时候子元素中的onClick方法就无法触发。但是父容器比较特殊,一旦它开始拦截任何一个事件,那么后续的处于同一个事件序列的事件都会交给它来处理,而ACTION_UP作为最后一个事件也必定会传递给父容器,即使父容器的onInterceptTouchEvent方法在ACTION_UP类型的触摸事件时返回了false。
2> 内部拦截法
内部拦截法是指父容器不拦截任何触摸事件,所有的触摸事件都传递给子元素,如果子元素需要此触摸事件就直接消耗掉,否者就交由父容器进行处理,这种方法和Android中的事件传递、处理机制不一致,需要配合requestDisallowInterceptTouchEvent方法才能正常工作,使用起来较外部拦截法稍显复杂。这种方法需要重写子元素的dispatchTouchEvent方法和父容器的onInterceptTouchEvent方法,这种方法的典型代码如下:

子元素的dispatchTouchEvent方法
@Override
public boolean dispatchTouchEvent(MotionEvent event) {
    int x = (int) event.getX();
    int y = (int) event.getY();

    switch (event.getAction()) {
    case MotionEvent.ACTION_DOWN: {
        getParent().requestDisallowInterceptTouchEvent(true);
        break;
    }
    case MotionEvent.ACTION_MOVE: {
        int deltaX = x - mLastX;
        int deltaY = y - mLastY;
        if (父容器需要当前触摸事件) {
            getParent().requestDisallowInterceptTouchEvent(false);
        }
        break;
    }
    case MotionEvent.ACTION_UP: {
        break;
    }
    default:
        break;
    }

    mLastX = x;
    mLastY = y;
    return super.dispatchTouchEvent(event);
}
父容器的onInterceptTouchEvent方法
@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
    int action = event.getAction();
    if (action == MotionEvent.ACTION_DOWN) {
        return false;
    } else {
        return true;
    }
}

上面的代码是内部拦截法的典型代码,当面对不同的滑动冲突处理规则时只需要修改里面的条件即可,其它的不需要修改而且也不能修改。除了子元素需要做处理以外,父容器也要默认拦截除了ACTION_MOVE类型触摸事件的其他事件,这样子元素调用requestDisallowInterceptTouchEvent(false)方法时,父容器才能继续拦截所需事件。

android提供了两种事件处理的方式

基于回调的事件处理和基于监听的事件处理,上面在onTouchEvent方法中处理触摸事件的方式就是基于回调的方式,下面介绍一下基于监听的事件处理方式和两种方式之间的关系。

  1. 基于监听的事件处理方式
    A 基于监听的事件处理模型如下图所示:


    基于监听的事件处理模型

    B 由A中的图可以得知在基于监听的事件处理模型中主要涉及的三个对象为:
    1> Event Source(事件源):事件发生的场所,通常就是各个组件,例如按钮、窗口、菜单等。
    2> Event(事件):事件封装了界面组件上发生的特定事件的具体信息,如果监听器需要获取界面组件上所发生事件的相关信息,一般通过事件Event对象来传递。
    3> Event Listener(事件监听器):负责监听事件源发生的事件,并对不同的事件做相应的处理。
    C 由A中的图可以得知在基于监听的事件处理模型中,只有将事件监听器注册到事件源和事件监听器需要程序员的参与,其他的不太需要程序员参与。

  2. 两种方式之间的关系
    2.1 对于android基于回调的事件处理而言,主要做法就是重写android组件特定的回调方法,或者重写activity的回调方法。android为绝大部分界面组件提供了事件响应的回调方法,开发者只要重写他们即可。一般来说,基于回调的事件处理可用于处理一些具有通用性的事件,基于回调的事件处理代码会显得非常简洁。但对于某些特定的事件,无法使用回调的事件处理,只能采用基于监听的事件处理。
    2.2 几乎所有基于回调的事件处理方法都有一个Boolean类型的返回值,该返回值用于标识该处理方法是否能完全处理该事件:
    1>如果事件处理的方法返回true,表明处理方法已完全处理该事件,该事件不会传播出去。
    2>如果事件处理的方法返回false,表明该处理方法并未完全处理该事件,该事件会传播出去
    Android的事件处理机制保证基于监听的事件监听器会被优先触发,接着才会触发该组件提供的事件回调方法,然后还会传播到该组件所在的activity,但是如果我们让任何一个事件处理方法返回了true,那么该事件将不会继续向外传播。

  3. 由上面的分析可知,当一个View同时设置了触摸事件的监听(OnTouchListener)和回调(onTouchEvent)时,触摸事件会先交到OnTouchListener中的onTouch方法中处理,这时事件如何处理还要看onTouch的返回值,如果返回false,则当前View的onTouchEvent方法会被回调;如果返回true,那么onTouchEvent方法不会被调用。

参考

  1. View的事件分发机制
  2. 《Android开发艺术探索》
  3. 《Android群英传》
  4. Android TouchEvent之requestDisallowInterceptTouchEvent

推荐阅读更多精彩内容