从源码角度分析Android 事件分发机制以及常见滑动冲突解决方案

1,224 阅读12分钟

一、何为事件

一般谈及事件分发,说到事件,就是指的 Android 中的 Touch 事件。

用官方话说:当用户触摸屏幕时,将产生的触摸行为,称之为触摸(Touch)事件。

既然是用户触摸行为产生的事件,那么事件的分类就清晰明了:

  • 手指刚触摸屏幕
  • 手指在屏幕上滑动
  • 手指离开屏幕
  • 非人为原因取消事件(系统触发,开发者无法掌控)

正常情况下,一次手指触摸屏幕的行为会触发一系列点击事件:

屏幕快照 2021-10-17 下午4.21.30.png


二、何为分发

听名字好像不太理解,其实就是触摸事件发生了,总得要有人(对象)去处理啊,这就是所谓的分发。

那么事件分发就是用户触摸事件产生后,到底是谁(对象)去处理事件的一个过程。

在 Android 中,处理 Touch 事件的对象有三个,分别是 Activiy 、ViewGroup 以及 View

我们知道了处理事件的对象有三个,那么既然处理事件,那么总得要有方法,就和解决事情需要给出方案一样。

在 Android 中,处理事件的方法主要有三个:

  • dispatchEvent():                  分发事件。
  • OnInteraceTouchEvent():    判断是否进行拦截。
  • OnTouchEvent():                    处理点击事件。

在这里举个例子:

一个互联网公司,老板 (Activity 对象) 有个 idea,那么他自己不可能去实现这个 idea(也不排除老板是个光杆司令😋),那么他开会交个手底下的人,所以会调用 dispatchEvent() 方法,也就是所谓的分发事件。

老板手底下有好多部门,例如技术部老大 (ViewGroup 对象) 认为这个任务自己就可以搞定了,他就不会把事情分发 (dispatchEvent) 给手底下的人做,那么就会调用 OnInteraceTouchEvent() 方法拦截事件。

如果技术部老大 (ViewGroup 对象) 认为需要给手底下人锻炼下,那么也会调用 dispatchEvent() 方法,继续把任务分发给最下面的人做。

既然任务一步步传递给最厉害(ku bi)的程序员了 (View 对象),他无法再分发事件了,只好老老实实处理事件,也即是调用 OnTouchEvent() 方法,消费事件了。

从上面的举例可以看出来:

  • Activity 具有 分发消费 事件的能力。
  • VieGroup 具有 分发拦截消费 事件的能力。
  • View 具有 消费 事件的能力。

同时事件传递的方向是从 Activity --> ViewGroup -->View 此方向传递的。


三、Activity 事件分发源码分析

来到 Activity 类的 dispatchEvent() 方法:

public boolean dispatchTouchEvent(MotionEvent ev) {
    if (ev.getAction() == MotionEvent.ACTION_DOWN) {
        onUserInteraction();
    }
    if (getWindow().superDispatchTouchEvent(ev)) {
        return true;
    }
    return onTouchEvent(ev);
}

这个方法里,总共就两个 if 判断语句,一个返回值,我们要理清整体逻辑:

  1. 第一个 if 语句无需过多关心,开发中暂未重写过 onUserInteraction() 方法。

  2. 第二个 if 判断语句,这边就要重点关注一下了:

// 1. getWindow 返回 Window 对象,Window 对象的唯一实现类是 PhoneWindow 类
getWindow().superDispatchTouchEvent() 
    
// 2. 间接调用   
PhoneWindow.superDispatchTouchEvent()

// 3. 此方法调用父类的 dispatchTouchEvent,而 DecorView 的父类是 FrameLayout,并未此方法,就找 FrameLayout 的父类,也就是 ViewGroup,他有此方法
DecorView.superDispatchTouchEvent()

// 4. 最终调用
ViewGroup.dispatchTouchEvent()   
  1. 如果第二个 if 语句不成立,那么此 Activity 就自己消费事件,返回 OnTouchEvent() 方法的返回值,默认返回 false

读者可以自己点击 getWindow() 这个方法,按照上面的步骤,一路跟踪下去,最终就是调用 ViewGroup 的 dispatchTouchEvent() 方法。

再用一张图来形象说明下:

屏幕快照 2021-10-18 下午3.38.13.png


