学习 View 事件分发,就像外地人上了黑车

14,129 阅读7分钟

前言

谈到 View 事件分发,网上爆款热文不计其数,待人仔细阅读,却 颇有 “外地人上了黑车” 之感 —— 一言不合先上 30 张图表,带你在城市外围饶个上百圈,就是不直奔主题解释一个现象为何存在、如此设计是为解决什么问题 ...

正是对此深通恶绝,故今日我们以 “深度思考” 方式拆解事件分发,相信阅读后你会豁然开朗。

文章目录一览

  • 前言
  • View 事件分发本质是递归
  • View 事件分发为何设计成递归?
  • View 排版规则为何设计为 “嵌套越深,显示层级越高” ?
  • 所以整个流程大致是怎样的?
  • 额外需要明确的 3 个小细节
    • 细节1:明确消费的概念
    • 细节2:明确拦截的作用
    • 细节3:拦截方法只走一次,不代表拦截只走一次
    • 细节4:ACTION_DOWN 不执行,那么没下次了
    • 细节5:内部拦截并不能阻止父容器对 ACTION_DOWN 的处理
  • 综上

View 事件分发本质是递归

何为递归?顾名思义,递归是一种包含 “递” 流程和 “归” 流程的算法。

当我们在找寻目标时,便是处于 “递” 流程,当我们找到目标,打算从目标出发执行事务时,便是开启 “归” 流程。

如这么说无体会,不妨结合现实案例理解递归:

职场任务的下发和上报,就是典型递归

领导 自上而下、逐级下达任务、寻找目标执行者,这就是 “递” 流程。

找到合适执行者,便开启 自下而上 “归”流程。

若当前执行者无法让结果 OK,那么上报给他上级,由他上级执行,如上级也不 OK,那么继续向上,直到结果 OK 为止。

伪代码表示,即:

boolean dispatch() {
  if (hasTargetChild && child.dispatch()) {
    return true;
  } else {
    return executeByMySelf();
  }
}

View 事件分发为何设计成递归?

如此设计,是为与 View 排版相呼应:

1.View 排版规则是:嵌套越深的,显示层级越高。显示层级越高,则越易覆盖层级低的、被用户看见。

  1. “所见即所得” 要求 “视图系统” 设计须符合用户直觉 —— “用户看到什么,触控到的也该是什么”,且嵌套越深、层级越高,触摸通常也交给层级高的来处理。

因而事件分发需设计成递归。

View 排版规则为何设计为 “嵌套越深,显示层级越高”?

因为这符合常理。越外层的,作为父容器而充当背景,越里层的,作为子控件而置于前景。

<LinearLayout>
  <ScrollView>
    <TextView/>
  </ScrollView>
</LinearLayout>

所以整个流程大致是怎样的?

首先明确 3 点:

1.每次完整的事件分发流程,都包含自上而下的 “递”,和自下而上的“归” 2 个流程。

2.每次完整的事件分发流程,都是针对一个事件(MotionEvent)完成的递归,而一个事件只对应一个 ACTION,例如 ACTION_DOWN。

3.一次用户触摸操作,我们称之为一个事件序列。一个事件序列会包含 ACTION_DOWN、ACTION_MOVE ... ACTION_MOVE、ACTION_UP 等多个事件。(其中 ACTION_MOVE 的数量是从 0 到多个不等)

也即一个事件序列,包含从 ACTION_DOWN 到 ACTION_UP 多次事件分发流程。

以下用一张图概括 View 事件分发的 “递” 和 “归” 流程。

如图所示:👆👆👆

事先分发包含 3 个重要方法:

dispatchTouchEventonInterceptTouchEventonTouchEvent

通过前面 《Activity 快乐你不懂》 易知,View 和 ViewGroup 是组合模式关系,因而 ViewGroup 为满足分发需要,会重写一些 View 方法,就包括这里的 dispatchTouchEvent。

故于 “递” 流程,当前层级是执行 child.dispatchTouchEvent:

  • 如 child 是 ViewGroup,那么实际执行的就是 ViewGroup 重写的 dispatchTouchEvent 方法。该方法内可判断是否在当前层级拦截当前事件、或是递给下一级。
  • 如 child 是不再有 child 的 View 或 ViewGroup,那么实际执行的即是 View 类实现的 super.dispatchTouchEvent 方法。该方法内可以判断,如果 View enabled 且实现了 onTouchListener 且 onTouch 返回 true,那么不执行 onTouchEvent,且直接返回结果。否则执行 onTouchEvent。

此外,在 onTouchEvent 中如果 clickable 且实现了 onClickListener 或 onLongClickListener,那么会执行 onClick 或 onLongClick。

总之,走到没有 child 的层级,即预示步入 “归” 流程,如果该层级的 super.dispatchTouchEvent 不返回 true,那么继续执行上一级的 super.dispatchTouchEvent,直到被某一级消费,也即返回 true 为止。

