本文分享的是笔者遇到的一个Android端滑动事件异常,从业务层排查到深入源码,从Input系统的framework native到base逐层进行分析。在翻阅git history逐个对比差异的过程中,定位到Android 11版本上一处有猫腻的提交,再经过一番死磕,最后真相大白,问题得解。并针对Android 11的提交进行修复,往AOSP(Android开源社区)上进行commit,得到google developer对此问题的回复。写这篇文章的目的除了读者大致了解下Input系统,更重要的是为读者提供一种思路,面对系统级的疑难杂症该如何一步一步定位源码,找到深层次的原因。
在View中调用getHandler().removeCallbacks系列方法是很常见的一种退出保护方法。然而在Android 11的系统上,这将有可能导致界面的触摸事件异常!
近几个月来收到了多起在Android手机上,拖拽界面时无法滑动的问题反馈。表现为在异常的界面上按住屏幕进行滑动没有任何响应,但又可以进行点击。而除了这个界面,其他界面一切正常。
在B界面(个人主页)发送事件(取消关注某个作者),界面A(列表界面)收到事件,进行RemoveData(移除对应作者的作品), 然后调用RecyclerView.Adapter#notifyDataSetChange操作通知刷新。再返回到A界面,此时的A界面变变得无法滑动,但可以点击。再点击进入其他界面C,C界面都可正常滑动。
大部分的滑动问题都是因为存在着嵌套滑动冲突。为了验证是否是嵌套的问题,我们需要在不同层级的View中打印接收到的MotionEvent. 很快,我们就排除了嵌套滑动的因素。因为当我们在 Activity#dispatchTouchEvent 的时候对MotionEvent进行打印,惊奇的发现MotionEvent在分发到Activity的时候就已经“不同寻常”。
1. 手指在按压滑动过程中不会收到任何Move事件。Move事件在手指抬起后,跟随Up事件一并发送,并且有仅只有一个Move事件。
2. 通过查看这个“唯一”的Move事件,发现其MotionEvent#getHistorySize()竟然达到几十上百,存放着Move过程中的所有轨迹点。
结合复现的场景,这里我们列出了问题相关的几个“嫌疑人”
1. VideoView。因业务涉及到视频播放,是否存在视频进行播放切换的时候,内部存在一些“操作”,例如SurfaceView的动态添加移除。这些操作在界面stop状态下存在异常?
在移除了视频播放相关的业务逻辑之后依旧复现此问题。!!#ff0000 ~~排除~~ !!
2. RecyclerView。RecyclerView的版本是从v7升级到androidx,会不会是RecyclerView的问题?
在将RecyclerView的版本降回到v7的版本也依旧可以复现这个问题。!!#ff0000 ~~排除~~ !!
3. 会不会是硬件层的触摸事件采集出现了问题?
结合异常情况出现时,是能同时存在正常界面的。底层的触摸事件采集跟业务的界面属于不同结构层级,业务的一些状态管理问题应该不会反作用于硬件层的触摸采集,因此这个问题与硬件层的关系不是很大。 !!#ff0000 ~~排除~~ !!
在排查了多个因素无果之后,我们将焦点放到反馈问题的手机上。出现问题的手机有一个共同点是支持高刷新频率(90HZ,120HZ...)。而一般手机的刷新频率是60HZ。难道是高刷新频率机制在某些场景下导致了触摸事件的异常? 此外,高刷机型的聚集也侧面反映了这些反馈问题的都是比较新款的手机,另一个共同点是对应的版本都是Android11。因此对刷新频率和Android版本这两个变量进行交叉组合验证
1. 60HZ(默认),90HZ和120HZ
2. Android 10和Android11
经过测试:
这意味着滑动问题与Android 11存在着紧密的联系,而Android 10是不存在这个问题的。那么要想彻底探究清楚这个问题,就必须深入了解Android10和Android 11这两版本在Input系统的事件处理上的差异,源码分析势在必行。
本文许多地方引用到了Android Framework中native,base这两部分的源码,这里提供源码的阅读的一些链接。
1、https://cs.android.com/android/platform/superproject 推荐,优点是可以进行搜索,速度也挺快的
2、https://android.googlesource.com/推荐,AOSP开源代码仓库,优点是可以查看最新的代码和提交记录
3、http://androidxref.com不推荐,已经很久不更新了,只有Android 9的源码,只适合考古
由于对Input事件的处理涉及到Android框架的多个结构层次,从native到base层,且为了探究Android 11与之前的版本差异,更需要用到翻看git history对比差异。这里我是同步整个开源仓库的代码,学有余力的同学可以参考下这个Android 开源项目指南 Wiki
Input系统结构
这里先放一张结构草图,让大家对Input系统结构层次有个粗略的印象。(PS:这里的流程是片面的)
源码中核心类及文件路径:
c++:
java:
Input系统基本单位 Window
Android Input系统中 Window 是接收用户Input事件的基本单位, 它可以是一个Activity,也可以是个Dialog,Toast,StatusBar,NavigationBar等等 ,每个Window都会对应一个ViewRootImpl.
前面分析的问题来说:界面A可以简单理解为Window A,界面B为Window B
Socket跨进程通信
Android Input事件的读取和分发是进行在一个 SystemServer进程 中的,因此从 System Server进程 中发送触摸事件到我们App 主进程 是需要进行 跨进称通信 ,这里选用的通信方式就是 socket
Activity初始化的时候, 每一个Activity实例都会创建一个用于接收事件的socket通讯通道, 通过对Windows的管理, 找到当前需要接收事件的Windows, 通过socket直接将事件数据发送给对应的Windows, Window内以RootViewImpl为起点, 对事件进行分发处理。
NativeInputEventReceiver
NativeInputEventReceiver运行在主进程,承担着socket cilent端的通信。其本质是一个 LooperCallback ,LooperCallback定义在system/core/include/utils/Looper.h中,作为Looper::addFd的回调
NativeInputEventReceiver的构造函数会接收Java层传递的Main Looper的MessageQueue指针, 初始化过程中, 调用Main Looper的 addFd 将该ViewRootImpl的InputChannel的接收端的fd添加到Main Looper的轮循中,同时将NativeInputEventReceiver注册为回调。每次receiver端的socket中的事件到达的时候就会触发到NativeInputEventReceiver的函数 handleEvent 调用。
ViewRootImpl 万View之祖
ViewRootImpl顾名思义,是所有View的根结点,也是我们的DecorView的parent。事件分发到ViewRootImpl之后,会调用其内部的dispatchInputEvent分发,也就是我们老生常谈的View事件分发。
每一个ViewRootImpl都有一个 WindowInputEventReceiver 对象,其继承自InputEventReceiver,WindowInputEventReceiver在 ViewRootImpl#setView 时, 对 InputEventReceiver 进行构造,在构造时调用 nativeInit ,创建NativeInputEventReceiver,将自己的指针传给NativeInputEventReceiver,同时保留NativeInputEventReceiver的指针。可以理解为WindowInputEventReceiver是NativeInputEventReceiver在java层的“代言人”。
所以,每一个ViewRootImpl对应一个NativeInputEventReceiver。ViewRootImpl中的WindowInputEventReceiver#onInputEvent ,onBatchedInputEventPending会在NativeInputEventReceiver#handleEvent中被调用。
InputReader和InputDispatcher
InputReader 和 InputDispatcher 是跑在 System Server进程 中的里面的两个 Native线程 ,负责读取和分发 Input 事件。要想分析input事件的流向,需要从这里开始入手。
o Connection: 与每个Window建立的通信链接对象,持有InputChannel(用于接收事件)
o OutboundQueue: 里面放的是即将要被派发给对应 Connection 的事件(每个Connection持有一个)
o WaitQueue: 里面记录的是已经派发给Connection,但是还没有得到App处理回应的事件(每个Connection持有一个)
工作流程
从InputReader和InputDispatcher这两个线程的角度,我们可以将整个input事件的处理流程简单归纳如下:
1. InputReader 读取 Input 事件
2. InputReader 将读取的 Input 事件放到 InboundQueue 中
3. InputDispatcher 从 InboundQueue 中取出 Input 事件派发到目标 Connection 的 OutBoundQueue(即发送给哪个Window是由InputDispatcher决定的)
4. 同时将事件记录到各个 Connection 的 WaitQueue
5. App 接收到 Input 事件,同时记录到 PendingInputEventQueue ,然后对事件进行分发处理
6. App 处理完成后,回调 InputManagerService 将负责监听的 WaitQueue 中对应的 Input 移除
InputDispatcher内部维护了一个 mConnectionsByFd ,根据File Descriptor存放了所有的Connection(与每个Window都有一个),Connection持有InputChannel用于发送Intput Message
Android系统中,Dispatch线程与众多APP密切联系,当我们创建一个APP时候,便于Dispatch线程产生联系,这些Connection由窗口管理器(WindowManager)创建的。故Dispatch线程便可通过这些Connection将输入事件发送给对应的APP。
了解了一些Input机制后,我们该怎么对 InputReader 和 InputDispatcher 这两个Native线程进行 Native 调试呢?
InputReader
这里我们使用的是sdk中自带的工具systrace.py. 我们对异常界面进行了Systrace(在native分析方面比AS更强大)
cd${AndroidHome}/platform-tools/systrace
pythonsystrace.py --time=10 -o trace.html
将生成html,拖入 chrome://tracing/ 中进行分析。
可以看到InputReader在 488ms , 496ms , 504ms 有明显的函数调用栈,即此时进行了input数据的采集,间隔约为8ms,符合当前120HZ的屏幕刷新频率(1s/120HZ)。如果是60HZ的刷新频率,则是约16ms进行input事件采集
可以看到InputReader采集事件之后有唤醒InputDispatcher进行事件分发。EventHub及InputReader只负责将读取到的事件分发给InputDispatcher,并不会关心到具体是那个界面,如果这里出了问题,那么应该是所有的界面都会出现同样的问题。因此所以问题不会出现在InputReader。
那么怀疑点便来到了InputDispatcher,回到我们Move Event被合并的问题:
Q1: 会不会是在InputReader线程发送的事件到Dispatcher的OutboundQueue中进行了合并处理?
Q2: 会不会在InputDispatcher进行分发给Connection的时候做了合并的操作?
InputDispatcher
源码核心类必能dump,源码核心类必能dump,源码核心类必能dump. 涉及到framework的核心类,在源码的实现上都可以看到dump方法的实现,dump方法会打印该类的一些内部信息,借助这个dump方法,我们可以获取framework类的大部分关键运行时信息
系统服务调试指令 adb shell dumpsys
adb shell dumpsys + service name 可以对系统框架服务进行当前的一些状态信息,例如 adb shell dumpsys activity 用于打印当前的所有activity信息等。具体的 service name 可以通过 adb shelldumpsys 或 adb shell service list 方法获取。点击了解更多dumpsys的使用
我们这里使用的是 adb shell dumpsys input ,可以看到
我们对出现问题的界面进行滑动,同时手指保持再屏幕上,不进行抬起,进行是 adb shell dumpsys input 可以看到 OutboundQueue 中是没有任何东西的,而 WaitQueue 中堆积了大量的MotionEvent(action=MOVE),此时也并没有被合并成一个。
与此同时,我们打开一个新的界面,在正常的界面上进行同样的操作,发现正常的界面的WaitQueue并不会堆积如此之多的MotionEvent。WaitQueue 依赖主线程消费 Input 事件后进行反馈,那么当 Input 事件没有及时被消耗,就会在 WaitQueue 这里的length上体现出来。当 Input 事件长时间没有被消费的话,我们常见的ANR Exception就是这里抛出的,最最常见的原因就是主线程的耗时操作,进而引发卡顿。
但我们这里的问题与主线程耗时卡顿有本质区别。如果是主线程做了耗时的操作,也不应该出现WaitQueue里的Move事件一直持续增加。
这里我们再放出系统结构图,前面我们已经通过 systrace 和 adb shell dumpsys input ,分析出
1,2,3 这流程是正常的,4 这个步骤是用socket的一个发送input message,对数据无感的一个流程,而且我们在问题界面也能够收到Down和Up事件。那么 4 这个步骤就是正常的。
NativeInputEventReceiver
这里需要对源码逐步分析,当InputEvent到来的时候,调用的是 NativeInputEventReceiver::handleEvent ,
其内部又调用了 NativeInputEventReceiver::consumeEvents ,核心对inputEvent的处理再 InputConsumer:consume 中。
在consumeEvents中可以看到正常的流程是会走native调用java方法InputEventReceiver#dispatchInputEvent .这里我们要留意的是其他分支情况,可以看到在 status== WOULD_BLOCK,我们是会走到里面的分支,从native调用java方法InputEventReceiver#onBatchedInputEventPending ,往下进行分析怎么场景会走到这里。
因为源码逻辑比较复杂,我们的注意力要放在对ACTION_MOVE这类关键字上,看哪些这类事件进行了特殊操作
在 InputConsumer#consume 的方法中,可以看到一处 AMOTION_EVENT_ACTION_MOVE ,果不其然,在该方法中,对是否是input事件进行了判断,如果是move类型的事件,会进行一个batch操作,然后直接返回,此时的 *outEvent = nullptr .而当事件为非move类型事件,会走到
*outEvent = motionEvent; .最终在外头会走到 InputEventReceiver#dispatchInputEvent
轮循还是通知
前面我们深入分析了源码,最终发现在分发的路径上,Move类型的事件并没有跟Down和Up事件一样走dispatchInputEvent直接分发到上层。之前的系统结构图是不完整的!!!
有些同学会认为,触摸事件的处理是由框架层每隔一定的周期(一帧)去调用某个native方法来触发input事件上传消费(轮循),或者是底层接收到触摸事件之后,native调用java主动通知上层进行消费(通知).源码分析到这里,可以发现在input事件分发消费机制中“轮循”和“通知”是并存的。
BatchedConsumption机制
首先需要了解下Batched Consumption机制。一般应用只在每个VSYNC的周期下进行一次绘制。因此,在每一帧的时候应用只能对一次input事件进行响应反馈。如果在一个VSYNC周期中出现了多个input事件,每次input事件到来的时候都立即分发到应用层是比较浪费资源的。为了避免浪费,就有了 Batched Consumption机制 ,input事件会被进行批处理,然后在每个Frame渲染时发送一个batched input事件给到应用层。
对于批量的Move事件,事件从分发到消费对的链路如下:
1. InputDispatcher 分发事件到app层
2. app层的 Looper 收到事件通知
3. 执行 handleEvent 方法. 从fd中读取 Event
4. 当存在batched event时,InputConsumer::hasPendingBatch 将会返回 true. 这个时候并不会发送event到我们的app上.
5. native层会调用 InputEventReceiver#onBatchedInputEventPending告知app,有batched event可供消费。这时候就会通过 Choreographer schedules一个 ConsumeBatchedInputRunnable 在下一帧之前来进行input event的消费
6. ConsumeBatchedInputRunnable 在执行的时候不只是进行batched input的消费,会尽可能将socket中所有的input event都进行消费
7. native调用到 InputEventReceiver#onInputEvent ,将所有传入的事件都发送到app层。
对于Down和Up事件来说,并没有batched event的概念,因此链路为 1 ,2 ,3 ,7 ,之前的系统图只适合描述Down和Up事件
最接近真相的猜想
将我们的异常现象的表现结合Batched Consumption机制,有了以下的猜想:
在一次触摸屏幕开始之后,Down事件由底层向上层正常进行分发,Move事件也到来了,但是没有立即分发给上层,此时只是在native进行batch,并通知上层来进行读取消费。而上层在此时调用底层进行读取Move事件的链路上出现了异常!导致Move事件在WaitQueue里面进行堆积,一直没有被消费。而手指抬起的时候,产生了Up事件,触发了向上层分发Up事件,顺带将队列前面的没有被消费的所有Move事件一并向上发送.(这里是个传递指针操作)
两种事件分发模式,最后都走到了native调用java方法, dispatchInputEvent 和 onBatchedInputEventPending ,这些方法运行在主进程。我们可以查看java堆栈来查看不同场景下Down,Up和Move事件的分发过程中的Java调用链
使用 AndroidStudio Profile 查看Java调用栈
使用AndroidStudioProfile工具,选择CPU,触摸界面并进行record,dump文件之后,可以看到java层的代码调用。(AS也可以进行native调用栈的查看)
那么我们来check下不同场景下,consumeBatchInput的调用情况。
这里罗列几个AS的trace图,可以更直观的看到系统对Down,Up和Move事件的不同处理过程。
实验手机是 oppo find x2 pro (Android 11)
Down和Up
Down和Up事件走dispatchInputEvent分发到上层
正常情况Consume Batched MoveEvent
异常情况Consume Batched MoveEvent
百花齐放的ROM
细心的读者可能会发现,上面正常情况的图中里面并没有出现 onBatchedInputEventPending 调用,而是由 ViewRootImpl 每隔一帧的时间触发一次消费 consumeBatchedInput .并不是照Android 11源码上的,只有当move事件到来的时候,触发 onBatchedInputEventPending,再下一帧绘制的时候触发一次 consumeBatchedInput 探究后,发现这手机(Oppo findx2 pro)虽然是Android 11的版本,但在input事件的处理上存在着诸多Android 9的代码调用,Android 9在消费Move事件上是轮循的机制,而Android 11在消费Move事件上是通知的机制。
从前面的java堆栈图中,我们可以看到java层是主动调用了一个 doConsumeBatchedInput 来进行input事件消费的。而这个 doConsumeBatchedInput 与两个Runnable有关 ConsumeBatchedInputRunnable 和 ConsumeBatchedInputImmediatelyRunnable
ConsumeBatchedInputRunnable 和ConsumeBatchedInputImmediatelyRunnable
ConsumeBatchedInputRunnable 和 ConsumeBatchedInputImmediatelyRunnable 这两个是
ViewRootImpl 中定义的Runnable,他们都会调用到native方法 nativeConsumeBatchedInputEvents 读取inputChannel中的input event,前者是等到下一个Frame绘制的时候再执行input事件消费。后者如其名称 immediately ,是立即进行input事件的消费,常用于一些异常场景下的事件清零操作。
与此对应的有
mConsumeBatchInputScheduled 和 mConsumeBatchInputImmediatelyScheduled 这两个变量,来标识是否已经将对应的Runnable添加到MessageQueue里面,避免加入重复的Runnable。在对应Runnable的内部执行中又会把这个变量置为false。
Lastest Change
现在压力传递到了ViewRootImpl,Android 11是去年年底发布的,有可能是最近的提交引入了这个问题。老规矩,甩锅常规操作,点开git history查看源码最近一段时间的改动提交。
改动点1: ViewRootImpl#scheduleConsumeBatchedInput
这里对 ConsumeBatchedInputRunnable 的添加新增了一个开关变量 mConsumeBatchedImmediatelyScheduled ,使得“延时消费input”和“立即消费input”变成两个互斥的操作。
改动点2: ViewRootImpl#setWindowStopped
可以看到在去年的六月,google developer A在 setWindowStopped 中新增调用一次 scheduleConsumeBatchedInputImmdiately() 。目的是在window切换为stopped状态后为了避免ANR,调用 scheduleConsumeBatchedInputImmdiately() 立即进行一次input事件消费也就是在这里 mConsumeBatchedInputImmediatelyScheduled 这个变量被置为 true ,从结果上来说,这个Runnable并没有被执行!
基于改动的猜想
针对这两次的修改,我们大胆猜测 mConsumeBatchInputImmediatelyScheduled 这个在置为true之后,出现了某种异常,对应的 ConsumeBatchedInputImmediatelyRunnable 并没有被执行,该变量并没有被置为 false ,导致另外一个 ConsumeBatchedInputRunnable 不满足执行条件,进而引发事件消费异常。Move Event没有被应用消费,导致界面无法滑动。那么我们如何进行验证呢?
虽然说ViewRootImpl是框架层的类,代码层没法直接引用到,但毕竟是 万view之祖 ,我们可以拿到DecorView,再拿到DecorView的父View来得到ViewRootImpl,进而探访这个ViewRootImpl对象。断点之下,一览无余!
可以看到出问题的界面上的ViewRootImpl对象的 mConsumeBatchedImmediatelyScheduled 为 true ,与我们的猜想一致。那问题来到了这个 mConsumeBatchedInputImmediatelyRunnable 为什么没有被执行!
Runnable没有被执行?是不是从消息队列中被remove了?
我们在 ViewRootImpl 中翻看,并没有看到有将
ConsumeBatchedInputImmediatelyRunnable 进行reomve的操作。
滑不动的直接原因找到了,那么我们就可以先“对症下药”,出了个临时的修复方案,我们针对 Android 11 的机型,在界面 onResume 的时候,取到 ViewRootImpl 对象(可以通过 DecorView#getParent 取到),运用反射,对 mConsumeBatchedImmediatelyScheduled 这个变量进行了检测,如果是 true 则需要进行修复,修改值为 false ,并调用一次 scheduleConsumeBatchedInput 触发原有的input消费流程。经过验证,界面恢复正常了!
意料之外的调用
再仔细阅读下 setWindowStopped ,这个函数是有个参数 bool stopped ,即在Stopped状态下的参数是 true ,但参数为 false 的时候也同样调用了 scheduleConsumeBatchedInputImmediately
追溯下 setWindowStopped 的调用,发现在 Activity#performStart 的时候也会调用到这里。而这次的调用显然是不符合预期的(预期只在Window stopped下进行调用,用于避免ANR,所以说Window start的时候的调用就属于意料之外)我们之前的操作场景下B界面回到A界面时,就会触发A界面的 performStart 进而调用到 scheduleConsumeBatchedInputImmediately 。
这个Runnable并没有设置任何延时,应该是要被立马执行的。
在回到 setWindowStopped 下阅读,看下不同参数下的执行路径,当 stopped 为 false 时,是先执行了 scheduleTraversals ,之后便调用了 scheduleConsumeBatchedInputImmediately
进入scheduleTraversals,发现方法内部调用了
mHandler.getLooper().getQueue().postSyncBarrier() 对MessageQueue直接进行了操作,这个操作很可能是 ConsumeBatchedInputImmediatelyRunnable 没有运行的关键所在。
scheduleTraversals 中的 postSyncBarrier 就是往MessageQueue中插入一个同步屏障消息。MessageQueue中的消息可以分为三种:普通消息(同步消息)、屏障消息(同步屏障)和异步消息。我们通常使用的都是普通消息,而屏障消息就是在消息队列中插入一个屏障,在屏障之后的所有普通消息都会被挡着,不能被处理。不过异步消息却例外,屏障不会挡住异步消息,因此可以这样认为:屏障消息就是为了确保异步消息的优先级,设置了屏障后,只能处理其后的异步消息,同步消息会被挡住,除非撤销屏障。
屏障消息
对于一个普通消息来说,它都是存在 target ,而屏障信息跟同步消息最大的区别就是没有 target ,因为屏障消息不需要被执行。
ViewRootImpl中的同步屏障
ViewRootImpl#scheduleTraversals 方法就使用了同步屏障,以此阻塞其他的同步消息,保证UI绘制优先执行。之后再移除这屏障,让同步消息执行起来。(这也是AOSP源码中唯一一处使用到同步屏障机制的地方)
mTraversalBarrier 是用于存放同步屏障的 token 的变量
结合前面提到同步屏障的机制,可以发现当 Activity#performStart 的时候会触发一次 ViewRootImpl#scheduleTraversals ,与此同时设置了一个同步屏障,并紧随其后添加了
ConsumeBatchedInputImmediatelyRunnable 这个同步消息。这个同步消息因同步屏障的存在并不会立即被执行,而是被阻塞住直到UI绘制完成。
到这里我们猜想是因为ViewRootImpl中同步屏障出现了问题,设置了多个屏障,但是只移除了一个屏障,仍有屏障没有被移除,导致了后续的 ConsumeBatchedInputImmediatelyRunnable 没有执行
那么怎么验证呢?
将消息队列中所有的消息打印出来!看是否存在 barrier 消息和被阻塞的 ConsumeBatchedInputImmediatelyRunnable 前面说过AOSP中大多数的核心类都提供了 dump 方法用于调试, Looper 和 MessageQueue 中也有, Looper 中的是 public 可以被调用到
ViewRootImpl 中的 mHandler 的Looper即主线程的Looper,我们可以调用以下的方法进行打印调试
我们在异常的界面上打印MainLooper的MesageQueue中的所有Message对象
但在打印面板上并没有发现Barrier Message和ConsumeBatchedInputImmediatelyRunnable Message的踪影,也就是说 ConsumeBatchedInputImmediatelyRunnable 并没有被阻塞在MessageQueue中,也没有被运行,那我们的Runnable哪去了? 前面我们提及了在 ViewRootImpl 中并没有找到对 mHandler 进行remove runnable的操作。
在正常的业务场景中,我们也会创建内部的handler对象,并在销毁等退出时机下,对该handler对象进行消息对象的移除,来避免内存泄漏问题。
因此,我们将排查的目标扩散到了我们的业务类,对所有涉及到 Handler 的remove操作的方法
removeCallbacks,removeMessage,removeCallbacksAndMessages等等进行排查。
果不其然,我们定位到了一个类A,其在内部 onDetachedFromWindow 的时候调用的是 View#getHandler ,并不是业务内创建的handler对象。
View#getHandler
前面我们提到过 ViewRootImpl 是万view之祖,这里拿到的getHandler取到对象就是 ViewRootImpl$ViewRootHandler ,与添加
ConsumeBatchedInputImmediatelyRunnable 的Handler是同一个,对此handler调用
handler.removeCallbacksAndMessages(null); 就会将同时处于MessageQueue中的
ConsumeBatchedInputImmediatelyRunnable 移除,从而造成连锁反应,进而导致我们这个滑动问题!
View#mAttachInfo
View中的getHandler()为什么会是ViewRootImpl$ViewRootHandler?先看下源码中View中是怎么取到handler的。
View是通过在一个mAttachInfo对象取到handler,而View中的mAttachInfo来自于父ViewGroup,ViewGroup在addView和dispatchAttachedToWindow中会将自己的mAttachInfo分发给子view,而ViewGroup的mAttachInfo正是来自于ViewRootImpl,ViewRootImpl在与DecorView的绑定中将mAttachInfo传递给DecorView,进而传递到每一个子View上。详细的可以自行翻看下源码。
在我们将业务内getHandler().removeCallbacksAndMessage的错误调用去除后,应用就恢复了正常
总结下滑动问题的链路流程:
1.我们业务对一个Stop的界面A进行了列表数据的remove
2.回到界面A,触发onStart,在Framework的ViewRootImpl会在此时,触发一次scheduleTraversals准备下一帧的界面重绘,在Android 11的版本上,还会额外调用一个ConsumeBatchedInputImmediatelyRunnable,因为scheduleTraversals会触发同步屏障,这个ConsumeBatchedInputImmediatelyRunnable并不会被立即运行,必须等到下一帧开始绘制后才可以运行。
3.绘制开始performTraversal中会调用到onMeasure,onLayout和onDraw等流程,由于我们进行了RecyclerView数据的移除,会触发到RecyclerView#onLayout,然后触发部分ItemView的onDetachedFromWindow
4.在这个onDetachedFromWindow
中我们调用了getHandler().removeCallbacksAndMessages(null),将target同为ViewRootImpl$ViewRootHandler的ConsumeBatchedInputImmediatelyRunnable从消息队列中移除。
5.渲染结束,但是ConsumeBatchedInputImmediatelyRunnable并没有被执行,mConsumeBatchInputImmediatelyScheduled却已经被置为true,没有被重置为false
6.触摸屏幕,底层Down事件分发正常。
7.当底层Input事件中的Move事件到来,触发了onBatchedInputEventPending,触发到scheduleConsumeBatchedInput,因为Android 11版本新增了对mConsumeBatchInputImmediatelyScheduled开关变量检测,没有往下触发流程,导致move事件没有被消费。
8.底层Up事件正常分发,顺带将前面被阻塞的BatchedMove事件上传.
向AOSP发一个小小的commit
前面分析过,ViewRootImpl#setWindowStopped 在 Activtity#performStart 阶段存在对
scheduleConsumeBatchedInputImmediately 不合理的调用,加上我们不合理的
Handler#removeCallbacksAndMessage 导致问题悲剧的发生,这里提一个小的commit到AOSP上来移除这个不合理的调用,并invite了当时对这里修改的Google developer前来code review. 这是当时的commit message
https://android-review.googlesource.com/c/platform/frameworks/base/+/1722623
Commit Message
Google developer's reply
不久后也收到Google developer的回复。Google内部早已经revert这一次有问题的提交(was invalid),此外还给出了另外一个解法,并热心的贴出一个内部的patch和文档来解释ComsumeBatch的机制。感兴趣的同学可以通过commit链接进行查看。短时间内Android 11依旧会保持现状,我们需要持续注意这个问题。
应对方案
这个滑动问题,造成的因素有Android 11框架层的一个冗余调用,也有业务侧对View#getHandler().removeCallbacks(null)系列方法的不规范调用。我们业务已经对内部存量的View#getHandler().removeCallbacks(null)调用进行替换和移除。考虑到Android 11框架层这个冗余调用会在短期内一直存在,同时也很难保证所有开发和第三方库在此系列方法上的规范调用,我们会维持临时修复方案。
Android Systrace 基础知识 - Input 解读