反思|Android 事件拦截机制的设计与实现

「反思」 系列是笔者一个新的尝试,其起源与目录请参考 这里html

概述

完整的掌握 Android 事件分发体系并不是易事,其整个流程涉及到了 系统启动流程SystemServer)、输入管理(InputManager)、系统服务和UI的通讯ViewRootImpl + Window + WindowManagerService)、View层级的 事件分发机制 等等一系列的环节。android

事件拦截机制 是基于View层级 事件分发机制 的一个进阶性的知识点,本文将对其进行更细致化的讲解。git

事件拦截机制 自己就相对比较独立,所以本文不须要读者有 事件分发机制 相关的预备知识,对后者感兴趣的读者能够参考如下资料:github

反思 | Android 事件分发机制的设计与实现面试

本文总体结构以下图:算法

从事件序列提及

一、什么是事件序列

想要说清 事件分发机制事件拦截机制事件序列 是首先要理解的概念。数据结构

什么是事件序列?Google官方文档中对其描述为 The duration of the touch,顾名思义,咱们能够将其理解为 用户一次完整的触摸操做流程—— 举例来讲,用户单击按钮、用户滑动屏幕、用户长按屏幕中某个UI元素等等,都属于该范畴。app

二、原因

为何 事件序列 是一个很是重要的概念?ide

上一篇文章 中,读者已经了解事件分发的本质原理就是递归,对此简单的实现方式是:每接收一个新的事件,都须要进行一次递归才能找到对应消费事件的View,并依次向上返回事件分发的结果。函数

以每一个触摸事件做为最基本的单元,都对View树进行一次遍历递归?这对性能的影响显而易见,所以这种设计是有改进空间的。

如何针对这个问题进行改进?将 事件序列 做为最基本的单元进行处理则更为合适。

首先,设计者根据用户的行为对MotionEvent中添加了一个Action的属性以描述该事件的行为:

  • ACTION_DOWN:手指触摸到屏幕的行为
  • ACTION_MOVE:手指在屏幕上移动的行为
  • ACTION_UP:手指离开屏幕的行为
  • ...其它Action,好比ACTION_CANCEL...

咱们知道,针对用户的一次触摸操做,必然对应了一个 事件序列,从用户手指接触屏幕,到移动手指,再到抬起手指 ——单个事件序列必然包含ACTION_DOWNACTION_MOVE ... ACTION_MOVEACTION_UP 等多个事件,这其中ACTION_MOVE的数量不肯定,ACTION_DOWNACTION_UP的数量则为1。

熟悉了 事件序列 的概念,设计者就能够着手对现有代码进行设计和改进,其思路以下:当接收到一个ACTION_DOWN时,意味着一次完整事件序列的开始,经过递归遍历找到真正对事件进行消费的Child,并将其进行保存,这以后接收到ACTION_MOVEACTION_UP行为时,则跳过遍历递归的过程,将事件直接分发对应的消费者:

因而可知,事件序列事件分发 的知识体系中的确是很是重要的核心概念(甚至没有之一),其最重要的意义是 足够节省性能:用户一次正常的触摸行为,其 事件序列 包含了若干个触摸事件,这些事件并不是每次都经过递归算法去找到事件的消费者,由于这会消耗很是多的内存——当事件序列越复杂、或者View树的层级嵌套越深,这种优点愈发明显。

那么,源码的设计者是如何保证经过一次递归算法找到View树中对应事件消费者的子View,其数据结构又是如何的呢?

认真思考,读者不可贵出答案:链表

为何是链表

为何采用链表,有没有更加简单粗暴的实现方案?

固然,最符合直觉 的实现方式彷佛是:在经过递归完成第一次事件分发以后,将事件的消费者做为成员保存在当前父View中:

不能否认,这样的设计彻底能够实现咱们须要的效果,但读者仔细思考得知,这种设计最大的问题就是破坏了树形结构的 内部自治性

最顶层View直接持有最下层某个View的引用合理吗?答案是否认的。首先,这致使View层级依赖之间的混乱;其次,顶层View自己持有了最下层某个View的引用,则这之间若干个层级的Viewtarget属性都毫无心义。

更能将树结构应用淋漓尽致的方式是构建一个链表:

每一个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

一、原因

为何设计出这样一种拦截机制?其实这是有必要的,以常规的ScrollView对应的滑动页面为例,当用户抛出了一个列表的滑动操做,这时,对应的触摸事件序列是否还有必要交给ScrollView的子View进行处理?

答案是否认的,当ScrollView接收到滑动操做时,理所固然,本次滑动操做相关事件都再也不须要交给子View,而是直接交给ScrollView去处理滑动操做。

