Android 中手指从按钮 A 平移到 B,会发生什么?为什么?

作者:TechMerger

前言

Touch 相关问题是 Android 面试中常问的点,不一定要求大家都从 InputFlinger 底层开始回答,但起码需要了解 Touch 抵达 App 之后的完整处理。而即便是这段偏上层的链路,也不要局限在老生常谈的过程复述,需要深刻理解、灵活运用其中的细节和原则。

本文结合一个简单的 Touch 场景的问答,带大家加深一下 Touch 分发的理解。

  1. Button A 和 B 相邻,手指不抬起、从 A 平移到 B,A 会发生什么?为什么?
  2. 此刻,B 又会发生什么?为什么?
  3. 之后,手指再从 B 平移回 A 后,又会发生什么?为什么?
  4. 最后,在 A 上抬起手指,A 会触发点击吗?为什么?

验证

我们自定义两个 Button 分别覆写其 onTouchEvent(),在一个 ConstraintLayout 中上下紧密地放置它们,并为了区分设置为不同的背景色。

Android 中手指从按钮 A 平移到 B,会发生什么?为什么?_第1张图片

按照提问的问题步骤开始尝试一下。

可以看到手指平移到 B 的那一刻,A 的 press 效果没有了,而 B 没有任何反应。即便移动回 A,A 也无法恢复 press 效果,抬起之后也没有触发 click。

解答

