[Android]触摸、滑动与嵌套滑动(二)几种场景下的事件处理分析和调试

543 阅读13分钟

本文用于记录各种场景下的事件接受、事件突然被父View拦截、子View在满足于一定条件下主动放弃事件等等的方法调用情况,以ScrollView和ScrollView嵌套,内层ScollView中包含多个Button,但是Button将不处理任何事件,并在内层ScrollView上滑动为例:

默认的视图结构:DecorView -> ViewGroup -> OutScrollView -> InnerScrollView -> CustomButton

1. 默认下的一次滑动

OutScrollView在该场景下不拦截任何事件,全交给内层处理,所以将其看成一个ViewGroup即可。

首先是ACTION_DOWN的下发,事件逐层下发,并且在所有经过的ViewGroup中,都会去调用onInterceptTouchEvent来询问是否需要拦截事件,最后下发到了叶子节点CustomButton,但是CustomButton并不处理这个事件,它dispatchTouchEvent直接返回了false,因此事件又下沉给它的父View:InnerScrollView:

ViewGroup(47454983,), dispatchTouchEvent:ACTION_DOWN
ViewGroup(47454983,), onInterceptTouchEvent:ACTION_DOWN
InnerScrollView(49665076,), dispatchTouchEvent:ACTION_DOWN
InnerScrollView(49665076,), onInterceptTouchEvent:ACTION_DOWN
CustomButton(227238493), dispatchTouchEvent:ACTION_DOWN
InnerScrollView(49665076,), onTouchEvent:ACTION_DOWN,consumed:true

接下来是ACTION_MOVE事件:

ViewGroup( 47454983 ,), dispatchTouchEvent:ACTION_MOVE
ViewGroup( 47454983 ,), onInterceptTouchEvent:ACTION_MOVE
InnerScrollView( 49665076 ,), dispatchTouchEvent:ACTION_MOVE
InnerScrollView( 49665076 ,), onTouchEvent:ACTION_MOVE,consumed: true
InnerScrollView( 49665076 ,), scolledY: 5
// 下一次ACTION_MOVE事件
ViewGroup(47454983,), dispatchTouchEvent:ACTION_MOVE
InnerScrollView(49665076,), dispatchTouchEvent:ACTION_MOVE

我们可以看到,即使一开始接受ACTION_DOWN的是InnerScrollView,它的父ViewGroup,还是能够「看到」这一次的事件的,这么设计是因为它的父ViewGroup随时可以去拦截它的事件,只不过在onInterceptTouchEvent中没有去处理这个事件。

InnerScrollView的onTouchEvent返回了true,即它消费了本次事件,并且消费后造成了内部视图的5点滑动量。

而它内部的CustomButton,因为没有接收到ACTION_DOWN事件,它也就没有机会去接收到后续的ACTION_MOVE和UP事件了。

接下就是很多个ACTION_MOVE,直到ACTION_UP的出现:

InnerScrollView(49665076,), onTouchEvent:ACTION_MOVE,consumed:true
InnerScrollView(49665076,), scolledY:35
ViewGroup( 47454983 ,), dispatchTouchEvent:ACTION_UP
InnerScrollView( 49665076 ,), dispatchTouchEvent:ACTION_UP
InnerScrollView( 49665076 ,), onTouchEvent:ACTION_UP,consumed: true
InnerScrollView( 49665076 ,), scolledY: 35

2. 父OutScrollView主动拦截

此前我们在OutScrollView中的dispatchTouchEvent返回了false,所以它就和一个普通的ViewGroup没什么两样,并不会对内部的InnerScrollView造成干扰。

现在我们不去修改原有的逻辑,我们看看它的事件下发:

OutScrollView(47454983,), dispatchTouchEvent:ACTION_DOWN
OutScrollView(47454983,), onInterceptTouchEvent:ACTION_DOWN
InnerScrollView(49665076,), dispatchTouchEvent:ACTION_DOWN
InnerScrollView(49665076,), onInterceptTouchEvent:ACTION_DOWN
CustomButton(227238493), dispatchTouchEvent:ACTION_DOWN
InnerScrollView(49665076,), scolledY:0

# ACTION_MOVE
OutScrollView(47454983,), dispatchTouchEvent:ACTION_MOVE
OutScrollView(47454983,), onInterceptTouchEvent:ACTION_MOVE
InnerScrollView(49665076,), dispatchTouchEvent:ACTION_MOVE
InnerScrollView(49665076,), onTouchEvent:ACTION_MOVE,consumed:true
InnerScrollView(49665076,), scolledY:0

