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

14,738 阅读18分钟

阅读原文:Android事件分发机制抽象--钓钩模型



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

用户体验小姐姐巧妙地利用有限的手机屏幕空间,完美地设计出简单实用的交互功能,如果多问一句 “怎么做到的” ?
答案必须是从事件分发机制的高超运用说起。
在我 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 中该方法用于判断是否拦截,不拦截则遍历子控件分发。 {:.warning}

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

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

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

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

《Android 开发艺术探索》第 3 章 142 页中使用了一个通俗易懂的例子一语道破了事件分发机制的“天机”。

假如点击事件是一个难题,这个难题最终被上级领导分给了一个程序员去处理(这是事件分发过程),
结果这个程序员搞不定(onTouchEvent 返回了 false),现在该怎么办呢?
难题必须要解决,那只能交给水平更高的上级解决(上级的 onTouchEvent 被调用),
如果上级再搞不定,那只能交给上级的上级去解决,就这样将难题一层层地向上抛。
这是公司内部一种很常见的处理问题的过程。

不设按键监听点击分发

1.不设置按键监听,在红色区域点击一下,顺序说出调用了哪个控件的哪个事件分发相关方法? {:.info}

这个问题比较简单,无需赘述,答案如下(首行缩进关系表示当前方法在上一步方法内部调用):

① 调用 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。
{:.success}

相信这个问题难不倒大部分同学。但是,问题结束了吗?
众所周知,普通点击事件包含 DOWN 事件和 UP 事件,上面说的只是 DOWN 事件,UP 事件呢?

1.1 因为 DOWN 事件无人消费,那么 UP 事件是否还能分发到 FrameLayout?
如果不能,那 UP 事件去哪了? {:.info}

这个问题其实我刚开始自问自答时,也没有回答上来。

在回答这个问题前,有必要科普一下 Android 开发者文档中描述的事件流一致性保证(Consistency Guarantees):

按下开始,中间可能伴随着移动,直到松开或者取消结束。
DOWN → MOVE(*) → UP/CANCEL。 {:.warning}

简单来说,一条事件流就像一辆火车,车头和车尾是必须要有的,中间的车厢可有可无,有的话可以是任意节。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 的事件分发相关方法,其他控件将无法收到事件分发调用。 {:.success}

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

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

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

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

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

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

这个简单,我来!

重写 FrameLayout 的 onInterceptTouchEvent 方法返回 true。 {:.success}

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

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

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

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

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

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

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

事实也是如此。

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

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

2.2 不拦截 DOWN 事件只拦截 UP 事件,UP 事件到底由谁消费? {:.info}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

基于上述按键逻辑,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 事件,但父控件拦截了之后的事件。 {:.success}

可能好奇的同学内心还犯嘀咕,会不会有 OnLongClickListener?

3.1 会不会触发 OnLongClickListener?OnClickListener 和 OnLongClickListener 又是什么关系? {:.info}

这个问题问得好!答案我也直接说了:

① 和 OnClickListener 在 UP 事件触发不同的是,OnLongClickListener 在 DOWN 事件触发,不过不是立即执行,而是延时执行,默认 500ms。
② OnClickListener 和 OnLongClickListener 最多两个都会执行。
MOVE 事件除了会根据当前坐标是否在控件内来判断是否取消按下状态位,也会来判断是否移除延迟执行 onLongClick。
UP 事件在触发 onClick 前,会检查是否已经执行过 onLongClick 逻辑且返回状态位(注意,是实际执行,不是触发延迟),
如果执行过 onLongClick 监听返回true,则不会触发 onClick,否则会。
如果没有执行过 onLongClick 监听,会先移除延迟执行 onLongClick 再触发 onClick。
拦截产生的 CANCEL 也会移除延迟执行 onLongClick。 {:.success}

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

总结

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

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

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

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

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

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

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

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

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

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