Android 触摸事件剖析:从分发链到滑动冲突

QuibblerQuibbler 2020-02-06 约 19 分钟 189 次阅读

Android 触摸事件剖析:从分发链到滑动冲突

触摸是 Android 交互最基础的方式,但事件分发的"U 型链"、拦截与消费的规则、以及常见的滑动冲突,常常让人困惑——为什么子 ListView 收不到事件?为什么父布局一拦截,子 View 就再也滑不动?要彻底搞懂,必须理解事件分发的完整链路。本文从三个核心方法入手,讲透 MotionEvent 序列的流转与 mFirstTouchTarget 的作用,并落到滑动冲突的实战解法。参考 官方 ViewGroup 触摸事件文档。

1、事件从哪来,到哪去

一次触摸的 MotionEvent 由硬件产生,经 InputManagerService、ViewRootImpl 注入到界面树,再沿着固定链路流转。核心是两条相反方向的链:分发自顶向下,消费自底向上。

// 分发方向(dispatchTouchEvent):从最外层传到最内层
Activity → PhoneWindow → DecorView → ViewGroup → ... → View

// 消费方向(onTouchEvent,逆流而上):没人消费则一层层回溯
View → ViewGroup → ... → DecorView → Activity

// 结论:分发向下、消费向上;谁消费了 DOWN,整个手势序列就归谁

核心实现(认知锚点):

1. 分发链的每一层都有 dispatchTouchEvent,是事件的"入口"

2. 当某层 onTouchEvent 返回 true(消费),事件不再向上回溯

3. 一路回溯到 Activity.onTouchEvent 都没人消费,这次触摸"落空"

4. Activity 是分发起点也是消费终点,构成了"U 型"的完整闭环

2、三个核心方法

理解事件分发,先记住三个方法和它们的返回值语义:

// 三个核心方法(View / ViewGroup / Activity 的角色不同)
boolean dispatchTouchEvent(MotionEvent ev)      // 分发入口,所有 View 都有
boolean onInterceptTouchEvent(MotionEvent ev)   // 是否拦截(仅 ViewGroup 有)
boolean onTouchEvent(MotionEvent event)         // 是否消费(真正处理事件处)

// 返回值语义
dispatchTouchEvent        true = 事件已处理(自己或子消费均可)
onInterceptTouchEvent     true = 拦截(交给本层 onTouchEvent);false = 放行给子
onTouchEvent              true = 消费;false = 不消费(事件回溯给父处理)

核心实现(职责区分):

1. dispatchTouchEvent:事件的调度中枢,决定"自己处理 / 发给子",每层都实现

2. onInterceptTouchEvent:ViewGroup 专属,决定"要不要把事件扣下来",View 没有

3. onTouchEvent:真正干活的地方,消费返回 true,不消费返回 false

4. 一句话口诀:dispatch 分发、onIntercept 拦截、onTouchEvent 消费

3、MotionEvent 与事件序列

一次完整的手势是一个"事件序列":从 DOWN 开始,中间若干个 MOVE,以 UP(正常结束)或 CANCEL(被打断)收尾。这是理解一切规则的最小单位。

// 一个事件序列
DOWN(1 个)→ MOVE(0~多个)→ UP(正常结束)/ CANCEL(被父 View 打断结束)

// 两套坐标系
event.getX()        // 相对当前 View 左上角(视图内坐标,最常用)
event.getRawX()     // 相对屏幕左上角(绝对坐标,跨 View 时用)

// 多点触控
int action  = event.getActionMasked();   // 去掉 pointer index,只取动作类型
int pointer = event.getPointerId(actionIndex);  // 某根手指的 id
int count   = event.getPointerCount();   // 当前屏幕上的手指数

核心实现:

1. DOWN 标志序列开始,UP/CANCEL 标志结束,每个序列独立认定消费者

2. getX/Y 是相对当前 View 的局部坐标,getRawX/Y 是屏幕绝对坐标

3. CANCEL 不是用户手势产生的,是父 View 中途拦截时系统下发给子的"强制结束"

4. 多点触控要用 getActionMasked,否则低位的 pointer index 会干扰 action 判断

4、分发流程:ViewGroup 源码骨架

把 ViewGroup.dispatchTouchEvent 的源码简化到骨架,就能看清"拦截 + 遍历分发 + 自身兜底"的三段逻辑,以及 mFirstTouchTarget 的作用:

