Appearance
触摸事件分发:三个方法、滑动冲突与手势抢占
本页导读:从手指按下到你的
onTouchEvent,一个 MotionEvent 要穿过 InputDispatcher、ViewRootImpl 与整棵 ViewGroup 树。本页讲清 down-move-up 的序列语义、dispatch/onIntercept/onTouch 三兄弟的真实契约、两类滑动冲突的标准解法,以及系统手势(返回/桌面)如何抢占你的事件。前置:View 体系、架构篇输入子系统页。
一、事件从哪来:不止是「屏幕触点」
触摸驱动 → EventHub →
InputReader解析出 MotionEvent →InputDispatcher按窗口层序找到目标窗口(input channel每个 ViewRootImpl 一条 socket)→ 应用侧InputEventReceiver收到 →ViewRootImpl.enqueueInputEvent→ 交给 DecorView 树。批量与合并:move 事件会 coalesce(合并)与 batched(批量上报,API 31+
MotionEvent.getHistorical*可取回中间点),省电也省 Binder;这就是快速滑动时你仍觉得「顺滑」的原因。焦点双轨:触摸不需要焦点,键盘/手柄需要
hasWindowFocus;遥控器类 App 的事件是 KEY 通道,别只测触摸。超时即 ANR:输入事件在队列里滞留过久(经典口径 5s,以 AOSP 为准)会产生著名的
Input dispatching timed outANR(ANR 页)。
bash
adb shell getevent -l # 驱动层原始事件流
adb shell dumpsys input # 窗口 input channel 与 focus 状态
adb shell dumpsys input method # IME 侧焦点(输入法收不到事件的案发现场)text
事件管线(极简):
driver → EventHub → InputReader → InputDispatcher
→ 目标窗口的 input channel(每 ViewRootImpl 一条)
→ InputEventReceiver → ViewRootImpl 派发入口 → DecorView 树二、down-move-up:序列的三种结局
一个手势 = ACTION_DOWN + n×ACTION_MOVE/POINTER_* + ACTION_UP/CANCEL。三个必须刻进肌肉记忆的规则:
DOWN 决定归属:DOWN 被谁消费(true),后续整个序列就只发给谁;中间没人想要也不能改嫁。
CANCEL 是「作废章」:被上层拦截转交、窗口失焦、系统手势接管时,你收到 CANCEL——必须复位动画/拖拽状态,漏处理就是「手指抬起来了列表还在滚」。
UP 不再过 intercept:拦截判定只发生在 DOWN/MOVE 阶段;UP 到达时链条早已定型。
ACTION_CANCEL 之后同手势的 UP 不会再发给你:别在 UP 里做「收尾」假设,把 CANCEL 当「提前 UP + 作废标记」处理。
多指:ACTION_POINTER_DOWN/POINTER_UP 携带 pointer index,手势语义(缩放/旋转)自己用 MotionEvent 的 pointer 对算;GestureDetector/GestureDetectorCompat 是官方封装。
kotlin
// 官方检测器仍是最省心的组合拳
val gd = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() {
override fun onFling(e1: MotionEvent?, e2: MotionEvent): Boolean {
e1 ?: return false; // 处理 API 33+ 可空签名
// velocityX/velocityY 来自系统估计,直接用别自算
return true
}
})
view.setOnTouchListener { _, e -> gd.onTouchEvent(e); true }三、三兄弟的真实契约(源码视角)
| 方法 | 所在层 | 职责 | 默认返回 |
|---|---|---|---|
dispatchTouchEvent | ViewGroup/View | 派发器:问拦截、找孩子、兜底转 onTouch | 交给孩子则 true |
onInterceptTouchEvent | 仅 ViewGroup | 岔路口:true=本轮序列改由自己 onTouch 处理 | false |
onTouchEvent | View | 消费者:真正处理点击/长按/滚动 | 可点击控件 true |
java
// frameworks/base/core/java/android/view/ViewGroup.java(dispatchTouchEvent 关键路径,节选大意)
// ACTION_DOWN 时:
// 1. onDispatchTouchEvent → onInterceptTouchEvent(ev)?
// 拦截 → target = this,走 dispatchTransformedTouchEvent(自己 onTouchEvent)
// 不拦截 → 遍历 child(Z 序自顶向下),命中则记录 mFirstTouchTarget
// 2. 无任何 child 消费 → 走 super.dispatchTouchEvent → 自己 onTouchEvent
// MOVE/UP 时:直接投递给已登记的 target;拦截翻转则先发 CANCEL 给旧 target三条源码级结论:
遍历逆 Z 序:浮在上面的(后画的)先拿到事件,不是「谁先注册」;
hit-test 的真实顺序:先按
getChildDrawingOrder(动画重排会影响命中!View.bringToFront有副作用)再逆序;setOnTouchListener优先于onTouchEvent:listener 返回 true 则不再调 onTouchEvent——这是把「点击监听」当「拦截」用翻车的根源;可见性陷阱:
INVISIBLE不参与命中测试但参与布局;GONE两者都不。hit-test 里mPrivateFlags的 PFLAG_DIRTY 家族与canViewReceivePointerEvents是关键判断。
四、滑动冲突:外 intercept 与内 disallow 两条路
经典场景「ViewPager 里嵌横滑列表」「CoordinatorLayout 套垂直长列表」:
方案 A(外部拦截,推荐默认):父容器 DOWN 记录起点,MOVE 超过 touchSlop 且方向属于自己时翻 intercept:
kotlin
class VP(ctx: Context, attrs: AttributeSet?) : ViewGroup(ctx, attrs) {
private val slop = ViewConfiguration.get(ctx).scaledTouchSlop
private var downX = 0f; private var downY = 0f
override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
when (ev.actionMasked) {
ACTION_DOWN -> { downX = ev.x; downY = ev.y }
ACTION_MOVE -> {
val dx = ev.x - downX; val dy = ev.y - downY
if (Math.abs(dx) > slop && Math.abs(dx) > Math.abs(dy))
return true // 横向意图明确,序列归我
}
}
return false
}
}方案 B(内部否决):子 View 在 onTouchEvent 里决定「这事我接了」,并 requestDisallowInterceptTouchEvent(true) 阻止父翻拦截;父在 DOWN 时必须清标志位(系统会帮清,自研容器别忘)。
| 对比项 | 方案 A 父拦截 | 方案 B 子否决 |
|---|---|---|
| 判定视角 | 父观察全局方向 | 子宣告局部意图 |
| 状态复位 | 父负责发 CANCEL 语义 | 子需处理被抢回 |
| 适用 | 父为自研手势容器 | 子为明确互斥控件 |
现代标准答案:嵌套滚动体系(NestedScrolling* 接口族,RecyclerView/CoordinatorLayout 全在用)——父不再「抢」,而是子先消费、剩余 dy/dx 上抛给父继续消费,一次分发双向协商。自研嵌套容器请实现
NestedScrollingParent3(API 29+)而不是再写手写拦截。
两条路线怎么选:
父容器是系统控件(RecyclerView/ViewPager2/CoordinatorLayout):一律走它的既有协商(nested scroll、
requestDisallowInterceptTouchEvent语义),手写 intercept 会破坏无障碍滚动语义;自研容器:优先实现内层先消费、溢出上报的协商协议,而不是简单的「我说了算」拦截。
判定「滑动意图」的黄金三参数:
touchSlop(越过才算动)、slop*2/maximumFlingVelocity(快甩判定)、longPressTimeout(长按取消拖拽)。
五、系统手势抢占:你抢不过,也不该抢
全面屏手势的边缘滑动(返回/回桌面)在 InputDispatcher 之上就有系统级手势监听窗口(SystemUI 的 InputMonitor 区域),你的 App 只有两种结局:拿到完整序列,或收到 CANCEL(被系统接管)。
预测式返回(Android 13~16):返回手势有了进度语义,
OnBackInvokedDispatcher(android.window包,API 33 引入、target 34 默认启用、15 起全面接管)替代onBackPressed——你的退出动画可以与手势进度联营,被中断可回弹。系统栏区域:
WindowInsets(API 30 起setDecorFitsSystemWindows模型)决定触摸落在 inset 区域归系统还是应用;edge-to-edge 应用(Android 15/16 默认形态)要显式处理手势条区域的触摸与视觉避让。show_touches/指针位置(开发者选项)看事件落点;dumpsys input的FocusedWindow行是「点击没反应」类工单的第一现场。结论:不要做「全屏边缘滑动自定义」——必然与系统手势打架,审核与体验双重翻车;需要类似交互用应用内明确的把手/把手区。
inset 三兄弟分清:
systemBars(状态栏/导航栏)、displayCutout(刘海)、mandatorySystemGestures(手势保留区)——触摸避让主要针对最后一项。
六、可访问性与触摸目标:48dp 不是建议
官方无障碍基线:触摸目标 ≥ 48×48dp(
ViewConfiguration的最小尺寸换算),密集小图标的命中区要外扩(TouchDelegate或给父容器加 padding 命中)。touchSlop(典型 8dp 量级,设备各异)、minFlingVelocity/maxFlingVelocity、longPressTimeout全部从ViewConfiguration取,别硬编码;硬编码阈值 = 换设备「手感怪」的根源。TalkBack 开启时部分触摸会转成无障碍导航事件;自绘控件务必补
AccessibilityNodeInfo(role/action),否则「点得动但读不出」。列表项内的按钮与整行点击共存时,优先给容器焦点/激活语义(
android:focusable/AccessibilityAction),别让两个可点击层叠在同一区域互相吞事件。
七、事件调试工具箱
bash
adb shell wm logging enable input # 开启输入事件记录(开发者选项同源)
adb shell input tap 540 960 # 脚本化点击复现
adb shell settings put system show_touches 1 # 触点可视化
adb shell dumpsys input windows | grep -i focus # 谁拿到了焦点窗口八、常见误区问答
Q1:performClick 和 onClickListener 谁先?onTouchEvent 内检测到 UP 且未越 slop → 先 performClick(触发 listener)。你在 onTouchEvent 里自己处理 ACTION_UP 又没调 performClick,listener 就「神秘失效」。
Q2:为什么 ScrollView 里的按钮也能点? 父 intercept 默认 false,DOWN 直接命中按钮;move 超出 slop 后父才翻 intercept,同时给按钮发 CANCEL——一套天然协同,就是 NestedScroll 思想的朴素版。
Q3:android:clickable=true 有什么用? 它同时影响事件响应、可访问性与 StateListDrawable 按压态;自定义 View 想「看起来能点」必须显式 clickable + focusable,否则 TalkBack 跳过。
Q4:MotionEvent 能存起来下一帧用吗? 不能长期持有:对象来自回收池,回调返回即可能复用。要存就 obtain() 拷贝,用完 recycle(API 33+ 回收器管理更严)。
Q5:RecyclerView 的 fling 和我的动画打架? fling 是 Scroller 系的惯性动画(computeScroll 每帧驱动),动画期间再开属性动画必须 cancel 掉 fling/scroll;现代方案用 OverScroller 的 FlingBehavior 统一接管,别再手写 Scroller。
版本注记:基于 Android 16 / API 36;Android 12 差异:批量化历史点 API(getHistorical*)成熟;Android 13~15 差异:预测式返回从可选到默认再到强制(
OnBackInvokedDispatcher);Android 16 差异:edge-to-edge 与手势条避让成为默认形态的一部分。
Q6:dispatchTouchEvent 返回 true 但界面没反应? true 只代表「序列归我」,不代表画了东西;排查顺序:hit-test 命中了吗(坐标/变换/可见性)→ onTouchEvent 收到 DOWN 了吗(listener 截胡)→ invalidate 了吗。
本页小结:事件分发是一条「DOWN 定归属、MOVE 可改嫁、CANCEL 要复位」的单向承诺链;三兄弟里 intercept 是父的特权、onTouch 是子的本分;冲突解法先想 NestedScroll,再想父拦截,最后才想 disallow;系统手势是物理规则不是竞争对手,触摸目标 48dp 与 ViewConfiguration 是体验的地板。
参考链接
https://developer.android.com/develop/ui/views/touch/dispatching-input-events
https://developer.android.com/about/versions/14/behavior-changes
https://developer.android.com/reference/android/window/OnBackInvokedCallback
https://developer.android.com/guide/topics/largescreen/compatibility/window-overlap
https://cs.android.com/android/platform/frameworks/base/+/main:core/java/android/view/ViewGroup.java
https://cs.android.com/android/platform/frameworks/base/+/main:core/java/android/view/View.java