事件分发和反馈音间的关系

72 阅读7分钟

遇到的问题非常经典,它完美地揭示了Android触摸事件分发链条(Touch Event Dispatch Chain)与音效反馈(Sound Effect)触发机制之间的微妙关系。

让我们抛开现象看本质。问题的核心不在于TextView本身,而在于谁最终消费(Consume)了这次点击事件


一、核心结论先行

  1. 声音的触发者: 在Android中,播放点击反馈音效的并不是被触摸的View,而是最终成功处理ACTION_UP事件的View。更准确地说,是调用了performClick()方法的View。
  2. RecyclerView的“窃取”行为: 当TextViewA覆盖在RecyclerView之上时,RecyclerView会认为自己是一个可滚动的列表,它可能会出于对滑动操作的预期,而提前“窃取”后续的触摸事件(包括ACTION_UP),从而成为事件的最终消费者,并由它触发了音效。
  3. setSoundEffectsEnabled(false) 为何失效: 这个方法只对当前View自身触发音效有效。但它无法阻止它的父View(比如RecyclerView)在消费事件后播放它自己的音效。
  4. OnTouchListener返回true为何有效: 因为它拦截了事件。它告诉系统:“这个View已经完全处理了这个事件,不要再往下传递了”。事件传递链在此被斩断,RecyclerView根本没有机会接触到ACTION_UP事件,自然也就无法触发音效。

下面,我们结合源码来详细论证这个逻辑。


二、源码深度剖析

1. 音效播放的源头:performClick()

一切的开端都在View.javaperformClick()方法中。这是官方点击事件处理的入口。

// View.java
public boolean performClick() {
    // ... 发送Accessibility事件等 ...

    boolean result = false;
    final ListenerInfo li = mListenerInfo;
    if (li != null && li.mOnClickListener != null) {
        playSoundEffect(SoundEffectConstants.CLICK);
        li.mOnClickListener.onClick(this);
        result = true;
    } else {
        result = false;
    }
    // ... 其他处理 ...
    return result;
}

看,这里非常清晰:只要执行到了performClick(),并且设置了OnClickListener,就一定会调用playSoundEffect(SoundEffectConstants.CLICK)来播放音效

所以,我们的问题就转化为:为什么重叠的TextViewA的点击,会导致RecyclerView去执行performClick()

2. 事件分发的路径:dispatchTouchEvent

触摸事件(MotionEvent)会从Activity -> Window -> DecorView -> ... 一直向下传递,最终到达我们的TextViewRecyclerView。这个过程由dispatchTouchEvent()方法控制。

关键在于ViewGroupRecyclerView的父类)在分发事件给子View时的逻辑。它可能会提前干预。

3. RecyclerView的“拦截”:onInterceptTouchEvent

RecyclerView继承自ViewGroup,它重写了onInterceptTouchEvent(MotionEvent e)方法。这个方法决定RecyclerView是否要从它的子View手中“抢夺”事件。

RecyclerView的内部有一个关键组件叫TouchListener(具体是RecyclerViewTouchListener),它负责处理滚动和点击。在onInterceptTouchEvent中,RecyclerView会判断用户的操作意图是点击还是滑动

重点来了:  为了流畅的滚动体验,RecyclerView(以及它的父类AbsListView等)有一套预先抢占(Preemptive Taking)  的机制:

  1. 当第一个ACTION_DOWN事件到来时,RecyclerView会先把它分发给子View(也就是你的TextViewA)。
  2. 但同时,RecyclerView会启动一个滑动判断。如果它检测到后续的移动事件(ACTION_MOVE)哪怕只有一丁点的距离,它就会认为用户可能是想滚动列表。
  3. 一旦它认为这是滚动操作,它就会在onInterceptTouchEvent返回true。这意味着:“从现在开始,所有后续事件(ACTION_MOVEACTION_UP)都归我RecyclerView处理,不再分发给子View了”。

在你的场景中发生了什么:

  • 你的手指按下(ACTION_DOWN)在TextViewA上。
  • RecyclerViewACTION_DOWN事件正常分发给TextViewA
  • RecyclerView同时开始监测滑动。
  • 由于人体操作的不精确性,从ACTION_DOWNACTION_UP之间,手指可能产生了极其微小(甚至一像素)的移动。这个移动足以被RecyclerView的滑动阈值检测到。
  • RecyclerView判定:“用户可能想滚动”,于是它拦截了接下来的ACTION_UP事件。
  • ACTION_UP事件不再传递给你的TextViewA,而是直接交给了RecyclerViewonTouchEvent()方法处理。

4. RecyclerView如何处理ACTION_UP

现在,ACTION_UP事件到了RecyclerView.onTouchEvent(MotionEvent e)方法中。

