android 一个textview多处点击_三劫连环胜负手Android事件分发机制

凡是我们看到的酷炫拉风的交互动效,如果多问一句怎么做到的?
答案必须是从事件分发机制的高超运用说起。

在我6年的Android应用开发打工仔生涯中,接触到最多的也就是如何运用事件分发机制和自定义控件,堆砌出一幅幅可交互的精致业务功能画面。

2018年在我技战术水平的小巅峰期,“正面硬刚”事件分发机制写下Android事件分发-来龙去脉,此后一度自诩事件分发“不敢说精通”,自我膨胀了一段时间(程序猿的快乐就是这么简单)。

直到今年苦练基本功,认真学习了玉刚哥的《Android开发艺术探索》,书中的几个问题“侧面迂回”暴露了我在事件分发机制上面的犹疑不定。

才知道,一山更比一山高。
既然问题来了,拉开差距的机会也就来了。

简易场景

尝试构造一个简易场景来推演三个大问题几个小问题,帮助自己理解精进。
页面中有一个300*300的蓝色背景FrameLayout,正中有一个100*100的红色背景TextView,如下图所示:▼

2eb84709c69a54a758826e42c37e9956.png

备注:下述问题只需要围绕FrameLayout和TextView两个控件的事件分发相关方法即可。因为场景固定,不存在如果,即答案对应的是唯一路径,不存在如果...就...

在红色区域点击一下,顺序说出调用了FrameLayout/TextView的事件分发相关方法?

众所周知,事件分发机制主要是dispatchTouchEvent、onInterceptTouchEvent、和onTouchEvent这三个方法。上述问题的答案是:

  • 调用FrameLayout的dispatchTouchEvent,即对应ViewGroup中的dispatchTouchEvent方法。

    • 调用FrameLayout的onInterceptTouchEvent。因为没有重写事件拦截,所以返回默认false。

    • 调用TextView的dispatchTouchEvent,即对应View中的dispatchTouchEvent方法。

      • 调用TextView的onTouchEvent。因为onInterceptTouchEvent只有ViewGroup有,TextView不存在子View,自然没有事件拦截的必要。因为未设置相关监听消费事件,所以返回默认false。

    • 调用FrameLayout的super.dispatchTouchEvent,即对应View中的dispatchTouchEvent方法。因为子控件TextView没有消费事件,转由FrameLayout尝试消费事件。

      • 调用FrameLayout的onTouchEvent。因为未设置相关监听消费事件,所以返回默认false。

一套下来,打完收工。相信这个问题难不倒大部分同学。但是,问题结束了吗?

严谨的同学微笑答道,上面只是Down事件的分发流程,还是Up事件。对,那Up事件的分发流程是什么?

这个问题其实我刚开始自问自答时,也没有回答上来。

ac373c708770a8adca1f8844e914ff6b.png

根据Android开发者文档中描述的事件流一致性保证(Consistency Guarantees):按下开始,中间可能伴随着移动,松开或者取消结束。ACTION_DOWN -> ACTION_MOVE(*) -> ACTION_UP/ACTION_CANCEL。

如事件流中有一个事件未消费,则不会收到接下来的事件流。

曾经听到一个形象的比喻,领导给你安排一件事,如果你中间掉链子,那就没有然后了,因为机会只有一次。

按此逻辑,Down事件没有消费,应该是不会收到Up事件了。如果是这样,那么问题来了,Up事件去哪了?毕竟没有控件消费Up事件。

凭直觉,可能是给Activity消费了,通过自定义重写Activity的dispatchTouchEvent和onTouchEvent,FrameLayout的dispatchTouchEvent、onInterceptTouchEvent和onTouchEvent,FrameLayout的dispatchTouchEvent和onTouchEvent,加上日志,点击一下。 

答案一目了然:Up事件会继续调用Activity的dispatchTouchEvent和onTouchEvent,但不会再调用FrameLayout和TextView。

阅读过源码的同学大概知道,Activity并没有事件分发逻辑,兜兜转转最终调用的还是DecorView的事件分发,而DecorView是继承自ViewGroup,也就是事件分发主体逻辑还是由ViewGroup和View完成的。