可以看到到一个ACTION_MOVE是由内层的InnerScrollView消费的,只不过我们手指滑动的比较慢,它的视图偏移量为0,言下之意是:虽然InnerScrollView消费了ACTION_MOVE,但是它并没有造成滑动。因为ScrollView内部会计算滑动量:yDiff,只有yDiff > TouchSlop才能算作是一次有效的滑动,否则不视为滑动。

这么做的目的是优化点按的体验,毕竟人很难控制手指在屏幕上不产生一点移动完成一次点击。如果移动一个像素都算滑动的话,那Click几乎没法用了。

我们聚焦到真正地,造成滑动的那一次ACTION_MOVE:

InnerScrollView(227238493,), scolledY:0
# ↑上一个ACTION_MOVE

# ↓下一个ACTION_MOVE
CustomViewGroup(151305542), dispatchTouchEvent:ACTION_MOVE
CustomViewGroup(151305542), onInterceptTouchEvent:ACTION_MOVE
OutScrollView(49665076,), dispatchTouchEvent:ACTION_MOVE
OutScrollView(49665076,), onInterceptTouchEvent:ACTION_MOVE
InnerScrollView( 227238493 ,), dispatchTouchEvent:ACTION_CANCEL
InnerScrollView( 227238493 ,), onTouchEvent:ACTION_CANCEL,consumed: true

# ↓下一个ACTION_MOVE
CustomViewGroup(151305542), dispatchTouchEvent:ACTION_MOVE
OutScrollView(49665076,), dispatchTouchEvent:ACTION_MOVE
OutScrollView(49665076,), onTouchEvent:ACTION_MOVE,consumed:true
OutScrollView(49665076,), scolledY:4
OutScrollView(49665076,), dispatchTouchEvent:ACTION_MOVE

首先事件是经过了OutScrollView,但是事件派发到InnerScrollView的时候ACTION就已经变成了****ACTION_CANCEL,更重要的是接下来,OutScrollView又开始消费一个新的ACTION_MOVE(由系统,经过DecorView新派发出来的),此时摇身一变OutScrollView开始「独吞」这个事件了,它不再将事件交给InnerScrollView了,最后外部的OutScrollView被成功滑动了,整体产生了4点的偏移量。

促使OutScrollView拦截事件的的ACTION_MOVE,通过setAction,摇身一变变成了ACTION_CANCEL,但是event还是那个event,并且当前事件OutScrollView并没有因为拦截了事件就立即滑动,而是等到下一次的ACTION_MOVE才滑动。

2.2 调试

我们想要调试这个过程的话,我们需要在InnerScrollView的onTouchEvent上打上断点,并新增断点条件:

因为是源码调试,我们需要选择对应的源码和对应的模拟器版本:比如现在选中的是Android APi32,那么我们的源码和模拟器都应该选择32的,否则Debug进入系统代码的时候,比如View类中的代码,行号会和实际的代码行号对不上,第三方品牌的真机大多数也不太靠谱,即使对应Api版本,也很可能行号对应不上,除非用Pixel或者nexus等等原版未经过修改的系统。

观察函数的调用栈,我们可以定位到OutScrollView的dispatchTransformedTouchEvent中,大致的内容如下:

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

重点可以关注一下**event.setAction(MotionEvent.ACTION_CANCEL);** 这个就是把原先的ACTION_MOVE更改为ACTION_CANCEL的方法。如果child不为空,则把这个取消事件派发给它。

child是什么?

child最终指向的是ViewGroup下的一个mFirstTouchTarget对象,从名称中,我们大致就可以猜出来它的作用:本ViewGroup第一次触摸的对象。

mFirstTouchTarget在各个组件响应ACTION_DOWN时间的时候,全部为NULL,直到到第一个组件接收ACTION_DOWN,在这里是InnerScrollView,我们看看所有控件的mFirstTouchTarget变量对应的child都是什么:

DecorView -> LinearLayout@892abf
LinearLayout@892abf -> FrameLayout
FrameLayout -> ActionBarOveraLayout
ActionBarOveraLayout -> ContentFrameLayout
ContentFrameLayout -> ConstraintLayout // 这个ConstraintLayout就是根布局
ConstraintLayout -> CustomViewGroup
CustomViewGroup -> OutScrollView
OutSrcollView -> LinearLayoutCompat@20889
LinearLayoutCompat@20889 -> InnerScrollView
InnerScrollView -> null

所有的ViewGroup,最终的mFirstTouchTarget都指向了接收触摸事件的那一个控件,最终这个链条会走向消费事件的View,也就是InnerScrollView。

但是一旦OutScrollView下发了ACTION_CANCEL之后,将滑动事件的消费权从InnerScrollView转移到自己身上的时候发生了什么呢?