到此 Activity 的事件分发源码就分析完了,还是很简单的的,总结下来就是:

  1. Activity 调用 dispatchTouchEvent(),这个方法里面再调用 ViewGroup 的 dispatchTouchEvent()。
  2. 若返回 true,表示 ViewGroup 要处理事件,方法结束。
  3. 如果返回 false,表示没有任何的 ViewGroup 需要处理事件,则 Activity 自己消费事件。

四、ViewGroup 事件分发源码分析

我们来到 ViewGroup 这个类中的 dispatchTouchEvent() 方法中,这个方法中的代码很多,但是我们还是来总体把握,一步步分析:

// ViewGroup 中的 dispatchEvent() 方法

@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
  //...省略上面代码
  
  //表示事件是否处理,默认false
  boolean handled = false;

 if (onFilterTouchEventForSecurity(ev)) {
 
 }
 //...省略代码
 return handled;
}

可以看到,我们所有的事件处理,都处于 onFilterTouchEventForSecurity(ev) 这个方法的返回值下,这个方法是出于安全规则考虑,用来过滤掉一些特殊情况的。

方法的返回值默认 true,只有在界面模糊,或者点击事件模糊的情况下才返回 false。 一般情况下都是返回 true。

那么我们就主要分析这个方法里面所做的事情,还是整体一段一段的把握,先来看一下最上面一波代码:


// 1. 初始化 Down 事件,清除操作
if (actionMasked == MotionEvent.ACTION_DOWN) {
    cancelAndClearTouchTargets(ev);
    resetTouchState();
}

// 2. 是否拦截的标志位,默认false
final boolean intercepted;
if (actionMasked == MotionEvent.ACTION_DOWN
        || mFirstTouchTarget != null) {
    final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;
    if (!disallowIntercept) {
        intercepted = onInterceptTouchEvent(ev);
        ev.setAction(action); // restore action in case it was changed
    } else {
        intercepted = false;
    }
} else {
    // There are no touch targets and this action is not an initial down
    // so this view group continues to intercept touches.
    intercepted = true;
}

在上面一坨代码中,先进行 down 事件的初始化操作,然后就是判断是否拦截的逻辑,重点在拦截代码的判断。

如果事件是 Down 事件或者 mFirstTouchTarget 不为空,则... ,否则返回 true。

这边要解释一下这个 mFirstTouchTarget 这个变量是什么意思?

当事件由 ViewGroup 的子元素成功处理时,mFirstTouchTarget 会被赋值并指向子元素。也就是说要么 down 事件,要么有子 View 要处理事件,否则 ViewGroup 都要拦截事件。

当第一个 if 语句成立时,里面还有个 if 语句判断,其中的 disallowIntercept 这个布尔值默认是 false 的,什么情况下是 true?当我们在子 View 的dispatchToucheEvent() 方法中,重写 parent.requestDisallowingInterceptTouchEvent(true) 时,表示子控件要关闭父控件的拦截功能,那么 disallowIntercept 这个值为 true,ViewGroup 不拦截事件。

需要注意的是,requestDisallowingInterceptTouchEvent(true) 这个方法只能拦截除了 Donw 事件之外的事件,因为 ViewGroup 在执行 dispatchTouchEvent() 的时候,会将 FLAG_DISALLOW_INTERCEPT 这个标记清除。

如果我们没有重写这个方法,那么 disallowIntercept 值为 false,会调用 ViewGroup 的 onInterceptTouchEvent() 方法,默认返回 false,想要拦截的话,就重写此方法返回true。


关于 ViewGroup 的拦截事件(独有的)的源码分析就结束了,我们来总结下:

  1. 最外面的 if 语句判断事件是 Down 事件或者 ViewGroup 下面有子元素要处理事件,如果有,则进入里面的 if 判断语句,如果没有,则 ViewGroup 拦截事件。

  2. 里面的 if 语句根据 子View 中是否重写了requestDisallowingInterceptTouchEvent() 来判断,重写了就不拦截,没有重写的话就调用 ViewGroup 的 onInterceptTouchEvent() 方法,默认返回 false,可以重写返回 true 去拦截事件。


接下来我们再往下分析,中间代码较多,也不是我们分析的重点,直接来到 final View[] children = mChildren; 这一行代码,从这边开始,就是倒序循环遍历子View,前面都是一些判断逻辑,比如这一段代码:

if (!child.canReceivePointerEvents()
        || !isTransformedTouchPointInView(x, y, child, null)) {
    ev.setTargetAccessibilityFocus(false);
    continue;
}

说的就是如果 View 不可见或者在播放动画,或者触摸不再指定区域内,就会继续找下一个,我们直到看到 dispatchTransformedTouchEvent() 这个方法, 这个方法本质上就是调用了子 View 的 dispatchTouchEvent(),将事件分发给子View,可以点进去看看:


private boolean dispatchTransformedTouchEvent(MotionEvent event, boolean cancel,
        View child, int desiredPointerIdBits) {
        
//....
if (child == null) {
    handled = super.dispatchTouchEvent(transformedEvent);
} else {
    final float offsetX = mScrollX - child.mLeft;
    final float offsetY = mScrollY - child.mTop;
    transformedEvent.offsetLocation(offsetX, offsetY);
    if (! child.hasIdentityMatrix()) {
        transformedEvent.transform(child.getInverseMatrix());
    }

    handled = child.dispatchTouchEvent(transformedEvent);
}
//.....
}

这个方法里面主要就看上面这一段代码,判断子 View 是否为空,为空则调用父类的 dispatchTouchEvent() 方法,不为空调用子 View 的 dispatchTouchEvent() 方法。至此 ViewGroup-->View 的事件分发源码分析就结束了。

其中有一点并没有说,就是这个 mFirstTouchTarget 到底是如何赋值给子View的,感兴趣的可以自己研究下,其实是调用newTouchTarget = addTouchTarget(child, idBitsToAssign); 这一行代码赋值的,并且采用的是单向链表的方式,这边就不过多叙述了。

用一张图来形象说明一下:

屏幕快照 2021-10-18 下午4.01.09.png


对于 ViewGroup 的事件分发,做一下总结:

  1. 最外层 if 判断,过滤一些模糊点击和模糊事件。

  2. 里层逻辑:

    • 先初始化 Down 事件,清除标记。
    • 然后判断 ViewGroup 是否拦截,没有子View要处理事件,则自己消费。有子 View 要消费事件,则调用子 View 的 dispatchEvent() 方法。

五、View 事件分发源码分析

最后我们来到 View 的 dispatchTouchEvent() 方法中,我们还是找到关键代码:

public boolean dispatchTouchEvent(MotionEvent event) {

//...
if (onFilterTouchEventForSecurity(event)) {
    if ((mViewFlags & ENABLED_MASK) == ENABLED && handleScrollBarDragging(event)) {
        result = true;
    }
    //noinspection SimplifiableIfStatement
    ListenerInfo li = mListenerInfo;
    if (li != null && li.mOnTouchListener != null
            && (mViewFlags & ENABLED_MASK) == ENABLED
            && li.mOnTouchListener.onTouch(this, event)) {
        result = true;
    }

    if (!result && onTouchEvent(event)) {
        result = true;
    }
} 
//...
}

这边这个 if 判断语句和 ViewGoup 中 dispatchTouchEvent 一样,都是过滤掉模糊事件。 在这个逻辑里面,主要就两件事:

    1. 如果设计监听器,同时控件可点击,同时 onTouch() 方法返回 true,此方法直接返回 true,此次事件分发结束。
    1. 如果条件1不成立,则调用 onTouchEvent() 方法。

这段 if 判断说明了 onTouch() 的优先级高于 onTouchEvent() 方法,如果我们在项目中重写了 onTouch() 方法,并且返回 true,则不会再调用 onTouchEvent() 方法。

接下来就进入在 View 的 onTouchEvent() 方法中了,我们前面看了这么多,终于来到具体消费事件的方法中了,此方法代码较多,需耐心分析。

onTouchEvent() 方法里,先判断 View 是否可点击,如果 View 可点击,走到 switch 语句中,我们先要看 case MotionEvent.ACTION_DOWN: 这个条件。

在这个条件中,主要关注的就是 checkForLongClick 方法,检查是否是长按事件。进入方法里,看到这样一行代码 mPendingCheckForLongPress = new CheckForLongPress(); 我们点击进入 CheckForLongPress 这个类中,它的 run 方法会执行 performLongClick 方法,一路点击,进入performLongClickInternal 方法中:

if (li != null && li.mOnLongClickListener != null) {
    handled = li.mOnLongClickListener.onLongClick(View.this);
}