6a6957c35da99187d06234bec8682d34.png

所以,事件大概率被DecorView消费了。如果继续靠猜,那效率就有点低了。Debug源码必须是不二之选。

在build.gradle中将compileSdkVersion和targetSdkVersion指定成和Android模拟器一样的版本,并且在Debug调试时下载对应源码。接下来,只是时间问题。

多说一句,千万别在ViewGroup或View中直接断点,这么做会很容易让你内心崩溃...
因为所有控件都会继承View,包括ViewGroup,而你在Activity中的setContentView并不是View树的全部,像状态栏、导航栏等都属于页面内容的一部分,而这些,系统帮你做了。

77da7811d5f50655e704068685639132.png

科学的操作是先通过日志摸清敌情,找到规律,然后控制局面,有的放矢,通过自定义控件重写相关方法,在自定义控件中打断点,断住后单点跟进,精准查看逻辑。

细节建议读者实操一遍,我直接说结果了:

  1. Down事件:TextView和FrameLayout未消费Down事件,会继续向上回传到DecorView,调用DecorView的onTouchEvent。
    但DecorView也不消费,继续传给Activity,调用Activity的onTouchEvent,Activity返回false。
    简而言之,Down事件会陆续调用到DecorView和Activity,始终没有被消费。

  2. Up事件:Activity的dispatchTouchEvent先调用到,接着调用DecorView的dispatchTouchEvent。
    因为mFirstTouchTarget为null,不会调用onInterceptTouchEvent,但会设置intercepted状态位为true。逻辑见下述ViewGroup中dispatchTouchEvent源码片段,执行逻辑为第4行和16行。
    接着调用DecorView的onTouchEvent,显然,DecorView也不消费,继续传给Activity,调用Activity的onTouchEvent,Activity返回false。
    简而言之,Up事件也不会被消费,而且只会调用DecorView和Activity的事件分发相关方法,其他控件将无法收到事件分发调用。

// 源码片段源于ViewGroup的dispatchTouchEvent方法,对应android-26中ViewGroup.java文件2498行至2513行
// 是否消费事件标识
final boolean intercepted;
if (actionMasked == MotionEvent.ACTION_DOWN || mFirstTouchTarget != null) {
// 是否允许拦截标识,滑动冲突的内部拦截方式就是通过控制该状态位达到拦截目的
final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;
if (!disallowIntercept) {
intercepted = onInterceptTouchEvent(ev);
ev.setAction(action); // restore action in case it was changed
} else {
intercepted = false;
}
} else {
// There are no touch targets and this action is not an initial down
// so this view group continues to intercept touches.
intercepted = true;
}

这个问题看似简单,但实际能回答上来的才是真的高手。

但可能有同学会问,不设置按键监听情况下,没啥实际意义,大部分人不会关心这种情况,换一题。

FrameLayout和TextView均设置OnClickListener,如何做到在FrameLayout任意位置按键,只响应FrameLayout的OnClick?

这个简单,重写FrameLayout的onInterceptTouchEvent方法返回true。Over!

答案没毛病。但小问题接踵而至,Down事件和Up事件可能都会触发调用onInterceptTouchEvent,上面的答案不区分Down还是Up,简单粗暴的返回了true。Down事件一定要返回true吗?返回false行不行?Up事件呢,需不需要返回true?

这里科普个小知识点,OnClick是由Up事件的onTouchEvent触发调用的,但是触发的前提条件是已标记PFLAG_PRESSED按下状态位,而标记操作恰恰是在Down事件中做的。这也就解释了事件流的连续性。相反,如果把事件流单纯地看成是离散的单个事件点,那就太不成熟了。

基于上述知识点,Down事件是一定要返回true的。但是Up事件,想必有部分同学开始模棱两可了,返回true肯定对,返回false好像也对...

从常识判断,如果一个返回布尔值的纯函数,调用后返回ture和返回false效果一样,那这个调用肯定是多余的。onInterceptTouchEvent基本可以看成是这种纯函数。
基于对Android Framework工程师的基本尊重,犯这种低级错误没有道理。
那么结论只能是:onInterceptTouchEvent在Down事件返回true,后续Up事件根本不会调用onInterceptTouchEvent。

