彻底掌握Android touch事件的分发机制


主要参考文献:任玉刚《Android开发艺术探索》

1. 基本介绍

1.1 MotionEvent分类

事件 简介
ACTION_DOWN 手指初次接触到屏幕时触发
ACTION_MOVE 手指在屏幕上滑动时触发、会多次触发
ACTION_UP 手指离开屏幕时触发
ACTION_CANECL 事件被上层拦截时触发

1.2 控件的事件分发、拦截、消费

√表示有该方法,×表示没有该方法

类型 相关方法 Activity ViewGroup View
事件分发 dispatchTouchEvent
事件拦截 onInterceptTouchEvent × ×
事件消费 OnTouchEvent ×

整个 View 之间的事件分发,实质上就是一个大的递归函数,而这个递归函数就是 dispatchTouchEvent 方法。在这个递归的过程中会适时调用 onInterceptTouchEvent 来拦截事件,或者调用 onTouchEvent 方法来处理事件。用下面一段伪代码帮助我们更好的理解他们之间的调用关系:

public boolean dispatchTouchEvent(MotionEvent ev) { 
    boolean consume = false;
    //ViewGroup是否拦截,若拦截则交给自己的onTouchEvent处理
    if (onInterceptTouchEvent(ev)) { 
        consume = onTouchEvent(ev); 
    } else {//不拦截,则继续向子控件进行分发
        consume = child.dispatchTouchEvent(ev); 
    }
    return consume; 
}

1.3 大致流程图

在这里插入图片描述

当一个点击事件产生以后,它的传递或者说分发过程遵循如下顺序:Activity—>Window—>DecorView—>ViewGroup—>View, 如果一个View的onTouchEvent返回false,表示它没有消费事件,那么

2. Activity对点击事件分发过程

点击事件用MotionEvent来表示,当一个点击操作发生时,事件最先传递给当前 Activity,由Activity的dispatchTouchEvent来进行事件派发,具体的工作是由Activity内部 的Window来完成的。Window会将事件传递给decor view,decor view一般就是当前界面的底层容器(即setContentView所设置的View的父容器),通过 Activity.getWindow.getDecorView()可以获得。我们先从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意味着事件没人处理,所有View的 onTouchEvent都返回了false,那么Activity的onTouchEvent就会被调用。 接下来看Window是如何将事件传递给ViewGroup的。通过源码我们知道,Window是 个抽象类,而Window的superDispatchTouchEvent方法也是个抽象方法,因此我们必须找到Window的实现类才行。而我们知道由于Window的唯一实现是PhoneWindow,因此接下来看一下PhoneWindow是如何处 理点击事件的,如下所示。

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

到这里逻辑就很清晰了,PhoneWindow将事件直接传递给了DecorView,这个 DecorView是什么呢?请看下面:

public class DecorView extends FrameLayout implements RootViewSurfaceTaker, WindowCallbacks { 
    // This is the top-level view of the window,containing the window decor. private DecorView mDecor; 
    @Override public final View getDecorView() { 
    if (mDecor == null) { 
        installDecor(); 
    }
    return mDecor; 
}

我们知道,通过 ((ViewGroup)getWindow().getDecorView().findViewById(android.R.id.content)).getChildAt(0) 这种方式就可以获取Activity所设置的View,这个mDecor显然就是getWindow().getDecorView()返回的View,而我们通过setContentView设置的View是它的一 个子View。目前事件传递到了DecorView这里,由于DecorView继承自FrameLayout,所以DecorView也是 ViewGroup,接下来分析ViewGroup的事件分发原理。

3.ViewGroup的touch事件分发过程

3.1 宏观结构

事件分发核心是 dispatchTouchEvent方法,先从宏观角度,纵览该方法源码结构:

public boolean dispatchTouchEvent() {
    /**
    * 步骤1:检查当前ViewGroup是否需要拦截事件
    */
    ...
     /**
    * 步骤2:将事件分发给子View
    */
    ...
     /**
    * 步骤3:根据mFirstTouchTarget,再次分发事件
    */
    ...
}

如代码中的注释,dispatchTouchEvent()方法主要分为3 大步骤

  • 步骤 1:判断当前 ViewGroup 是否需要拦截此 touch 事件,如果拦截则此次 touch 事件不再会传递给子 View(或者以 CANCEL 的方式通知子 View)。
  • 步骤 2:如果没有拦截,则将事件分发给子 View 继续处理,如果子 View 将此次事件捕获,则将 mFirstTouchTarget 赋值给捕获 touch 事件的 View。
  • 步骤 3:根据 mFirstTouchTarget 重新分发事件。

接下来详细的分析每一个步骤:

3.1 步骤1的具体代码及分析

在这里插入图片描述
图中红框标出了是否需要拦截的条件:

