Android事件分发机制抽象--钓钩模型

0 阅读



如果你对上图的问题没有把握,那这篇文章会告诉你答案。
本文尝试从“问题驱动理解” 角度将错综复杂的事件分发机制一言以蔽之–钓钩模型,像钓鱼钩那样迎刃解决各种事件分发机制疑难杂症。
内含大量有深度有趣味的图片,给枯燥的原理分析加点甜。

用户体验小姐姐巧妙地利用有限的手机屏幕空间,完美地设计出简单实用的交互功能,如果多问一句 “怎么做到的” ?
答案必须是从事件分发机制的高超运用说起。
在我 Android 应用业务开发职业生涯中,接触到最多的也正是如何运用事件分发机制和自定义控件,堆砌出一幅幅可交互的精致业务功能画面。下图是我分别在手机百度 App 和美团 App 上研发的“列表拖动排序”和“卡片抽屉效果”代表作。

2018 年在我编码技战术水平的小巅峰期,创造性将 MECE(Mutually Exclusive Collectively Exhaustive,相互独立,完全穷尽)分析法用于专业技术原理剖析,“正面硬刚” 事件分发机制写下 Android事件分发-来龙去脉,此后一度自我膨胀事件分发 “不敢说精通”(程序猿的快乐就是这么简单)。

须知道,一山更比一山高。今年力行 “书上学” 苦练基本功,认真学习了玉刚哥的《Android开发艺术探索》,书中的几个问题 “侧面迂回” 暴击了我掌握的事件分发机制“不过尔尔”。

猛然让我意识到 “问题驱动理解” 这种学习方式简单有效,我也来试试。

考考你

提问,谁不会呢?张嘴就能来,但我们需要的是能检验出水平高低的那种。这就不禁让我想到了工作中令人难忘的事–写线上故障 CaseStudy ,相信亲身经历过的小伙伴一定忘不了直击灵魂深处的 “5 Whys”(针对问题的原因层层递进问 5 个为什么,差不多也就从事物的表象深入到了本质)。

当然,我们没必要老是跟自己过不去。差不多问 3 个就行了。问题呢,也不能太啰嗦,大道至简,最好能从最 “简单” 的问题来接近事物的本质。
我尝试构造一个简易场景来推演三个大问题和几个小问题,帮助自己理解精进事件分发机制。
页面中有一个 300*300 的蓝色背景 FrameLayout,正中有一个 100*100 的红色背景 TextView。
如下图所示:▼

接下来的问题只需要围绕 FrameLayout 和 TextView 两个控件的顺序说出事件分发相关方法调用即可。因为场景固定,不存在如果,即答案对应的是唯一路径,不存在如果…就…
为了便于理解,在回答上述问题前,我先介绍一下事件分发机制的核心方法以及对应的功能:

dispatchTouchEvent:控件事件分发主体逻辑,View 中的该方法用于调用 OnTouchListener.onTouch 和 onTouchEvent,ViewGroup 中该方法用于判断是否拦截,不拦截则遍历子控件分发。

onInterceptTouchEvent:是否拦截事件。若拦截事件,则事件不会分发给子控件,而是直接给自己消费。

onTouchEvent:消费事件主体逻辑,用于处理按键状态、OnClickListener.onClick 和 OnLongClickListener.onLongClick。

玉刚哥的几行伪代码,已将上面三大核心方法融会贯通。拿来帮助大家重温经典。中间夹带了自己的一点思考,详见第 12、13 行。

基于上述我夹带的伪代码画了一幅流程图,如下图所示:▼

《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。

简单来说,一条事件流就像一辆火车,车头和车尾是必须要有的,中间的车厢可有可无,有的话可以是任意节。DOWN 事件相当于火车头,UP 或 CANCEL 相当于火车尾,MOVE 事件相当于火车厢。我们所熟悉的 onClick 按键监听就是由完整事件流共同决定是否触发响应。
事件流火车模型如下图所示:▼

如果控件及其子孙控件都没有消费 DOWN 事件,则该控件不会收到接下来的事件流。
《Android 开发艺术探索》中的比喻十分生动形象:领导给你安排一件事,如果你中间掉链子,那就没有然后了,因为机会只有一次。

按此逻辑,DOWN 事件没有消费,那应该是不会收到 UP 事件了。如果是这样,那么问题来了,UP 事件去哪了?毕竟没有控件消费 UP 事件。

凭直觉,可能是给 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 调试时下载对应源码。

接下来,只是时间问题。

多说一句,千万别在 ViewGroup 或 View 中直接断点,这么做会很容易让你内心崩溃…
因为所有控件都会继承 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 的事件分发相关方法,其他控件将无法收到事件分发调用。