当然是把这个链条切断了,所以它就成了最终的mFirstTouchTarget,事件也就都由它来消费。

CustomViewGroup -> OutScrollView
OutSrcollView -> LinearLayoutCompat@20889
OutSrcollView -> null

LinearLayoutCompat@20889 -> null
InnerScrollView -> null

所以,mFirstTouchTarget暂且可以看做是一个依赖于View Hierarchy的链表,最终的item就是当前的事件的接收者,假设现在有布局构成的mFirstTouch链:A->B->C->D->E,此时的事件就由E消费:

如果此时C要拦截事件,该链条就变成了A->B->C、D、E,事件由C来消费,DE不再在mTouchTarget构成的一个链之上:

3. 子View主动放弃事件

假设在某个场景之下,View主动放弃了事件,根据事件传递机制的特性,此时的事件会开始上浮给上层的视图,例如:A->B->C->D->E,这五个控件构成的视图树,如果E放弃了ACTION_DOWN事件,那么事件会上浮到D的dispatchTouchEvent中,表现为E的dispatchTouchEvnet方法调用弹出方法栈。

如果此时D要消费事件,则会在onTouchEvent中返回true,否则返回false,后者则会继续走上述的流程,上浮到C。

那么如果是E接受了ACTION_DOWN,然后主动放弃了某一个ACTION_MOVE,接下来会发生什么呢?

其实怀疑的点,就在于E放弃了ACTION_MOVE事件之后,E还能不能收到后续事件,如果收不到,后者是D会不会像面对ACTION_DOWN被E放弃了一样,去重新接收事件。

InnerScrollView(86112637,), scolledY:499 
CustomViewGroup(149935399), dispatchTouchEvent:ACTION_MOVE
OutScrollView(70898644,), dispatchTouchEvent:ACTION_MOVE
InnerScrollView(86112637,), dispatchTouchEvent:ACTION_MOVE
InnerScrollView(86112637,), onTouchEvent:ACTION_MOVE,consumed:true
InnerScrollView(86112637,), scolledY:504
// 上一次的滑动之后,偏移量达到了504,
// 下一次滑动开始时,ScrollY将超出500,超出500之后,InnerScrollView的onTouchEvent将会返回false,即不再处理;
CustomViewGroup(149935399), dispatchTouchEvent:ACTION_MOVE
OutScrollView(70898644,), dispatchTouchEvent:ACTION_MOVE
InnerScrollView(86112637,), dispatchTouchEvent:ACTION_MOVE
InnerScrollView(86112637,), onTouchEvent:ACTION_MOVE,consumed:false
InnerScrollView(86112637,), scolledY:504
// next
CustomViewGroup(149935399), dispatchTouchEvent:ACTION_MOVE
OutScrollView(70898644,), dispatchTouchEvent:ACTION_MOVE
InnerScrollView(86112637,), dispatchTouchEvent:ACTION_MOVE
InnerScrollView(86112637,), onTouchEvent:ACTION_MOVE,consumed:false
InnerScrollView(86112637,), scolledY:504

我们可以清楚地看到,即使我们的InnerScrollView在某一次的ACTION_MOVE中,在onTouchEvent返回false之后,此后的ACTION_MOVE仍然会下发到InnerScrollView上, 对应着上面的例子,ABCDE五个控件中,如果E放弃了ACTION_MOVE之后,仍然能够收到ACTION_MOVE事件,也就是说mFirstTouchTarget构成的链表没有断开的情况下,E控件还是能收到事件的。

mFirstTouchTarget的定义也越来与明朗了,就是当前ViewGroup在一个滑动过程中(按下,滑动,抬起)第一次触摸的View控件,每个ViewGroup的mFirstTouchTarget构成的链表的最后一项就是事件的消费者,如果中间的VewGroup主动去拦截事件,那么就会将余下的链表项目斩断,自己成为最后一个ListNode来消费事件

如果控件开始不接受ACTION_DOWN事件,那么就放弃了自己被挂载在mFirstTouchTarget上的机会,自然也就没有机会再去接受事件了。

只有首个ACITON_MOVE会根据坐标确定被点击的View,其余的都是根据mFirstTouchTarget的链条来下发的,所以,父ViewGroup拦截事件的时候,很重要的一件事情就是断开ViewGroup自己的mFirstTouchTarget对下层View的连接。

而对于ACTION_DOWN事件来说,会根据手指触控的坐标,比如(500,500)来确定控件在当前ViewGroup中的位置,这个过程需要按顺序遍历所有的子View,直到找到某个子View,并且它能够接受处理(在dispatchTouchEvent和onTouchEvent中返回true)为止。

