16、触摸事件分发机制:从InputReader到InputDispatcher,ViewGroup与View的事件处理

触摸事件分发,说白了就是手指点到屏幕上之后,系统怎么把这个「点一下」传递到你的App里,再传递到具体的某个按钮上。很多做Android开发两三年的朋友,遇到滑动冲突就头大,其实就是这块没吃透。

我个人习惯把事件分发分成两个大阶段来看:系统层应用层。系统层负责把硬件信号变成MotionEvent对象,应用层负责把这个对象交给合适的View去处理。今天我们就从InputReader开始,一路走到ViewGroup的onInterceptTouchEvent。

系统层:InputReader与InputDispatcher

先聊聊系统层。你按下屏幕,硬件产生中断,Linux内核把这个中断包装成一个原始事件,扔给InputReader。InputReader是啥?它跑在InputReader线程里,负责从设备节点读取原始输入数据。

核心流程:

  • InputReader 读取原始事件
  • InputReader 将原始事件加工成KeyEvent或MotionEvent
  • InputReader 把事件交给InputDispatcher
  • InputDispatcher 找到目标窗口,把事件发过去

我记得有一次排查一个触摸延迟问题,发现是InputReader线程被某个驱动阻塞了。嗯,这种问题很难定位,因为系统层的东西你没法直接打断点。后来我是在systrace里看到InputReader的dispatchNormal事件耗时异常,才顺藤摸瓜找到的。

InputDispatcher拿到事件后,会做一件很重要的事——找到焦点窗口。它维护了一个窗口列表,根据触摸坐标命中哪个窗口,就把事件发过去。这里有个细节:如果窗口是分层的,比如有个Dialog盖在Activity上面,InputDispatcher会把事件发给最上层的窗口。

应用层:ViewRootImpl到DecorView

事件到了应用层,首先经过ViewRootImpl。ViewRootImpl内部有个叫ViewPostImeInputStage的玩意儿,它负责把事件分发给DecorView。DecorView是啥?就是PhoneWindow的顶层View,你的Activity的setContentView设置的内容,最终都挂在它下面。

这里有个我踩过的坑:DecorView的宽高在onCreate里是0。为什么?因为ViewRootImpl还没完成measure和layout。如果你在onCreate里通过getWidth获取DecorView的尺寸,拿到的就是0。我曾经因为这个bug排查了一下午,最后发现是事件分发的坐标计算出了问题——因为DecorView的尺寸不对,导致触摸点映射到子View时偏移了。

ViewGroup的事件分发三兄弟

好,事件到了ViewGroup。ViewGroup处理事件有三个核心方法,我管它们叫「三兄弟」:

方法 作用 返回值含义
dispatchTouchEvent 事件分发入口 true表示消费了事件
onInterceptTouchEvent 是否拦截事件 true表示拦截,不往下传
onTouchEvent 处理事件 true表示消费了事件

流程是这样的:

  1. dispatchTouchEvent 被调用
  2. 它先问 onInterceptTouchEvent:你要不要拦截?
  3. 如果不拦截,就遍历子View,找到触摸点所在的子View
  4. 把事件传给子View的 dispatchTouchEvent
  5. 如果子View不消费,或者没有子View命中,就调用自己的 onTouchEvent

你想想看,这个流程是不是很像一个「层层下放」的机制?上级先问下级要不要处理,下级不处理就自己处理。

避坑指南:我曾经在自定义ViewGroup里重写了onInterceptTouchEvent,返回true想拦截所有事件。结果发现子View的onClick再也触发不了了。为什么?因为一旦拦截,子View就收不到ACTION_UP事件,自然无法触发点击。正确的做法是只在ACTION_MOVE时拦截,ACTION_DOWN时不要拦截。

View的事件处理

View的事件处理相对简单。它没有onInterceptTouchEvent,只有dispatchTouchEvent和onTouchEvent。dispatchTouchEvent内部会先检查有没有设置OnTouchListener,如果有就调用它。如果OnTouchListener返回true,事件就被消费了,onTouchEvent就不会被调用。

