Android Touch事件分发处理机制详解

一、前言

Android应用的开发过程不可能不涉及到Touch事件的处理,简单地如设置OnClickListener、OnLongClickListener等监听器处理View的点击事件,复杂地如在自定义View中通过重写onTouchEvent来捕获用户交互事件以定制出各种效果,在使用的过程中或多或少会遇到一些奇怪的Bug,让你对Touch事件“从哪来,到哪去”产生迷之疑惑,经过多少次徘徊之后终于决定系统的分析下源码,本文就给大家分享下我的收获。

二、MotionEvent

MotionEvent作为Touch事件的载体,采用时间片来管理Touch事件所有相关行为的数据,本文这样理解时间片这个概念:

  • 纵向上,MotionEvent(一般为Move事件)会在一段时间内多次采样合成为一个事件进行处理,由于每一次采样均对应一份采样得到的Touch数据,因此该事件中就包含了多份Touch数据,并将最近采样的数据作为当前数据,其他数据存储为历史数据,采取这种批量的处理方式主要出于效率的考虑。
  • 横向上,一个时间片对应特定时间点(可通过getEventTime获取)的采样数据数据,该采样点中可能包含多个触摸点,MotionEvent中采用Pointer才标记每一个采样点,每一个Pointer的激活周期为从Down事件至Up或Cancel事件,在激活周期内会分配一个在不同MotionEvent中保持唯一的PointerId,但是在不同MotionEvent种Pointer的排序会不断调整,因此Pointer在不同MotionEvent中对应的PointerIndex也会不断变化,根据PointerId可以找到该Pointer在某一MotionEvent种的PointerIndex,根据PointerIndex则可以获取该Pointer在MotionEvent中相关Touch数据:

rawX:相对于屏幕坐标系的原始X坐标,通过getRawX获取
rawY:相对于屏幕坐标系的原始Y坐标,通过getRawY获取
x:相对于事件处理主体坐标系的X坐标,通过getX(int index)获取
y:相对于事件处理主体坐标系的Y坐标,通过getY(int index)获取
size:按压区域的大小,通过getSize(int index)获取
pressure:按压的压力,通过getPressure(int index)获取
orientation:按压时屏幕的方向,通过getOrientation(int index)获取
touchMajor:按压椭圆区域长边长,通过getTouchMajor(int index)获取
touchMinor:按压椭圆区域短边长,通过getTouchMinor(int index)获取

通常MotionEvent会将触发当前事件的Pointer作为主要Pointer,其PointerIndex为0,而MotionEvent通过提供getX()这类不带index参数的接口以更方便的操作主要Pointer的数据。
了解了MotionEvent的组成结构之后,接下来就可以分析MotionEvent包含的事件类型了,MotionEvent通过getAction接口来获取事件Action,而Action中低8位地址存储的是事件类型(对于触摸事件来说,主要包括Down、Move、Up、Cancel、PointerDown、PointerUp),高8位地址存储的是PointerId(当事件类型为PointerDown、PointerUp时)。通常来说事件会以Down开始,以Up或Cancel结束,各事件所承担的角色以及各自的特点在分析事件分发与处理的过程时再详细说明。
另外,MotionEvent中的Flag需要说明一下:

FLAG_WINDOW_IS_OBSCURED:是否被透明View遮挡
FLAG_TAINTED:事件是否出现不一致
FLAG_TARGET_ACCESSIBILITY_FOCUS:事件是否需要先触发辅助功能View

三、ViewGroup.dispatchEvent

1. 事件分发逻辑

本文仅分析Touch事件在Framework中Java层的传递,因此从事件传递到Activity开始分析。当Touch事件传递给Activity时,会调用Activity.dispatchTouchEvent(MotionEvent),Activity会将事件传递给其Window进行处理,实际会调用PhoneWindow.superDispatchTouchEvent(MotionEvent),PhoneWindow会将该事件传递给Android中View层级中的顶层View(即DecorView)进行处理:

public boolean dispatchTouchEvent(MotionEvent ev) {
    final Callback cb = getCallback();
    return cb != null && !isDestroyed() && mFeatureId < 0 ? cb.dispatchTouchEvent(ev)
            : super.dispatchTouchEvent(ev);
    }

在Window未设置Callback的情况下,会调用父类的dispatchTouchEvent,DecorView继承自FrameLayout,然后FrameLayout并未实现dispatchEvent,因此最终调用ViewGroup.dispatchTouchEvent,也就是Touch事件分发的核心逻辑所在,前文中提到MotionEvent中事件类型主要包括Down、Move、Up、Cancel、PointerDown、PointerUp,而dispatchTouchEvent根据事件的不同类型会做不同处理,因此这里分别进行分析:

Down事件处理

非异常情况下,Touch事件的事件周期总是以Down事件开始的,因此Down事件在整个事件分发逻辑中起关键作用,将决定了后续Move、Up及Cancel事件的处理主体,先看一张Down事件分发的流程图:

Down事件处理逻辑

