反思|Android 事件拦截机制的设计与实现,android 串口工具 apk
从事件序列说起
1、什么是事件序列
想要说清 事件分发机制 和 事件拦截机制,事件序列 是首先要理解的概念。
什么是事件序列?Google
官方文档中对其描述为 The duration of the touch
,顾名思义,我们可以将其理解为 用户一次完整的触摸操作流程—— 举例来说,用户单击按钮、用户滑动屏幕、用户长按屏幕中某个 UI 元素等等,都属于该范畴。
2、缘由
为什么 事件序列 是一个非常重要的概念?
[上一篇文章](
) 中,读者已经了解事件分发的本质原理就是递归,对此简单的实现方式是:每接收一个新的事件,都需要进行一次递归才能找到对应消费事件的View
,并依次向上返回事件分发的结果。
以每个触摸事件作为最基本的单元,都对View
树进行一次遍历递归?这对性能的影响显而易见,因此这种设计是有改进空间的。
如何针对这个问题进行改进?将 事件序列 作为最基本的单元进行处理则更为合适。
首先,设计者根据用户的行为对MotionEvent
中添加了一个Action
的属性以描述该事件的行为:
ACTION_DOWN
:手指触摸到屏幕的行为ACTION_MOVE
:手指在屏幕上移动的行为ACTION_UP
:手指离开屏幕的行为...其它 Action,比如
ACTION_CANCEL
...
我们知道,针对用户的一次触摸操作,必然对应了一个 事件序列,从用户手指接触屏幕,到移动手指,再到抬起手指 ——单个事件序列必然包含ACTION_DOWN
、ACTION_MOVE
... ACTION_MOVE
、ACTION_UP
等多个事件,这其中ACTION_MOVE
的数量不确定,ACTION_DOWN
和ACTION_UP
的数量则为 1。
熟悉了 事件序列 的概念,设计者就可以着手对现有代码进行设计和改进,其思路如下:当接收到一个ACTION_DOWN
时,意味着一次完整事件序列的开始,通过递归遍历找到真正对事件进行消费的Child
,并将其进行保存,这之后接收到ACTION_MOVE
和ACTION_UP
行为时,则跳过遍历递归的过程,将事件直接分发对应的消费者:
由此可见,事件序列 在 事件分发 的知识体系中的确是非常重要的核心概念(甚至没有之一),其最重要的意义是 足够节省性能:用户一次正常的触摸行为,其 事件序列 包含了若干个触摸事件,这些事件并非每次都通过递归算法去找到事件的消费者,因为这会消耗非常多的内存——当事件序列越复杂、或者View
树的层级嵌套越深,这种优势愈发明显。
那么,源码的设计者是如何保证通过一次递归算法找到View
树中对应事件消费者的子View
,其数据结构又是如何的呢?
认真思考,读者不难得出答案:链表。
为什么是链表
为什么采用链表,有没有更加简单粗暴的实现方案?
当然,最符合直觉 的实现方式似乎是:在通过递归完成第一次事件分发之后,将事件的消费者作为成员保存在当前父View
中:
不可否认,这样的设计完全可以实现我们需要的效果,但读者仔细思考得知,这种设计最大的问题就是破坏了树形结构的 内部自治性。
最顶层View
直接持有最下层某个View
的引用合理吗?答案是否定的。首先,这导致View
层级依赖之间的混乱;其次,顶层View
本身持有了最下层某个View
的引用,则这之间若干个层级的View
的target
属性都毫无意义。
更能将树结构应用淋漓尽致的方式是构建一个链表:
每个View
节点都持有事件的下一级消费者,当同一事件序列后续的触摸事件抵达时,不再需要进行消耗性能的DFS
算法,而是直接交给下一级的子View
,子View
则直接交给下下一级的子View
,直到事件到达真正的消费者:
和链表的定义类似,设计者设计了TouchTarget
类,同时为每一个ViewGroup
都声明这样一个成员,作为链表的一个结点,以描述当前事件序列的传递方向:
public abstract class ViewGroup extends View {
// 链表的下一级结点 private TouchTarget mFirstTouchTarget;
private static final class TouchTarget {// 描述接下来的触摸事件由哪一个子 View 接收并分发 public View child;}}
那么这个链表是怎么构建的呢?正如上文所说,当接收到一个ACTION_DOWN
时,意味着一次完整事件序列的开始,通过递归遍历找到真正对事件进行消费的Child
读者需认真揣摩 事件序列 的相关概念,因为这个知识点贯穿了整个 事件分发机制 流程,可以说是非常核心的知识点;同时,掌握它也是下文快速掌握 事件拦截机制 的关键。
事件拦截机制
大多数Android
开发者对 事件拦截机制 都不会陌生,读者应该都有了解,ViewGroup
层级额外设计了onInterceptTouchEvent()
函数并向外暴露给开发者,以达到让ViewGroup
不再将触摸事件交给View
处理,而是自身决定是否消费事件,并将结果反馈给上层级的ViewGroup
。
1、缘由
为什么设计出这样一种拦截机制?其实这是有必要的,以常规的ScrollView
对应的滑动页面为例,当用户抛出了一个列表的滑动操作,这时,对应的触摸事件序列是否还有必要交给ScrollView
的子View
进行处理?
答案是否定的,当ScrollView
接收到滑动操作时,理所当然,本次滑动操作相关事件都不再需要交给子View
,而是直接交给ScrollView
去处理滑动操作。
读者同样需要明白,并非所有事件序列都会被拦截——当用户点击ScrollView
中的某个按钮时,设计者又期望这次的点击操对应的系列事件能够被ScrollView
分发给子Button
去处理,这样开发者最终能够在按钮本身的OnClickListener
中观察到这次点击事件,并进行对应的业务操作。
因此,对于不同类型的ViewGroup
,开发者需要在不同的场景下,做出是否拦截事件的决定,这种 父控件根据本身职责去拦截指定场景的事件序列 的行为,我们称之为 事件拦截机制。
2、拦截函数:onInterceptTouchEvent()
那么开发者如何做,才能保证 不同场景的事件被合理的向下分发或直接拦截 呢?设计者据此提供了 onInterceptTouchEvent()
拦截函数:
public abstract class ViewGroup extends View {
public boolean onInterceptTouchEvent(MotionEvent ev) {// ...return false;}}
其定义是,当触摸事件到来时,事件首先作为参数传入onInterceptTouchEvent
函数中,开发者自定义onInterceptTouchEvent
内部逻辑,以决定是否对该事件进行拦截,并将boolean
类型的结果进行返回。当返回值为true
时,该事件序列接下来所有的事件都会被当前的ViewGroup
拦截;通常情况下,ViewGroup
的该函数默认返回false
,即不对事件进行拦截。
以上文为例,我们可以对ScrollView
添加类似如下策略——当用户发起一个 点击事件 的操作时,onInterceptTouchEvent
返回false
,将事件交给下游的子控件去决定消费与否;而当用户 滑动屏幕 时,则将事件序列进行拦截:
public class ScrollView extends ViewGroup {
public boolean onInterceptTouchEvent(MotionEvent ev) {// 这里模拟一个抽象的函数代替实际的业务逻辑// 实际源码中,这里是根据对触摸事件序列的复杂判断,得出操作是否是滑动事件 if (isUserScrollAction(ev)) {return true;} else {return false;}}}
事件序列 在这个过程中再次起到了 至关重要 的作用。针对单独一个触摸事件——例如 ACTION_DOWN
或ACTION_MOVE
而言,我们都无法确定这是否是我们希望拦截的操作。而当我们获取到 事件序列 中连续若干个事件后,我们则可以根据手势操作的方向和距离(判断是否是滑动)、触摸屏幕的时间(判断是点击事件还是长按事件)对用户的这次行为进行定义,最终决定是否进行拦截。
——这意味着,当ScrollView
接收到最初的ACTION_DOWN
事件时,父控件并没有立即对事件进行拦截,而是交给了子Button
去消费;而当接收了若干个ACTION_MOVE
事件时,ScrollView
的onInterceptTouchEvent()
函数中判断得出 本次触摸行为方向朝下,是滑动事件,然后该函数返回true
,导致本次和接下来的触摸事件都会被拦截。
等等!到了这里,读者似乎推断出了一个怪异的结论: 针对一个完整事件序列的向下分发过程而言,触摸事件的消费者并不一定只有一个角色——这似乎不太符合直觉。
但事实的确如此。
既然一个完整的 事件序列 其事件可能会交给不同的角色,这是否意味着极端情况下,用户的一次 滑动行为 不但会触发了父控件本身的 滑动 效果,用户也会同时接收到Button
子控件的 点击 效果?
目前为止的设计中确实存在这个缺陷,因此接下来我们需要增加新的逻辑单元去弥补这个问题,ACTION_CANCEL
闪亮登场。
3、ACTION_CANCEL:弥补与终结
终于来到了ACTION_CANCEL
的舞台,报幕员对这名演员的介绍是两个单词:弥补 和 终结。
现在我们希望,当Button
的父控件ScrollView
对滑动操作进行了拦截时,Button
的点击事件不再会被响应。
正常的逻辑处理中,Button
需要在接收到ACTION_UP
时,判断整个事件序列持续的时间,如果符合一系列单击操作的前置定义(比如touchable = true
或clickable = true
等等),就直接交给单击事件的监听器View.OnClickListener
去处理。
我们可以将ACTION_UP
视为 事件序列 中的 终止事件,但很明显,这个逻辑在 事件拦截机制 中并不适用,因为当父控件对事件进行了拦截后,接下来整个序列中所有的事件都转交给了父控件,子控件再也接受不到任何事件,包括ACTION_UP
。
我们总是希望有始有终(比如期待面试结果的及时反馈),事件分发机制 中也是一样,当子控件事件被父控件拦截,子控件也需要一个 终止事件 的通知以作出对应的行为。
因此,设计者额外提供了ACTION_CANCEL
事件,以通知当前的View
作出对 事件被拦截 之后的收尾工作,比如取消点击事件或长按事件相关判断逻辑中的计时器(如果有的话,下同),或者对当前控件
滑动距离计算的重置等等,避免了「既发生父控件滑动」又「触发子控件点击」的尴尬场景。
现在,当父控件拦截了触摸事件后,子控件立即接收到一个额外的ACTION_CANCEL
作为弥补,并草草进行了相关的收尾工作,之后的业务逻辑则统统交给了父控件去处理。
事件拦截机制 到此似乎告一段落,读者认真思考,这样的逻辑处理目前已经是完美的了么?
拦截机制与反拦截机制
父控件:「我无效你的效果。」 子控件:「我无效你的无效。」
评论