ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Android触摸事件分发机制:事件序列、拦截与滑动冲突

Android触摸事件分发机制:事件序列、拦截与滑动冲突 “我写过好几个带长列表的信息流页面最烦的就是触摸事件相关的疑难杂症。比如 RecyclerView 里的 Item 明明设置了点击事件但在某个边缘位置怎么点都没反应又或者外层 ScrollView 和内部横向滑动的 ViewPager 打架滑动总是一卡一卡的。这些问题绕来绕去最后几乎都会指向同一个源头Android 触摸事件传递。这篇文章就从头到尾把这套分发机制讲透包括三个核心方法做了什么、事件序列如何定向、父子 View 冲突怎么解决以及我自己排查问题时常用的打日志套路。适合正在做自定义 View 的开发者也适合被点击失灵折磨到怀疑人生的新手。1. 触摸事件到底在传递什么1.1 一次触摸是一个事件序列不是一个事件很多人第一次学触摸事件以为按下产生一个 DOWN抬起产生一个 UP移动再产生一个 MOVE每个事件独立处理就行。实际上Android 把一次完整的触摸流程定义为一个事件序列从手指按下开始到手指抬起结束中间可能穿插很多个 MOVE。系统通过 MotionEvent 这个数据结构把序列里的每个采样点封装起来action 字段标明当前是 DOWN、MOVE 还是 UPx、y 坐标则代表当前触摸点相对 View 的位置getRawX、getRawY 拿到的是屏幕坐标。这套设计里最关键的一点是 DOWN 事件。可以说DOWN 决定了这个序列最终交给哪个 View 去处理。分发链路上有一个“锚定”逻辑如果某个 View 在分发时被选为目标并且它的 onTouchEvent 或相关处理函数对 DOWN 返回了 true那么后续的 MOVE、UP 无论落在哪里都会继续定向给同一个 View。反过来如果 DOWN 没有被消费系统就认为这个 View 不想要这个序列后续事件也绝不会再来找它。我在项目里见过很多次这类 Bug自定义 View 的 onTouchEvent 里只处理了 MOVEACTION_DOWN 分支只记录起始坐标却忘了返回 true。结果就是第一次触摸什么效果都没有松手以后系统不再派发事件整个控件像是“死”了。所以分析触摸分发先把“事件序列”这个心智模型建立起来后面的东西都顺了。1.2 三个核心方法各自的职责边界Android 的触摸分发体系里真正参与决策的方法只有三个dispatchTouchEvent、onInterceptTouchEvent、onTouchEvent。搞清楚这三个方法分别属于谁、什么时候被调用、返回值意味着什么就掌握了 80% 的分发逻辑。方法定义位置核心作用返回 true 的含义dispatchTouchEventActivity、ViewGroup、View 都有负责把事件往下分发或者把事件交给自己的 onTouchEvent事件已经被消费或已由子 View 处理onInterceptTouchEvent只有 ViewGroup 有在向子 View 分发前决定是否自己拦截拦截事件不发给子 ViewonTouchEventView、ViewGroup 都有真正处理触摸逻辑比如拖动、点击、长按自己消费了这个事件这里有一个容易混淆的点View 里也有 dispatchTouchEvent但它和 ViewGroup 里的逻辑不一样。View 没有子节点可分发所以它的 dispatchTouchEvent 默认就是把事件交给自己的 onTouchEvent或者交给自己设置的 OnTouchListener。ViewGroup 的 dispatchTouchEvent 才会做拦截判断、遍历子 View、命中测试这一套完整流程。Activity 里没有 onInterceptTouchEvent它不是 ViewGroup不能拦截事件只能通过 dispatchTouchEvent 方法在一开始决定要不要把事件交给窗口继续分发。实际编码中大家很少去改 Activity 的 dispatchTouchEvent但理解它的位置很重要因为事件真的是从这个入口一路往下走的。2. 沿着一棵 View 树把流程重新走一遍2.1 从 Activity 到 DecorView事件的第一步手指按下屏幕硬件层把信号包装成 MotionEvent一路送到当前 Activity 的 dispatchTouchEvent。如果这里返回 false事件分发直接终止后续都不会再往 View 树里传递默认情况下会调用 super.dispatchTouchEvent(ev)也就是继续交给 Window.Callback 和 DecorView。DecorView 是整个窗口的顶级 ViewGroup拿到事件后会按照 ViewGroup 的分发逻辑再往下传递。你会发现无论是 Activity 还是顶层容器代码里都不需要你显式去调用 child.dispatchTouchEvent系统会把调用链串联好。我们日常看到的分发流程基本可以用一句话概括事件从根部出发向下递归寻找目标找不到就逐层向上冒泡。很多资料喜欢画很复杂的时序图但我觉得核心逻辑并不复杂。关键是分清两段第一段是“寻找目标”的下降阶段ViewGroup 会通过 onInterceptTouchEvent 决定要不要在这一层拦住第二段是“处理事件”的上升阶段如果子 View 或者 ViewGroup 都不消费事件就会沿着分发路径原路返回到上层最终没人要就交给 Activity 的 onTouchEvent。2.2 onInterceptTouchEvent 的调用时机ViewGroup 在处理事件时会首先调用自己的 onInterceptTouchEvent。注意只要 ViewGroup 收到了事件这个调用就会发生不管它最终是否拦截。默认实现返回 false也就是不拦截事件继续往下分发。如果一个 ViewGroup 在 ACTION_MOVE 时返回了 true会发生什么系统会把当前的 MOVE 事件交给这个 ViewGroup 自己的 onTouchEvent同时给正在处理的子 View 发送一个 ACTION_CANCEL。子 View 必须放弃之前持有的触摸目标后面所有 MOVE、UP 都只交给父层处理。我经常用“领导接手”来类比这个行为下属正在干活领导觉得干得不对中途把任务抢过来自己接手下属就收到“取消”消息之前的权限全部收回。父容器一旦拦截子 View 之后即使再想处理后续事件也晚了。这也是为什么很多滑动冲突方案真正决定权都在父容器这一层。2.3 命中测试与子 View 的分发在 ViewGroup 不拦截的前提下事件需要继续寻找具体的目标 View。系统会遍历子 View通过触摸点的坐标和各子 View 的边界做命中判断。源码里看起来是倒序遍历因为要考虑重叠时最上面 View 优先。命中后就调用子 View 的 dispatchTouchEvent让子 View 自己决定是处理还是继续往下传。这个阶段有一个新手容易误解的地方只要手指按在子 View 的范围内事件就一定会派发给它。严格来说dispatchTouchEvent 确实会把事件传进去但真正决定“要不要留下”的还是子 View 对 DOWN 的返回值。如果子 View 的 onTouchEvent 返回 false事件会回传给父 ViewGroup父 ViewGroup 再调用自己的 onTouchEvent。这时你就可以理解为什么一个 TextView 即使没有设置点击事件它也不会“吃掉”事件因为它的默认 onTouchEvent 返回 false父容器还能处理后续逻辑。实际写自定义控件时“子 View 要不要消费 DOWN”这个决策点非常关键。比如我要实现一个侧滑菜单里的条目希望用户左右滑动条目时触发删除菜单上下滑动时让列表正常滚动。那么条目在 DOWN 时返回 true 就理所应当否则整个手势序列根本不会落到这个条目上。3. 这些行为我敢打赌你踩过坑3.1 ON_DOWN 返回 false整个序列跟你没关系这种现象实在太常见了。很多人写 onTouchEvent 时习惯把 ACTION_MOVE 的代码写在最前面看了半天逻辑没问题但控件就是没反应。最后把日志打出来发现只收到了 DOWN 事件后面的 MOVE 和 UP 压根没进来。根源就是 DOWN 分支没有返回 true。我们来复盘一下错误的代码override fun onTouchEvent(event: MotionEvent): Boolean { when (event.actionMasked) { MotionEvent.ACTION_DOWN - { downX event.x downY event.y } MotionEvent.ACTION_MOVE - { translationX event.x - downX translationY event.y - downY } } return super.onTouchEvent(event) }super.onTouchEvent 在 View 不是 clickable 的情况下默认返回 false所以 DOWN 一旦交给 super这个 View 就被系统判定为“不接收这个序列”。后面的 MOVE 永远也不会进来。正确写法是至少在 ACTION_DOWN 分支也返回 true。override fun onTouchEvent(event: MotionEvent): Boolean { when (event.actionMasked) { MotionEvent.ACTION_DOWN - { downX event.x downY event.y return true } MotionEvent.ACTION_MOVE - { translationX event.x - downX translationY event.y - downY return true } MotionEvent.ACTION_UP - { // 结束手势后的逻辑 return true } } return super.onTouchEvent(event) }这里还有一个小细节UP 事件返回 true 或 false 对当前序列来说已经没有“保住后续事件”的意义了因为手指已经抬起。但为了语义统一和避免一些 View 内部的点击状态判断混乱我通常也会返回 true。3.2 OnClickListener 和 onTouchEvent 的真实关系Click 事件的触发本质上也是触摸分发的一环。一个设置了 OnClickListener 的 View它的 clickable 属性会被系统设为 true从而影响 onTouchEvent 的返回值。在 View 的默认 onTouchEvent 实现中如果 view 是 clickable 或 longClickable它会对 DOWN 返回 true对 UP 会触发 performClick从而回调 OnClickListener。如果不是 clickable默认返回 false。这就是为什么普通 TextView 默认不响应触摸而 Button 天生就能点击。我踩过的一个坑是这样的我在自定义 View 的 onTouchEvent 中处理了拖动逻辑但同时也设置了 OnClickListener结果在拖动结束后点击回调也会触发。原因就是我在 UP 分支没有判断是否发生了位移直接返回 trueView 就认为这次触摸是有效的点击。解决方案是在 DOWN 时记录起始坐标UP 时根据位移差判断该当作点击还是拖动如果是拖动就不要让系统走 performClick 的逻辑。MotionEvent.ACTION_UP - { val distance hypot(event.x - downX, event.y - downY) if (distance touchSlop) { performClick() } return true }这个习惯会让自定义 View 的语义清晰很多不会再出现“我明明在滑动却触发了一个 click”的怪异现象。3.3 外层的 ScrollView 为什么抢走了我的事件父容器拦截事件的经典场景就是竖向 ScrollView 内嵌套横向滑动区域。ScrollView 会在 ACTION_MOVE 中判断垂直方向的位移一旦发现纵向滑动大于 touchSlop它会返回 true把事件交给自己的滚动逻辑处理。这时候横向子 View 收到的就是一个 ACTION_CANCEL所有触摸状态都会被重置。有时候自定义子 View 需要处理手势而父容器偏偏是个系统控件你要么改父容器源码要么用前面提到的 requestDisallowInterceptTouchEvent 子控件的路数。从我项目的经验看只要子 View 确实需要控制一段手势并且父容器只在特定方向滑动内部拦截法往往能写出比较干净的代码。不过一旦用上 disallow就要小心父容器在某些情况下仍然会被系统强制取回事件控制权这个稍后会在实战部分详细展开。4. 手势冲突解决思路直接抄作业4.1 touchSlop先确定手指有没有真的在“动”Android 系统本身对手指抖动有一个阈值叫 scaledTouchSlop通常可以通过 ViewConfiguration.get(context).scaledTouchSlop 拿到。这个值在不同设备上不完全一样真实环境中大概在 8dp 到 16dp 之间。为什么要在意它因为人的手按在屏幕上总会轻微晃动如果每次稍微动了 1、2 个像素就判定为滑动手势用户体验会很神经质。我自己在实现手势冲突时习惯把 touchSlop 当作一个必须用到的常量而不是直接写死 10 或者 20 这种数字。尤其在不同屏幕密度下写死的数字可能让人感觉特别灵敏或者特别迟钝。拿到系统推荐阈值后用 dx 和 dy 做方向判断整体手感会稳定很多。4.2 外部拦截法父容器说了算外部拦截法是最容易理解的做法。父容器在 onInterceptTouchEvent 中判断是否需要拦截不拦截时就放行给子 View一旦拦截后面的事件就都归父容器。class CustomParentView JvmOverloads constructor(...) : FrameLayout(...) { private var downX 0f private var downY 0f override fun onInterceptTouchEvent(ev: MotionEvent): Boolean { when (ev.actionMasked) { MotionEvent.ACTION_DOWN - { downX ev.x downY ev.y // 注意ACTION_DOWN 一般不要拦截否则所有事件都无法到达子 View return false } MotionEvent.ACTION_MOVE - { val dx ev.x - downX val dy ev.y - downY // 横向位移大于竖向位移且超过阈值父容器接管 if (abs(dx) abs(dy) abs(dx) touchSlop) { return true } } MotionEvent.ACTION_UP - { // 抬起时不需要拦截 return false } } return super.onInterceptTouchEvent(ev) } }这里有个必须强调的细节ACTION_DOWN 尽量不要返回 true。如果你在 DOWN 阶段就把事件拦截了那么后续根本不可能再让子 View 参与触摸相当于子 View 在这个父容器里永远收不到手势。外部拦截法通常都是“按下时不拦截移动中按需拦截”的套路。还有个容易忽略的点一旦 onInterceptTouchEvent 在 MOVE 阶段返回 true子 View 会收到 ACTION_CANCEL。如果子 View 在 ACTION_CANCEL 中不做状态清理可能留下一个按下的背景色或者拖动状态没复原。所以写自定义子 View 时我总会把 ACTION_CANCEL 和 ACTION_UP 里公用的清理逻辑拿出来复用。4.3 内部拦截法子 View 主动争取控制权内部拦截法的核心是让子 View 在 dispatchTouchEvent 里主动调用 parent.requestDisallowInterceptTouchEvent(true)禁止父容器拦截事件。注意要放在 DOWN 分支因为 DOWN 一旦不禁止父容器可能立刻把事件拿走。之后如果发现这个手势需要父容器配合再调用 parent.requestDisallowInterceptTouchEvent(false)把控制权交还出去。class ChildTouchView JvmOverloads constructor(context: Context, attrs: AttributeSet? null) : View(context, attrs) { private var downX 0f private var downY 0f override fun dispatchTouchEvent(ev: MotionEvent): Boolean { when (ev.actionMasked) { MotionEvent.ACTION_DOWN - { parent.requestDisallowInterceptTouchEvent(true) downX ev.x downY ev.y } MotionEvent.ACTION_MOVE - { val dx ev.x - downX val dy ev.y - downY // 当纵向位移大于横向位移时说明用户想上下滚动交还给父容器 if (abs(dy) abs(dx)) { parent.requestDisallowInterceptTouchEvent(false) } } } return super.dispatchTouchEvent(ev) } }这里一个比较隐蔽的问题是 requestDisallowInterceptTouchEvent 只在事件正在经过父容器时有效一旦父容器已经拦截再调用就晚了。另外如果子 View 在 MOVE 过程中切换了 disallow 状态父容器是否立刻开始拦截还要看它的 onInterceptTouchEvent 返回值所以两个层级配合时父容器里不能写得过于死板。从实战效果来看内部拦截法适合那些子 View 需要主动掌握整段手势的场景比如横向滑动删除、手势控件等。它把“要不要给父容器让路”的决策权放在子 View逻辑上更贴近业务方向。4.4 我常用的一个完整布局方案举一个我做过的实际方案外层是竖向滚动列表内层 Item 需要支持横向滑动翻页。我希望手指横向移动时Item 能翻页手指纵向移动时列表能滚动。这种情况下我用外部拦截法写在父容器上因为父容器是明确的 ScrollView 或 RecyclerView本身已经有拦截逻辑我需要做的只是增强判断条件。如果我用内部拦截法去改变系统控件的默认写实反而会绕很多。伪代码如下class VerticalRecyclerView JvmOverloads constructor(...) : RecyclerView(...) { private var downX 0f private var downY 0f override fun onInterceptTouchEvent(ev: MotionEvent): Boolean { if (ev.actionMasked MotionEvent.ACTION_DOWN) { downX ev.x downY ev.y } if (ev.actionMasked MotionEvent.ACTION_MOVE) { val dx ev.x - downX val dy ev.y - downY // 横向滑动交给子 View纵向滑动父容器处理 if (abs(dx) abs(dy) abs(dx) touchSlop) { return false } } return super.onInterceptTouchEvent(ev) } }这个写法没有在代码层面对子 View 做什么操作但真正用起来子 View 的横向手势很顺畅。原因很简单父容器在 MOVE 阶段看到横向位移时主动放行子 View 自然能收到后续事件看到纵向位移时父容器走自己的滚动逻辑两者也不会打架。如果还要支持更复杂的嵌套那就再配合 requestDisallowInterceptTouchEvent 做精细化控制。5. 发生诡异事件时的排查套路5.1 日志打点法排查触摸问题最笨但也最有效的方法是在相关类的 dispatchTouchEvent 和 onTouchEvent 里加日志。加日志时最好把 MotionEvent 的 action 转成字符串否则看到一堆数字人很容易懵。fun logEvent(source: String, ev: MotionEvent) { Log.d(TouchTrace, $source - ${MotionEvent.actionToString(ev.actionMasked)}) }然后在父容器和子 View 中分别埋点看事件到底走到了哪一层、哪一层返回了什么。我一般会连返回值一起打出来因为只看调用关系还不够返回值才决定事件归属。比如在自定义 ViewGroup 里可以这样override fun dispatchTouchEvent(ev: MotionEvent): Boolean { logEvent(Parent dispatch, ev) val result super.dispatchTouchEvent(ev) Log.d(TouchTrace, Parent dispatch result $result) return result } override fun onInterceptTouchEvent(ev: MotionEvent): Boolean { val result super.onInterceptTouchEvent(ev) if (result) { Log.d(TouchTrace, Parent intercept true) } return result }这样排查两三层嵌套的 View 树基本能在一个小时内把问题定位到“是父容器拦截了还是子 View 自己没消费 DOWN”这个级别。5.2 一个常见问题的速查表我根据自己这些年的排错经验整理了一个小表格遇到问题可以先对号入座现象可能原因排查重点View 的 onTouchEvent 只收到 DOWN后续 MOVE/UP 不再进来DOWN 分支返回 false或者事件被父容器在 DOWN 阶段拦截检查 DOWN 的返回值检查父容器 onInterceptTouchEvent子 View 明明设置了 OnClickListener但点击没反应父容器在 MOVE 阶段拦截了事件导致子 View 收到 ACTION_CANCEL看父容器拦截条件以及子 View 是否处理了 ACTION_CANCEL自定义 View 能够接收 MOVE但松手后状态异常UP 分支没做收尾处理或者事件被系统判定为 CANCEL确认 UP 和 CANCEL 分支都执行清理逻辑滑动时先触发子 View 的逻辑然后父容器又抢走父容器的 onInterceptTouchEvent 在 MOVE 阶段返回 true调整父容器拦截条件或者让子 View 使用 requestDisallowInterceptTouchEvent事件能到达 Activity但 View 树内没有任何 View 响应触摸点没有命中任何可点击区域或者所有 View 都返回 false检查坐标命中检查根布局是否设置了透明的拦截视图这张表解决不了所有问题但它能帮你在面对“怪问题”时快速划定排查范围。特别是 ACTION_CANCEL 相关的现象很多人会忽略但很多状态残留的 Bug 都是从 CANCEL 清理开始解决的。5.3 ACTION_CANCEL 的处理不能敷衍父容器一旦拦截事件子 View 收到的那个 ACTION_CANCEL 不会让你有“思考时间”它会立刻打断你的手势。如果子 View 正在拖动或者按下高亮必须在这里把状态归位否则界面会留下一个永远不消失的高亮背景或者浮动控件卡在屏幕中间。我通常会把复位逻辑抽成一个 resetTouchState() 方法在 UP 和 CANCEL 里都调用。这样即使后来父容器拦截条件改动了子 View 也不会因为少清理一个分支而出现肉眼可见的 Bug。override fun onTouchEvent(ev: MotionEvent): Boolean { when (ev.actionMasked) { MotionEvent.ACTION_UP - { resetTouchState() return true } MotionEvent.ACTION_CANCEL - { resetTouchState() return true } } return super.onTouchEvent(ev) }这段代码看着简单但在我做过的项目里它让不少“奇怪”的触摸问题从根上消停了。6. 触摸分发相关的性能提醒6.1 dispatch 阶段不要做耗时操作dispatchTouchEvent 是每个触摸采样都会调用的方法手指移动时可能一秒调用几十次。如果在里面做文件读写、网络请求、甚至只是大量的字符串拼接都会直接拖慢触摸响应表现就是界面卡顿或者跟手率下降。我见过有人为了调试在 dispatchTouchEvent 里直接 Log.d 输出一整个 MotionEvent 的所有字段并开启文件日志。正式环境如果忘了关每一个 MOVE 都会产生大量的 I/O手机马上就热了。这个坑听起来很蠢但真遇到确实很折磨人。更稳妥的做法是只在 Debug 构建时开启日志或者用类似编译常量开关来控制。6.2 onTouchEvent 和 OnTouchListener 不要叠加过度使用OnTouchListener 会在 View 的 dispatchTouchEvent 内部被优先调用它本身的返回值会直接影响 dispatchTouchEvent 的结果。如果你同时设置了 OnTouchListener 又重写了 onTouchEvent很容易出现事件被某层直接消费掉另一层逻辑永远执行不到的场面。我的习惯是在同一个控件里只保留一种触摸处理方式。复杂的自定义控件直接重写 onTouchEvent不用 OnTouchListener简单的 UI 组合里需要临时监听触摸才用 OnTouchListener。并且每次都在 UP 和 DOWN 分支显式写好返回值服务不依赖默认值。6.3 保留一份“原始事件记录”的习惯排查触摸问题的时候我喜欢把 DOWN 事件的原始坐标、父容器此时滚动状态、子 View 是否处于点击态一起记录下来。因为很多问题不是单次触摸造成的而是上一次事件序列遗留下的状态影响到了下一次。比如父容器在某个序列中被拦截过一次子 View 的某些状态没有清理下一次 DOWN 事件进来时计算出来的位移和点击区域就全乱了。有了原始坐标和状态记录再看日志会比较痛苦但真正遇到难解的 Bug 时这种“多维记录”能省很多时间。尤其是面对导航栏、状态栏这些系统区域的触摸事件记录里多一些信息就不会被表象迷惑。做 Android 开发这几年我感受最深的是触摸事件这条链路不像背八股文那样记结论就行而要去验证自己的每个假设。过度依赖网上搜索出来的“点击没反应通用解法”往往会在隐蔽的边界条件下栽跟头。如果你的自定义控件开始出现跟手率差、点击失灵、滑动抢事件这类情况先把 View 树中每个节点的 dispatchTouchEvent 返回值理顺再把事件序列从头到尾打印一遍问题通常就藏在这个过程里。
返回列表