// ViewGroup.dispatchTouchEvent 简化骨架
boolean dispatchTouchEvent(MotionEvent ev) {
    int action = ev.getActionMasked();
    // ① DOWN 起手:清空上一轮的消费者,新一轮重新认定
    if (action == DOWN) {
        cancelAndClearTouchTargets(ev);   // 清除 mFirstTouchTarget
        resetTouchState();                // 清除 DISALLOW_INTERCEPT 等标记
    }
    // ② 判断是否拦截
    boolean intercepted;
    if (action == DOWN || mFirstTouchTarget != null) {
        // DOWN、或之前有子消费过 → 询问 onInterceptTouchEvent
        intercepted = onInterceptTouchEvent(ev);
    } else {
        intercepted = true;   // 非 DOWN 且无子消费 → 直接自己处理
    }
    // ③ 不拦截且是 DOWN / 新 pointer → 倒序遍历子 View,找到命中的消费者
    if (!intercepted) {
        for (View child : reverse(children)) {     // 顶层 z-index 优先
            if (isTransformedTouchPointInView(child, ev)) {
                if (child.dispatchTouchEvent(ev)) {
                    mFirstTouchTarget = addTouchTarget(child);  // 记下消费者
                    break;
                }
            }
        }
    }
    // ④ 兜底:没有子消费(被拦截或子都拒绝)→ 本层 onTouchEvent
    if (mFirstTouchTarget == null) {
        return super.dispatchTouchEvent(ev);   // 走 View.onTouchEvent
    }
    // ⑤ 后续 MOVE/UP 直接派给已认定的 mFirstTouchTarget,不再遍历
    return mFirstTouchTarget.child.dispatchTouchEvent(transformedEvent);
}

核心实现:

1. DOWN 时清除 mFirstTouchTarget,每个序列重新认定消费者

2. onInterceptTouchEvent 仅在 "DOWN" 或 "mFirstTouchTarget != null" 时调用;无子消费时直接 intercepted=true

3. 倒序遍历子 View(顶层 z-index 优先),命中坐标且消费 DOWN 的成为 mFirstTouchTarget

4. 一旦 DOWN 被某子消费,后续 MOVE/UP 直接派给它,不再遍历——这就是"DOWN 决定归属"

5、关键规则与优先级

三条规则几乎能解释所有"事件去哪了"的疑问,再加一组监听器优先级:

// View.dispatchTouchEvent 内部(简化):OnTouchListener 优先级最高
public boolean dispatchTouchEvent(MotionEvent event) {
    if (mOnTouchListener != null && mOnTouchListener.onTouch(this, event)) {
        return true;   // ① OnTouchListener 消费了,直接结束
    }
    return onTouchEvent(event);   // ② 否则交给 onTouchEvent
}

// View.onTouchEvent 内部:UP 时触发点击
boolean onTouchEvent(MotionEvent event) {
    // 只要 enable 且 clickable/longClickable,默认就 return true(消费)
    if (action == UP && isClickable()) {
        performClick();   // ③ 内部回调 OnClickListener.onClick
    }
    return true;
}

// 优先级:OnTouchListener.onTouch  >  onTouchEvent  >  OnClickListener.onClick

核心实现(三条规则):

1. DOWN 决定序列归属:谁 onTouchEvent 消费了 DOWN,整个序列的 MOVE/UP 都归它(由 mFirstTouchTarget 绑定)

2. 中途拦截触发 CANCEL:父在 DOWN 后某次 MOVE 返回 onIntercept=true,子收到的是 CANCEL 而非 UP,得以"清理状态"

3. 监听器优先级:OnTouchListener.onTouch > onTouchEvent > onClick;setOnTouchListener 可不重写 View 就拦截事件

4. onTouchEvent 对 clickable/enabled 的 View 默认返回 true(消费),这是 Button 默认能收事件的根本原因

6、requestDisallowInterceptTouchEvent:子 View 夺回控制权

拦截主要由父 View 主导,但子 View 可以"反向"请求父不要拦截——调用 parent.requestDisallowInterceptTouchEvent(true),设置 FLAG_DISALLOW_INTERCEPT,让父在后续事件中跳过 onInterceptTouchEvent。

// 子 View 内部:判定为水平滑动后,禁止父 View(如 ViewPager)拦截
public boolean dispatchTouchEvent(MotionEvent ev) {
    switch (ev.getActionMasked()) {
        case DOWN:
            downX = ev.getX();
            downY = ev.getY();
            break;
        case MOVE:
            float dx = Math.abs(ev.getX() - downX);
            float dy = Math.abs(ev.getY() - downY);
            // 水平位移更大 → 判定为水平滑动,请求父不要拦截
            if (dx > dy && dx > touchSlop) {
                getParent().requestDisallowInterceptTouchEvent(true);
            }
            break;
    }
    return super.dispatchTouchEvent(ev);
}
// 注意:DOWN 会清除 DISALLOW_INTERCEPT 标记,所以每次 DOWN 后都要重新设置

核心实现:

1. requestDisallowInterceptTouchEvent(true) 让父跳过 onInterceptTouchEvent,事件留在子

2. DOWN 会重置该标记,所以不能在 DOWN 时一劳永逸地设,需在 MOVE 时按方向判断后再设

