Android事件分发机制抽象--钓钩模型
共 8812字,需浏览 18分钟
·
2020-12-12 20:48
答案必须是从事件分发机制的高超运用说起。
在我 Android 应用业务开发职业生涯中,接触到最多的也正是如何运用事件分发机制和自定义控件,堆砌出一幅幅可交互的精致业务功能画面。下图是我分别在手机百度 App 和美团 App 上研发的“列表拖动排序”和“卡片抽屉效果”代表作。
2018 年在我编码技战术水平的小巅峰期,创造性将 MECE(Mutually Exclusive Collectively Exhaustive,相互独立,完全穷尽)分析法用于专业技术原理剖析,“正面硬刚” 事件分发机制写下 Android事件分发-来龙去脉,此后一度自我膨胀事件分发 “不敢说精通”(程序猿的快乐就是这么简单)。
须知道,一山更比一山高。今年力行 “书上学” 苦练基本功,认真学习了玉刚哥的《Android开发艺术探索》,书中的几个问题 “侧面迂回” 暴击了我掌握的事件分发机制“不过尔尔”。
猛然让我意识到 “问题驱动理解” 这种学习方式简单有效,我也来试试。
考考你
▼
页面中有一个 300*300 的蓝色背景 FrameLayout,正中有一个 100*100 的红色背景 TextView。
如下图所示:▼
接下来的问题只需要围绕 FrameLayout 和 TextView 两个控件的顺序说出事件分发相关方法调用即可。因为场景固定,不存在如果,即答案对应的是唯一路径,不存在如果...就...
onInterceptTouchEvent:是否拦截事件。若拦截事件,则事件不会分发给子控件,而是直接给自己消费。
《Android 开发艺术探索》第 3 章 142 页中使用了一个通俗易懂的例子一语道破了事件分发机制的“天机”。
假如点击事件是一个难题,这个难题最终被上级领导分给了一个程序员去处理(这是事件分发过程),结果这个程序员搞不定(onTouchEvent 返回了 false),现在该怎么办呢?
难题必须要解决,那只能交给水平更高的上级解决(上级的 onTouchEvent 被调用),如果上级再搞不定,那只能交给上级的上级去解决,就这样将难题一层层地向上抛,这是公司内部一种很常见的处理问题的过程。
不设按键监听点击分发
▼
1. 不设置按键监听,在红色区域点击一下,顺序说出调用了哪个控件的哪个事件分发相关方法?
① 调用 FrameLayout 的 dispatchTouchEvent,即对应 ViewGroup 中的 dispatchTouchEvent 方法。
② 调用 FrameLayout 的 onInterceptTouchEvent。因为没有重写事件拦截,所以返回默认 false。
③ 调用 TextView 的 dispatchTouchEvent,即对应 View 中的 dispatchTouchEvent 方法。
④ 调用 TextView 的 onTouchEvent。因为 onInterceptTouchEvent 只有 ViewGroup 有,TextView 不是 ViewGroup,也就不存在事件拦截方法。因为未设置相关按键监听消费事件,所以返回默认 false。
⑤ 调用 FrameLayout 的 super.dispatchTouchEvent,即对应 View 中的 dispatchTouchEvent 方法。因为子控件 TextView 没有消费事件,转由 FrameLayout 尝试消费事件。
⑥ 调用 FrameLayout 的 onTouchEvent。因为未设置相关按键监听消费事件,所以返回默认 false。
相信这个问题难不倒大部分同学。但是,问题结束了吗?
众所周知,普通点击事件包含 DOWN 事件和 UP 事件,上面说的只是 DOWN 事件,UP 事件呢?
1.1 因为 DOWN 事件无人消费,那么 UP 事件是否还能分发到 FrameLayout?
如果不能,那 UP 事件去哪了?
这个问题其实我刚开始自问自答时,也没有回答上来。
在回答这个问题前,有必要科普一下 Android 开发者文档中描述的事件流一致性保证(Consistency Guarantees):
按下开始,中间可能伴随着移动,直到松开或者取消结束。
DOWN → MOVE(*) → UP/CANCEL。
事件流火车模型如下图所示:▼
《Android 开发艺术探索》中的比喻十分生动形象:领导给你安排一件事,如果你中间掉链子,那就没有然后了,因为机会只有一次。
凭直觉,可能是给 Activity 消费了,通过自定义重写 Activity 的 dispatchTouchEvent 和 onTouchEvent,FrameLayout 的 dispatchTouchEvent、onInterceptTouchEvent 和 onTouchEvent,FrameLayout 的 dispatchTouchEvent 和 onTouchEvent,加上日志,点击一下。
答案一目了然:UP 事件会继续调用 Activity 的 dispatchTouchEvent 和 onTouchEvent,但不会再调用 FrameLayout 和 TextView。
阅读过源码的同学大概知道,Activity 并没有事件分发逻辑,兜兜转转最终调用的还是 DecorView 的事件分发,而 DecorView 是继承自 ViewGroup,也就是事件分发主体逻辑还是由 ViewGroup 和 View 完成的。
按键响应调用如下图所示:▼
所以,事件大概率被 DecorView 消费了。如果继续靠猜,那效率就有点低了。最直接最有效的方式就是 Debug 源码。
在 build.gradle 中将 compileSdkVersion 和 targetSdkVersion 指定成和 Android 模拟器一样的版本,并且在 Debug 调试时下载对应源码。
接下来,只是时间问题。
因为所有控件都会继承 View(包括 ViewGroup),而你在 Activity 中的 setContentView 并不是 View 树的全部,像状态栏、导航栏等都属于页面内容的一部分,而这些,系统帮你做了。
页面布局如下图所示:▼
科学的操作是先通过日志摸清情况,找到规律,然后控制局面,有的放矢,通过自定义控件重写相关方法,在自定义控件中打断点,断住后单点跟进,精准查看逻辑。
细节建议读者实操一遍,我直接说结果了:
① DOWN 事件:TextView 和 FrameLayout 未消费DOWN事件,会继续向上回传到 DecorView,调用 DecorView 的 onTouchEvent。
但 DecorView 也不消费,继续传给 Activity,调用 Activity 的 onTouchEvent,Activity 返回 false。
简而言之,DOWN 事件会陆续调用到 DecorView 和 Activity,始终没有被消费。
② UP 事件:Activity 的 dispatchTouchEvent 先调用到,接着调用 DecorView 的 dispatchTouchEvent。
因为 mFirstTouchTarget 为 null,不会调用 onInterceptTouchEvent,但会设置 intercepted 状态位为 true。逻辑见下述 ViewGroup 中 dispatchTouchEvent 源码片段,执行逻辑为第 4 行和 16 行。
接着调用 DecorView 的 onTouchEvent,显然,DecorView 也不消费,继续传给 Activity,调用 Activity 的 onTouchEvent,Activity 返回 false。
简而言之,UP 事件也不会被消费,而且只会调用 DecorView 和 Activity 的事件分发相关方法,其他控件将无法收到事件分发调用。
画一幅时序图总结一下:▼
但可能有同学会问,不设置按键监听情况下,没啥实际意义,大部分人不会关心这种情况,换一题。
▼
这个简单,我来!
重写 FrameLayout 的 onInterceptTouchEvent 方法返回 true。
答案没毛病。但小问题接踵而至,DOWN 事件和 UP 事件可能都会触发调用 onInterceptTouchEvent,上面的答案不区分 DOWN 还是 UP,简单粗暴的返回了 true。DOWN 事件一定要返回 true 吗?返回 false 行不行?UP 事件呢,需不需要返回 true?
2.1 只能拦截 DOWN 事件吗?拦截 DWON 事件后,UP 事件需不需要返回 true?
这里有必要先科普一下按键监听 OnClickListener 的小知识点:
基于对 Android Framework 工程师的基本尊重,犯这种低级错误没有道理。
那么结论只能是:onInterceptTouchEvent 在 DOWN 事件返回 true,那么后续 UP 事件根本不会再调用 onInterceptTouchEvent。
换个角度看,如果 onInterceptTouchEvent 在 DOWN 事件返回 true,意味着本控件将拦截处理后续的事件流,后续事件调用自然也就用不着再问要不要拦截。
② 无需处理 UP 事件,因为 DOWN 事件拦截后,后续事件流根本不会再调用 onInterceptTouchEvent。
2.2 不拦截 DOWN 事件只拦截 UP 事件,UP 事件到底由谁消费?
好比领导给了机会,我也兢兢业业的投入工作,然后就戛然而止...让不让干好歹给个痛快话,我还在干杵着呢...
显然,拦截的控件满意了,但被拦截的控件也不能不管,成熟的事件分发机制必须能妥善解决这些 “民事纠纷”。
这就涉及到了一个高级知识点了-- CANCEL 事件。
这年头,不知道 CANCEL 事件的都不好意思说自己精通事件分发(反正我不敢说精通)。
以上是事件拦截的大致逻辑,但是细心的同学会发现,上面只回答了 CANCEL 事件到哪去,那它是从哪来的呢?被拦截的那个事件,又是谁消费的?
① 被拦截的事件会被转换为 CANCEL 事件,即event.setAction(MotionEvent.ACTION_CANCEL)
,会传递给被拦截的子控件告知事件流取消,View 中的 onTouchEvent 会消费 CANCEL 事件返回 true。
② 此后的事件流,将调用拦截控件的 dispatchTouchEvent 和 onTouchEvent。
其实这里面还有一个问题...
磨刀不耽误砍柴,画两幅时序图总结一下:▼
▼
3. 都设置按键监听,在红色区域按下,移动到蓝色区域抬起,谁的按键监听会响应?
我的做法是,手按着不放,慢慢移动到按钮以外区域,然后再小心抬起,如愿以偿的没有触发点击操作(终于在付款的最后一刻冷静了下来,机智)。
基于这个常识,上面问题的答案是 FrameLayout 和 TextView 的监听事件均不会调到。
突然想到我爸问过我一个段子:公山羊和母山羊谁有胡子?
我当然没有观察过山羊的胡子,不过问题既然这么问,答案必须是反常识的。
母山羊有胡子,我得意地大声回答。
这时,我爸哈哈大笑,都有胡子...
言归正传,为什么监听事件都不会调用到?
① DOWN 和红色区域内的 MOVE 事件都由 TextView 消费。
第一个在蓝色区域的 MOVE 事件以及之后的 MOVE 事件和 UP 事件依旧还是 TextView 消费(没想到吧)。
② 如果整个事件流都是 TextView 消费,那么为什么没有响应 onClick?问题的关键在于 MOVE 事件会根据当前坐标是否在控件内来判断是否取消 PFLAG_PRESSED 按下状态位。第一个蓝色区域的 MOVE 事件会将按下状态位标记为未按下(不用机灵地以为移出去再移回来可以响应,没有机会了,MOVE 只能取消按下状态,只有 DOWN 才能标记按下状态)。UP 事件时会检查按下状态位,只有按下情况才会触发 onClick。
③ 过程中不会有 CANCEL 事件,这是一部分同学对 CANCEL 事件的误解。
④ CANCEL 事件产生两个前提条件:子控件已经消费了 DOWN 事件,但父控件拦截了之后的事件。
可能好奇的同学内心还犯嘀咕,会不会有 OnLongClickListener?
3.1 会不会触发 OnLongClickListener?OnClickListener 和 OnLongClickListener 又是什么关系?
这个问题问得好!答案我也直接说了:
② OnClickListener 和 OnLongClickListener 最多只有一个会执行。
MOVE 事件除了会根据当前坐标是否在控件内来判断是否取消按下状态位,也会来判断是否移除延迟执行 onLongClick。
UP 事件在触发 onClick 前,会检查是否已经执行过 onLongClick 逻辑(注意,是实际执行,不是触发延迟),
如果执行过 onLongClick 监听,则不会触发 onClick,
如果没有执行过 onLongClick 监听,会先移除延迟执行 onLongClick 再触发 onClick。
拦截产生的 CANCEL 也会移除延迟执行 onLongClick。
▼
受《Android 开发艺术探索》的启发,尝试使用简明扼要的伪代码来总结回顾一下事件分发机制。
事件分发的难点在于一连串的事件流,把单点的独立问题变成了多点的连续问题,而且所有控件都走这套逻辑,目不暇接难免稀里哗啦稀里糊涂。
致敬玉刚哥的“领导分配任务”流程解释事件分发机制,我也尝试总结一下:
来了个项目,领导优先“分发”下去,问你接不接?
当然,没有人能强迫你,你可以不接(这对应事件分发不消费场景)。那后果就是,没有然后了,你不干有的是人干,机会只有一次。
所以你信心满满地对领导说,我好好干(这对应事件分发消费场景)。
然后这个项目的人力物力财力都会源源不断(一个项目对应一个完整事件流)给到你,大家都开心。
过了一段时间,领导发现项目不及预期,找你来了场触及灵魂的沟通。
最后领导和你说,现在我来负责这个项目(这对应事件拦截),你好好休息一段时间(这是你收到的 CANCEL 事件)。
后续的资源不断调拨给领导(对应拦截后的事件流改道),领导也没得选,只能自己加班加点干(拦截事件流后要对事件流负责到底,不论你干不干,这就是“项目闭环”)。
公司管这叫“补位”。“分发”和“补位”是领导的基本素质。
小伙伴可能会说,枯燥。现在好像懂了,过两天只能假装懂了,过一段时间可能就忘得精光。
有没有简单又好记的一个模型或者一幅图,方便让我们想起生活更美好的那种。
我也思索过这个问题,但没有找到答案,所以,我尝试挑战一下。
通过观察事件分发流程,发现有点像钓鱼:
第一步先放下鱼饵等鱼上钩(DOWN 事件分发),找到最终的一个 U 型路径,有点像钓鱼钩,这也是模型名称的由来。
第二步将鱼线收回来(MOVE / UP / CANCEL 事件分发),这个阶段是否消费(onTouchEvent 返回值)不重要,重要的是是否拦截(onInterceptTouchEvent),拦截只能是在当前 U 型路径上截断,而且拦截后不再调用该控件的拦截方法。
这个过程有点像你把鱼线直线往回拉,正常情况下这条鱼是你的了,但是意外的惊喜是有条大鱼把你鱼钩上的鱼当成了鱼饵,这你发财了,因为钓到的是这条更大的鱼(放长线钓大鱼)。
想想是不是这么回事,你抓住了这个长得像鱼钩的 U 型路径,是不是也就能对事件分发的各种问题给出答案了。逻辑详情如下图所示:▼