解答原理之前,我们先看下 log,再逐一解释。

 // 手指在 A 上按下
 2023-09-12 18:11:25.209 8944-8944/com.ellison.demo D/Touch: ButtonA#onTouchEvent() event:MotionEvent { action=ACTION_DOWN, actionButton=0, id[0]=0, x[0]=109.9668, y[0]=74.92432, toolType[0]=TOOL_TYPE_FINGER, buttonState=0, classification=NONE, metaState=0, flags=0x0, edgeFlags=0x0, pointerCount=1, historySize=0, eventTime=1823125, downTime=1823125, deviceId=8, source=0x5002, displayId=0, eventId=530500549 }
 ​
 // 手指开始向下移动
 2023-09-12 18:11:25.586 8944-8944/com.ellison.demo D/Touch: ButtonA#onTouchEvent() event:MotionEvent { action=ACTION_MOVE, actionButton=0, id[0]=0, x[0]=109.9668, y[0]=78.92334, toolType[0]=TOOL_TYPE_FINGER, buttonState=0, classification=NONE, metaState=0, flags=0x0, edgeFlags=0x0, pointerCount=1, historySize=0, eventTime=1823538, downTime=1823125, deviceId=8, source=0x5002, displayId=0, eventId=348888341 }
 2023-09-12 18:11:25.633 8944-8944/com.ellison.demo D/Touch: ButtonA#onTouchEvent() event:MotionEvent { action=ACTION_MOVE, actionButton=0, id[0]=0, x[0]=109.9668, y[0]=82.92236, toolType[0]=TOOL_TYPE_FINGER, buttonState=0, classification=NONE, metaState=0, flags=0x0, edgeFlags=0x0, pointerCount=1, historySize=0, eventTime=1823591, downTime=1823125, deviceId=8, source=0x5002, displayId=0, eventId=354173977 }
 ...
 2023-09-12 18:11:26.200 8944-8944/com.ellison.demo D/Touch: ButtonA#onTouchEvent() event:MotionEvent { action=ACTION_MOVE, actionButton=0, id[0]=0, x[0]=121.96387, y[0]=155.50244, toolType[0]=TOOL_TYPE_FINGER, buttonState=0, classification=NONE, metaState=0, flags=0x0, edgeFlags=0x0, pointerCount=1, historySize=2, eventTime=1824161, downTime=1823125, deviceId=8, source=0x5002, displayId=0, eventId=195296965 }
 2023-09-12 18:11:26.216 8944-8944/com.ellison.demo D/Touch: ButtonA#onTouchEvent() event:MotionEvent { action=ACTION_MOVE, actionButton=0, id[0]=0, x[0]=121.96387, y[0]=163.84363, toolType[0]=TOOL_TYPE_FINGER, buttonState=0, classification=NONE, metaState=0, flags=0x0, edgeFlags=0x0, pointerCount=1, historySize=2, eventTime=1824177, downTime=1823125, deviceId=8, source=0x5002, displayId=0, eventId=273686682 }
 ​
 // Button 高度为 168px,此刻已开始出界到 B
 2023-09-12 18:11:26.233 8944-8944/com.ellison.demo D/Touch: ButtonA#onTouchEvent() event:MotionEvent { action=ACTION_MOVE, actionButton=0, id[0]=0, x[0]=121.96387, y[0]=174.2472, toolType[0]=TOOL_TYPE_FINGER, buttonState=0, classification=NONE, metaState=0, flags=0x0, edgeFlags=0x0, pointerCount=1, historySize=2, eventTime=1824194, downTime=1823125, deviceId=8, source=0x5002, displayId=0, eventId=758026894 }
 2023-09-12 18:11:26.250 8944-8944/com.ellison.demo D/Touch: ButtonA#onTouchEvent() event:MotionEvent { action=ACTION_MOVE, actionButton=0, id[0]=0, x[0]=121.96387, y[0]=178.18982, toolType[0]=TOOL_TYPE_FINGER, buttonState=0, classification=NONE, metaState=0, flags=0x0, edgeFlags=0x0, pointerCount=1, historySize=1, eventTime=1824211, downTime=1823125, deviceId=8, source=0x5002, displayId=0, eventId=498491454 }
 ...
 2023-09-12 18:11:26.801 8944-8944/com.ellison.demo D/Touch: ButtonA#onTouchEvent() event:MotionEvent { action=ACTION_MOVE, actionButton=0, id[0]=0, x[0]=129.96191, y[0]=266.87744, toolType[0]=TOOL_TYPE_FINGER, buttonState=0, classification=NONE, metaState=0, flags=0x0, edgeFlags=0x0, pointerCount=1, historySize=0, eventTime=1824754, downTime=1823125, deviceId=8, source=0x5002, displayId=0, eventId=936130601 }
 ​
 // 手指开始往上移动
 2023-09-12 18:11:27.484 8944-8944/com.ellison.demo D/Touch: ButtonA#onTouchEvent() event:MotionEvent { action=ACTION_MOVE, actionButton=0, id[0]=0, x[0]=129.96191, y[0]=262.87842, toolType[0]=TOOL_TYPE_FINGER, buttonState=0, classification=NONE, metaState=0, flags=0x0, edgeFlags=0x0, pointerCount=1, historySize=0, eventTime=1825443, downTime=1823125, deviceId=8, source=0x5002, displayId=0, eventId=17662257 }
 ...
 2023-09-12 18:11:27.585 8944-8944/com.ellison.demo D/Touch: ButtonA#onTouchEvent() event:MotionEvent { action=ACTION_MOVE, actionButton=0, id[0]=0, x[0]=137.95996, y[0]=244.88281, toolType[0]=TOOL_TYPE_FINGER, buttonState=0, classification=NONE, metaState=0, flags=0x0, edgeFlags=0x0, pointerCount=1, historySize=2, eventTime=1825541, downTime=1823125, deviceId=8, source=0x5002, displayId=0, eventId=507118427 }
 ...
 2023-09-12 18:11:27.966 8944-8944/com.ellison.demo D/Touch: ButtonA#onTouchEvent() event:MotionEvent { action=ACTION_MOVE, actionButton=0, id[0]=0, x[0]=145.16235, y[0]=175.69556, toolType[0]=TOOL_TYPE_FINGER, buttonState=0, classification=NONE, metaState=0, flags=0x0, edgeFlags=0x0, pointerCount=1, historySize=2, eventTime=1825927, downTime=1823125, deviceId=8, source=0x5002, displayId=0, eventId=876127266 }
 ​
 // Button 高度为 168px,此刻已移动回到 A
 2023-09-12 18:11:27.985 8944-8944/com.ellison.demo D/Touch: ButtonA#onTouchEvent() event:MotionEvent { action=ACTION_MOVE, actionButton=0, id[0]=0, x[0]=145.95801, y[0]=166.91626, toolType[0]=TOOL_TYPE_FINGER, buttonState=0, classification=NONE, metaState=0, flags=0x0, edgeFlags=0x0, pointerCount=1, historySize=2, eventTime=1825944, downTime=1823125, deviceId=8, source=0x5002, displayId=0, eventId=352798882 }
 2023-09-12 18:11:28.000 8944-8944/com.ellison.demo D/Touch: ButtonA#onTouchEvent() event:MotionEvent { action=ACTION_MOVE, actionButton=0, id[0]=0, x[0]=149.15863, y[0]=162.90283, toolType[0]=TOOL_TYPE_FINGER, buttonState=0, classification=NONE, metaState=0, flags=0x0, edgeFlags=0x0, pointerCount=1, historySize=2, eventTime=1825961, downTime=1823125, deviceId=8, source=0x5002, displayId=0, eventId=99105321 }
 ...
 2023-09-12 18:11:28.369 8944-8944/com.ellison.demo D/Touch: ButtonA#onTouchEvent() event:MotionEvent { action=ACTION_MOVE, actionButton=0, id[0]=0, x[0]=161.9541, y[0]=86.92139, toolType[0]=TOOL_TYPE_FINGER, buttonState=0, classification=NONE, metaState=0, flags=0x0, edgeFlags=0x0, pointerCount=1, historySize=0, eventTime=1826312, downTime=1823125, deviceId=8, source=0x5002, displayId=0, eventId=764248821 }
 2023-09-12 18:11:28.722 8944-8944/com.ellison.demo D/Touch: ButtonA#onTouchEvent() event:MotionEvent { action=ACTION_MOVE, actionButton=0, id[0]=0, x[0]=161.9541, y[0]=90.92041, toolType[0]=TOOL_TYPE_FINGER, buttonState=0, classification=NONE, metaState=0, flags=0x0, edgeFlags=0x0, pointerCount=1, historySize=0, eventTime=1826673, downTime=1823125, deviceId=8, source=0x5002, displayId=0, eventId=197617005 }
 ​
 // 手指从 A 上抬起
 2023-09-12 18:11:28.947 8944-8944/com.ellison.demo D/Touch: ButtonA#onTouchEvent() event:MotionEvent { action=ACTION_UP, actionButton=0, id[0]=0, x[0]=161.9541, y[0]=90.92041, toolType[0]=TOOL_TYPE_FINGER, buttonState=0, classification=NONE, metaState=0, flags=0x0, edgeFlags=0x0, pointerCount=1, historySize=0, eventTime=1826912, downTime=1823125, deviceId=8, source=0x5002, displayId=0, eventId=250168391 }

