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表示消费了事件 |
流程是这样的:
- dispatchTouchEvent 被调用
- 它先问 onInterceptTouchEvent:你要不要拦截?
- 如果不拦截,就遍历子View,找到触摸点所在的子View
- 把事件传给子View的 dispatchTouchEvent
- 如果子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流程图:触摸事件分发核心路径
实战:解决滑动冲突
滑动冲突是事件分发最经典的应用场景。比如一个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