从流程图中可以看到,Down事件的分发逻辑主要目的在于寻找到能处理该Touch事件的View控件(该View为以当前ViewGroup为Root节点的View层级中的View,利用寻找到的View创建事件处理Target),整个处理逻辑主要包含以下几步:

  • 清空异常及已有状态
    • 给所有之前选择出的Target发送Cancel事件,确保之前Target能收到完整的事件周期;
    • 清除已有Target,复位所有标志位(如PFLAG_CANCEL_NEXT_UP_EVENT、FLAG_DISALLOW_INTERCEPT等)
  • 调用onInterceptTouchEvent以确定当前ViewGroup是否拦截该Down事件
    • 若拦截,此事件不会向下传递给子View,而调用super.dispatchEvent判断该ViewGroup本身是否需要消费事件
    • 若不拦截,则会遍历所有子View寻找是否有子View需要消费该事件
      • 若有子View需要消费该事件,则设定该事件的处理Target为该子View
      • 若无子View需要消费该事件,则调用super.dispatchEvent判断该ViewGroup本身是否需要处理该事件
  • 若事件处理Target不为空或该ViewGroup消费该事件,则返回true;否则返回false。返回值将决定该ViewGroup的上级ViewGroup是否需要继续询问其他子View是否需要消费该事件;对于处于顶层的DecorView来说,其返回值会决定包含该DecorView的Activity是否需要调用Activity.onTouchEvent进行处理。

Move、Up、Cancel事件处理

完成Down事件的分发逻辑后,就确定了该Down事件后续Move、Up及Cancel事件的处理主体(注意:这里并没有确定PointerDown事件的处理主体,关于PointerDown事件的分发逻辑稍后分析),先通过一张流程图来感受下Move、Up、Cancel事件的分发逻辑:

Move、Up、Cancel事件的处理逻辑

从流程图可以看出,对于Move、Up、Cancel事件的分发步骤如下:

  • 判断在Down事件的处理中是否找到可处理该事件的Target:
    • 存在Target,则调用onInterceptTouchEvent以确定当前ViewGroup是否拦截该事件:
      • 拦截,直接调用super.dispatchEvent判断该ViewGroup本身是否需要消费事件
      • 不拦截,传递该事件至所有已有Target
    • 不存在Target,直接调用super.dispatchEvent判断该ViewGroup本身是否需要消费事件
  • 若事件为Up或Cancel,表明一个完整事件周期结束,则清除已有Target,复位被置位的标志位(如PFLAG_CANCEL_NEXT_UP_EVENT、FLAG_DISALLOW_INTERCEPT等)

PointerDown事件处理

PointerDown事件是在支持多Pointer(调用setMotionEventSplittingEnabled将FLAG_SPLIT_MOTION_EVENTS置位)的环境下,当有新的Pointer按下时产生的,该事件处理的特殊性在于会重新遍历View层级,寻找可以处理新Pointer事件的Target,具体流程参考Down事件的分发逻辑;遍历结束若仍没有找到处理该事件的Target,则会将新Pointer的处理权设置给已有Target中最早被添加的Target。完成Target的寻找之后,会将该事件通过dispatchTransformedTouchEvent传递至所有已有Target进行处理,可以通过下面流程图,对PointerDown事件的处理有一个更全局的认识:

PointerDown事件处理逻辑

PointerUp事件处理

相对于Up事件来说,对于PointerUp事件的处理区别在于当传递至所有已有Target结束之后并不能标记以Down事件起始的整个事件周期结束,仅能标记其关联Pointer(以PointerDown事件起始)的事件周期结束,因此不会清除所有状态,而仅会从已有Target中移除掉与该Pointer相关的部分。

2. 关键函数分析

onInterceptTouchEvent

在ViewGroup进行事件分发的过程中,会调用该函数来确定是否需要拦截事件,当该函数返回true时该事件将会被拦截,即不会进行正常的View层级传递,而是直接由该ViewGroup来处理,而拦截后的操作需要根据拦截事件的类型不同而不同:

  • Down事件:该事件及后续所有事件均不会传递至其子View,而该事件会由该ViewGroup尝试消费:
    • 若消费,则后续所有事件均有该ViewGroup消费
    • 若不消费,则后续事件均不会传递至以该ViewGroup为root的View层级
  • PointerDown事件:
    • 若存在处理了Down事件的Target,则传递Cancel事件给所有已有Target;
    • 该事件不会传递至子View,而直接由ViewGroup接手处理;
  • Move、Up、PointerUp、Cancel事件:若存在处理了Down事件的Target,则会先传递Cancel事件给所有已有Target,而该事件及后续事件均由该ViewGroup接手消费(不管是否实际消费)

dispatchTransformedTouchEvent(MotionEvent event, boolean cancel, View child, int desiredPointerIdBits)