1. 平移到 B,A 会发生什么?

A 的 pressed 效果会被重置。

以往大家会直观地以为这是 ViewGroup 发送 ACTION_CANCEL 给 ButtonA 造成了的。

但观察 log 你会发现,即便出界了,ACTION_MOVE 始终发给了 ButtonA。同时,随着手指的不断向下移动,ACTION_MOVE 的 y 相对坐标不断增大,当该 y 数值超过了 mBottom - mTop 的高度差的时候,Button 的父亲 View 的 onTouchEvent() 会基于其离开了 View 边界调用 setPressed(false) 去刷新 View 的 Press 状态,继而促使 ButtonA 的按下状态消失了。

 public class View ... {
     ...
     public boolean onTouchEvent(MotionEvent event) {
         ...
         if (clickable || (viewFlags & TOOLTIP) == TOOLTIP) {
             switch (action) {
                 ...
                 case MotionEvent.ACTION_MOVE:
                     ...
                     // Be lenient about moving outside of buttons
                     if (!pointInView(x, y, touchSlop)) {
                         ...
                         if ((mPrivateFlags & PFLAG_PRESSED) != 0) {
                             setPressed(false);
                         }
                         mPrivateFlags3 &= ~PFLAG3_FINGER_DOWN;
                     }
                     ...
                     break;
             }
 ​
             return true;
         }
 ​
         return false;
     }
 ​
     /*package*/ final boolean pointInView(float localX, float localY) {
         return pointInView(localX, localY, 0);
     }
 ​
     public boolean pointInView(float localX, float localY, float slop) {
         return localX >= -slop && localY >= -slop && localX < ((mRight - mLeft) + slop) &&
                 localY < ((mBottom - mTop) + slop);
     }
     ...
 }