读者一样须要明白,并不是全部事件序列都会被拦截——当用户点击ScrollView中的某个按钮时,设计者又指望此次的点击操对应的系列事件可以被ScrollView分发给子Button去处理,这样开发者最终可以在按钮自己的OnClickListener中观察到此次点击事件,并进行对应的业务操做。

所以,对于不一样类型的ViewGroup,开发者须要在不一样的场景下,作出是否拦截事件的决定,这种 父控件根据自己职责去拦截指定场景的事件序列 的行为,咱们称之为 事件拦截机制

二、拦截函数: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_DOWNACTION_MOVE而言,咱们都没法肯定这是不是咱们但愿拦截的操做。而当咱们获取到 事件序列 中连续若干个事件后,咱们则能够根据手势操做的方向和距离(判断是不是滑动)、触摸屏幕的时间(判断是点击事件仍是长按事件)对用户的此次行为进行定义,最终决定是否进行拦截。

——这意味着,当ScrollView接收到最初的ACTION_DOWN事件时,父控件并无当即对事件进行拦截,而是交给了子Button去消费;而当接收了若干个ACTION_MOVE事件时,ScrollViewonInterceptTouchEvent()函数中判断得出 本次触摸行为方向朝下,是滑动事件,而后该函数返回true,致使本次和接下来的触摸事件都会被拦截。

等等!到了这里,读者彷佛推断出了一个怪异的结论: 针对一个完整事件序列的向下分发过程而言,触摸事件的消费者并不必定只有一个角色——这彷佛不太符合直觉。

但事实的确如此。

既然一个完整的 事件序列 其事件可能会交给不一样的角色,这是否意味着极端状况下,用户的一次 滑动行为 不但会触发了父控件自己的 滑动 效果,用户也会同时接收到Button子控件的 点击 效果?

目前为止的设计中确实存在这个缺陷,所以接下来咱们须要增长新的逻辑单元去弥补这个问题,ACTION_CANCEL闪亮登场。

三、ACTION_CANCEL:弥补与终结

终于来到了ACTION_CANCEL的舞台,报幕员对这名演员的介绍是两个单词:弥补终结

如今咱们但愿,当Button的父控件ScrollView对滑动操做进行了拦截时,Button的点击事件再也不会被响应。

正常的逻辑处理中,Button须要在接收到ACTION_UP时,判断整个事件序列持续的时间,若是符合一系列单击操做的前置定义(好比touchable = trueclickable = true等等),就直接交给单击事件的监听器View.OnClickListener去处理。

咱们能够将ACTION_UP视为 事件序列 中的 终止事件,但很明显,这个逻辑在 事件拦截机制 中并不适用,由于当父控件对事件进行了拦截后,接下来整个序列中全部的事件都转交给了父控件,子控件再也接受不到任何事件,包括ACTION_UP

咱们老是但愿善始善终(好比期待面试结果的及时反馈),事件分发机制 中也是同样,当子控件事件被父控件拦截,子控件也须要一个 终止事件 的通知以做出对应的行为。

所以,设计者额外提供了ACTION_CANCEL事件,以通知当前的View做出对 事件被拦截 以后的收尾工做,好比取消点击事件或长按事件相关判断逻辑中的计时器(若是有的话,下同),或者对当前控件滑动距离计算的重置等等,避免了「既发生父控件滑动」又「触发子控件点击」的尴尬场景。

如今,当父控件拦截了触摸事件后,子控件当即接收到一个额外的ACTION_CANCEL做为弥补,并草草进行了相关的收尾工做,以后的业务逻辑则通通交给了父控件去处理。

事件拦截机制 到此彷佛告一段落,读者认真思考,这样的逻辑处理目前已是完美的了么?

拦截机制与反拦截机制

父控件:「我无效你的效果。」 子控件:「我无效你的无效。」

一、压迫与抗争

音乐播放器的进度条控件SeekBar提出了严重抗议。

SeekBarScrollView搭配使用时,前者愕然发现,做为子控件,其最引觉得豪的技能——滑动调整音频进度的功能彻底被废掉了。

这是固然的,ScrollView接收到滑动事件时,会很天然的将接下来相关的全部事件都进行拦截,而做为子控件的SeekBar连汤都喝不上。

这是不合理的设计,父控件的权利实在太大了,而子控件对此彻底一筹莫展。所以设计者为ViewGroup设计了一个另一个API——requestDisallowInterceptTouchEvent(boolean)

