(拉钩)Android工程师进阶34讲-14:Android Touch事件分发时序

0. 前言

Android Touch事件的分发是Android工程师必备的技能之一。关于事件分发主要有几个方向可以展开分析:

  • 1、touch 事件是如何从驱动层传递给Framework层的InputManagerService
  • 2、WMS 是如何通过ViewRootImpl将事件传递到目标窗口。
  • 3、touch 事件达到DecorView后,是如何一步步传递到内部的子View中。

本文是基于Android-28的源码分析的。

1. 思路梳理

2个概念。

1.1 ViewGroup

ViewGroup是一组View的组合,在其内部有可能包含多个子View,当手指触摸屏幕时,手指所在的区域既能在ViewGroup显示范围内,也可能在其内部View控件上。

因此它内部的事件分发的重心是处理当前Group和子View之间的逻辑关系:

  • 1、当前Group是否需要拦截touch事件。
  • 2、是否需要将touch事件继续分发给子View
  • 3、如何将touch事件分发给子View

1.2 View

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

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

2. 事件分发核心dispatchTouchEvent(ViewGroup)

整个View之间的事件分发,实质上就是一个大的递归函数,而这个递归函数就是dispatchTouchEvent方法。在这个递归的过程中会适时调用onInterceptTouchEvent来拦截事件,或者调用onTouchEvent方法来处理事件。

先从宏观角度,纵览整个dispatch的源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public boolean dispatchTouchEvent(){
/**
* 步骤1:检查当前ViewGroup是否需要拦截事件
*/
...
/**
* 步骤2:将事件分发给子View
*/
...
/**
* 步骤3:根据mFirstTouchTarget,再次分发事件
*/
...
}

如代码中的注释,dispatch主要分为三个步骤:

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

下面分析每个步骤:

2.1 步骤1具体代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* 1、检查当前ViewGroup是否需要拦截事件
*/
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;
}

actionMasked == MotionEvent.ACTION_DOWN || mFirstTouchTarget != null判断了是否需要拦截的条件:

  • 如果事件为DOWN事件,则调用onInterceptTouchEvent()进行拦截判断。
  • 或者mFirstTouchTarget不为null,代表已经有子View捕获了这个事件,子View的dispatchTouchEvent返回true表示捕获touch事件。

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

2.2 步骤2具体代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
/**
* 2、将事件分发给子View
*/
if (!canceled && !intercepted) {
View childWithAccessibilityFocus = ev.isTargetAccessibilityFocus()
? findChildWithAccessibilityFocus() : null;

if (actionMasked == MotionEvent.ACTION_DOWN // 1
|| (split && actionMasked == MotionEvent.ACTION_POINTER_DOWN)
|| actionMasked == MotionEvent.ACTION_HOVER_MOVE) {
final int actionIndex = ev.getActionIndex();
final int idBitsToAssign = split ? 1 << ev.getPointerId(actionIndex)
: TouchTarget.ALL_POINTER_IDS;

removePointersFromTouchTargets(idBitsToAssign);

final int childrenCount = mChildrenCount;
if (newTouchTarget == null && childrenCount != 0) {
final float x = ev.getX(actionIndex);
final float y = ev.getY(actionIndex);
final ArrayList<View> preorderedList = buildTouchDispatchChildList();
final boolean customOrder = preorderedList == null
&& isChildrenDrawingOrderEnabled();
final View[] children = mChildren;
for (int i = childrenCount - 1; i >= 0; i--) { // 2
final int childIndex = getAndVerifyPreorderedIndex(
childrenCount, i, customOrder);
final View child = getAndVerifyPreorderedView(
preorderedList, children, childIndex);

if (childWithAccessibilityFocus != null) {
if (childWithAccessibilityFocus != child) {
continue;
}
childWithAccessibilityFocus = null;
i = childrenCount - 1;
}

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

newTouchTarget = getTouchTarget(child);
if (newTouchTarget != null) {
newTouchTarget.pointerIdBits |= idBitsToAssign;
break;
}

resetCancelNextUpFlag(child);
// 4
if (dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign)) {
...
newTouchTarget = addTouchTarget(child, idBitsToAssign);
alreadyDispatchedToNewTouchTarget = true;
break;
}
ev.setTargetAccessibilityFocus(false);
}
if (preorderedList != null) preorderedList.clear();
}
...
}
}

说明:

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

2.3 步骤3具体代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/**
* 3、根据mFirstTouchTarget,再次分发事件
*/
if (mFirstTouchTarget == null) { // 1
// No touch targets so treat this as an ordinary view.
handled = dispatchTransformedTouchEvent(ev, canceled, null, // 传入child为null
TouchTarget.ALL_POINTER_IDS);
} else {
// Dispatch to touch targets, excluding the new touch target if we already
// dispatched to it. Cancel touch targets if necessary.
TouchTarget predecessor = null;
TouchTarget target = mFirstTouchTarget;
while (target != null) {
final TouchTarget next = target.next;
if (alreadyDispatchedToNewTouchTarget && target == newTouchTarget) {
handled = true;
} else {
final boolean cancelChild = resetCancelNextUpFlag(target.child)
|| intercepted;
// 2
if (dispatchTransformedTouchEvent(ev, cancelChild,
target.child, target.pointerIdBits)) {
handled = true;
}
...
}
predecessor = target;
target = next;
}
}

