Skip to content

触摸事件分发:三个方法、滑动冲突与手势抢占 ​

本页导读:从手指按下到你的 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 out ANR(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。三个必须刻进肌肉记忆的规则:

  1. DOWN 决定归属:DOWN 被谁消费(true),后续整个序列就只发给谁;中间没人想要也不能改嫁。

  2. CANCEL 是「作废章」:被上层拦截转交、窗口失焦、系统手势接管时,你收到 CANCEL——必须复位动画/拖拽状态,漏处理就是「手指抬起来了列表还在滚」。

  3. UP 不再过 intercept:拦截判定只发生在 DOWN/MOVE 阶段;UP 到达时链条早已定型。

  4. 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 }

三、三兄弟的真实契约(源码视角) ​

方法所在层职责默认返回
dispatchTouchEventViewGroup/View派发器:问拦截、找孩子、兜底转 onTouch交给孩子则 true
onInterceptTouchEvent仅 ViewGroup岔路口:true=本轮序列改由自己 onTouch 处理false
onTouchEventView消费者:真正处理点击/长按/滚动可点击控件 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 是体验的地板。

参考链接 ​

基于 Android 16 / API 36 · 内容为原创整理,转载请注明出处