  • 如果事件为 DOWN 事件,则调用 onInterceptTouchEvent 进行拦截判断;
  • 或者 mFirstTouchTarget 不为 null;

第一个条件为DOWN事件,比较好理解,第二个条件mFirstTouchTarget 不为 null是什么意思呢?这个从后面的代码逻辑可以看出来, 当事件由ViewGroup的子元素成功处理时,mFirstTouchTarget会被赋值并指向子元素,换 种方式来说,当ViewGroup不拦截事件并将事件交由子元素处理时mFirstTouchTarget != null。反过来,一旦事件由当前ViewGroup拦截时,mFirstTouchTarget != null就不成立。那 么当ACTION_MOVE和ACTION_UP事件到来时,由于(actionMasked == MotionEvent. ACTION_DOWN || mFirstTouchTarget != null)这个条件为false,将导致ViewGroup的 onInterceptTouchEvent不会再被调用,并且同一序列中的其他事件都会默认交给它处理。

这里有一种特殊情况,那就是FLAG_DISALLOW_INTERCEPT标记位,这个 标记位是通过requestDisallowInterceptTouchEvent方法来设置的,一般用于子View中。 FLAG_DISALLOW_INTERCEPT一旦设置后,ViewGroup将无法拦截除了 ACTION_DOWN以外的其他点击事件。为什么说是除了ACTION_DOWN以外的其他事件 呢?这是因为ViewGroup在分发事件时,如果是ACTION_DOWN就会重置 FLAG_DISALLOW_INTERCEPT这个标记位,将导致子View中设置的这个标记位无效。 因此,当面对ACTION_DOWN事件时,ViewGroup总是会调用自己的 onInterceptTouchEvent方法来询问自己是否要拦截事件,这一点从源码中也可以看出来。 在下面的代码中,ViewGroup会在ACTION_DOWN事件到来时做重置状态的操作,而在 resetTouchState方法中会对FLAG_DISALLOW_INTERCEPT进行重置,因此子View调用 request-DisallowInterceptTouchEvent方法并不能影响ViewGroup对ACTION_DOWN事件的 处理

// Handle an initial down. 
if (actionMasked == MotionEvent.ACTION_DOWN) { 
// Throw away all previous state when starting a new touch gesture. 
// The framework may have dropped the up or cancel event for the previous
// due to an app switch,ANR,or some other state change. 
    cancelAndClearTouchTargets(ev); 
    resetTouchState(); 
}

从上面的源码分析,我们可以得出结论:当ViewGroup决定拦截事件后,那么后续的 点击事件将会默认交给它处理并且不再调用它的onInterceptTouchEvent方法,就是说当一个View决定拦截一个事件后,那么系统会把同一个事件序列内的其他方 法都直接交给它来处理,因此就不用再调用这个View的onInterceptTouchEvent去询问它是 否要拦截了。

FLAG_DISALLOW_INTERCEPT这个标志的作用是让 ViewGroup不再拦截事件,当然前提是ViewGroup不拦截ACTION_DOWN事件,事件传递过程是由外向内的,即事件总是先传递给父元素,然后再由父元素分 发给子View,通过requestDisallowInterceptTouchEvent方法可以在子元素中干预父元素的事件分发过程,但是ACTION_DOWN事件除外。那么这段分析对我们有什么价值呢?总结起来有两点:第一 点,onInterceptTouchEvent不是每次事件都会被调用的,如果我们想提前处理所有的点击 事件,要选择dispatchTouchEvent方法,只有这个方法能确保每次都会调用,当然前提是事件能够传递到当前ViewGroup;另外一点,FLAG_DISALLOW_INTERCEPT标记位的 作用给我们提供了一个思路,当面对滑动冲突时,我们可以是不是考虑用这种方法去解决 问题?

如果在上面步骤 1 中,当前 ViewGroup 并没有对事件进行拦截,则执行步骤 2。

3.2 步骤 2 具体代码及分析

在这里插入图片描述
仔细看上述的代码可以看出:

  • 图中 ① 处表明事件主动分发的前提是事件为 DOWN 事件;
  • 图中 ② 处遍历所有子 View;
  • 图中 ③ 处判断事件坐标是否在子 View 坐标范围内,并且子 View 并没有处在动画状态;
  • 图中 ④ 处调用 dispatchTransformedTouchEvent 方法将事件分发给子 View,如果子 View 捕获事件成功,则将 mFirstTouchTarget 赋值给子 View。

上面这段代码逻辑也很清晰,首先遍历ViewGroup的所有子元素,然后判断子元素是 否能够接收到点击事件。是否能够接收点击事件主要由两点来衡量:子元素是否在播动画 和点击事件的坐标是否落在子元素的区域内。如果某个子元素满足这两个条件,那么事件就会传递给它来处理。

dispatchTransformedTouchEvent实际上调用的就是子元 素的dispatchTouchEvent方法,在它的内部有如下一段内容,而在上面的代码中child传递 的不是null,因此它会直接调用子View的dispatchTouchEvent方法,这样事件就交由子元素 处理了,从而完成了一轮事件分发。

if (child == null) {
handled = super.dispatchTouchEvent(event); 
} else { 
handled = child.dispatchTouchEvent(event); 
}

如果子元素的dispatchTouchEvent返回true,这时我们暂时不用考虑事件在子元素内部 是怎么分发的,那么mFirstTouchTarget就会被(addTouchTarget方法)赋值同时(break)跳出for循环,如下所示。

newTouchTarget = addTouchTarget(child,idBitsToAssign); 
alreadyDispatchedToNewTouchTarget = true; 
break;

这几行代码完成了mFirstTouchTarget的赋值并终止对子元素的遍历。如果子元素的 dispatchTouchEvent返回false,ViewGroup就会把事件分发给下一个子元素(如果还有下一 个子元素的话)。 其实mFirstTouchTarget真正的赋值过程是在addTouchTarget内部完成的,从下面的 addTouchTarget方法的内部结构可以看出,可以看出其实 mFirstTouchTarget 是一个 TouchTarget 类型的链表结构。而这个 TouchTarget 的作用就是用来记录捕获了 DOWN 事件的 View,具体保存在上图中的 child 变量。可是为什么是链表类型的结构呢?因为 Android 设备是支持多指操作的,每一个手指的 DOWN 事件都可以当做一个 TouchTarget 保存起来。 mFirstTouchTarget是否被赋值,将直接影响到ViewGroup对事件的拦截策略,如果 mFirstTouchTarget为null,那么ViewGroup就默认拦截接下来同一序列中所有的点击事件。

private TouchTarget addTouchTarget(View child,int pointerIdBits) { 
    TouchTarget target = TouchTarget.obtain(child,pointerIdBits); 
    target.next = mFirstTouchTarget; 
    mFirstTouchTarget = target; 
    return target; 
}

在这里插入图片描述
可以看出其实 mFirstTouchTarget 是一个 TouchTarget 类型的链表结构。而这个 TouchTarget 的作用就是用来记录捕获了 DOWN 事件的 View,具体保存在上图中的 child 变量。可是为什么是链表类型的结构呢?因为 Android 设备是支持多指操作的,每一个手指的 DOWN 事件都可以当做一个 TouchTarget 保存起来。

3.3 步骤3具体代码及分析

在这里插入图片描述

步骤 3 有 2 个分支判断

  • 分支 1:如果此时 mFirstTouchTarget 为 null,说明在上述的事件分发中并没有子 View 对事件进行了捕获操作。这种情况下,直接调用 dispatchTransformedTouchEvent 方法,并传入 child 为 null,最终会调用 super.dispatchTouchEvent 方法。实际上最终会调用自身的 onTouchEvent 方法,进行处理 touch 事件。也就是说:如果没有子 View 捕获处理 touch 事件,ViewGroup 会通过自身的 onTouchEvent 方法进行处理。
  • 分支 2:mFirstTouchTarget 不为 null,说明在上面步骤 2 中有子 View 对 touch 事件进行了捕获,则直接将当前以及后续的事件交给 mFirstTouchTarget 指向的 (子)View 进行处理。

3.4 为什么 DOWN 事件如此特殊