2. B 会发生什么?为什么?

B 没有任何反应。

其实,解答问题 1 时已经侧面解答了 B 没有反应的直接原因:ButtonB 没有收到任何 TouchEvent。

那为什么即便手指移动到了 B 区域,系统仍不发送事件过去呢?

Button 的父布局 ViewGroup 在分发 ACTION_DOWN 的时候,通过 addTouchTarget() 将处理 DOWN 事件的 child 赋值到 mFirstTouchTarget。后续来了 ACTION_MOVE 的时候,发现 mFirstTouchTarget 已存在,就将后续事件通过 dispatchTransformedTouchEvent() 继续发给该 TouchTarget

源码中的注释也体现了这点:

Dispatch to touch targets, excluding the new touch target if we already dispatched to it.

 public abstract class ViewGroup extends View implements ViewParent, ViewManager {
     ...
     @Override
     public boolean dispatchTouchEvent(MotionEvent ev) {
         ...
         boolean handled = false;
         if (onFilterTouchEventForSecurity(ev)) {
             ...
             if (!canceled && !intercepted) {
                 ...
                 if (actionMasked == MotionEvent.ACTION_DOWN ...) {
                     ...
 ​
                     final int childrenCount = mChildrenCount;
                     if (newTouchTarget == null && childrenCount != 0) {
                         ...
                         final View[] children = mChildren;
                         for (int i = childrenCount - 1; i >= 0; i--) {
                             ...
                             resetCancelNextUpFlag(child);
                             if (dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign)) {
                                 // Child wants to receive touch within its bounds.
                                 mLastTouchDownTime = ev.getDownTime();
                                 if (preorderedList != null) {
                                     // childIndex points into presorted list, find original index
                                     for (int j = 0; j < childrenCount; j++) {
                                         if (children[childIndex] == mChildren[j]) {
                                             mLastTouchDownIndex = j;
                                             break;
                                         }
                                     }
                                 } else {
                                     mLastTouchDownIndex = childIndex;
                                 }
                                 mLastTouchDownX = ev.getX();
                                 mLastTouchDownY = ev.getY();
                                 newTouchTarget = addTouchTarget(child, idBitsToAssign);
                                 alreadyDispatchedToNewTouchTarget = true;
                                 break;
                             }
                             ...
                         }
                         ...
                     }
                     ...
                 }
             }
 ​
             if (mFirstTouchTarget == null) {
                 handled = dispatchTransformedTouchEvent(ev, canceled, null,
                         TouchTarget.ALL_POINTER_IDS);
             } else {
                 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;
                         if (dispatchTransformedTouchEvent(ev, cancelChild,
                                 target.child, target.pointerIdBits)) {
                             handled = true;
                         }
                         ...
                     }
                     predecessor = target;
                     target = next;
                 }
             }
             ...
         }
         ...
         return handled;
     }
     ...
 }

3. B 平移回 A 后,又会发生什么?

A 也不再有任何反应。

