Android Touch事件分发机制
Android Touch事件的分发是 Android 工程师必备技能之一。手指触摸屏幕时,即产生了触摸信息。这个触摸信息由屏幕这个硬件产生,被系统底层驱动获取,交给Android的输入系统服务:InputManagerService,也就是IMS
IMS会对这个触摸信息进行处理,经过WMS找到要分发的window,随后发送给对应的viewRootImpl。因此发送触摸信息的并非WMS,WMS提供的是window的相关信息。
当viewRootImpl接收到触摸信息时,也正是应用程序进程事件分发的开始。
事件如何到达Activity
事件分发:
DecorView -> Activity -> PhoneWindow -> DecorView
当屏幕被触摸input系统事件从Native层分发Framework层的InputEventReceiver.dispachInputEvent()调用了
ViewRootImpl.WindowInputEventReceiver.dispachInputEvent()->
ViewRootImpl中的DecorView.dispatchTouchEvent->Window.Callback.dispatchTouchEvent(ev) or super.dispatchTouchEvent(ev)
Activity.dispatchTouchEvent->getWindow().superDispatchTouchEvent(ev) “PhoneWindow”
window.superDispatchTouchEvent()->mDecor.superDispatchTouchEvent(event)
DecorView.superDispatchTouchEvent()->super.dispatchTouchEvent(event)
ViewGroup.dispatchTouchEvent()
android的view管理是以window为单位的,每一个window对应一个view树。Window机制不只管理着view的显示,也负责view的事件分发。关于window的本质,能够阅读笔者的另外一篇文章window机制。研究事件分发的来源,须要从window机制入手。布局
因此,首先要了解一个概念:view树,即viewRootImpl。
每一棵view树都有一个根,叫作ViewRootImpl ,他负责管理这整一棵view树的绘制、事件分发等。因此能够说,事件分发是从viewRootImpl开始的。
应用界面通常会有多个view树,activity布局就是一个view树、其余应用的悬浮窗也是一个view树、dialog界面也是一个view树、使用windowManager添加的view也是一个view树等等。最简单的view树能够只有一个view。
android中view的绘制和事件分发,都是以view树为单位。每一棵view树,则为一个window 。系统服务WindowManagerService,管理界面的显示就是以window为单位,也能够说是以view树为单位。而view树是由viewRootImpl来负责管理的,因此能够说,wms(WindowManagerService的简写)管理的是viewRootImpl。
对上图作个简单解释。
wms是运行在系统服务进程的,负责管理全部应用的window。应用程序与wms的通讯必须经过Binder进行跨进程通讯。
每一个viewRootImpl在wms中都有一个windowState对应,wms能够经过windowState找到对应的viewRootImpl进行管理
了解window机制的一个重要缘由是:事件分发并非由Activity驱动的,而是由系统服务驱动viewRootImpl来进行分
ViewRootImpl是如何分发事件的
1、viewRootImpl会直接调用管理的view的 dispatchTouchEvent 方法,根据具体的view的类型,调用具体的方法。
2、view树的根view多是一个view,也多是一个viewGroup,view会直接处理事件,而viewGroup则会进行分发。
3、DecorView重写了 dispatchTouchEvent 方法,会先判断是否存在callBack,优先调用callBack的方法,也就是把事件传递给了Activity。
4、其余的viewGroup子类会根据自身的逻辑进行事件分发。
所以,触摸事件必定是从Activity开始的吗?不是,Activity只是其中的一种状况,只有Activity本身负责的那一棵view树,才必定会到达activity,而其余的window,则不会通过Activity。触摸事件是从viewRootImpl开始,而不是Activity。
Touch事件分发中只有两个主角:ViewGroup和View。Activity的Touch事件事实上是调用它内部的ViewGroup的Touch事件,可以直接当成ViewGroup处理。
View在ViewGroup内,ViewGroup也可以在其他ViewGroup内,这时候把内部的ViewGroup当成View来分析。
ViewGroup的相关事件有三个:onInterceptTouchEvent、dispatchTouchEvent、onTouchEvent。View的相关事件只有两个:dispatchTouchEvent、onTouchEvent。
先分析ViewGroup的处理流程:首先得有个结构模型概念:ViewGroup和View组成了一棵树形结构,最顶层为Activity的ViewGroup,下面有若干的ViewGroup节点,每个节点之下又有若干的ViewGroup节点或者View节点,依次类推。如图:
Activity 的dispatchTouchEvent()事件:
1 | /** |
DecorView会调用super.DispatchTouchEvent方法:
1 | public boolean superDispatchTouchEvent(MotionEvent event){ |
因为DecorView是一个FrameLayout,它最终还是调用了我们熟悉的ViewGroup的dispatchTouchEvent()
所谓的事件分发,本质上就是一个递归函数的调用,这个递归函数就是dispatchTouchEvent,至于onIntercepterTouchEvent,onTouchEvent,OnTouchListener,onClickListener…balabala都是在这个递归函数里面的操作而已,最核心,最骨干的还是dispatchTouchEvent。
当一个Touch事件(触摸事件为例)到达根节点,即Acitivty的ViewGroup时,它会依次下发,下发的过程是调用子View(ViewGroup)的dispatchTouchEvent方法实现的。简单来说,就是ViewGroup遍历它包含着的子View,调用每个View的dispatchTouchEvent方法,而当子View为ViewGroup时,又会通过调用ViwGroup的dispatchTouchEvent方法继续调用其内部的View的dispatchTouchEvent方法。上述例子中的消息下发顺序是这样的:①-②-⑤-⑥-⑦-③-④。dispatchTouchEvent方法只负责事件的分发,它拥有boolean类型的返回值,当返回为true时,顺序下发会中断。在上述例子中如果⑤的dispatchTouchEvent返回结果为true,那么⑥-⑦-③-④将都接收不到本次Touch事件。
ViewGroup的事件分发
1 |
|
View的事件分发
1 | /** |
负责对事件进行分发的方法主要有三个,分别是:
dispatchTouchEvent()
onTouchEvent()
onInterceptTouchEvent()
Down事件的分发决定了那个view要捕获事件,如果捕获了,后续的事件就直接分发给它,也就是说move up等事件的分发交给谁,取决于它们的起始事件Down由谁捕获。
在以上可看出,ViewGroup的dispatchTouchEvent是真正在执行“分发”工作,而View的dispatchTouchEvent方法,并不执行分发工作,或者说它分发的对象就是自己,决定是否把touch事件交给自己处理,而处理的方法,便是onTouchEvent事件,事实上子View的dispatchTouchEvent方法真正执行的代码是自己内部的onTouchEvent方法。
一般情况下,我们不该在普通View内重写dispatchTouchEvent方法,因为它并不执行分发逻辑。当Touch事件到达View时,我们该做的就是是否在onTouchEvent事件中处理它。
那么,ViewGroup的onTouchEvent事件是什么时候处理的呢?当ViewGroup所有的子View都返回false时,onTouchEvent事件便会执行。由于ViewGroup是继承于View的,它其实也是通过调用View的dispatchTouchEvent方法来执行onTouchEvent事件。
在目前的情况看来,似乎只要我们把所有的onTouchEvent都返回false,就能保证所有的子控件都响应本次Touch事件了。但必须要说明的是,这里的Touch事件,只限于Acition_Down事件,即触摸按下事件,而Aciton_UP和Action_MOVE却不会执行。事实上,一次完整的Touch事件,应该是由一个Down、一个Up和若干个Move组成的。Down方式通过dispatchTouchEvent分发,分发的目的是为了找到真正需要处理完整Touch请求的View。当某个View或者ViewGroup的onTouchEvent事件返回true时,便表示它是真正要处理这次请求的View,之后的Aciton_UP和Action_MOVE将由它处理。当所有子View的onTouchEvent都返回false时,这次的Touch请求就由根ViewGroup,即Activity自己处理了。
Down事件的分发决定了那个view要捕获事件,如果捕获了,后续的事件就直接分发给它,也就是说move up等事件的分发交给谁,取决于它们的起始事件Down由谁捕获。
1 | //本源码来自 api 28,不同版本略有不同。 |
拦截事件
ViewGroup还有个onInterceptTouchEvent,看名字便知道这是个拦截事件。这个拦截事件需要分两种情况来说明:
1.假如我们在某个ViewGroup的onInterceptTouchEvent中,将Action为Down的Touch事件返回true,那便表示将该ViewGroup的所有下发操作拦截掉,这种情况下,mTarget会一直为null,因为mTarget是在Down事件中赋值的。由于mTarge为null,该ViewGroup的onTouchEvent事件被执行。这种情况下可以把这个ViewGroup直接当成View来对待。
2.假如我们在某个ViewGroup的onInterceptTouchEvent中,将Acion为Down的Touch事件都返回false,其他的都返回True,这种情况下,Down事件能正常分发,若子View都返回false,那mTarget还是为空,无影响。若某个子View返回了true,mTarget被赋值了,在ACTION_MOVE和ACTION_UP分发到该ViewGroup时,便会给mTarget分发一个ACTION_CANCEL的MotionEvent,同时清空mTarget的值,使得接下去的ACTION_MOVE(如果上一个操作不是UP)将由ViewGroup的onTouchEvent处理。
事件分发汇总
1、IMS从系统底层接收到事件以后,会从WMS中获取window信息,并将事件信息发送给对应的viewRootImpl
2、viewRootImpl接收到事件信息,封装成motionEvent对象后,发送给管理的view
3、view会根据自身的类型,对事件进行分发仍是本身处理
4、顶层viewGroup通常是DecorView,DecorView会根据自身callBack的状况,选择调用callBack或者调用父类ViewGroup的方法
5、后面的Touch事件分发中有两个主角:ViewGroup和View。ViewGroup包含onInterceptTouchEvent、dispatchTouchEvent、onTouchEvent三个相关事件。View包含dispatchTouchEvent、onTouchEvent两个相关事件。其中ViewGroup又继承于View。
6、ViewGroup和View组成了一个树状结构,根节点为Activity内部包含的一个ViwGroup。
7、当Acitivty接收到Touch事件时,将遍历子View进行Down事件的分发。ViewGroup的遍历可以看成是递归的。分发的目的是为了找到真正要处理本次完整触摸事件的View,这个View会在onTouchuEvent结果返回true。
8、当某个子View返回true时,会中止Down事件的分发,同时在ViewGroup中记录该子View。接下去的Move和Up事件将由该子View直接进行处理。由于子View是保存在ViewGroup中的,多层ViewGroup的节点结构时,上级ViewGroup保存的会是真实处理事件的View所在的ViewGroup对象:如ViewGroup0-ViewGroup1-TextView的结构中,TextView返回了true,它将被保存在ViewGroup1中,而ViewGroup1也会返回true,被保存在ViewGroup0中。当Move和UP事件来时,会先从ViewGroup0传递至ViewGroup1,再由ViewGroup1传递至TextView。
9、当ViewGroup中所有子View都不捕获Down事件时,将触发ViewGroup自身的onTouch事件。触发的方式是调用super.dispatchTouchEvent函数,即父类View的dispatchTouchEvent方法。在所有子View都不处理的情况下,触发Acitivity的onTouchEvent方法。
10、onInterceptTouchEvent有两个作用:1.拦截Down事件的分发。2.中止Up和Move事件向目标View传递,使得目标View所在的ViewGroup捕获Up和Move事件。
11、触摸事件由ACTION_DOWN、ACTION_MOVE、ACTION_UP组成,其中一次完整的触摸事件中,DOWN只有一个,MOVE有若干个,可以为0个。UP可以为1个或0个。
滑动冲突处理
滑动冲突场景
Android中有许多控件支持用户进行拖拽,滑动等操作,比如SeekBar,ViewPager,ScrollView,RecyclerView等等。随着业务的展现的需求变化,UI越来越复杂,不可避免的就会出现嵌套的多个可滑动View的情况,比如ViewPager中套ViewPager,ScrollView中套ViewPager,ViewPager中套RecyclerView,还有一些开发者自行开发的,可接受滑动手势的控件与标准控件的嵌套。
当ViewTree中从根到某一叶子节点的路径上,存在多个可接受滑动手势的控件时,就有可能发生滑动冲突。
滑动冲突原因
一般而言,产生滑动冲突的时候,一定有一个可以滑动的父控件作为容器,包裹着一个可以滑动的子控件。
Android的touch事件分发的方向是从父控件到子控件,而事件消费方向则是从子控件到父控件,对于一个可滑动的ViewGroup,假如他有一个子View是一个按钮,那么当用户手触摸该按钮时,该按钮默认会消费掉这一个touch事件序列中的所有的touch事件,直到用户抬手。
理论上父控件没有机会处理滑动事件,因为父控件的onTouchEvent并不会收到touch事件。
此时通常的做法是重写ViewGroup#onInterceptTouchEvent,在其中判断用户的手指在该控件上滑动的距离,如果距离超过一个阈值,则认为用户是在滑动而不是点击,此时ViewGroup#onInterceptTouchEvent返回true,所有事件均直接传给该ViewGroup的onTouchEvent,由拦截事件的控件自身进行处理。
当然重写ViewGroup#dispatchTouchEvent也可以做到,只不过一般不重写它,重写它的要么是经验丰富的人,要么就是略懂的新人。
一般而言,Android官方控件,包括support包中的控件,对滑动冲突都有一定的避免能力,天然就能互相嵌套,且滑动效果符合开发者预期,这是多种手段互相配合的结果。
滑动冲突解决思想
由于事件一定是通过父控件派发,因此父控件可以监听触摸事件,识别滑动手势,在需要处理滑动时让ViewGroup#onInterceptTouchEvent返回true。但这并不足够,因为父控件并不知道自己内部的子控件到底是什么业务逻辑,可滑动的子控件也不知道自己的父控件到底知不知道什么时候能拦截,什么时候不能拦截,因此父控件提供了ViewGroup#requestDisallowInterceptTouchEvent方法给子控件调用,让子控件能及时通知父控件,什么时候可以拦截,什么时候不能拦截。
一般的,如果有嵌套的可滑动控件,一定是子控件优先滑动,父控件在适当时机拦截事件,自行处理滑动事件。对于父控件如何识别滑动手势,并识别是否可以拦截,也有两种常见的方案。
滑动阈值
事件流经父控件时,父控件不对事件做拦截操作,但时刻计算用户的滑动方向和距离,一旦用户的滑动方向与自己可滑动的方向夹角小于一定程度,并且滑动距离超过一个阈值,同时子控件没有禁止父控件拦截的情况下,父控件在ViewGroup#onInterceptTouchEvent中返回true,以拦截事件,之后交由ViewGroup#onTouchEvent处理滑动的具体事务。如果子控件禁止父控件拦截事件,则父控件不拦截事件,也不需要识别滑动手势。
与此同时,如果子控件识别到自己可滑动,将会通过requestDisallowInterceptTouchEvent来禁止父控件对自己可能的拦截行为,并在合适的时机重新允许父控件拦截事件。
从原理上讲,滑动阈值本身并不是为了处理滑动冲突,因为一个正常的可滑动容器,必须要能做到识别滑动手势并拦截,如果不拦截,一旦内部有任何控件吃一切事件,它就滑不动了,不要觉得吃一切事件的控件是极端情况,一个clickable的View,默认就会吃全部事件,也就是说,如果父控件不拦截滑动事件,那么当用户手指落在按钮上开始滑动时,父控件永远收不到事件。
只不过很多人在写一些自定义的可滑动容器时,第一反应就是做阈值判断拦截事件,因此也算时处理滑动冲突的方案。
是不是只要有滑动阈值判断就高枕无忧了呢?
并不是。
一个有滑动阈值的父控件,我们可以说它对子控件自行处理滑动事件是宽容的,而子控件,一般而言没那么宽容,比如SeekBar只要收到DOWN事件,就会请求父控件不拦截事件,相当于当可滑动的子控件完全不给父控件机会拦截自己,当然也就不会有冲突。这种场景下,当用户滑动子控件时,父控件是无论如何不会滑动的。
但假如子控件也是一个有滑动阈值的控件,也就是说两个宽容的控件凑一块了,会怎么样呢?
原生Android事件分发体系里面,涉及到滑动事件的处理,要么是父控件拦截掉事件并处理滑动手势;要么是子控件自行处理滑动,禁止父控件拦截,无论如何,只有一方会处理滑动事件。
而我们知道事件是从父控件派发到子控件的,父控件拦截发生在子控件收到事件之前,假如父控件的阈值是10,子控件的阈值是20,那么一旦达到阈值
最先判断需要处理滑动事件的一定是父控件,因为父控件拦截在前,且阈值小于子控件,子控件根本没机会检测到滑动手势。
有人说将父控件的阈值调整到大于子控件就可以了,这样就能让子控件率先达到阈值,自行处理滑动了。
这种想法还是忽略了一个问题,用户滑动的距离并不是一个从0开始平滑增长的值,而是一系列离散的数,用户的两个touch时间之间的距离,是可能突然变得很大的,比如一上来距离就达到了40,假如父控件的阈值是30,子控件的阈值是10,由于父控件的拦截判断在先,还是父控件先拦截的事件,而不是我们想要的子控件来处理滑动。
所以遇到两个有滑动阈值的控件嵌套,且他们滑动的方向一致时,滑动冲突无法避免。
主动检测
既然滑动阈值这种纯靠父子控件自我感觉的方案在某些情况下行不通,那么就需要有主动检测的手段。
即父控件检测到滑动事件后,首先对子控件在该方向和距离上的可滑动性进行检测,如果子控件不可滑动,则事件由父控件拦截;如果子控件可以滑动,则正常放行,由子控件自行处理滑动事件并禁止父控件拦截。恰好有这么两个方法:
View.canScrollHorizontally和View.canScrollVertically。support包中还有兼容版本的实现。
最典型的例子就是ViewPager,我们知道多个ViewPager嵌套是不会有滑动冲突的,并且还能在子ViewPager无法滑动时,改为滑动父ViewPager,它的原理就是使用View.canScrollHorizontally对子控件的可滑动性进行检测。
大部分原生控件都正确实现了这两个方法。
嵌套滑动机制
嵌套滑动机制本身是为了解决可滑动父子View的联动问题,正如前面所说,一个滑动事件要么是父控件处理,要么是子控件处理,很难做到子控件处理一部分之后再交给父控件处理,或者父控件处理一部分之后再交给子控件处理。
嵌套滑动机制可以解决可滑动View的联动问题,天然就是解决滑动冲突的方案,只是嵌套滑动机制,对于早期版本的支持有限,我并没有深入了解过,这里就不讨论了。
实践思路
如果是写可滑动的父控件(即逻辑上的View容器,内部可能嵌套其他可滑动View)
一般使用滑动阈值的方法就可以正确实现,如果想实现实现更精确的控制,可以使用View.canScrollXXX来检测子控件的可滑动性。
如果是写可滑动的子控件(即逻辑上的子控件,内部不再嵌套其他可滑动View),务必不要通过阈值来判断是否需要禁止父控件拦截事件,而是在收到ACTION_DOWN的时候立即请求禁止拦截,在合适的时机再取消禁止。如果能准确知道自己的父控件会使用View.canScrollXXX来检测自己,也可以直接通过正确实现该方法来与父控件配合。
如果在实践中,遇到两个嵌套的可滑动View,均使用了滑动阈值来判断是否处理滑动,且这俩View的源码我们均不能修改,那么可以考虑给子控件设置一个OnTouchListener,遇到ACTION_DOWN直接请求禁止拦截,在合适的时候再取消禁止,虽然体验上会有些奇怪,至少能保证不出很明显的滑动冲突问题。