  • 所有 touch 事件都是从 DOWN 事件开始的,这是 DOWN 事件比较特殊的原因之一。另一个原因是 DOWN 事件的处理结果会直接影响后续 MOVE、UP 事件的逻辑。
  • FLAG_DISALLOW_INTERCEPT这个标志的作用是让 ViewGroup不再拦截事件,当然前提是ViewGroup不拦截ACTION_DOWN事件,事件传递过程是由外向内的,即事件总是先传递给父元素,然后再由父元素分 发给子元素,通过requestDisallowInterceptTouchEvent方法可以在子元素中干预父元素的事件分发过程,但是ACTION_DOWN事件除外。
  • 在**步骤 2 **中,只有 DOWN 事件会传递给子 View 进行捕获判断,一旦子 View 捕获成功,后续的 MOVE 和 UP 事件是通过遍历 mFirstTouchTarget 链表,查找之前接受 ACTION_DOWN 的子 View,并将触摸事件分配给这些子 View。也就是说后续的 MOVE、UP 等事件的分发交给谁,取决于它们的起始事件 Down 是由谁捕获的。

3.5 容易被遗漏的 CANCEL 事件

在上面的步骤 3 中,继续向子 View 分发事件的代码中,有一段比较有趣的逻辑:

在这里插入图片描述

上图红框中表明已经有子 View 捕获了 touch 事件,但是蓝色框中的 intercepted boolean 变量又是 true。这种情况下,事件主导权会重新回到父视图 ViewGroup 中,并传递给子 View 的分发事件中传入一个 cancelChild == true。

看一下 dispatchTransformedTouchEvent 方法的部分源码如下:

在这里插入图片描述
因为之前传入参数 cancel 为 true,并且 child 不为 null,最终这个事件会被包装为一个 ACTION_CANCEL 事件传给 child。

什么情况下会触发这段逻辑呢?

当父视图的 onInterceptTouchEvent 先返回 false,然后在子 View 的 dispatchTouchEvent 中返回 true(表示子 View 捕获事件),关键步骤就是在接下来的 MOVE 的过程中,父视图的 onInterceptTouchEvent 又返回 true,intercepted 被重新置为 true,此时上述逻辑就会被触发,子控件就会收到 ACTION_CANCEL 的 touch 事件。

实际上有个很经典的例子可以用来演示这种情况:
当在 Scrollview 中添加自定义 View 时,ScrollView 默认在 DOWN 事件中并不会进行拦截,事件会被传递给 ScrollView 内的子控件。只有当手指进行滑动并到达一定的距离之后,onInterceptTouchEvent 方法返回 true,并触发 ScrollView 的滚动效果。当 ScrollView 进行滚动的瞬间,内部的子 View 会接收到一个 CANCEL 事件,并丢失touch焦点(达到了拦截MOVE事件的效果)。这也就是ScrollView嵌套滑动冲突(内层ScrollView无法滑动)的原因,解决方案就是自定义内层的ScrollView,重写onInterceptTouchEvent方法。

@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
    // 请求父控件不要拦截事件,让子View处理
    getParent().requestDisallowInterceptTouchEvent(true);
    return super.onInterceptTouchEvent(ev);
}

这个案例比较简单,有兴趣的可以自行演示并尝试上面的方案解决内层ScrollView的滑动问题。

4. View对touch事件的处理过程

View 是一个单纯的控件,不能再被细分,内部也并不会存在子 View,没有onInterceptTouchEvent()事件拦截方法,所以它的事件分发的重点在于当前 View dispatchTouchEvent()方法和OnTouchEvent方法,如何去处理 touch 事件,并根据相应的手势逻辑进行一些列的效果展示(比如滑动,放大,点击,长按等)。

  • 是否存在 TouchListener;
  • 是否自己接收处理 touch 事件(主要逻辑在 onTouchEvent 方法中)。

下面通过一个例子,同时给一个Button设置OnClickListener和OnTouchListener监听,简单分析二者之间的关系

public class MainActivity extends AppCompatActivity {
	...
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        button = findViewById(R.id.btn);
        
        button.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                Log.d(TAG, "onClick: " );
            }
        });

        button.setOnTouchListener(new View.OnTouchListener() {
            @Override
            public boolean onTouch(View v, MotionEvent event) {
                Log.d(TAG, "onTouch: " + event.getAction());
                return false;
            }
        });
    }
}

此时点击button,日志信息如下:

2340-2340/com.example.touch_dispatch_sample D/MainActivity: onTouch: 0
2340-2340/com.example.touch_dispatch_sample D/MainActivity: onTouch: 0
2340-2340/com.example.touch_dispatch_sample D/MainActivity: onTouch: 1
2340-2340/com.example.touch_dispatch_sample D/MainActivity: onClick:

说明onClick()和onTouch的回调都执行了,现在将onTouch()方法的返回值改为true,日志如下:

4831-4831/com.example.touch_dispatch_sample D/MainActivity: onTouch: 0
4831-4831/com.example.touch_dispatch_sample D/MainActivity: onTouch: 1