Button 的父亲 View 只在接受到 ACTION_DOWN 的时候能够调用 setPressed() 展示 pressed 效果。所以即便手指回到了 A 区域也不会触发按下 UI 的变化。

 public class View ... {
     ...
     public boolean onTouchEvent(MotionEvent event) {
         ...
         if (clickable || (viewFlags & TOOLTIP) == TOOLTIP) {
             switch (action) {
                 case MotionEvent.ACTION_UP:
                     mPrivateFlags3 &= ~PFLAG3_FINGER_DOWN;
                     if ((viewFlags & TOOLTIP) == TOOLTIP) {
                         handleTooltipUp();
                     }
                     if (!clickable) {
                         removeTapCallback();
                         removeLongPressCallback();
                         mInContextButtonPress = false;
                         mHasPerformedLongPress = false;
                         mIgnoreNextUpEvent = false;
                         break;
                     }
                     boolean prepressed = (mPrivateFlags & PFLAG_PREPRESSED) != 0;
                     if ((mPrivateFlags & PFLAG_PRESSED) != 0 || prepressed) {
                         // take focus if we don't have it already and we should in
                         // touch mode.
                         boolean focusTaken = false;
                         if (isFocusable() && isFocusableInTouchMode() && !isFocused()) {
                             focusTaken = requestFocus();
                         }
 ​
                         if (prepressed) {
                             // The button is being released before we actually
                             // showed it as pressed.  Make it show the pressed
                             // state now (before scheduling the click) to ensure
                             // the user sees it.
                             setPressed(true, x, y);
                         }
 ​
                         if (!mHasPerformedLongPress && !mIgnoreNextUpEvent) {
                             // This is a tap, so remove the longpress check
                             removeLongPressCallback();
 ​
                             // Only perform take click actions if we were in the pressed state
                             if (!focusTaken) {
                                 // Use a Runnable and post this rather than calling
                                 // performClick directly. This lets other visual state
                                 // of the view update before click actions start.
                                 if (mPerformClick == null) {
                                     mPerformClick = new PerformClick();
                                 }
                                 if (!post(mPerformClick)) {
                                     performClickInternal();
                                 }
                             }
                         }
 ​
                         if (mUnsetPressedState == null) {
                             mUnsetPressedState = new UnsetPressedState();
                         }
 ​
                         if (prepressed) {
                             postDelayed(mUnsetPressedState,
                                     ViewConfiguration.getPressedStateDuration());
                         } else if (!post(mUnsetPressedState)) {
                             // If the post failed, unpress right now
                             mUnsetPressedState.run();
                         }
 ​
                         removeTapCallback();
                     }
                     mIgnoreNextUpEvent = false;
                     break;         
                 case MotionEvent.ACTION_DOWN:
                     ...
                     if (isInScrollingContainer) {
                         mPrivateFlags |= PFLAG_PREPRESSED;
                         if (mPendingCheckForTap == null) {
                             mPendingCheckForTap = new CheckForTap();
                         }
                         mPendingCheckForTap.x = event.getX();
                         mPendingCheckForTap.y = event.getY();
                         postDelayed(mPendingCheckForTap, ViewConfiguration.getTapTimeout());
                     } else {
                         // Not inside a scrolling container, so show the feedback right away
                         setPressed(true, x, y);
                         checkForLongClick(
                                 ViewConfiguration.getLongPressTimeout(),
                                 x,
                                 y,
                                 TOUCH_GESTURE_CLASSIFIED__CLASSIFICATION__LONG_PRESS);
                     }
                 ...
             }
 ​
             return true;
         }
 ​
         return false;
     }
 ​
     /*package*/ final boolean pointInView(float localX, float localY) {
         return pointInView(localX, localY, 0);
     }
 ​
     public boolean pointInView(float localX, float localY, float slop) {
         return localX >= -slop && localY >= -slop && localX < ((mRight - mLeft) + slop) &&
                 localY < ((mBottom - mTop) + slop);
     }
     ...
 }

4. A 会触发点击吗?为什么?

无法触发点击。