// RecyclerView.java
@Override
public boolean onTouchEvent(MotionEvent e) {
    // ... 大量关于滚动、拖拽的判断逻辑 ...

    switch (action) {
        case MotionEvent.ACTION_UP: {
            // ... 在UP事件里,会做一些清理工作,并可能触发点击操作 ...
            if (mScrollState == SCROLL_STATE_DRAGGING) {
                // 如果是滚动状态,则停止滚动
                final VelocityTracker velocityTracker = mVelocityTracker;
                // ... 计算速度等 ...
            } else if (mScrollState == SCROLL_STATE_SETTLING) {
                // ... 处理惯性滑动 ...
            } else {
                // !!!关键点 !!!
                // 如果不是在滚动,也不是在惯性滑动,它可能会尝试触发一个点击事件
                // 例如,如果它之前记录了一个按下状态的项目,现在UP了,就会触发那个项目的点击
                // 但对于一个空的或者说没有处理好的RecyclerView,它可能会fallback到自己的处理
                if (/* ... 一些条件判断 ... */) {
                    // 可能会调用performClick()
                    performClick();
                }
            }
            resetTouch();
        } break;
        // ... 其他case ...
    }
    // ... 
    return true; // 通常消费事件会返回true
}

虽然这里的逻辑很复杂,但简化理解是:当RecyclerView消费了ACTION_UP事件,并且它自身处于一个“可点击”的状态(即使它没有设置点击监听器),它仍然有可能去调用自身的performClick()方法

一旦RecyclerView.performClick()被调用,就会走到我们第一部分看到的源码:

public boolean performClick() {
    // ... 如果RecyclerView自己设置了mOnClickListener,就会触发...
    if (li != null && li.mOnClickListener != null) {
        playSoundEffect(SoundEffectConstants.CLICK); // 播放音效!
        li.mOnClickListener.onClick(this);
        result = true;
    }
    // ...
}

即使RecyclerView自己没有设置OnClickListenerli.mOnClickListener为null,不会播放音效?但请注意!

还有一个隐藏机制:ViewmViewFlags中有一个PFLAG_SOUND_EFFECTS_ENABLED标志位。  即使没有OnClickListener,如果RecyclerViewisSoundEffectsEnabled()返回true,并且系统认为这是一个有效的点击(比如通过Accessibility服务或者其他内部逻辑),它仍然有可能播放音效RecyclerView默认很可能是启用音效的。

5. 为什么你的解决方案有效?

  • TextViewA.setSoundEffectsEnabled(false)

    • 这个方法只设置TextViewA自己的PFLAG_SOUND_EFFECTS_ENABLED标志位。它只影响TextViewA.playSoundEffect()的行为。
    • 但现在最终播放音效的是RecyclerView,不是TextViewA。所以这个设置无效。你需要设置的是RecyclerView.setSoundEffectsEnabled(false)
  • TextViewA.setOnTouchListener((v, event) -> true)

    • OnTouchListener的优先级非常高。如果它返回true,表示这个View已经消费了这个事件

    • View.dispatchTouchEvent()中,有如下逻辑:

      ListenerInfo li = mListenerInfo;
      if (li != null && li.mOnTouchListener != null
              && (mViewFlags & ENABLED_MASK) == ENABLED
              && li.mOnTouchListener.onTouch(this, event)) {
          // !!!如果OnTouchListener返回true,事件处理就此结束!
          result = true; 
      }
      if (!result && onTouchEvent(event)) { // 否则,才会调用onTouchEvent
          result = true;
      }
      
    • 当你返回trueTextViewAdispatchTouchEvent会直接返回true,表示事件已消费。

    • 整个事件传递链在此中断,ACTION_UP事件根本到不了RecyclerViewonInterceptTouchEventonTouchEvent方法。RecyclerView完全没有机会接触到这个事件,自然也就无法触发它自己的音效。而TextViewA虽然消费了事件,但因为它的OnTouchListener只是简单返回了true,并没有触发performClick()的流程,所以也不会播放声音。


三、总结与通俗比喻

让我们用一个比喻来彻底理解它:

  • 舞台(View Hierarchy) : 你的界面是一个舞台。
  • 演员A(TextViewA) : 站在前排。
  • 演员B(TextViewB) : 站在舞台边缘。
  • 后台机关(RecyclerView) : 演员A脚下有一个巨大的后台机关(可以滑动)。
  • 音效师(System) : 在后台待命。

场景1:点击不与机关重叠的TextViewB

  • 你戳了一下演员B。
  • 演员B自己做出了反应(performClick),并告诉音效师:“播一下点击音效”。
  • 结果: 声音来自演员B。

场景2:点击与机关重叠的TextViewA

  • 你戳了一下演员A。
  • 演员A感觉被戳了(收到ACTION_DOWN)。
  • 但同时,它脚下的机关也被触发了。机关心想:“嗯?有人碰我,是想让我滑动吗?”。
  • 由于你的手指抬起时可能有微小晃动,机关断定:“是的,他是想滑动!”。
  • 于是,当你手指抬起(ACTION_UP)时,机关猛地运作了一下。机关运作完毕后,自己发出了“咔嚓”一声(performClickplaySoundEffect)。
  • 演员A虽然一开始被戳了,但最后的“抬起”动作被机关抢去了,所以演员A自己没来得及发出声音。
  • 结果: 声音来自机关(RecyclerView),而不是演员A(TextViewA)。

你的操作:

  • TextViewA.setSoundEffectsEnabled(false): 你只是堵住了演员A的嘴,但机关的嘴没堵住,机关照样会叫。
  • TextViewA.setOnTouchListener((v, event) -> true): 你给演员A下达了死命令:“不管发生什么,只要碰到你,你就必须全部吞下,不准让后台机关知道”。于是机关完全不知道发生了什么事,自然不会发出声音。