在将事件传递给Target进行处理之前会调用该函数对MotionEvent进行处理:

  • cancel为true,会将事件Action转换成ACTION_CACEL
    • child为null:调用ViewGroup.super.dispatchTouchEvent
    • child不为null:调用child.dispatchTouchEvent
  • cancel为false
    • Down、Move、Up事件
      • child为null:调用ViewGroup.super.dispatchTouchEvent
      • child不为null:转换事件的X、Y坐标至child的坐标系,调用child.dispatchTouchEvent
    • PointerDown、PointerUp事件:调用MotionEvent.split分离并创建出该Pointer的事件
      • child为null:调用ViewGroup.super.dispatchTouchEvent
      • child不为null:转换事件的X、Y坐标至child的坐标系,调用child.dispatchTouchEvent

MotionEvent.split(int idBits)

  • 参数idBits:需要分离的pointerId,MotionEvent的Pointer最大为32,因此用整数可唯一标记任一事件中的任一Pointer的Id;
    • 分割步骤:
      • 遍历当前事件中所有Pointer,找出与idBits中id相对应的Pointer
      • 计算出新pointerCount
      • 计算出新pointerIndex:对于Action_Pointer_Down或Action_Pointer_Up事件,事件中需存在pointerIndex;
      • 转化Action:
        • Action_Pointer_Down或Action_Pointer_Up事件类型:
          • 无新pointerIndex,表明该事件中按下的pointer并不在idBits的兴趣范围,因此转换为ACTION_MOVE事件
          • 新pointerCount等于1,表明新事件中仅有一个Pointer,因此Action中事件类型由转换Action_Pointer_Down为ACTION_DOWN或由Action_Pointer_Up转换为ACTION_UP
          • 其他情况表明新事件中有多个Pointer,因此不转换Action中事件类型,但由原事件类型和新pointerIndex合成为新的Action
        • 其他事件无需转换
      • 创建新事件
      • 添加Batch中的History Event
      • 返回分割得到的新事件

四、View.dispatchEvent

判断一个View控件是否消费一个事件,是由View.dispatchEvent的返回值来决定的,而View.dispatchEvent用于寻找事件的最终消费者,话不多说,还是通过一张流程图来个直观感受:

事件消费流程

从流程图中可以看出,View会根据ouch事件对Scroll状态进行调整,并寻找该事件的最终处理器:

  • 首先传递给mTouchListener.onTouch
    • 返回true,消费掉该事件
    • 返回false,未消费该事件,则继续传递给View.onTouchEvent
      • 返回true,消费掉该事件
      • 返回false,未消费该事件

View.dispatchEvent将向其直接ViewGroup返回是否消费掉该事件,返回值将决定上级ViewGroup是否需要继续询问其他子View是否需要消费该事件。这就是View中分发事件的逻辑,真是简单粗暴!

五、View.onTouchEvent

从View.dispatchEvent的分析中可以发现当未对View设置mTouchListener或mTouchListener未消费掉该事件时,Touch事件最终将由View.onTouchEvent来决定是否消费,自定义View可以重写该方法实现自身的逻辑,此处仅分析View中的通用处理逻辑:

  • View处于Disabled状态,若可点击(Clickable、LongClickable、ContextClickable某一项为true)则消费掉改事件,但不执行任何具体逻辑;
  • View处于enable状态下
    • 若存在touchDelegate(可用于调整View的可点击区域),则将事件转发给touchDelegate
    • 不存在touchDelegate:
      • 可点击:
        • Action_Down:
          • 若在Scroll容器内,则设置为PrePressed状态,并延迟判定该事件是否点击事件;
          • 若不在Scroll容器,设置为Pressed状态,并延迟判定该事件是否为长按事件;
        • Action_Move:若移出该View区域,则取消点击及长按判定,并设置为非Pressed状态;
        • Action_Cancel:取消点击长按判定,设置为非Pressed状态,清除其他状态;
        • Action_Up:
          • 若为PrePressed状态,则设置为Pressed状态
          • 若为Pressed状态:
            • 未执行长按逻辑:移除长按判定,执行点击逻辑
            • 已执行长按逻辑:无需执行点击逻辑
          • 设置为非Pressed状态,移除当前时间点之前的点击及长按判定
      • 不可点击:不消费该事件

从上述分析可以很开心地发现熟悉的onClick及onLongClick事件的产生逻辑,若是之前没看过类似的文章,应该会有原来如此的感觉吧,哈哈~~

六、后记

至此,Touch事件的分发与处理流程算是走通了,个人看完整个源码之后有种豁然开朗的感觉,能很清晰的分析向“为什么事件有时候传到某个View有时候却不传?”、“有时候只传前面几个事件后面却不传了?”等问题,也希望本文的分析能让你更清晰地感知Android中Touch事件的传递流程,如果发现文中有何错误,希望不吝赐教!

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 157,198评论 4 359
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,663评论 1 290
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 106,985评论 0 237
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,673评论 0 202
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 51,994评论 3 285
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,399评论 1 211
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,717评论 2 310
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,407评论 0 194
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,112评论 1 239
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,371评论 2 241
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,891评论 1 256
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,255评论 2 250
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,881评论 3 233
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,010评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,764评论 0 192
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,412评论 2 269
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,299评论 2 260

推荐阅读更多精彩内容