ViewGroup 中 dispatchTouchEvent 逻辑源码片段如下图所示:▼

这个问题看似简单,但实际能回答上来的才是真的高手。

画一幅时序图总结一下:▼

但可能有同学会问,不设置按键监听情况下,没啥实际意义,大部分人不会关心这种情况,换一题。

设按键监听&拦截点击分发

  1. FrameLayout 和 TextView 均设置按键监听,要求在红色和蓝色区域任意位置点击,只由 FrameLayout 的按键监听响应,怎么做?

这个简单,我来!

重写 FrameLayout 的 onInterceptTouchEvent 方法返回 true。

答案没毛病。但小问题接踵而至,DOWN 事件和 UP 事件可能都会触发调用 onInterceptTouchEvent,上面的答案不区分 DOWN 还是 UP,简单粗暴的返回了 true。DOWN 事件一定要返回 true 吗?返回 false 行不行?UP 事件呢,需不需要返回 true?

2.1 只能拦截 DOWN 事件吗?拦截 DWON 事件后,UP 事件需不需要返回 true?

这里有必要先科普一下按键监听 OnClickListener 的小知识点:

onClick 是由UP 事件的 onTouchEvent 触发调用的,但是触发的前提条件是已标记 PFLAG_PRESSED 按下状态位,而标记操作恰恰是在 DOWN 事件中做的。这也就解释了事件流的连续性。MOVE 事件呢?这是第三题,这里先按下不表。

基于上述理论,DOWN 事件是一定要拦截的。但是 UP 事件,想必有部分同学开始模棱两可了,返回 true 肯定对,返回 false 好像也对…

从常识判断,如果一个返回布尔值的纯函数,调用后返回 ture 和返回 false 效果一样,那这个调用肯定是冗余的。onInterceptTouchEvent 基本可以看成是这种纯函数。
基于对 Android Framework 工程师的基本尊重,犯这种低级错误没有道理。
那么结论只能是:onInterceptTouchEvent 在 DOWN 事件返回 true,那么后续 UP 事件根本不会再调用 onInterceptTouchEvent。

换个角度看,如果 onInterceptTouchEvent 在 DOWN 事件返回 true,意味着本控件将拦截处理后续的事件流,后续事件调用自然也就用不着再问要不要拦截。

事实也是如此。

① onInterceptTouchEvent 只能拦截 DOWN 事件,否则 FrameLayout 的按键监听不会响应。
② 无需处理 UP 事件,因为 DOWN 事件拦截后,后续事件流根本不会再调用 onInterceptTouchEvent。

因为 FrameLayout 直接在 DOWN 事件就拦截了,TextView 没有机会消费事件,不会有什么问题。但如果我们继续向前走一步,进一步窥探事件分发机制。

2.2 不拦截 DOWN 事件只拦截 UP 事件,UP 事件到底由谁消费?

按理说,DOWN 事件由 TextView 消费,UP 事件被 FrameLayout 拦截,当然是 FrameLayout消费。但如果是这样,TextView 怎么办,考虑过被拦截的子控件的感受了吗?
好比领导给了机会,我也兢兢业业的投入工作,然后就戛然而止…让不让干好歹给个痛快话,我还在干杵着呢…

显然,拦截的控件满意了,但被拦截的控件也不能不管,成熟的事件分发机制必须能妥善解决这些 “民事纠纷”。

这就涉及到了一个高级知识点了– CANCEL 事件。
这年头,不知道 CANCEL 事件的都不好意思说自己精通事件分发(反正我不敢说精通)。

当 ViewGroup 的子类重写 onInterceptTouchEvent 返回 true 拦截事件后,如果存在被拦截的子控件(该事件流的头部事件已被子控件消费),子控件将会收到一个 CANCEL 事件被告知事件流到此为止。

以上是事件拦截的大致逻辑,但是细心的同学会发现,上面只回答了 CANCEL 事件到哪去,那它是从哪来的呢?被拦截的那个事件,又是谁消费的?

相信这个问题难不倒深入阅读分析事件分发源码的同学,答案如下:

① 被拦截的事件会被转换为 CANCEL 事件,即event.setAction(MotionEvent.ACTION_CANCEL),会传递给被拦截的子控件告知事件流取消,View 中的 onTouchEvent 会消费 CANCEL 事件返回 true。
② 此后的事件流,将调用拦截控件的 dispatchTouchEvent 和 onTouchEvent。

其实这里面还有一个问题…

2.3 如果拦截了 FrameLayout 的 DOWN 事件,但是不消费,又会怎么样?

再这么推演下去,没完没了了,换一题。