以上我们介绍了正常流程会执行的方法,包括 View 实现的 dispatchTouchEvent,ViewGroup 重写的 dispatchTouchEvent,及 onTouchEvent。

如图。👆👆👆

其实在事件 “递” 流程中,ViewGroup 可在当前层级,通过设置 onInterceptTouchEvent 方法返回 true 来拦截事件下发并直接步入 “归” 流程。

正所谓 “上有正策、下有对策”。在 ViewGroup 可拦截事件下发的同时,child 也可通过 getParent.requestDisallowInterceptTouchEvent 方法,阻止上一级的下发拦截。

也正是如此,构成了 “结合对交互体验的预期来解决滑动冲突” 的现实基础。

额外需要明确的 3 个小细节

细节1:明确消费的概念

要将 “消费” 和 “执行” 这两个概念明确区分开。

网上内容总让人误以为,当前层级不消费,即是不执行 super.dispatchTouchEvent。

事实上,不消费,简单理解即是,“事情做了、只是结果不 OK” —— 在归流程中,如当前层级的 super.dispatchTouchEvent return true,那么再往上的层级都不再执行自己的 super.dispatchTouchEvent,而是直接 return true。且当前层级的下级,都执行过 super.dispatchTouchEvent,只是结果返回 false 而已。

细节2:明确拦截的作用

网上内容总让人误以为,当前层级拦截了,就直接在当前层级消费。

实际上,当前层级拦截了,只是提前结束了 “递” 流程,并从当前层级步入 “归” 流程而已。

具体判定是在哪个层级被消费,还需根据 <细节1> 指标:看在哪个层级的 super.dispatchTouchEvent return true。

细节3:拦截方法只走一次,不代表拦截只走一次

网上内容总让人误以为,本次 ACTION_DOWN 被拦截,那么往后的 ACTION_MOVE 和 ACTION_UP 都不被拦截。

实际上,是 onInterceptTouchEvent 方法只走一次,一旦走过,就会留下记号(mFirstTouchTarget == null)那么下一次直接根据这个记号来判断拦不拦截。

为何如此设计?因为一连串的事件序列,要求在几百微秒内完成。如每次都完整走一遍方法,岂不耽误事?故本着 “能省即省” 原则,凡已确认会拦截的,后续便不再走方法判断,而是直接走 “变量标记” 判断。

到此已经讲完 3 细节,要不要再讲 2 个?

讲?不讲?讲?不讲?

好嘛,再讲 2 个 ~

细节4:ACTION_DOWN 不执行,那么没下次了

这个很好理解,同 <细节3>。

连事件序列的第一个事件都不接了(父容器走后续事件的分发时发现 mFirstTouchTarget == null),那就意味着不接了呗 —— 那后续的活就不会交给你了(不会再走你的 super.dispatchTouchEvent 来试探),直接根据 “变量标记”(mFirstTouchTarget == null)判断,“能省即省”。

细节5:内部拦截并不能阻止父容器对 ACTION_DOWN 的处理

也即在 child 的 onTouch、onTouchEvent 中调用 getParent.requestDisallowInterceptTouchEvent 时,被设计为对父容器的 ACTION_DOWN 无效 —— 在父容器 dispatchTouchEvent 时,会首先重置 mGroupFlags。( ViewGroup 正是根据 mGroupFlags 是否包含 FLAG_DISALLOW_INTERCEPT 来判断是否不拦截的)

为何如此设计?

因为如此才有机会让前几个事件分发到 child 那里,从而征询 child 意见,否则 child 毫无机会从自身出发去锁定 “本次事件序列中” 后续事件的消费权

综上

  • View 事件分发的本质是递归。
  • 递归的本质是,任务下发和结果上报。
  • View 事件分发设计成递归,是为配合 View 的排版规则,形成符合用户直觉的触控体验。
  • View 事件分发的对象是一个 MotionEvent。
  • 一次用户触控操作包含多个 MotionEvent(例如从 ACTION_DOWN 到 ACTION_UP ),也即会走多次事件分发流程。
  • 一次 View 事件分发流程包含 “递” 流程和 “归” 流程,“递” 流程可因 ViewGroup 的拦截而提前步入 “归” 流程。
  • child 可以通过 getParent.requestDisallowInterceptTouchEvent 阻止父容器的拦截。因而需差异化配置阈值,来确保 child 执行 getParent.requestDisallowInterceptTouchEvent 优先于父容器 onInterceptTouchEvent 返回 true(不然都先被拦截了,child 哪有机会阻止)
  • 在 “归” 流程中,唯有当前层级的 super.dispatchTouchEvent 返回 true,才认定被消费,被消费前,下级都有干活,只是结果不 OK。被消费后,上级都不需要干活,直接向上传达消费者的功。