这里就是长按事件的监听,如果代码中设置了,那么 handled 会返回 true,事件分发结束。

看完了 down 事件,来看 move 事件,在 move 事件中,代码不多,主要会移除长按事件。

最后分析下 up 事件,在此事件中,如果不是长按事件,且 mPerformClick 不为null,那么就初始化 PerformClick(),进入内部 run 方法中的 performInternal() 中,再进入 performClick():

final boolean result;
final ListenerInfo li = mListenerInfo;
if (li != null && li.mOnClickListener != null) {
    playSoundEffect(SoundEffectConstants.CLICK);
    li.mOnClickListener.onClick(this);
    result = true;
} else {
    result = false;
}

这边就是我们熟悉的 onClickListener()事件的监听了。如果设置了点击事件,那么返回 true,事件分发结束。

由此可见,如果同时设置了长按和点击事件,那么长按事件优先级比点击事件优先级高,如果长按事件返回true,则不会触发点击事件。

好了,关于 View 的事件分发源码就分析结束了,还是用一张图来总结下:

屏幕快照 2021-10-18 下午4.13.22.png


六 整体流程图

写到这里,关于 Android 的事件分发机制的源码解读就分析结束了。再来用一张总体的流程图来总结下:

屏幕快照 2021-10-16 下午9.28.47.png

可以看到,我们的事件分发机制是一个 U型 结构。由 Activity 一步步分发给 子View,如果 子View 处理不了,再一步步向上传递给我们的 Activity。

总结几点

  1. 正常情况,一个事件序列只能被一个 View 拦截且消耗。
  2. ViewGroup 默认情况不拦截事件。
  3. View 的 onTouchEvent 默认消耗事件,除非它是不可点击的。
  4. 可以通过requestDisallowingInterceptTouchEvent() 方法来干预父元素的事件分发过程,但是 donw 事件除外。

七、滑动冲突常见案例解决

常见的滑动冲突有三种:

  1. 外部滑动方向与内部滑动方向一致。
  2. 外部滑动方向和内部滑动方向不一致。
  3. 以上两种情况嵌套。

解决滑动冲突的步骤:

  1. 制定合适的滑动策略。
  2. 按滑动策略分发事件。

不管多么滑动冲突多么复杂,都有既定规则,这边说个简单的,可以通过水平和竖直方向的距离差来判断,竖直方向滑动距离大就判断为竖直滑动,否则为水平滑动。当然了,还是要根据具体业务去制定合适的滑动策略。

这边介绍两种通用解决方案:

  1. 外部拦截法
  2. 内部拦截法

什么是外部拦截法?即点击事件都先经过父容器的拦截处理,如果父容器需要此事件就进行拦截,不需要就不拦截。

外部拦截具体做法:

@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
    boolean intercepted = false;
    switch (ev.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;
    }
    return intercepted;
}

这里需要注意的是 down 事件必须返回 false。一旦设置为 true,那么后续的 move 事件不会再传递给子View了。


那什么又是内部拦截法?即父容器不拦截任何事件,所有的事件都传递给子元素。如果子元素需要此事件就直接消耗掉,否则就交给父容器处理。

具体做法:

@Override
public boolean dispatchTouchEvent(MotionEvent event) {
    switch (event.getAction()){
        case MotionEvent.ACTION_DOWN:
            getParent().requestDisallowInterceptTouchEvent(true);
            
            break;
        case MotionEvent.ACTION_MOVE:
            if(父容器需要事件){
                getParent().requestDisallowInterceptTouchEvent(false);
            }
            break;
        case MotionEvent.ACTION_UP:
            break;
    }
    return super.dispatchTouchEvent(event);
}

除此之外,父容器要拦截除了 down 之外的其它事件。

@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
   int action = ev.getAction();
   if(action == MotionEvent.ACTION_DOWN){
       return false;
   }else {
       return true;
   }
}

以上就是这两个通用方法,至于在 move 事件中添加具体的逻辑代码,这得要结合实际需求去写,无法通用,不过上面两个方案思路是可以运用到任何的滑动冲突中的,推荐使用方案一外部拦截法。



写在文末

纸上得来终觉浅,绝知此事要躬行。 《冬夜读书示子聿》-- 陆游

好了,关于 事件分发的源码分析以及滑动冲突两种通用思路方案 就说到这了,各位看官食用愉快。