你真的看懂Android事件分发了吗

引子

Android事件分发其实是老生常谈了,但是说实话,我觉得很多人都只是懂其大概,模棱两可。不信我可以先抛出几个问题:

  • ACTION_DOWN和其他触摸事件的处理方式一样吗?如果不,有什么不同之处?
  • 如果一个ViewGroup的所有子View都没消费DOWN事件,这些子View在手指抬起前还会收到其他事件吗?
  • 子View一定可以通过requestDisallowIntercept干预父布局分发吗?
  • 如果有多个View都包含触摸坐标,它们都能接收到事件分发吗?如果不是,谁会接受?原理?
  • 多指操作怎么处理的?

应该会有不少读者无法答对上面的所有问题吧,多数文章都在讲事件分发的递归调用链,整体概念确实重要,但还不够深入,比如没有把ACTION_DOWN事件单独拿出来讲。如果真想彻底弄懂分发原理,必须得把源码的思路理清楚,然后才能对证下药。本文的目的就是从源码层次梳理一下,重点放在ViewGroup的dispatchTouchEvent方法上,这个方法是事件分发的核心中的核心!我们借此以小见大,理解事件分发的机制。ps,本文着重在源码和分析,就不怎么画图了(其实是懒),大家可以看网上相关图片,随便一搜很多。本文力求深入浅出,我来深入源码,然后尽量用浅显的语言讲出来。

先简单讲一下事件分发的源头

很多人讲事件分发,都说其开始是从Activity的dispatchTouchEvent开始的,大家可以简单这么理解,但是肯定会有人疑问,Activity的这个方法从哪儿调用的呢?我写了一个简单的Demo,然后在Activity的dispatchTouchEvent方法里加了一个断点得到其函数调用栈,看下图:

stack.png

好家伙,原来Activity分发之前还有这么多过程,简单梳理了一下:大概是从InputEventReceiver开始,经过ViewRootImpl,里面各种InputStage调用之后,最后给了DecorView,然后DecorView传给的Activity。其实这里挺有意思的,本来DecorView先获取到事件的,但是后来它又分配给了Activity,Activity之后又通过phoneWindow把事件传回给了DecorView,一来一回,就是为了让Activity去处理一下事件而已。Activity传给DecorView之后,DecorView会调用superDispatchTouchEvent方法:

    public boolean superDispatchTouchEvent(MotionEvent event){
        return super.dispatchTouchEvent(event);
    }

因为DecorView是一个FrameLayout,它最终还是调用了我们熟悉的ViewGroup的dispatchTouchEvent(),这也是本文的主角。所谓的事件分发,本质上就是一个递归函数的调用,这个递归函数就是dispatchTouchEvent,至于onIntercepterTouchEvent,onTouchEvent,OnTouchListener,onClickListener...balabala都是在这个递归函数里面的操作而已,最核心,最骨干的还是dispatchTouchEvent,所以我们来分析它:

ViewGroup的事件分发

大家应该或多或少读过其源码,源码虽然不是太长,但乍一看还是会头大的,我想大多数人可能大概看懂了其逻辑,对于里面很多东西不明所以。比如mFirstTouchTarget是干嘛的?临时变量alreadyDispatchedToNewTouchTarget是干嘛的?里面好像有链表啊,干嘛使的?

这里稍微补充一句,对于事件分发来说,从用户按下到抬起,这是一组事件,以ACTION_DOWN为开头,UP或CANCEL结束。我们后面分析的也是这一组事件。

源码较长,我写了伪代码给大家看看,说是伪代码,其实还是比较全面详细的,省略了部分函数参数,但重点的代码都包含了,重点看注释。如果嫌长,可以直接先看后面的结论,再回头看伪代码。