从另外一个角度看,如果onInterceptTouchEvent在Down事件返回true,意味着本控件将拦截处理后续的事件,后续事件调用自然也就用不着傻傻地调用onInterceptTouchEvent询问。

事实也是如此。

这就结束了吗?当然,这一题其实已经结束。
因为FrameLayout直接在Down事件就拦截了,TextView没有机会消费事件,不会有什么问题。但如果考虑只拦截了Up事件的情形,会发生什么?

Down事件由TextView消费,Up事件被FrameLayout拦截,那Up事件会是谁消费呢?

按理说,FrameLayout拦截,当然是FrameLayout消费。
如果是这样,TextView怎么办,考虑过被拦截的子控件的感受了吗?
好比领导给了机会,我也兢兢业业的投入工作,然后就戛然而止...让不让干好歹给个痛快话呀,我还在干杵着呢...

显然,拦截的控件满意了,但被拦截的控件也不能不管,成熟的事件分发机制必须能妥善解决这些“纠纷”。

这就涉及到了一个高级知识点了--Cancel事件。
这年头,不知道Cancel事件的都不好意思说自己精通事件分发(反正我永远不敢说精通)。
当ViewGroup的子类重写onInterceptTouchEvent返回true拦截事件后,如果存在被拦截的子控件(该事件流的头部事件已被子控件消费),子控件将会收到一个Cancel事件被告知事件流到此为止。

以上是事件拦截的大致逻辑,但是细心的同学会发现,上面只回答了Cancel事件到哪去,那它是从哪来的呢?被拦截的那个事件,又是谁消费的?

相信这个问题难不倒深入阅读分析事件分发源码的同学,答案如下:

  1. 被拦截的事件会被转换为Cancel事件,即event.setAction(MotionEvent.ACTION_CANCEL),会传递给被拦截的子控件告知事件流取消,View中的onTouchEvent会消费Cancel事件返回true。

  2. 此后的事件流,将调用拦截控件的dispatchTouchEvent和onTouchEvent。

其实这里面还有一个问题,如果父控件只拦截,不消费,会怎样?

再这么推演下去,没完没了了,换一题。

FrameLayout和TextView均设置OnClickListener,在红色区域按下,移动到蓝色区域抬起,调用谁的OnClick?

这个问题,好像还真没想过...

基于上述按键逻辑,Down事件由TextView消费没有争议,关键问题就是第一个不在红色区域但在蓝色区域的Move事件怎么处理,以及最终的Up事件到底是谁消费?

太伤头发了...

分享个生活小妙招放松一下:当我们在按下按钮那一刻,后悔了怎么办?
我的做法是,手按着不放,慢慢移动到按钮以外区域,然后再小心抬起,如愿以偿的没有触发点击操作(终于在付款的最后一刻冷静了下来,机智)。

基于这个常识,上面问题的答案是FrameLayout和TextView的监听事件均不会调到。
突然想到我爸问过我一个问题:公山羊和母山羊谁有胡子?
我当然没有观察过山羊的胡子,不过问题既然这么问,答案必须是反常识的。
母山羊有胡子,我得意地大声回答。
这时,我爸哈哈大笑,都有胡子...

言归正传,为什么监听事件都不会调用到?

答案都在源码里,我直接公布了:

  1. Down和红色区域内的Move事件都由TextView消费。

  2. 第一个在蓝色区域的Move事件以及之后的Move事件和Up事件依旧还是TextView消费(没想到吧)。

  3. 如果整个事件流都是TextView消费,那么为什么没有响应OnClick?问题的关键在于Move事件会根据当前坐标是否在控件内来判断是否取消PFLAG_PRESSED按下状态位。第一个蓝色区域的Move事件会将按下状态位标记为未按下(不用机灵地以为移出去再移回来可以响应,没有机会了,Move只能取消按下状态,只有Down才能标记按下状态)。Up事件时会检查按下状态位,只有按下情况才会触发OnClick。

  4. 过程中不会有Cancel事件,这是一部分同学对Cancel事件的误解。
    Cancel事件产生两个前提条件:子控件已经消费了Down事件,但父控件拦截了之后的事件。