该函数的做用是,命令指定的ViewGroup 是否 再也不针对事件序列进行拦截 ,而是正常将事件交给子控件去处理是否消费事件。

SeekBar为例,其彻底能够这样设计:

public abstract class AbsSeekBar extends ProgressBar {

   // ...代码大幅简化,具体逻辑请参考源码...
   @Override
   public boolean onTouchEvent(MotionEvent event) {
        switch (event.getAction()) {
          // 当接收到ACTION_DOWN事件时,命令父控件不能拦截事件序列
          case MotionEvent.ACTION_DOWN:
              mParent.requestDisallowInterceptTouchEvent(true);
              break;
          // ...
        }
   }
}
复制代码

如今,即便ScrollView内部持有对 滑动操做 相关的拦截机制,但SeekBar依然能够经过更高等级的API对其进行压制,从而跳过父控件相关的拦截,并本身消费滑动事件——最终用户获得了他但愿获得的操做体验(滑动调节播放进度)。

二、更深刻性思考

上一小节的叙述自己是存在瑕疵的,一般来讲,调节进度的SeekBar处理的是横向滑动,而ScrollView处理的则是竖向滑动,本质上二者逻辑并不冲突。

这样描述,只是为了让读者可以更容易的理解 反拦截机制 对应的requestDisallowInterceptTouchEvent()函数设计的目的及意义,对此读者没必要深究——固然,读者也可自定义实现一个横向滑动的HorizontalScrollView,以获得上一小节中滑动冲突的效果,本文不赘述。

另一点须要思考的是,当子控件调用了父控件的requestDisallowInterceptTouchEvent(true)函数无效化了父控件的拦截机制以后,父控件拦截机制的无效化须要一直存在吗

答案是否认的,正确的方式是应该在某个时间点 对父控件拦截机制进行重启——即调用requestDisallowInterceptTouchEvent(false),这样才能保证在触摸到其它子控件时,父控件依然可以对 事件拦截机制 进行正常的运转。

那么这个重置的时间点如何把握,在子控件接收到ACTION_UP时调用吗?

在子控件 事件序列的终止事件中重置状态,这听起来不错,可是须要注意的是,拦截机制被无效化的状态是存在父控件ViewGroup中的,所以换个思路,更好的时机会不会实际上是隐藏在ViewGroup中的呢?

三、更好的时机

设计者最终将重置的时机放在了父控件 事件序列的起始事件——ACTION_DOWN的处理逻辑中。

public abstract class ViewGroup extends View {

  @Override
  public boolean dispatchTouchEvent(MotionEvent ev) {
      // ...
      if (actionMasked == MotionEvent.ACTION_DOWN) {
         // 1.这个函数内部将事件拦截功能的开关进行了重置
         resetTouchState();
      }
      // ...2.继续处理事件拦截和事件分发
  }
}
复制代码

这确实是重置拦截机制的更好时机,既保证了其它子控件的触摸事件不会被以前的反拦截机制所影响,同时也维护了ViewGroup内部自己的自治性。

这也证实了 事件序列 中的起始事件 ACTION_DOWN 老是能够被父控件接收到并进行拦截处理,所以,开发者绝大多数状况下不能在 ViewGrouponInterceptTouchEvent() 中,直接对ACTION_DOWN事件返回true,由于这将会致使父控件拦截了整个 事件序列 ,子控件连ACTION_DOWN都接受不到,反拦截机制完全失效。

总结

事件拦截机制 是一个很是重要的基础知识点,而 事件序列 又是其中最核心的概念,不管是 事件分发 仍是 事件拦截,搞懂了 事件序列 的意义,其它逻辑概念的理解都再也不困难。

参考 & 额外的话

这一篇文章就能让我理解Android事件拦截机制吗?

固然不能,在撰写本文的过程当中,笔者最终删除了若干更细节知识点的讲解,好比:

  • 父控件拦截了事件后,其内部的mFirstTouchTarget发生了怎样的变化?(事件传递链表的更新操做)
  • 事件的拦截机制经常用于解决开发中的哪些问题?(解决滑动冲突)

等等,这些细节一样十分重要,它们是填充 事件拦截机制 完总体系的血与肉,建议读者结合本文与下列相关资料,开启一次更细致的探究之旅。

关于我

Hello,我是 却把清梅嗅,女儿奴,源码的眷者,观众途径序列1,杀人游戏信徒,大头菜投机者,端茶递水工程师。欢迎关注个人 博客 或者 GitHub

若是您以为文章对您有价值,欢迎 ❤️,或经过下方打赏功能,督促我写出更好的文章 :)

相关文章
相关标签/搜索