//本源码来自 api 28,不同版本略有不同。
public boolean dispatchTouchEvent(MotionEvent ev) {
    // 第一步:处理拦截
   boolean intercepted;  
     // 注意这个条件,后者代表着有子view消费事件。后面会讲
   if (actionMasked == MotionEvent.ACTION_DOWN || mFirstTouchTarget != null) {
    // 子view调用了parent.requestDisallowInterceptTouchEvent干预父布局的拦截,不让它爸拦截它
       final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;
       if (!disallowIntercept) {
             intercepted = onInterceptTouchEvent(ev);
             ev.setAction(action); 
         } else {
             intercepted = false;
         }
     } else {
        //既不是DOWN事件,mFirstTouchTarget还是null,这种情况挺常见:如果ViewGroup的所有的子View都不消费                //事件,那么当ACTION_MOVE等非DOWN事件到来时,都被拦截了。
         intercepted = true;
     }

    // 第二步,分发ACTION_DOWN
    boolean handled = false;
    boolean alreadyDispatchedToNewTouchTarget = false; //注意这个变量,会用到
   // 不拦截才会分发它,如果拦截了,就不分发ACTION_DOWN了
    if (!intercepted) {
        //处理DOWN事件,捕获第一个被触摸的mFirstTouchTarget,mFirstTouchTarget很重要,
        保存了消费了ACTION_DOWN事件的子view
        if (ev.getAction == MotionEvent.ACTION_DOWN) {
            //遍历所有子view(看源码知子View是按照Z轴排好序的)
            for (int i = childrenCount - 1; i >= 0; i--) {
                //子view如果:1.不包含事件坐标 2. 在动画  则跳过
                if (!isTransformedTouchPointInView() || !canViewReceivePointerEvents()) {
                    continue;
                }
                //将事件传递给子view的坐标空间,并且判断该子view是否消费这个触摸事件(分发Down事件)
                if (dispatchTransformedTouchEvent()) {
                    //将该view加入头节点,并且赋值给mFirstTouchTarget
                    newTouchTarget = addTouchTarget(child, idBitsToAssign);
                    alreadyDispatchedToNewTouchTarget = true;
                    break;
                }

            }
        }
    }

        //第三步:分发非DOWN事件
        //如果没有子view捕获ACTION_DOWN,则交给本ViewGroup处理这个事件。我们看到,这里并没有判断是否拦截,
        //为什么呢?因为如果拦截的话,上面的代码不会执行,就会导致mFirstTouchTarget== null,于是就走下面第一                         //个条件里的逻辑了
        if (mFirstTouchTarget == null) {
            super.dispatchTouchEvent(ev); //调用View的dispatchTouchEvent,也就是自己处理
        } else {
            //遍历touchTargets链表,依次分发事件
            TouchTarget target = mFirstTouchTarget;
            while (target != null) {
                if (alreadyDispatchedToNewTouchTarget) {
                  handled = true
                } else {
                    if (dispatchTransformedTouchEvent()) {
                      handled = true;
                    }
                  target = target.next;
                }
            }
        }

        //处理ACTION_UP和CANCEL,手指抬起来以后相关变量重置
        if (ev.getAction == MotionEvent.ACTION_UP) {
            reset();
        }
    }
    return handled;
}

总结一下:ViewGroup事件分发分为三步

  1. 第一步:判断要不要拦截:这里的条件分支要看清,外层的判断语句意思是,要么肯定会拦截,要么可能不拦截,可能不拦截的话需要满足以下两个条件之一:

    1. 事件是DOWN事件。

    2. 非DOWN事件也可以,但是需要满足mFirstTouchTarget != null 。这个条件意味着什么呢?意味着在之前的DOWN事件中,至少有一个子View捕获(消费)了DOWN事件,也就是意味着对于这一组分发事件来说,有子View愿意处理这个事件。

    在可能拦截的情况下,我们进入拦截判断流程,很简单: 先看子view有没有调parent.requestDisallowIntercept,如果调用了,不拦截,没有的话走到onIntercepteTouchEvent方法,根据其返回值决定是否拦截。

  1. 第二步:如果没有拦截,分发DOWN事件:遍历所有子View,查看触摸区域是否有子view有资格消费这个事件,判断依据有二:子View不能在动画?触摸点坐标得落在子View的范围内。如果前两者都满足,则将DOWN事件分发给子View,这一步引出了一个重要的方法:dispatchTransformedTouchEvent ,这个方法干的活就是最重要的事情:分发给子view,也就是说,这个方法进行了递归的调用,感兴趣的同学可以自己阅读其源码。遍历的范围是什么呢?源码中告诉我们是按照z轴顺序排列好的一个view list,这里的z轴顺序保证了我们先把事件分发给z轴坐标大的值,也就是更靠外层的view。另外,这个分发方法有个返回值,如果为true,则为mFirstTouchTarget赋值,否则其值仍为null。最后有个方法,addTouchTarget,这个方法一方面为mFirstTouchTarget赋值,另外也构建了一个链表,链表保存的什么呢?其实这个跟多指操作有关,它保存了所有“mFirstTouchTarget”,mFirstTouchTarget是啥呢?其实字面意思很明白了,就是手指碰触的位置最外层的View,对于多个手指来说,每个手指都会有一个mFirstTouchTarget,于是就保存到了这个链表中了。为什么要保存mFirstTouchTarget呢?很简单,为了让后续的该事件组的其他事件知道谁要处理事件啊!要不然总不能每个事件都要判断一下,那效率就低多了。这里就得出来一个结论,Down事件的分发决定了那个view要捕获事件,如果捕获了,后续的事件就直接分发给它,也就是说move up等事件的分发交给谁,取决于它们的起始事件Down由谁捕获
  1. 第三步:分发其他事件:首先判断mFirstTouchTarget,如果为null,说明前一步的DOWN事件没有子view消费掉,这种情况表示该ViewGroup的孩子View都不打算处理事件,这种情况自然要交给ViewGroup自身处理,代码里交给了super.dispatchTouchEvent,也就是调用了ViewGroup的父类View处理(onTouchEvent)。如果不为null,说明有子View要处理事件,进入else语句里,把事件分发下去。 这里眼尖的读者应该看到了,第二步不会已经分发了DOWN事件了吗,这里为啥还要再分发一次呢?不重复了吗,这里就到了前面讲的另外一个变量出场了,alreadyDispatchedToNewTouchTarget,这个变量在伪代码里第二步的开头提到了,当第二步里有子View消费了事件后,该变量会变成true,此时第三步会判断该值,如果为true,就直接返回handle=true,不再分发事件了。这就避免了DOWN事件被两次分发。对于其他事件,这个变量肯定是false,所以一定会走else的逻辑,进行分发。

