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

前语

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,会发生什么?为什么?

依照提问的问题过程开端尝试一下。

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

可以看到手指平移到 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 处理的理解。