触摸事件的分发机制
事件从哪里来,到哪里去
每次手指触碰屏幕,底层驱动都会产生一个 MotionEvent 对象。这个对象需要被送到"关心它的那个 View"——但屏幕上可能叠了十几层 View,系统怎么知道该给谁?
这就是事件分发机制要回答的问题:一次触摸,沿着什么路径流动,最终被谁处理?
整个流程是一棵树上的"传球游戏":从根往叶子递,传不出去就往回收。
三个核心方法与它们的关系
理解事件分发,先死记三个方法的职责:
| 方法 | 存在于 | 作用 |
|---|---|---|
dispatchTouchEvent() |
View、ViewGroup | 事件的入口,控制"要不要把事件继续分发" |
onInterceptTouchEvent() |
仅 ViewGroup | 决定"要不要把事件拦截下来自己处理" |
onTouchEvent() |
View、ViewGroup | 真正处理事件的地方,返回是否消费 |
三者的关系用伪代码描述:
// ViewGroup 的 dispatchTouchEvent 伪代码(简化)
fun dispatchTouchEvent(ev: MotionEvent): Boolean {
var consumed = false
// ① 先问自己:要拦截吗?
val intercepted = onInterceptTouchEvent(ev)
if (!intercepted) {
// ② 不拦截 → 找合适的子 View 递送
for (child in children.reversed()) {
if (child 在触摸范围内) {
consumed = child.dispatchTouchEvent(ev)
if (consumed) break // 有人处理了,停止遍历
}
}
}
// ③ 子 View 没处理 或 自己拦截了 → 自己来处理
if (!consumed) {
consumed = onTouchEvent(ev)
}
return consumed
}
关键结论:dispatchTouchEvent 是总指挥,它要么把事件往下传(onInterceptTouchEvent 返回 false),要么截住自己处理(onInterceptTouchEvent 返回 true),最后兜底的是 onTouchEvent。
完整的事件传递路径
一次完整的手指按下事件:
屏幕驱动产生 MotionEvent(ACTION_DOWN)
│
▼
ViewRootImpl.dispatchInputEvent()
│
▼
DecorView.dispatchTouchEvent() ← Window 的根 View
│
▼
Activity.dispatchTouchEvent() ← Activity 也可以拦截!
│
▼
PhoneWindow.superDispatchTouchEvent()
│
▼
DecorView(作为 ViewGroup)
└── onInterceptTouchEvent()
│ 不拦截
▼
子 ViewGroup(如 LinearLayout)
└── onInterceptTouchEvent()
│ 不拦截
▼
目标 View(如 Button)
└── onTouchEvent() ← 消费,返回 true
← 结果沿原路回传 true
注意:Activity 的 dispatchTouchEvent() 在最东层,如果整棵树都没人消费(全部返回 false),事件会最终回到 Activity.onTouchEvent()。
ACTION_DOWN 是所有事件的"门票"
触摸事件由一个序列构成:ACTION_DOWN → ACTION_MOVE × N → ACTION_UP。
核心规则:如果一个 View 在 ACTION_DOWN 时返回 false(不消费),那么它就不会再收到这个序列的后续事件(MOVE、UP)。
这个规则的实现依赖 mFirstTouchTarget:
ACTION_DOWN 被 View 消费 → ViewGroup 记录 mFirstTouchTarget 指向该 View
后续 ACTION_MOVE / ACTION_UP → ViewGroup 直接交给 mFirstTouchTarget,不再重新寻找
所以 ACTION_DOWN 是整个事件序列的"门票",不接 DOWN,就不参与后续。
拦截的时机与坑
onInterceptTouchEvent 只在 ViewGroup 里有,普通 View 没有(无从拦截)。
拦截后的行为
ViewGroup 一旦决定拦截(返回 true):
- 本次事件传给自己的
onTouchEvent() - 后续事件不会再问子 View,直接在
onInterceptTouchEvent返回 true(不过通常后续事件已经不走onInterceptTouchEvent了)
被拦截的子 View 会收到一个 ACTION_CANCEL 事件——这是"通知对方放弃"的信号。子 View 收到 CANCEL 后应该重置状态(例如,取消按下高亮效果)。
最常见的拦截场景:ScrollView 与 Button 的冲突
ScrollView 在 y 方向滑动超过阈值后,会拦截 ACTION_MOVE,这导致子 View 的点击会被误判为滑动。
这类冲突的经典解法是"内部拦截法",让子 View 在需要时调用 requestDisallowInterceptTouchEvent(true),告诉父 View"别拦截我":
// 子 View(如 ViewPager)的 dispatchTouchEvent
override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
when (ev.action) {
MotionEvent.ACTION_DOWN -> {
// 告诉父 View 不要拦截
parent.requestDisallowInterceptTouchEvent(true)
}
MotionEvent.ACTION_MOVE -> {
if (isHorizontalScroll) {
// 水平滑动,继续禁止父 View 拦截
parent.requestDisallowInterceptTouchEvent(true)
} else {
// 垂直滑动,允许父 View(ScrollView)拦截
parent.requestDisallowInterceptTouchEvent(false)
}
}
}
return super.dispatchTouchEvent(ev)
}
requestDisallowInterceptTouchEvent(true) 内部会在父 View 的 mGroupFlags 上设置 FLAG_DISALLOW_INTERCEPT 标志位,只要这个标志存在,父 View 的 onInterceptTouchEvent 就不会被调用。
onTouchListener 和 OnClickListener 的优先级
View 上能注册的事件回调有三层,优先级从高到低:
1. onTouchListener.onTouch() ← 最优先,返回 true 则后面都不走
2. onTouchEvent() ← View 自己的逻辑(内部处理 CLICK 等)
3. onClickListener.onClick() ← 在 onTouchEvent 处理 UP 事件时触发
onClick 是在 onTouchEvent 的 ACTION_UP 分支里调用的——它不是一个独立的事件,而是 onTouchEvent 解析出来的"语义"(按下 + 没超时 + 没移动 = 点击)。
MotionEvent 的核心字段
分析事件不能只看 action,以下字段是实际开发中最常用到的:
ev.action // 事件类型:DOWN / MOVE / UP / CANCEL / POINTER_DOWN 等
ev.x, ev.y // 相对当前 View 左上角的坐标
ev.rawX, ev.rawY // 相对屏幕左上角的绝对坐标
ev.pointerCount // 同时触摸的手指数量(多点触控)
ev.getX(i) // 第 i 个手指相对当前 View 的 x 坐标
ev.pressure // 压力值(0f - 1f,支持压感屏)
ev.eventTime // 事件发生时间(毫秒,从系统启动开始计)
ev.downTime // ACTION_DOWN 发生的时间(用于判断长按)
多点触控中 action 包含了 pointer index,需要用 mask 分离:
val action = ev.actionMasked // 纯事件类型(去掉 pointer index)
val index = ev.actionIndex // 发生事件的那个手指的 index
val id = ev.getPointerId(index) // 手指的稳定 ID(index 可能变,ID 不变)
滑动冲突的三种解决套路
场景一:父垂直,子水平(最常见)
典型案例:外层 ScrollView 包内层水平 ViewPager。
外部拦截法(在父 View 的 onInterceptTouchEvent 里判断方向):
// 父 ViewGroup(垂直 ScrollView)
override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
when (ev.action) {
MotionEvent.ACTION_DOWN -> {
lastX = ev.x; lastY = ev.y
return false // DOWN 绝对不拦截,否则子 View 收不到
}
MotionEvent.ACTION_MOVE -> {
val dx = abs(ev.x - lastX)
val dy = abs(ev.y - lastY)
// 纵向滑动幅度大于横向 → 父 View 处理(拦截)
return dy > dx
}
else -> return false
}
}
场景二:父子方向相同,语义不同
典型案例:外 RecyclerView 内嵌 RecyclerView(两者都是垂直滑动)。
这种场景没有通用解,需要根据业务逻辑判断"此刻应该由谁滚动",通常配合 NestedScrolling 机制处理(NestedScrollingChild + NestedScrollingParent)。
场景三:多层嵌套,层级超过两层
超过两层的嵌套通常意味着设计有问题——考虑用 CoordinatorLayout 配合 Behavior 来协调多个组件的滑动行为,这是 Material Design 提供的声明式解法。
事件分发在性能上的影响
每一次 ACTION_MOVE 都要走一遍 dispatchTouchEvent → onInterceptTouchEvent 的链路。如果视图层级很深(例如 15 层嵌套),移动事件可能每帧触发 60 次,每次都要遍历 15 个节点,这是可观的 CPU 开销。
优化方向:
- 减少 View 层级(使用
ConstraintLayout扁平化布局) onInterceptTouchEvent内避免复杂计算- 如果子 View 本身不需要点击/滑动处理,设置
android:clickable="false"让事件直接跳过它
一道经典架构问题的完整推导
"ViewGroup 的 A 包含 View 的 B,点击 B,事件传递顺序是什么?"
手指按下:
A.dispatchTouchEvent(DOWN)
→ A.onInterceptTouchEvent(DOWN) → 返回 false(不拦截)
→ B.dispatchTouchEvent(DOWN)
→ B.onTouchEvent(DOWN) → 返回 true(消费,B 有 clickListener)
手指抬起:
A.dispatchTouchEvent(UP)
→ A.onInterceptTouchEvent(UP) → 不调用(有 mFirstTouchTarget 指向 B)
→ B.dispatchTouchEvent(UP)
→ B.onTouchEvent(UP) → 触发 onClick,返回 true
最终:B.onClick() 被调用
"如果 A 在 ACTION_MOVE 时拦截,B 会发生什么?"
B 会收到一个 ACTION_CANCEL,然后此次事件序列的后续事件都由 A 的 onTouchEvent 处理。