再简化一下,加点大白话:

  public boolean dispatchTouchEvent(MotionEvent event) {

        boolean intercepted = false;
        if (DOWN 或者 DOWN的时候没有孩子想处理) {
            if (孩子不让拦截?) {
                intercepted = false;
            } else {
                intercepted = onIntercept();
            }
        } else {
          intercepted = true;
        }

        if (DOWN && !intercepted) {
            for (遍历孩子View) {
                if(如果该孩子能消费就给分发给它,如果它真消费了DOWN事件){
                    给mFirstTouchTarget赋值 ;
                    Down事件已经分发了;
                    跳出循环;
                }
            }
        }

        if (mFirstTouchTarget == null) {
            孩子都不想消费,交给我自己处理吧;
        } else {
            while(遍历所有孩子,将事件分发下去) {
                if (DOWN事件已经分发了) {
                    return true;
                }else {
                    分发给之前保存的mFirstTouchTarget对应的子View;
                }
            }
        }

    }

到这里,我们就把ViewGroup的事件分发讲完了,接下来分析一下View的dispatchTouchEvent

View的事件分发

View的非常简单

public boolean dispatchTouchEvent(MotionEvent event) {
    boolean result = false;
    if (onTouchListener.onTouch()) {
        result = true;
    }
    if (!result && onTouchEvent()) {
        result = true;
    }
    return result;
}

可见,先判断listener,如果listener返回true了,onTouchEvent就不进入了,否则,走onTouchEvent方法。

View的复杂点的地方在onTouchEvent方法的默认实现里,里面处理了很多onClick,onLongclick事件的逻辑,感兴趣的同学可以自行阅读源码,这里只说一点,一旦设置了onClickListener或者onLongclickListener,那么onTouchEvent就会返回true,也就是消费,其他情况下默认不消费,源码里这么写的

        final boolean clickable = ((viewFlags & CLICKABLE) == CLICKABLE
                || (viewFlags & LONG_CLICKABLE) == LONG_CLICKABLE)
                || (viewFlags & CONTEXT_CLICKABLE) == CONTEXT_CLICKABLE;

clickable为true,则返回true,否则返回false。

举个例子练习一下

问题很简单,一个FrameLayout中间放了一个按钮,Framelayout和按钮都添加了点击事件,那么,请问点击按钮和点击按钮之外的区域事件分发过程是怎样的?

先看按钮之外: FrameLayout是一个ViewGroup,而且没有重写dispatchTouchEvent方法。根据以上分析:

  • 第一步,down来了以后,进入拦截逻辑,framelayout不拦截,所以intercepted == false
  • 第二步,处理down事件,发现触摸点没有子view,所以不会有人处理这个事件的,mFirstTouchTarget == null
  • 第三步,交给自身处理,自身会调用onTouchEvent,在这里由于设置了clickListener,返回true,消费了事件。
  • 后续move和up,由于mFirstTouchTarget == null,第一步会拦截,所以直接交给自身处理,同上面的第三步,同时,up的时候会响应click事件。

按钮内:

  • 第一步,同上
  • 第二步, 发现触摸点有子view,mFirstTouchTarget != null,且将DOWN事件分发给了子View。
  • 第三步,mFirstTouchTarget非null,但alreadyDispatchedToNewTouchTarget这个变量为true,所以直接返回true。
  • 后续move和up,第一步不会拦截,因为不是down事件所以第二步跳过,第三步将事件分发给了子View,子View响应了点击事件,返回true,而这个过程中,ViewGroup没有消费任何事件,所以自然不会响应onClick事件。

这样,是不是就解释了两层View都添加click事件时的响应结果了~

总结

总的来说,事件分发分两步,拦截和分发,其中分发有两种情况,Down事件和非Down事件,down事件是事件链的起点,决定了要不要消费事件,而且将消费的子View保存下来给后面使用。如果所有的子View都不消费down事件或者压根没有子View,会使得mFirstTouchTarget为null,后面的所有事件就不再分发给子view了,直接由本view group处理。当然这里的交给本人处理,实际上可能它也不消费,会继续往上传,最终“归”到Activity处理。
越来越感到读源码的重要性,Let's read the fucking sourceCode!

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

推荐阅读更多精彩内容