并且记录为mFirstTouchTarget,后续的无数多个ACTION_MOVE就不需要再次去根据坐标定位了,直接根据当前mFirstTouchTarget的child域,就可以找到下一个ViewGroup或者最终找到ACTION_DOWN的接收者,所以,ACTION_DOWN的下发和其它事件的下发是不一样的,前者是在查找最终消费该事件的View,而ACTION_MOVE/UP等等则是只需要沿着mFirstTouchTarget的链条,不断下发即可。

跟踪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 gesture
    // due to an app switch, ANR, or some other state change.
    cancelAndClearTouchTargets(ev);
    resetTouchState();
}

大概意思就是,在收到ACTION_DOWN事件的时候会先清理既有的TouchTarget数据,也就是mFirstTouchTarget的数据。接着,判断一下是否需要拦截事件,和FLAG_DISALLOW_INTERCEPT这个标记位相关,子View可以通过一个方法来请求该ViewGroup不要拦截,如果设置了之后,对应的disallowIntercept的数据为true,就不会拦截事件了。

// Check for interception.
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;
}

然后通过for循环遍历它的children:

for (int i = childrenCount - 1; i >= 0; i--) {
    final int childIndex = getAndVerifyPreorderedIndex(
            childrenCount, i, customOrder);
    final View child = getAndVerifyPreorderedView(
            preorderedList, children, childIndex);
            ……

你会发现,这里的for的起始下标是childrenCount - 1,也就是说for是倒着遍历的,为什么?

在一个视图中,如果我们在XML中,按照如下的顺序编写视图:

<FrameLayout>
    <A />
    <B />
    <C />
    <D />
    <E />
</FrameLayout>

此时如果A的尺寸最大,B次之,E最小,在渲染出来后应该是这样的视图:

因为A是排在最靠前的,所以A会先被渲染出来,B次之,所以B盖在A上方,E在最上方。但是如果我们点击一个View,比如我们点击E,如果View的加入顺序去遍历,事件会先派发给A。所以这里View的排列顺序和我们的点击事件派发顺序是反着的,越靠上层的View越晚被加入视图,视觉上也就越靠上,自然而然也应该优先响应点击事件。

也就是说,View被显示的顺序越靠后,在视图层上就越靠上(靠近人眼),接受事件的优先级就越高。但是这个高 = View越晚渲染

回到正题,getAndVerifyPreorderedIndex其实是根据View在children中的下标,取出绘制的顺序。但是如果你使用getChildDrawingOrder重写了Draw的顺序,getChildDrawingOrder取出来的值就会发生改变, getAndVerifyPreorderedView就是去取View了。

在子View接受事件之后,返回到此处,调用addTouchTarget方法添加FirstTouchTarget。

/**
 * Adds a touch target for specified child to the beginning of the list.
 * Assumes the target child is not already present.
 */
private TouchTarget addTouchTarget(@NonNull View child, int pointerIdBits) {
    final TouchTarget target = TouchTarget.obtain(child, pointerIdBits);
    target.next = mFirstTouchTarget;
    mFirstTouchTarget = target;
    return target;
}

而ACTION_MOVE,就是直接通过mFirstTouchTarget的child域来下发的:

if (dispatchTransformedTouchEvent(ev, cancelChild,
        target.child, target.pointerIdBits)) {
    handled = true;
}

4. 总结

上面我们分析了事件传递机制的三种非常常见的情况:

  1. 完整而正常的一次滑动;
  2. 父View主动拦截的事件派发;
  3. 子View主动放弃事件的派发(主要是ACTION_MOVE事件)

还有mFirstTouchTarget在事件传递机制中的作用。

进一步验证了在(一)中,我们总结出来的几个规律:

  1. ACTION_DOWN事件优先派发给叶子节点的View,必须保证叶子节点的View能够有挂在mFirstTouchTarget链上的机会;
  2. 如果一个控件不接受ACTION_DOWN事件,那后续就不会得到ACTION_MOVE和ACTION_UP, 因为后续事件的下发是根据mFirstTouchTarget的child域下发的,如果在ACTION_DOWN中没有把自己挂在链上,后续的事件就没有机会再去消费了。
  3. 即使一个控件消费了ACTION_DOWN事件,也不意味着它就能收到本次触摸后续的所有事件。ACTION_MOVE事件在父View滑动和子View点击发生冲突的时候,可能会被父View拦截,并向子View派发ACTION_CANCEL事件,此后的ACTION_MOVE事件由父View来消费。
  4. 即使一个控件没有消费ACTION_DOWN事件,也可能会通过拦截事件的方式消费后续的ACTION_MOVE事件。

~end