说明点击事件的回调onClick并没有执行,这是为什么?触摸监听的onTouch返回值代表了什么呢?带着这个问题,来分析源码,Button继承View,我们可以直接进入View.java源码进行查看:

frameworks/base/core/java/android/view.java

public boolean dispatchTouchEvent(MotionEvent event) {
    ...
    if (onFilterTouchEventForSecurity(event)) {
        if ((mViewFlags & ENABLED_MASK) == ENABLED && handleScrollBarDragging(event)) {
               result = true;
        }
        //noinspection SimplifiableIfStatement
        ListenerInfo li = mListenerInfo;
        //注释1
        if (li != null && li.mOnTouchListener != null
            && (mViewFlags & ENABLED_MASK) == ENABLED
            && li.mOnTouchListener.onTouch(this, event)) {
            result = true;
        }
        if (!result && onTouchEvent(event)) {
            result = true;
        }
    }       
    ...    
}

关键代码就是注释1处的条件判断,只有满足相关条件result才为true,现在进入setOnTouchListener方法

public void setOnTouchListener(OnTouchListener l) {
    getListenerInfo().mOnTouchListener = l;
}

接着进入getListenerInfo()方法:

ListenerInfo getListenerInfo() {
    if (mListenerInfo != null) {
        return mListenerInfo;
    }
    mListenerInfo = new ListenerInfo();
    return mListenerInfo;
}

可知ListenerInfo是单例的,如果为空,则会实例化对象,所以在dispatchTouchEvent方法中,li!=null, li.mOnTouchListener = l即setOnTouchListener方法中new的OnTouchListener也不为空,button可点击触摸,第三个条件也满足,再看最后一个条件li.mOnTouchListener.onTouch(this, event))就是onTouch的返回值,为true时,result为true,再看下面一行,此时onTouchEvent方法不会执行,所以里面的onClick方法也不会执行。

onTouch方法的返回值为true,表示消费ACTION_DOWN事件,该事件不会在传递到onTouchEvent, 也就不会执行onClick,由此可见:View的OnTouchListener,其优先级比OnTouchEvent优先级要高。

再次将onTouch方法返回值改为false,点击button,但是不放手,根据日志打印的信息,发现只有onTouch方法调用了,松开手时,根据日志信息,发现onClick方法调用了,所以不难推测出onClick的回调是在ACTION_UP事件中执行的。

public boolean onTouchEvent(MotionEvent event) {
	...
	swithch(action){
		case MotionEvent.ACTION_UP:
			...
			if (!post(mPerformClick)) {
            performClick();
            }
            ...
            break;
		case MotionEvent.ACTION_DOWN:
			...
			break;
		
		case MotionEvent.ACTION_CANCEL:
			...
			break;
        case MotionEvent.ACTION_MOVE:
			...
			break;
	}
	...
	
}

进入performClick()方法,如果View设置了OnClickListener监听,那么performClick方法内部就会调用它的onClick方法。

public boolean performClick() {
    final boolean result;
    final ListenerInfo li = mListenerInfo;
    if (li != null && li.mOnClickListener != null) {
        //关于点击事件声音的处理
        playSoundEffect(SoundEffectConstants.CLICK);
        //OnClickListener的最终回调
        li.mOnClickListener.onClick(this);
        result = true;
    } else {
        result = false;
    }
	...
    return result;
}

5. 总结

本文时重点分析了 dispatchTouchEvent 的事件的流程机制,这一过程主要分 3 部分:

  • 判断是否需要拦截 —> 主要是根据 onInterceptTouchEvent 方法的返回值来决定是否拦截;
  • 在 DOWN 事件中将 touch 事件分发给子 View —> 这一过程如果有子 View 捕获消费了 touch 事件,会对 mFirstTouchTarget 进行赋值;
  • 最后一步,DOWN、MOVE、UP 事件都会根据 mFirstTouchTarget 是否为 null,决定是自己处理 touch 事件,还是再次分发给子 View。

然后介绍了整个事件分发中的几个特殊的点。

  • DOWN 事件的特殊之处:事件的起点;决定后续事件由谁来消费处理;

  • mFirstTouchTarget 的作用:记录捕获消费 touch 事件的 View,是一个链表结构;

  • CANCEL 事件的触发场景:当父视图先不拦截,然后在 MOVE 事件中重新拦截,此时子 View 会接收到一个 CANCEL 事件。

原创文章 23 获赞 30 访问量 9559

猜你喜欢

转载自blog.csdn.net/my_csdnboke/article/details/106123050
今日推荐