磨刀不耽误砍柴,画两幅时序图总结一下:▼

设按键监听&按键移动分发

  1. 都设置按键监听,在红色区域按下,移动到蓝色区域抬起,谁的按键监听会响应?

这个问题,好像还真没想过…

基于上述按键逻辑,DOWN 事件由 TextView 消费没有争议,关键问题就是第一个不在红色区域但在蓝色区域的 MOVE 事件怎么处理,以及最终的 UP 事件到底是谁消费?
太伤头发了…

分享个生活小妙招放松一下:当我们在按下按钮那一刻,后悔了怎么办?
我的做法是,手按着不放,慢慢移动到按钮以外区域,然后再小心抬起,如愿以偿的没有触发点击操作(终于在付款的最后一刻冷静了下来,机智)。

基于这个常识,上面问题的答案是 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 在 UP 事件触发不同的是,OnLongClickListener 在 DOWN 事件触发,不过不是立即执行,而是延时执行,默认 500ms。
② OnClickListener 和 OnLongClickListener 最多两个都会执行。
MOVE 事件除了会根据当前坐标是否在控件内来判断是否取消按下状态位,也会来判断是否移除延迟执行 onLongClick。
UP 事件在触发 onClick 前,会检查是否已经执行过 onLongClick 逻辑且返回状态位(注意,是实际执行,不是触发延迟),
如果执行过 onLongClick 监听返回true,则不会触发 onClick,否则会。
如果没有执行过 onLongClick 监听,会先移除延迟执行 onLongClick 再触发 onClick。
拦截产生的 CANCEL 也会移除延迟执行 onLongClick。

还是老规矩,画一幅时序图总结一下:▼

总结

受《Android 开发艺术探索》的启发,尝试使用简明扼要的伪代码来总结回顾一下事件分发机制。

事件分发的难点在于一连串的事件流,把单点的独立问题变成了多点的连续问题,而且所有控件都走这套逻辑,目不暇接难免稀里哗啦稀里糊涂。

致敬玉刚哥的“领导分配任务”流程解释事件分发机制,我也尝试总结一下:

来了个项目,领导优先“分发”下去,问你接不接?
当然,没有人能强迫你,你可以不接(这对应事件分发不消费场景)。那后果就是,没有然后了,你不干有的是人干,机会只有一次。
所以你信心满满地对领导说,我好好干(这对应事件分发消费场景)。
然后这个项目的人力物力财力都会源源不断(一个项目对应一个完整事件流)给到你,大家都开心。
过了一段时间,领导发现项目不及预期,找你来了场触及灵魂的沟通。
最后领导和你说,现在我来负责这个项目(这对应事件拦截),你好好休息一段时间(这是你收到的 CANCEL 事件)。
后续的资源不断调拨给领导(对应拦截后的事件流改道),领导也没得选,只能自己加班加点干(拦截事件流后要对事件流负责到底,不论你干不干,这就是“项目闭环”)。
公司管这叫“补位”。“分发”和“补位”是领导的基本素质。

小伙伴可能会说,枯燥。现在好像懂了,过两天只能假装懂了,过一段时间可能就忘得精光。
有没有简单又好记的一个模型或者一幅图,方便让我们想起生活更美好的那种。
我也思索过这个问题,但没有找到答案,所以,我尝试挑战一下。

通过观察事件分发流程,发现有点像钓鱼:

第一步先放下鱼饵等鱼上钩(DOWN 事件分发),找到最终的一个 U 型路径,有点像钓鱼钩,这也是模型名称的由来。

第二步将鱼线收回来(MOVE / UP / CANCEL 事件分发),这个阶段是否消费(onTouchEvent 返回值)不重要,重要的是是否拦截(onInterceptTouchEvent),拦截只能是在当前 U 型路径上截断,而且拦截后不再调用该控件的拦截方法。
这个过程有点像你把鱼线直线往回拉,正常情况下这条鱼是你的了,但是意外的惊喜是有条大鱼把你鱼钩上的鱼当成了鱼饵,这你发财了,因为钓到的是这条更大的鱼(放长线钓大鱼)。

想想是不是这么回事,你抓住了这个长得像鱼钩的 U 型路径,是不是也就能对事件分发的各种问题给出答案了。逻辑详情如下图所示:▼

引用柳宗元《江雪》为本篇做个结尾:孤舟蓑笠翁,独寒江雪。

本文遵守 CC-BY-NC-4.0 许可协议。

Creative Commons License

欢迎转载,转载需注明出处,且禁止用于商业目的。

上篇View 测量算法我知道
下篇进击ReactNative-徐如林-React源码解析