3. RecyclerView 内部就靠它配合 ViewPager/ScrollView,实现"自己想滑时父别插手"

4. 这是解决滑动冲突时,子 View 唯一能主动干预的手段

7、滑动冲突实战

滑动冲突分两类:异向(如水平 ViewPager 嵌竖直 RecyclerView)和同向(如外层 ScrollView 嵌内层 RecyclerView)。异向用方向判定,同向用临界值或嵌套滚动机制。

// 异向冲突解法:父 ViewGroup 按位移方向决定是否拦截
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
    switch (ev.getActionMasked()) {
        case DOWN:
            downX = ev.getX();
            downY = ev.getY();
            return false;   // DOWN 不拦截,让子有机会消费
        case MOVE:
            float dx = Math.abs(ev.getX() - downX);
            float dy = Math.abs(ev.getY() - downY);
            // 竖向位移更大 → 判定为竖向滑动 → 父拦截
            // 横向位移更大 → 放行给子(子处理水平,配合 requestDisallow 锁定)
            return dy > dx;
    }
    return false;
}

// 同向冲突:优先用 NestedScrollingChild/Parent(AndroidX 推荐)
// 内层实现 NestedScrollingChild,外层 CoordinatorLayout/NestedScrollView
// 由系统协调滚动分配,无需手写 onIntercept 搏斗

核心实现:

1. 异向冲突用 dx / dy 比较方向,决定拦截与否,DOWN 必须放行(否则子拿不到 DOWN,后续都断)

2. 父拦截后子会收到 CANCEL,记得在 CANCEL 里复位子 View 的滑动状态

3. 同向冲突靠业务临界值(如"内层滑到顶才交父"),或直接用 NestedScrollingChild/Parent 机制

4. 解决滑动冲突的口诀:"谁要谁拿"——判定条件 + onIntercept / requestDisallow 组合

- 配合 ViewConfiguration.get(context).scaledTouchSlop() 拿到最小滑动阈值,避免抖动误判

8、总结

触摸事件的本质,是一个 MotionEvent 序列在 View 树上的"分发向下、消费向上"流转。抓住三个方法、mFirstTouchTarget 的绑定机制,以及"DOWN 决定归属"这条铁律,绝大部分问题都能推导出来。

关键要点:

- 三方法:dispatch 分发、onIntercept 拦截(ViewGroup)、onTouchEvent 消费

- DOWN 清除 mFirstTouchTarget 并重新认定消费者;DOWN 被谁消费,序列就归谁

- 父中途拦截 → 子收到 CANCEL;优先级 OnTouchListener > onTouchEvent > onClick

- 滑动冲突:异向用 dx/dy 方向判定,同向用临界值或 NestedScrolling 机制

- requestDisallowInterceptTouchEvent 是子 View 夺回控制权的唯一手段,但 DOWN 会重置它

对于做自定义控件、复杂交互列表、嵌套滚动的开发者而言,把事件分发的链路与规则内化是必要的——它不只是面试考点,更是排查"点击无响应""滑动卡顿""父布局抢事件"等问题的底层依据。掌握这套机制后,再遇到任何触摸相关问题,都能顺着"DOWN→拦截→消费→CANCEL"的链条一步步定位,而不是凭感觉试错。

相关推荐

精选
ViewPager和PagerAdapter、FragmentPagerAdapter、FragmentStatePager
Android

ViewPager和PagerAdapter、FragmentPagerAdapter、FragmentStatePager

ViewPager1、ViewPagerandroidx.viewpager.widget.ViewPager,Android中使用非常广泛的控件,可以说是APP必备:首次打开引导页、页面Banner广告等。常用方法:setAdapter() 设置适配器setOffscreenPageLimit() 设置缓存的页面个数,默认是 1setCurrentItem() 跳转到特定的页面setOnPage

1.1k
获取Android内置WebView内核版本
Android

获取Android内置WebView内核版本

获取Android内置WebView内核版本竟然能遇到这样奇葩的事情,网页用的技术过于新颖,以至于只支持高版本Chromium内核的,低版本安卓系统中内置的内核版本较低,无法加载前端页面。1、设置查看在系统设置里 > 应用 > 应用管理 > 显示系统应用,查看WebView组件:2、页面查看通过WebView发起的网络请求,都会带上浏览器的UA,通常页面都可以通过UA判断浏览器的内核版本。这里有两

1.1w
Android 14适配总结
Android

Android 14适配总结

Android 14适配总结毕业工作至今已经适配了三个Android大版本,从Android 11到Android 12、再到Android 13。2023年,Google即将推出的Android 14,上半年已经开始第一批适配。现在,第四个Android版本已经适配完,总结记录一下。1、Android 14计划Google一般会在2月份对外发布预告,同时放出开发者预览版。“拉通”各大平台、厂商以

1.1w