这里有个优先级问题:

  • OnTouchListener 优先级最高
  • 其次是 onTouchEvent
  • 最后是 OnClickListener(在onTouchEvent里触发的)

我记得有一次,一个同事在ListView的Item上设置了OnTouchListener,返回了true,结果Item的点击事件死活不触发。他查了半天,最后发现是OnTouchListener把事件消费了,根本没传到onTouchEvent里去。

事件序列与ACTION_CANCEL

一个完整的手指操作,从按下到抬起,是一个事件序列。比如:ACTION_DOWN -> ACTION_MOVE -> ACTION_MOVE -> ... -> ACTION_UP。系统保证同一个事件序列会发给同一个View,除非中间发生了拦截。

如果父View在事件序列中间突然决定拦截,比如在ACTION_MOVE时返回true,那么子View会收到一个ACTION_CANCEL。这个ACTION_CANCEL就是告诉子View:「你别再处理了,这个事件我要接管了」。

注意:ACTION_CANCEL不是用户主动触发的,是系统或父View主动发出的。如果你在自定义View里处理了ACTION_DOWN和ACTION_MOVE,但没处理ACTION_CANCEL,可能会导致状态不一致。比如一个按钮按下去变蓝了,结果父View拦截了事件,按钮收到了ACTION_CANCEL,但你没重置颜色,按钮就一直蓝着。

SVG流程图:触摸事件分发核心路径

触摸事件分发核心路径 系统层 InputReader → InputDispatcher → ViewRootImpl ViewGroup.dispatchTouchEvent() 先调用 onInterceptTouchEvent() 判断是否拦截 不拦截 → 遍历子View,找到触摸点所在的子View 拦截 不拦截 调用自己的 onTouchEvent() 如果子View之前在处理,发送ACTION_CANCEL 子View.dispatchTouchEvent() 子View可能是View或ViewGroup 子View消费?→ 结束 子View未消费?→ 父View.onTouchEvent() 返回结果

实战:解决滑动冲突

滑动冲突是事件分发最经典的应用场景。比如一个ScrollView里嵌套了一个ViewPager,左右滑动和上下滑动就会打架。解决思路无非两种:

  • 外部拦截法:父View在onInterceptTouchEvent里根据滑动方向决定是否拦截
  • 内部拦截法:子View通过requestDisallowInterceptTouchEvent阻止父View拦截

我个人更推荐外部拦截法,因为逻辑集中,好维护。内部拦截法需要子View和父View配合,容易出问题。

外部拦截法核心代码:

@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
    int action = ev.getActionMasked();
    if (action == MotionEvent.ACTION_DOWN) {
        // 记录初始坐标
        mLastX = ev.getX();
        mLastY = ev.getY();
        // 不要拦截ACTION_DOWN,否则子View收不到事件
        return false;
    }
    if (action == MotionEvent.ACTION_MOVE) {
        float dx = Math.abs(ev.getX() - mLastX);
        float dy = Math.abs(ev.getY() - mLastY);
        // 如果水平滑动距离大于垂直滑动,就拦截
        if (dx > dy && dx > mTouchSlop) {
            return true;
        }
    }
    return false;
}

这里有个细节:ACTION_DOWN时不要拦截。为什么?因为如果ACTION_DOWN被拦截了,子View就收不到任何事件了,包括后续的ACTION_UP。你想想看,一个按钮连ACTION_DOWN都收不到,它怎么知道自己被按下了?

总结

触摸事件分发,说白了就是一套「谁该处理这个事件」的决策机制。系统层负责把硬件信号变成MotionEvent,应用层负责把这个事件交给合适的View。ViewGroup通过dispatchTouchEvent、onInterceptTouchEvent、onTouchEvent三个方法协作完成分发。

嗯,这块内容确实有点绕,但只要你理解了「事件序列」和「拦截与消费」这两个核心概念,大部分问题都能迎刃而解。我在项目中遇到过很多次因为事件分发导致的bug,最后发现都是对ACTION_CANCEL的处理不到位,或者拦截时机不对。希望今天的分享能帮你少踩一些坑。


公众号:蓝海资料掘金营,微信deep3321