步骤3有两个分支判断:

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

3. 事件分发流程代码演示

布局文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
<?xml version="1.0" encoding="utf-8"?>
<com.ly.lgdemoandroid.DownInterceptedGroup
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/colorPrimary">

<com.ly.lgdemoandroid.CaptureTouchView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@color/colorAccent"/>

</com.ly.lgdemoandroid.DownInterceptedGroup>

DownInterceptedGroupCaptureTouchView是两个自定义View,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
public class DownInterceptedGroup extends FrameLayout {
private static final String TAG = DownInterceptedGroup.class.getSimpleName();

public DownInterceptedGroup(@NonNull Context context) {
super(context);
}

public DownInterceptedGroup(@NonNull Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
}

public DownInterceptedGroup(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}

@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
Log.i(TAG, "dispatchTouchEvent: " + ev);
return super.dispatchTouchEvent(ev);
}

@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
Log.i(TAG, "onInterceptTouchEvent: " + ev);
return super.onInterceptTouchEvent(ev);
}
}

public class CaptureTouchView extends View {
private static final String TAG = CaptureTouchView.class.getSimpleName();

public CaptureTouchView(Context context) {
super(context);
}

public CaptureTouchView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
}

public CaptureTouchView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}

@Override
public boolean dispatchTouchEvent(MotionEvent event) {
Log.i(TAG, "dispatchTouchEvent: " + event);
boolean result = super.dispatchTouchEvent(event);
Log.i(TAG, "dispatchTouchEvent result is " + result);
return result;
}

@Override
public boolean onTouchEvent(MotionEvent event) {
Log.i(TAG, "onTouchEvent: " + event);
return true;
}

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
setMeasuredDimension(500, 300);
}
}