可能好奇的同学对上面问题有另外一个答案,会不会触发OnLongClickListener?OnClickListener和OnLongClickListener关系又是什么?

这个问题问得好!答案我也直接说了:

  1. 和OnClickListener在Up事件触发不同的是,OnLongClickListener在Down事件触发,不过不是立即执行,而是延时执行,默认500ms。

  2. OnClickListener和OnLongClickListener最多只有一个会执行。
    Move事件除了会根据当前坐标是否在控件内来判断是否取消按下状态位,也会来判断是否移除延迟执行OnLongClick。
    Up事件在触发OnClick前,会检查是否已经执行过OnLongClick逻辑(注意,是实际执行,不是触发延迟),
    如果执行过OnLongClick监听,则不会触发OnClick,
    如果没有执行过OnLongClick监听,会先移除延迟执行OnLongClick再触发OnClick。
    拦截产生的Cancel也会移除延迟执行OnLongClick。

总结

受《Android开发艺术探索》的启发,尝试使用简明扼要的伪代码来总结回顾一下事件分发机制。

private boolean dispatchTouchEvent(MotionEvent event) {
// 是否拦截标识
final boolean intercepted;
if (event.getAction() == MotionEvent.ACTION_DOWN || mFirstTouchTargetChild != null) {
// 只有在Down事件和子控件已消费事件流头部事件(mFirstTouchTargetChild非空),才有必要拦截
intercepted = onInterceptTouchEvent(event);
} else {
// 其他情况,直接拦截
// 目前已知有两种场景:
// 场景一:没有控件消费事件,事件流都会分发给DecorView,无论DecorView是否消费。
// 场景二:子控件已消费事件,但是父控件中途拦截事件却没有消费,事件流仍旧分发给该父控件,无论该父控件是否消费。
intercepted = true;
}
if (!intercepted && event.getAction() == MotionEvent.ACTION_DOWN) {
// 没有拦截且事件为按下时,逆序(如果子控件有重叠,后添加的会盖在上面)遍历子控件,依次调用子控件的dispatchTouchEvent方法,寻找目标消费子控件
for (int i = mChildrenCount - 1; i >= 0; i--) {
if (mChildren[i].dispatchTouchEvent(event)) {
mFirstTouchTargetChild = mChildren[i];
break;
}
}
}

// 是否消费标识
final boolean handled;
if (mFirstTouchTargetChild != null) {
// 找到目标消费子控件
handled = true;
} else {
// 没有找到目标消费子控件,自己尝试消费,实际调用的是super.dispatchTouchEvent,里面会先判断调用OnTouchListener等额外逻辑,
// 为了表意,此处写主体逻辑调用onTouchEvent
handled = onTouchEvent(event);
}
return handled;
}

事件分发的难点在于一连串的事件流,把单点的独立问题变成了多点的连续问题,而且所有控件都走这套逻辑,目不暇接难免稀里哗啦稀里糊涂。

用个段子总结一下吧:

来了个项目,领导优先“分发”下去,问你接不接?
当然,没有人能强迫你,你可以不接(这对应事件分发不消费场景)。那后果就是,没有然后了,你不干有的是人干,机会只有一次。
所以你信心满满地对领导说,我好好干(这对应事件分发消费场景)。
然后这个项目的人力物力财力都会源源不断(一个项目对应一个完整事件流)给到你,大家都开心。
过了一段时间,领导发现项目不及预期,找你来了场触及灵魂的沟通。
最后领导和你说,现在我来负责这个项目(这对应事件拦截),你好好休息一段时间(这是你收到的Cancel事件)。
后续的资源不断调拨给领导(对应拦截后的事件流改道),领导也没得选,只能自己加班加点干(拦截事件流后要对事件流负责到底,不论你干不干,这就是“项目闭环”)。
公司管这叫“补位”。“分发”和“补位”是领导的基本素质。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值