原因很简单,从 A 移走的那刻将执行 performClickRunnable 删除了,继而没有机会触发 click 或 longClick。

 public class View ... {
     ...
     public boolean onTouchEvent(MotionEvent event) {
         ...
         if (clickable || (viewFlags & TOOLTIP) == TOOLTIP) {
             switch (action) {
                 ...
                 case MotionEvent.ACTION_MOVE:
                     ...
                     if (!pointInView(x, y, touchSlop)) {
                         // Outside button
                         // Remove any future long press/tap checks
                         removeTapCallback();
                         removeLongPressCallback();
                         ...
                     }
                     ...
                     break;
             }
 ​
             return true;
         }
 ​
         return false;
     }
     ...
 }

结语

回顾下这 4 个问题的答案和原因。

  1. Button A 和 B 相邻,手指不抬起、从 A 平移到 B,A 会发生什么?为什么?

    A 的按下效果会消失。

    即便手指移出界了,但 MOVE 事件仍然发给了 A,View 发现坐标超过 Button 范围之后重置了 pressed 状态。

  2. 此刻,B 又会发生什么?为什么?

    B 没有任何变化。

    Button A 先收到了 DOWN 事件,导致后续的事件都发给了 A,B 没有收到任何事件,故没有反应。

  3. 之后,手指再从 B 平移回 A 后,又会发生什么?为什么?

    A 也不恢复按下效果。

    View 只在接受到 DOWN 时设置 pressed 状态,即便手指回到了 A,因为没有新的 DOWN 产生,所以无法再次呈现按下效果。

  4. 最后,在 A 上抬起手指,A 会触发点击吗?为什么?

    无法触发 A 的点击。

    手指从 A 出界的那刻将执行 click runnable 一并移除了,后面 UP 的时候没有可以执行的 runnable,故不会执行任何点击、长按点击的回调。

毫无疑问,Android 进行这样的处理是没有问题的。那如果我们想要改变这个逻辑:

  1. 让移动到的目标 Button 呈现 pressed 状态,并在手指抬起的时候响应 click 呢,该怎么实现?

思路也不复杂,简单来说复写 ViewGroupdispatchTouchEvent() 作如下处理即可:

  1. 发现 touchTarget 变更了,向原 target 发送 CANCEL 取消 pressed 效果
  2. 手动 obtain 一个 DOWN event 发送给移动到的 target,进而能使得新 target 能展示 pressed 状态和设置 click runnable
  3. 之后再发送物理上的实际 MOVE 事件给新 target,后面当 UP 的时候因为 DOWN 的时候补充了 runnable,确保 up 时可以执行 click

到这里也就讲完了,这 5 个问题你都答对了吗? 希望本文能帮你加深 Touch 处理的理解。

Android 学习笔录

Android 性能优化篇:https://qr18.cn/FVlo89
Android Framework底层原理篇:https://qr18.cn/AQpN4J
Android 车载篇:https://qr18.cn/F05ZCM
Android 逆向安全学习笔记:https://qr18.cn/CQ5TcL
Android 音视频篇:https://qr18.cn/Ei3VPD
Jetpack全家桶篇(内含Compose):https://qr18.cn/A0gajp
OkHttp 源码解析笔记:https://qr18.cn/Cw0pBD
Kotlin 篇:https://qr18.cn/CdjtAF
Gradle 篇:https://qr18.cn/DzrmMB
Flutter 篇:https://qr18.cn/DIvKma
Android 八大知识体:https://qr18.cn/CyxarU
Android 核心笔记:https://qr21.cn/CaZQLo
Android 往年面试题锦:https://qr18.cn/CKV8OZ
2023年最新Android 面试题集:https://qr18.cn/CgxrRy
Android 车载开发岗位面试习题:https://qr18.cn/FTlyCJ
音视频面试题锦:https://qr18.cn/AcV6Ap

你可能感兴趣的:(Android,面试题,移动开发,android,移动开发,安卓,面试)