用手触摸CaptureTouchView并滑动一段距离后抬起,打印日志如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
com.ly.lgdemoandroid I/DownInterceptedGroup: dispatchTouchEvent: MotionEvent { action=ACTION_DOWN
com.ly.lgdemoandroid I/DownInterceptedGroup: onInterceptTouchEvent: MotionEvent { action=ACTION_DOWN
com.ly.lgdemoandroid I/CaptureTouchView: dispatchTouchEvent: MotionEvent { action=ACTION_DOWN
com.ly.lgdemoandroid I/CaptureTouchView: onTouchEvent: MotionEvent { action=ACTION_DOWN
com.ly.lgdemoandroid I/CaptureTouchView: dispatchTouchEvent result is true
com.ly.lgdemoandroid I/DownInterceptedGroup: dispatchTouchEvent: MotionEvent { action=ACTION_MOVE
com.ly.lgdemoandroid I/DownInterceptedGroup: onInterceptTouchEvent: MotionEvent { action=ACTION_MOVE
com.ly.lgdemoandroid I/CaptureTouchView: dispatchTouchEvent: MotionEvent { action=ACTION_MOVE
com.ly.lgdemoandroid I/CaptureTouchView: onTouchEvent: MotionEvent { action=ACTION_MOVE
com.ly.lgdemoandroid I/CaptureTouchView: dispatchTouchEvent result is true
......
com.ly.lgdemoandroid I/DownInterceptedGroup: dispatchTouchEvent: MotionEvent { action=ACTION_UP
com.ly.lgdemoandroid I/DownInterceptedGroup: onInterceptTouchEvent: MotionEvent { action=ACTION_UP
com.ly.lgdemoandroid I/CaptureTouchView: dispatchTouchEvent: MotionEvent { action=ACTION_UP
com.ly.lgdemoandroid I/CaptureTouchView: onTouchEvent: MotionEvent { action=ACTION_UP
com.ly.lgdemoandroid I/CaptureTouchView: dispatchTouchEvent result is true

上图中在DOWN事件中,DownInterceptGrouponInterceptTouchEvent被触发一次;然后在子View CaptureTouchEventdispatchTouchEvent中返回true,代表它捕获消费了这个DOWN事件。这种情况下CaptureTouchEvent会被添加到父视图(DownInterceptGroup)中的mFirstTouchTarget中。因此后续的MOVE和UP事件都会经过DownInterceptGrouponInterceptTouchEvent进行拦截判断。

3.1 为什么DOWN事件特殊

所有touch事件都会从DOWN事件开始的,这是DOWN事件比较特殊的原因之一。另一个原因是DOWN事件的处理结果会直接影响后续MOVE、UP事件的逻辑。

在步骤2中,只有DOWN事件会传递给子View进行捕获判断,一旦子View捕获成功,后续的MOVE和UP事件是通过遍历mFirstTouchTarget链表,查找之前接受ACTION_DOWN的子View,并将触摸事件分配给这些子View。也就是说后续的MOVE、UP等事件的分发交给谁,取决于它们的起始事件DOWN是由谁捕获的。

3.2 mFirstTouchTarget有什么作用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private TouchTarget mFirstTouchTarget;

private static final class TouchTarget {
...
public static final int ALL_POINTER_IDS = -1; // all ones
// The touched child view.
public View child;
// The combined bit mask of pointer ids for all pointers captured by the target.
public int pointerIdBits;
// The next target in the target list.
public TouchTarget next;
private TouchTarget() {
}
...
}

可以看出mFirstTouchTarget是一个TouchTarget类型的链表结构。而这个TouchTarget的作用就是用来记录捕获了DOWN事件的View,具体保存在上图中的child变量。为什么要用链表类型的结构呢?因为Android设备是支持多指操作的,每一个手指的DOWN事件都可以当做一个TouchTarget保存起来。在步骤3中判断如果mFirstTouchTarget不为null,则再次将事件分发给相应的TouchTarget

3.3 容易被遗漏的CANCEL事件

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
while (target != null) { // 1
final TouchTarget next = target.next;
if (alreadyDispatchedToNewTouchTarget && target == newTouchTarget) {
handled = true;
} else {
final boolean cancelChild = resetCancelNextUpFlag(target.child)
|| intercepted; // 2
if (dispatchTransformedTouchEvent(ev, cancelChild,
target.child, target.pointerIdBits)) {
handled = true;
}
if (cancelChild) {
if (predecessor == null) {
mFirstTouchTarget = next;
} else {
predecessor.next = next;
}
target.recycle();
target = next;
continue;
}
}
predecessor = target;
target = next;
}

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

dispatchTransformedTouchEvent方法部分源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
private boolean dispatchTransformedTouchEvent(MotionEvent event, boolean cancel, View child, int desiredPointerIdBits) {

...

final int oldAction = event.getAction();
if (cancel || oldAction == MotionEvent.ACTION_CANCEL) { // 1
event.setAction(MotionEvent.ACTION_CANCEL);
if (child == null) {
handled = super.dispatchTouchEvent(event);
} else {
handled = child.dispatchTouchEvent(event);
}
event.setAction(oldAction);
return handled;
}

...

}

1处因为之前传入的canceltrue,并且child不为null最终这个事件会被包装成一个ACTIOON_CANCEL事件传递给child

3.3.1 什么情况下会触发这段代码?

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

3.3.2 经典案例演示上述情况

当在ScrollView中添加自定义View时,ScrollView默认在DOWN事件中并不会进行拦截,事件会被传递给ScrollView内的子控件。只有当手指进行滑动并到达一定距离之后,onInterceptTouchEvent方法返回true,并触发ScrollView的滚动效果。当ScrollView进行滚动的瞬间,内部的子View会接收到一个CANCEL事件,并丢失touch焦点。

如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<?xml version="1.0" encoding="utf-8"?>
<ScrollView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/colorPrimaryDark"
tools:context=".MainActivity">

<LinearLayout android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">

<com.ly.lgdemoandroid.CaptureTouchView
android:layout_width="300dp"
android:layout_height="300dp"
android:layout_marginTop="10dp"
android:layout_marginBottom="10dp"
android:background="@color/colorAccent"/>

<!-- 重复上面的CaptureTouchView -->

</LinearLayout>

</ScrollView>

CaptureTouchViewonTouchEvent返回true,表示它会将接收到的touch事件进行捕获消费。

上述代码执行后,当手指点击屏幕时DOWN事件会被传递给CaptureTouchView,手指滑动屏幕将ScrollView向下滚动,刚开始MOVE事件还是由CaptureTouchView来消费处理,但是当ScrollView开始滚动时,CaptureTouchView会接收一个CANCEL事件,并不在接收后续的touch事件。日志如下:

1
2
3
4
5
6
7
8
9
CaptureTouchView: dispatchTouchEvent: MotionEvent { action=ACTION_DOWN
CaptureTouchView: onTouchEvent: MotionEvent { action=ACTION_DOWN
CaptureTouchView: dispatchTouchEvent result is true
CaptureTouchView: dispatchTouchEvent: MotionEvent { action=ACTION_MOVE
CaptureTouchView: onTouchEvent: MotionEvent { action=ACTION_MOVE
CaptureTouchView: dispatchTouchEvent result is true
CaptureTouchView: dispatchTouchEvent: MotionEvent { action=ACTION_CANCEL
CaptureTouchView: onTouchEvent: MotionEvent { action=ACTION_CANCEL
CaptureTouchView: dispatchTouchEvent result is true

因此,平时自定义View时,尤其是有可能被ScrollView或者ViewPager嵌套使用的控件,不要遗漏对CANCEL事件的处理,否则有可能引起UI显示异常。

4. 总结

dispatchTouchEvent事件的流程机制:

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

事件分发的几个特殊点:

  • DOWN事件的特殊之处:事件的起点;决定后续事件由谁来消费处理;
  • mFirstTouchTarget的作用:记录捕获消费touch事件的View,是一个链表结构;
  • CANCEL事件的触发场景:当父视图先不拦截,然后在MOVE事件中重新拦截,此时子View会接收到一个CANCEL事件。
  • Copyrights © 2019-2020 Tyler Liu

请我喝杯咖啡吧~

支付宝
微信