1. 事件分发最容易被忽略的起点我们要梳理的到底是什么写这个系列的时候我的想法很简单。第一篇把事件分发的主干流程捋了一遍从dispatchTouchEvent到onInterceptTouchEvent再到onTouchEvent把“责任链”式的分发逻辑、DOWN事件在整条链路由父到子的传递顺序、以及返回值的真实含义都做了梳理。但我也在文章结尾留了句话——真正难的不是背下那几个方法的调用顺序而是把这些顺序放到真实业务场景里时各种“意外”就会冒出来。这第二篇换个角度切入不按方法执行的先后讲而是按事件的生命周期讲。你会发现很多问题其实不在某个单一方法里而是出在事件“从一个控件转移到另一个控件”的过程以及“一个事件序列应当保持完整”这条潜在规则上。这一篇适合谁看已经看完第一篇、对dispatchTouchEvent/onInterceptTouchEvent/onTouchEvent三者关系有基本印象的开发者。如果你第一次接触这个话题建议先补一下第一篇的基础否则下面讲到“拦截”“取消”“转移”这些概念时容易和对错责任方混淆。当然如果你已经写过不少自定义控件、也处理过滑动冲突这篇里的源码解读和问题排查部分能帮你把零散的经验串成明确的规则。为什么要把“事件序列”单独拎出来当一个核心话题因为我在团队评审代码时发现九成以上的点击相关 bug——点击穿透、点击延迟、子控件点击失灵、父容器抢走点击——本质上都不是“某个方法写错了”而是没有意识到一次完整交互是一连串事件而不是孤立的一两个事件。如果把目光只锁定在 ACTION_DOWN 上很多问题就成了玄学把视野拉到完整的事件序列上大部分问题都能推断出原因甚至可以在写代码前就预判到。这一篇的主体内容包括这么几块先深挖事件序列里各个阶段到底是谁在通知谁、真实调用顺序和背后原因再讲 ACTION_CANCEL 这个平时很少碰、但一到嵌套滑动就异常关键的事件接着切入最常见的滑动冲突场景给出通用判定逻辑和两套拦截方案最后带一段源码级的数据流解读和常见问题排查把系列第一篇里没展开的细节补全。2. 一个完整事件序列是怎么诞生的从 DOWN 到 UP 的完整生命周期2.1 为什么 DOWN 事件的返回值决定整个序列的归属先从一个场景说起。你在一个自定义ViewGroup里放了一个子Button正常情况下点击 Button 会触发它的点击事件。但如果你让父容器重写了onInterceptTouchEvent在ACTION_MOVE阶段返回了true这时候会发生什么答案是Button 会收到一个ACTION_CANCEL后续的事件全部转交给父容器处理。注意这个细节——不是父容器“抢”走了 Button 后续的事件而是系统先给 Button 发了一个“取消”信号这条序列的归属权才发生了转移。这个设计非常关键也非常容易被误解。我曾见过有同学在项目里通过判断子 View 收没收到后续的事件来判断父容器是否拦截这是不严谨的。正确的判断方式是父容器一旦在序列中途拦截子 View 会先收到一次ACTION_CANCEL然后从下一个事件开始事件才不再发给子 View。那 DOWN 事件在其中扮演什么角色呢Android 的事件分发逻辑里DOWN 事件的返回值从根本上决定了一个事件序列的归属。当 DOWN 事件被某个 View 消费后整个后续序列就默认以这个 View 为核心。如果中途没有其他容器拦截后续的 MOVE、UP 都会优先送达到它。源码层面dispatchTouchEvent里有一套mTarget机制在非TOUCH_TARGETS模式的老版本实现中大致逻辑是 DOWN 时确定mTarget后续事件都发给它虽然在新版本里换成了TouchTarget链表结构但核心思想一致DOWN 就是一次“签约”签约对象决定了后续事件的默认走向。这里就带出一个实际开发里经常踩的问题为什么有时候父容器明明在 DOWN 时拦截了子 View 点击逻辑却还是能触发因为有些开发者把判断写在ACTION_DOWN里返回值false后续的 MOVE、UP 自然也轮不到它但如果他写的是super.onInterceptTouchEvent(ev)并在条件满足时返回true那么父容器在 DOWN 阶段确实会拦截成功子 View 收不到 DOWN点击自然无从谈起。这里的关键在于DOWN 的拦截应该被看做“不让子 View 参与这次事件序列”而 MOVE 阶段的拦截则更像是“中途换人旧人先收到解约通知”。2.2 为什么 UP 事件的落点如此重要顺着事件序列往下走就是 UP 事件。很多人会以为 UP 只是“结束信号”没有太多讲究。但在源码实现里UP 事件的走向直接决定了点击、长按、轻扫等手势是否成立。以View.onTouchEvent为例Click的启动条件是在 DOWN 时进入PREPRESSED状态经过postDelayed后变为PRESSED状态最终在 UP 时如果仍然满足“没有取消、没有移出控件范围、没有父控件拦截触发的 CANCEL”才会执行performClick()。这意味着什么你在onTouchEvent里干了很多事但 UP 事件根本不一定会到达这里。当事件序列在 UP 之前被父容器拦截子 View 会收到 CANCELPRESSED状态被强制清除点击自然不会触发。这也就是很多“点击无响应”问题的真正原因——不是监听器没绑而是事件序列半路被终结了。另外有个细节值得注意如果onTouchEvent只处理了ACTION_DOWN并返回true但 MOVE、UP 都交给super.onTouchEvent(ev)那么在 UP 时View内部的performClick有概率因为状态不符而不执行。我见过有人这样写“自定义点击区域”结果时灵时不灵。正确做法是如果你自己接管了 DOWN 事件就应该管理好整个序列的接收不能只处理一半除非你明确知道自己在干什么。这一类“只处理 DOWN”导致的点击异常在排查时非常隐蔽。因为你从日志里看到的onTouchEvent确实调用了但点击事件就是没触发这通常就是状态机没走通。建议把这部分作为事件机制排查的一个固定检查项确认 DOWN、UP 是否由同一个控件消费中间有没有被加入消费链的其他角色。3. 深度拆解 ViewGroup 的三个分发动作拦截的粒度到底细在哪里3.1 dispatchTouchEvent 里真实的调用顺序先别急着看onInterceptTouchEvent我建议按源码头往下读一遍ViewGroup.dispatchTouchEvent的实现。即便你现在项目用的是AppCompat或者Material组件最终都会收敛到这个架构里。一个大致的顺序是这样当一个触摸事件到达ViewGroup.dispatchTouchEvent时它会先自己判断事件类型。对于 DOWN 事件会进入onInterceptTouchEvent检查是否拦截如果拦截就直接进入自身onTouchEvent消费如果不拦截就遍历子 View 寻找目标。这个寻找逻辑里有几个细节遍历顺序默认是逆序的也就是后添加的子 View 优先被检查只有isEnabled、isClickable之类条件满足的 View 会成为候选事件的坐标ev.getX()和ev.getY()会被转换为相对于子 View 的坐标系。这里有个特别容易忽略的点onInterceptTouchEvent并不是只在 DOWN 时被调用。很多文章为了简化告诉你“DOWN 时是否拦截决定后续”实际上系统在 MOVE、UP 阶段都会调用onInterceptTouchEvent。区别在拦截之后如果某个阶段拦截成功系统会向此前消费事件的子 View 发送ACTION_CANCEL然后从下一个事件开始进入自身处理。所以设计一个“可移动的浮窗容器”时你会希望在 DOWN 时先不拦截让子内容正常响应在 MOVE 且位移超过阈值时再拦截让整体拖动生效。这个逻辑用onInterceptTouchEvent非常自然——只要在 MOVE 阶段根据手势位移返回真值即可。很多人纠结的“怎么让子 View 也能收到拖动前后的事件”本质上是靠这套“先放行、后拦截”的机制实现的。3.2 拦截后的事件怎么走从 mTarget 到 TouchTarget 链路老版本的 Android 里ViewGroup通常用一个成员变量mTarget记录当前事件的目标。新版则是用TouchTarget对象组成链表。工作方式大概这样当第一个 DOWN 事件到来时ViewGroup会尝试为每个候选子 View 构建一条链路如果某个子 View 的dispatchTouchEvent返回了true那就把它加到mFirstTouchTarget链表里后续的 MOVE、UP 事件会遍历这个链表来分发而不再重新“找目标”。这套设计的逻辑是为了保证事件序列的稳定性。你想想如果在第二次 MOVE 时系统重新遍历一下所有子 View很可能因为坐标变化导致目标切换那么正在拖动的控件就会莫名其妙地失去事件这在用户手指还在屏幕上时就非常糟糕。所以一旦确定了目标后续事件的分发就带上了“惯性”目标不能轻易改变。但这也导致了一个副作用如果子 View 在 DOWN 时条件不满足比如当时不可见、不可点击、坐标未命中那它就和整个序列无缘了即便后续 MOVE 到了它的上面系统也不会临时把它加进链路除非你手动触发重写dispatchTouchEvent的逻辑。这个特性在实现“命中区域动态变化”的自定义控件时特别容易踩坑——如果 DOWN 时按钮坐标没命中后面手指移到上面也不会触发点击。所以从源码里我们能提炼出一条开发原则“DOWN 决定归属”是事件分发的第一原则别指望在事件中部做复杂的归属切换那需要设计层面去配合。如果确实有动态归属的需求比如实现“手势过程中不断切换可拖拽对象”就需要在父容器里做分发重写或者干脆做成单控件处理所有触摸事件后手动命中检测。3.3 一个小实验帮你理解拦截粒度纸上谈兵不如写一段验证代码。你可以构造这样一个场景一个LinearLayout里面放一个Button父容器重写onInterceptTouchEvent再各自打日志。然后按下面几个步骤操作点击 Button不移动观察 DOWN、UP 分别进了谁。按下后快速横向移动再抬起观察父容器在哪个事件开始拦截Button 是否收到ACTION_CANCEL。把onInterceptTouchEvent改为始终返回true重复步骤 1、2。把 Button 换成TextView且设置clickabletrue重复步骤 1。我在做这个实验时最直观的感受是“父子容器之间的事件竞争并不是两败俱伤的争夺而是带着完整协议的交接”。你会看到 Button 在 DOWN 时高高兴兴地拿到了事件但当父容器在 MOVE 中拦截后它收到的不是后续的 MOVE而是一个单独的ACTION_CANCEL之后所有事件都和它无关。同时Button 内部的PRESSED状态会被清除UI 上按钮的高亮消失。这个实验建议所有阅读本文的开发者都动手做一次因为真正理解这套流程后很多开发中的“为什么点击没反应”“为什么滑动卡顿”都会迎刃而解。它比看十篇文章的收获都大。4. 专治各种滑动冲突事件序列如何在大规模滑动手势中“稳定交接”4.1 滑动冲突的本质谁来决定横向还是纵向滑动冲突大概是实际业务中遇到最多、也最让人头疼的问题。常见的场景有外层是纵向ScrollView里面有一个横向滑动的ViewPager或者是纵向RecyclerView里嵌了一个横向RecyclerView再或者是一个既可以上下滑又可以左右滑的自定义容器。表面上这看起来是“方向判断”的问题但本质上是事件序列的归属权在“滑动意图明确之前”应该如何保持模糊在“滑动意图明确之后”又应该如何快速收敛。Android 自身的机制给了我们破坏和修复这种模糊的能力。区别在哪里如果外层容器在 DOWN 时就把事件拦截了那子容器永远没机会滑动——哪怕它是横向的。反之如果外层一直不拦截子容器横滑一帆风顺但一旦纵滑就经常出现“父容器和子容器互相抢事件”的抖动。所以冲突处理的本质是如何让事件在一开始交给正确的对象同时在中途允许根据手势变化切换归属。4.2 外部拦截法 vs 内部拦截法两条路线的取舍处理滑动冲突的两套经典方案我在这里只展开最关键的差异和适用场景。外部拦截法是重写父容器的onInterceptTouchEvent。核心逻辑是在ACTION_MOVE时根据当前拖动方向和业务规则决定是否拦截。典型写法Override public boolean onInterceptTouchEvent(MotionEvent ev) { boolean intercept false; switch (ev.getActionMasked()) { case MotionEvent.ACTION_DOWN: intercept false; break; case MotionEvent.ACTION_MOVE: // 如果父容器判定这次滑动应该自己处理就拦截 if (shouldTakeOver(ev)) { intercept true; } break; case MotionEvent.ACTION_UP: intercept false; break; } return intercept; }这种方式的优点是逻辑集中、易于维护父容器对事件有绝对的控制权。缺点是拦截判定发生在子 View 之前如果父容器想“放一点但又不要全放”就很难做到精细控制。内部拦截法则是子 View 先拿到事件再在dispatchTouchEvent里决定是否请求父容器不要拦截。这需要配合requestDisallowInterceptTouchEvent方法这也是很多开发者没有系统理解的一个 API。它的本质是子 View 通过设置一个标志位让父容器在分发逻辑里放弃拦截。内部拦截法适用在子 View 对自己滑动的“意图”更清晰的场景。比如一个自定义的横向滑动卡片它知道当前手指横向位移是否超过纵位移那么它可以自己决定何时请求父容器“放权”。Override public boolean dispatchTouchEvent(MotionEvent ev) { switch (ev.getActionMasked()) { case MotionEvent.ACTION_DOWN: getParent().requestDisallowInterceptTouchEvent(true); break; case MotionEvent.ACTION_MOVE: getParent().requestDisallowInterceptTouchEvent(shouldHandle(ev)); break; case MotionEvent.ACTION_UP: getParent().requestDisallowInterceptTouchEvent(false); break; } return super.dispatchTouchEvent(ev); }在实际项目里我更偏好外部拦截法。原因很简单冲突判定的核心逻辑在父容器父容器视野更完整不易出现子 View 之间互相“抢权”的场面。当然如果某个项目有多个层级都参与滑动判定那么内部拦截法在个别场景里会更灵活但维护成本也会更高。4.3 别被“滑动角度”困住了事件归属的切换时机比方向判断更重要很多人写冲突处理时陷入一个误区拼命追求“精准的方向识别”比如算斜率、算夹角、比较 dx 和 dy 的绝对值。这些数学计算本身不难难的是你无法在手指刚开始动的瞬间就知道它后面要怎么滑。因此真正合理的处理方式应该是初始阶段保持开放不拦截给子 View 机会一旦滑动意图明确比如横向位移显著大于纵向再果断拦截。这个“意图明确”的门槛值通常取触摸事件里的touchSlop。它可以通过ViewConfiguration.get(getContext()).getScaledTouchSlop()获取系统默认的值在 8dp 左右。它的含义是手指移动不超过这个距离时系统不会认为这是滑动。我们的判定逻辑也应该以它为基准if (Math.abs(dx) touchSlop Math.abs(dx) Math.abs(dy)) { // 判定为横向滑动按业务决定是否拦截 }这里还有一个微妙的地方一旦 MOVE 事件已经被父容器扣下子 View 会收到 CANCEL然后父容器开始处理后续事件。此时子 View 内部的“滚动距离”可能还没达到触发滚动的阈值如果你们直接在子 View 里监听onScroll这个事件会被中断。所以在冲突处理的边界条件里永远要评估“被打断的一方”的感知给它一个合理的回调机制。5. ACTION_CANCEL 不可小觑它在嵌套滚动里的真实作用5.1 什么情况下你会收到 CANCEL很多开发者开发几年都没主动处理过ACTION_CANCEL因为日常点击场景里系统很少发这个事件。但一旦涉及嵌套滑动、下拉刷新、自定义拖拽它就频繁出现。这里列举几类常见场景父容器在事件序列中途拦截了原本给子 View 的事件系统由于窗口失去焦点等原因主动终结了正在进行的触摸序列在RecyclerView嵌套滚动的场景里子列表的滚动状态与父列表竞争时其中一个会被强制“取消”。收到ACTION_CANCEL并不意味着程序出错它只是系统在说“这次交互结束了但原因不是正常的抬起而是中途发生了转移或中断。”如果你的代码没有针对 CANCEL 做状态清理比如onTouchEvent里用布尔值记录“是否正在拖动”那么 CANCEL 后这个布尔值会残留为 true导致下一次 DOWN 到来时判断错乱。5.2 正确响应 CANCEL别把取消和抬起混为一谈看代码时我见过不少“图省事”的写法把ACTION_UP和ACTION_CANCEL合并处理。这样如果不是特别严格的场景确实看不出问题但在自定义拖拽、滑动开关这类控件里这就是隐患。你可以想想用户正常抬起手指UP 事件里你应该执行“松手后的操作”因为被父容器拦截而收到了 CANCEL本质上你并没有真正“松手”而是被迫放弃了这次事件序列。如果我们这时也执行了“松手后的操作”就会表现为拖拽被异常中断、点击被误触发等。正确的姿势是分开处理case MotionEvent.ACTION_CANCEL: isDragging false; // 清理状态但不要执行“松手后的最终动作” break; case MotionEvent.ACTION_UP: isDragging false; // 执行最终动作 break;这个区分在实现“滑动返回上一页”的页面交互时尤其重要。如果在 CANCEL 时不小心执行了“滑动结束判断”你会在用户试图下拉返回但最终放弃时出现页面莫名返回的问题。5.3 在嵌套滑动的实现中 CANCEL 的触发路径以 NestedScrolling 机制为例事件在子 View 和父容器之间传递时会通过startNestedScroll、dispatchNestedPreScroll、dispatchNestedScroll等方法协作。当一个触摸序列在子 View 处理到一半父容器决定接管时最终仍会走事件分发层的拦截逻辑——这就触发了 CANCEL。很多人在用CoordinatorLayout或SwipeRefreshLayout时会发现某些自定义子 View 的触摸逻辑异常。排查看似复杂但排查方向其实很明确去查父容器有没有在某些情况下调用requestDisallowInterceptTouchEvent。比如SwipeRefreshLayout在子 View 纵向滚动到自己顶部后会把后续的下拉事件接管并转换为刷新手势这时子 View 收到的就是 CANCEL。如果子 View 里有基于“按下/抬起”状态而执行的动画就需要在 CANCEL 时做一次复位。6. 用源码视角解释三个最“反直觉”的行为6.1 onTouchEvent 返回 true 和 onClick 的关系这绝对是面试高频题也是开发中的经典困惑为什么onClick偶尔会不触发随手翻View.dispatchTouchEvent的源码就能找到答案。简化之后内部大致逻辑是if (onTouchEvent(event)) { return true; } // 后续才有 performClick 的机会但这里的“后续”不是只要onTouchEvent返回 true 就一定触发点击它取决于onTouchEvent内部对 UP 事件的处理是否把mHasPerformedLongPress、mPressed等状态调整到满足触发条件。比如如果 DOWN 时消费了事件UP 时却因为某种原因返回了 false或者被父容器的某个拦截行为导致 UP 压根没到View这里点击就不会触发。所以当你在自定义 View 里同时存在触摸逻辑和点击事件时程序上应该是onTouchEvent处理触摸并返回布尔值不要轻易拦截 UP 的事件走向如果你要消费某个手势就明确消费整个序列否则别碰。6.2 为什么 performClick 的调用时机往往在 dispatchTouchEvent 之后源码里performClick()的调用点在View.dispatchTouchEvent内部并不是在onTouchEvent之后立刻调用而是在LI中通过handleClick等逻辑最终触发。平时我们感知不到差异但如果你在dispatchTouchEvent里做了某些提前返回的判断或者在子类里重写dispatchTouchEvent时没有调用super.dispatchTouchEvent点击事件就会完全不触发。这个点排查起来非常隐蔽因为你可能只是改了一点事件拦截逻辑结果整个点击都失效了。较好的实践是除非确有必要不要重写dispatchTouchEvent而不调用super。如果你确实需要在上层做全局的触摸监控建议把真正的事件消费逻辑委托给super自己只做记录或前置处理。6.3 坐标系转换里的“坑”getRawX 和 getX 不能混用这不算源码行为但却是事件处理里最常见的低级错误。在子 View 的onTouchEvent里ev.getX()返回的是相对于当前 View 的坐标而ev.getRawX()返回的是屏幕绝对坐标。你判断触摸点是否位于某块区域应该用getX()判断手势整体的横向位移或跨组件交互应该用getRawX()。混用后列表项和整个页面之间的坐标一致性就会出现偏差点击区域经常漂移。我见过不止一个案例一个 Item 内的按钮明明看着在屏幕上某个位置点击却老是触发不了最后排查发现是在父容器里做命中判断时用了getRawX()去比对子 View 的local矩形。解决方案很简单坐标统一后问题立刻消失。7. 避坑清单我在事件处理上总结的六条经验这一节做个速查都是这些年实际开发中积累出来的经验能帮你少走弯路事件序列的归属在 DOWN 时确定。不要在 MOVE 阶段期待系统“重新分配”那是破坏稳定性的行为。如果父容器需要拦截子 View 的事件务必处理好子 View 的ACTION_CANCEL。否则子 View 状态残留后续交互就会出现诡异现象。onTouchEvent返回 true 意味着你消费了这个事件并不意味着你可以不处理剩下的序列。如果你只消费 DOWN后续事件交回super请确认你真的理解了这带来的后果。自定义 View 中同时重写dispatchTouchEvent和onTouchEvent时要理清它们的调用顺序和职责边界。前者负责分发后者负责消费。滑动冲突的处理里优先使用外部拦截法逻辑更集中。碰到“子 View 明确有滑动意图”的场景时再用requestDisallowInterceptTouchEvent。用touchSlop做滑动判定门槛别用自己拍脑袋定的阈值。系统值和用户手指精度、屏幕分辨率都有关联。以上这六条每一条都对应着至少一个真实踩坑案例。把事情放在“事件序列完整性”的框架下来思考绝大部分问题都能顺藤摸瓜找到原因。8. 深度梳理的边界点击事件往前的路还有很多这篇接续第一篇把事件序列、拦截粒度、滑动冲突、CANCEL 机制、源码视角这几个话题做了深挖。很多人学到这里会有一个感觉好像懂了但写起自定义控件时还是会犹豫。这个阶段是正常的说明你对分发的“骨架”已经清楚还缺“血肉”——也就是对不同场景里各种View的实际表现有足够多的体感。这个只能靠多看源码和积累调试经验来补。我自己在带团队的时候最推荐的进阶方式是做“可交互的源码实验”。所谓实验不是改一行代码看一次效果而是结合断点在ViewGroup.dispatchTouchEvent、View.onTouchEvent的关键分支上打点上日志观察真实项目里一次简单点击背后的完整调用链。当你能不看文档直接根据日志说出“这一步为什么这样走”时事件分发这块就真正学通了。如果后续有机会我可能会写第三篇从“性能角度”来看触摸事件的处理——包括事件合并、批量处理、在Choreographer框架下触摸事件与绘制的时序关系以及MotionEvent的回收复用机制。这些内容平时不太起眼但在复杂交互页面里的作用非常明显。写这个系列的目的从来都不是教人背概念而是希望开发者能真正把机制内化成自己的思维工具。