系统bug修复 (系统bug升级系统能解决吗)

作者:edworldwang,腾讯PCG客户端开发工程师

本文分享的是笔者遇到的一个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界面都可正常滑动。

被合并的Move事件

大部分的滑动问题都是因为存在着嵌套滑动冲突。为了验证是否是嵌套的问题,我们需要在不同层级的View中打印接收到的MotionEvent. 很快,我们就排除了嵌套滑动的因素。因为当我们在 Activity#dispatchTouchEvent的时候对MotionEvent进行打印,惊奇的发现MotionEvent在分发到Activity的时候就已经“ 不同寻常 ”。 1. 手指在按压滑动过程中不会收到任何Move事件。Move事件在手指抬起后,跟随Up事件一并发送,并且有仅只有一个Move事件。 2. 通过查看这个“唯一”的Move事件,发现其MotionEvent#getHistorySize()竟然达到几十上百,存放着Move过程中的所有轨迹点。

前期问题定位

结合复现的场景,这里我们列出了问题相关的几个“嫌疑人”

1. VideoView。因业务涉及到视频*放播**,是否存在视频进行*放播**切换的时候,内部存在一些“操作”,例如SurfaceView的动态添加移除。这些操作在界面stop状态下存在异常?

在移除了视频*放播**相关的业务逻辑之后依旧复现此问题。 排除

2. RecyclerView。RecyclerView的版本是从v7升级到androidx,会不会是RecyclerView的问题?

在将RecyclerView的版本降回到v7的版本也依旧可以复现这个问题。 排除

3. 会不会是硬件层的触摸事件采集出现了问题?

结合异常情况出现时,是能同时存在正常界面的。底层的触摸事件采集跟业务的界面属于不同结构层级,业务的一些状态管理问题应该不会反作用于硬件层的触摸采集,因此这个问题与硬件层的关系不是很大。 排除

Android 11有猫腻

在排查了多个因素无果之后,我们将焦点放到反馈问题的手机上。出现问题的手机有一个共同点是 支持高刷新频率 (90HZ,120HZ...)。而一般手机的刷新频率是60HZ。 难道是高刷新频率机制在某些场景下导致了触摸事件的异常? 此外,高刷机型的聚集也侧面反映了这些反馈问题的都是比较新款的手机,另一个共同点是对应的版本都是 Android11 。因此对刷新频率和Android版本这两个变量进行交叉组合验证

  1. 60HZ(默认),90HZ和120HZ
  2. Android 10和Android 11

经过测试:

  • 出现问题Android 11的手机的刷新频率从120HZ设置为60HZ,依旧出现滑不动的问题
  • Android 10的手机即便设置了高刷新频率,也不会出现滑不动的问题(华为Mate40Pro)

这意味着滑动问题与Android 11存在着紧密的联系,而Android 10是不存在这个问题的。那么要想彻底探究清楚这个问题,就必须深入了解Android 10和Android 11这两版本在Input系统的事件处理上的差异, 源码分析势在必行

Framework源码阅读

本文许多地方引用到了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

Android Input系统

Input系统结构

这里先放一张结构草图,让大家对Input系统结构层次有个粗略的印象。(PS:这里的流程是片面的)

系统bug修复了么,系统bug处理

源码中核心类及文件路径:

c++:

  • NativeInputEventReceiver /frameworks/base/core/jni/android_view_InputEventReceiver.cpp
  • InputReader /frameworks/native/services/inputflinger/reader/InputReader.cpp
  • InputDispatcher /frameworks/native/services/inputflinger/dispatcher/InputDispatcher.cpp
  • InputConsumer,InputChannel /frameworks/native/libs/input/InputTransport.cpp

java:

  • ViewRootImpl /frameworks/base/core/java/android/view/ViewRootImpl.java
  • Choreographer /frameworks/base/core/java/android/view/Choreographer.java
  • Handler /frameworks/base/core/java/android/os/Handler.java

Input系统基本单位 Window

Android Input系统中 Window是接收用户Input事件的基本单位, 它可以是一个Activity,也可以是个Dialog,Toast,StatusBar,NavigationBar等等 ,每个Window都会对应一个ViewRootImpl. 前面分析的问题来说:界面A可以简单理解为Window A,界面B为Window B

Socket跨进程通信

Android Input事件的读取和分发是进行在一个 System Server进程中的,因此从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中被调用。

寻找消失的MotionEvent

InputReader和InputDispatcher

InputReaderInputDispatcher 是跑在System Server进程中的里面的两个 Native 线程,负责 读取和分发 Input 事件 。要想分析input事件的流向,需要从这里开始入手。

  • InputReader: 负责从 EventHub 里面把 Input 事件读取出来,然后交给 InputDispatcher 进行事件分发
  • InputDispatcher: 在拿到 InputReader 获取的事件之后,对事件进行包装和分发 (也就是发给对应的Window) Connection: 与每个Window建立的通信链接对象,持有InputChannel(用于接收事件)OutboundQueue: 里面放的是即将要被派发给对应 Connection 的事件(每个Connection持有一个)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

//Allregisteredconnectionsmappedbychannelfiledescriptor.
std::unordered_map<int,sp<Connection>>mConnectionsByFdGUARDED_BY(mLock);

Android系统中,Dispatch线程与众多APP密切联系,当我们创建一个APP时候,便于Dispatch线程产生联系,这些Connection由窗口管理器(WindowManager)创建的。故Dispatch线程便可通过这些Connection将输入事件发送给对应的APP。

了解了一些Input机制后,我们该怎么对 InputReaderInputDispatcher这两个Native线程进行Native调试呢?

InputReader

这里我们使用的是sdk中自带的工具systrace.py. 我们对异常界面进行了Systrace(在native分析方面比AS更强大)

cd ${AndroidHome}/platform-tools/systrace python systrace.py --time=10 -o trace.html

将生成html,拖入 chrome://tracing/中进行分析。 可以看到InputReader在488ms,496ms,504ms有明显的函数调用栈,即此时进行了input数据的采集,间隔约为8ms,符合当前120HZ的屏幕刷新频率(1s/120HZ)。如果是60HZ的刷新频率,则是约16ms进行input事件采集

系统bug修复了么,系统bug处理

可以看到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 shell dumpsysadb shell service list方法获取。点击了解更多dumpsys的使用

我们这里使用的是 adb shell dumpsys input,可以看到

我们对出现问题的界面进行滑动,同时手指保持再屏幕上,不进行抬起,进行是 adb shell dumpsys input 可以看到OutboundQueue中是没有任何东西的,而WaitQueue中堆积了大量的MotionEvent(action=MOVE),此时也并没有被合并成一个。

系统bug修复了么,系统bug处理

与此同时,我们打开一个新的界面,在正常的界面上进行同样的操作,发现正常的界面的WaitQueue并不会堆积如此之多的MotionEvent。 WaitQueue 依赖主线程消费 Input 事件后进行反馈,那么当 Input 事件没有及时被消耗,就会在 WaitQueue 这里的length上体现出来。当 Input 事件长时间没有被消费的话,我们常见的ANR Exception就是这里抛出的,最最常见的原因就是主线程的耗时操作,进而引发卡顿。

但我们这里的问题与主线程耗时卡顿有本质区别。如果是主线程做了耗时的操作,也不应该出现WaitQueue里的Move事件一直持续增加。

系统bug修复了么,系统bug处理

这里我们再放出系统结构图,前面我们已经通过 systraceadb shell dumpsys input,分析出1,2,3这流程是正常的,4这个步骤是用socket的一个发送input message,对数据无感的一个流程,而且我们在问题界面也能够收到Down和Up事件。那么4这个步骤就是正常的。

NativeInputEventReceiver

这里需要对源码逐步分析,当InputEvent到来的时候,调用的是 NativeInputEventReceiver::handleEvent,其内部又调用了NativeInputEventReceiver::consumeEvents,核心对inputEvent的处理再InputConsumer:consume中。

//以下代码经过裁剪,去除了一些日志打印和非关键路径的代码
intNativeInputEventReceiver::handleEvent(intreceiveFd,intevents,void*data){
if(events&ALOOPER_EVENT_INPUT){
JNIEnv*env=AndroidRuntime::getJNIEnv();
status_tstatus=consumeEvents(env,false/*consumeBatches*/,-1,nullptr);
mMessageQueue->raiseAndClearException(env,"handleReceiveCallback");
returnstatus==OK||status==NO_MEMORY?1:0;
}
if(events&LOOPER_EVENT_OUTPUT){
return1;
}
return1;
}

在consumeEvents中可以看到正常的流程是会走native调用java方法 InputEventReceiver#dispatchInputEvent.这里我们要 留意的是其他分支情况 ,可以看到在status == WOULD_BLOCK,我们是会走到里面的分支,从native调用java方法InputEventReceiver#onBatchedInputEventPending,往下进行分析怎么场景会走到这里。 因为源码逻辑比较复杂,我们的注意力要放在对ACTION_MOVE这类关键字上,看哪些这类事件进行了特殊操作

//以下代码经过裁剪,去除了一些日志打印和非关键路径的代码
status_tNativeInputEventReceiver::consumeEvents(JNIEnv*env,
boolconsumeBatches,nsecs_tframeTime,bool*outConsumedBatch){
if(consumeBatches){
mBatchedInputEventPending=false;
}
if(outConsumedBatch){
*outConsumedBatch=false;
}
ScopedLocalRef<jobject>receiverObj(env,nullptr);
boolskipCallbacks=false;
for(;;){
uint32_tseq;
InputEvent*inputEvent;
status_tstatus=mInputConsumer.consume(&mInputEventFactory,
consumeBatches,frameTime,&seq,&inputEvent);
if(status==WOULD_BLOCK){
//收到socket传来的inputevent时,以下条件为true
if(!skipCallbacks&&!mBatchedInputEventPending&&mInputConsumer.hasPendingBatch()){
//Thereisapendingbatch.Comebacklater.
if(!receiverObj.get()){
receiverObj.reset(jniGetReferent(env,mReceiverWeakGlobal));
}
mBatchedInputEventPending=true;
env->CallVoidMethod(receiverObj.get(),
gInputEventReceiverClassInfo.onBatchedInputEventPending,
mInputConsumer.getPendingBatchSource());
}
returnOK;
}
if(!skipCallbacks){
jobjectinputEventObj;
switch(inputEvent->getType()){
caseAINPUT_EVENT_TYPE_MOTION:{
MotionEvent*motionEvent=static_cast<MotionEvent*>(inputEvent);
if((motionEvent->getAction()&AMOTION_EVENT_ACTION_MOVE)&&outConsumedBatch){
*outConsumedBatch=true;
}
inputEventObj=android_view_MotionEvent_obtainAsCopy(env,motionEvent);
break;
}
if(inputEventObj){
env->CallVoidMethod(receiverObj.get(),
gInputEventReceiverClassInfo.dispatchInputEvent,seq,inputEventObj);
}else{
skipCallbacks=true;
}
}

if(skipCallbacks){
mInputConsumer.sendFinishedSignal(seq,false);
}
}
}

InputConsumer#consume的方法中,可以看到一处AMOTION_EVENT_ACTION_MOVE, 果不其然,在该方法中,对是否是input事件进行了判断,如果是move类型的事件,会进行一个 batch操作 ,然后直接返回,此时的*outEvent = nullptr.而当 事件为非move类型事件,会走到*outEvent = motionEvent;.最终在外头会走到InputEventReceiver#dispatchInputEvent. 也就是 MOVE类型的事件并没有像Down和Up事件一样走dispatchInputEvent方法分发到上层,而是走了另外一个onBatchedInputEventPending方法

//以下代码经过裁剪,去除了一些日志打印和非关键路径的代码
status_tInputConsumer::consume(InputEventFactoryInterface*factory,boolconsumeBatches,
nsecs_tframeTime,uint32_t*outSeq,InputEvent**outEvent){
*outSeq=0;
*outEvent=nullptr;
//Fetchthenextinputmessage.
//Loopuntilaneventcanbereturnedornoadditionaleventsarereceived.
while(!*outEvent){
if(mMsgDeferred){
//mMsgcontainsavalidinputmessagefromthepreviouscalltoconsume
//thathasnotyetbeenprocessed.
mMsgDeferred=false;
}else{
//Receiveafreshmessage.
status_tresult=mChannel->receiveMessage(&mMsg);
if(result){
//Consumethenextbatchedeventunlessbatchesarebeingheldforlater.
if(consumeBatches||result!=WOULD_BLOCK){
result=consumeBatch(factory,frameTime,outSeq,outEvent);
if(*outEvent){
break;
}
}
returnresult;
}
}

switch(mMsg.header.type){
...
caseInputMessage::Type::MOTION:{
ssize_tbatchIndex=findBatch(mMsg.body.motion.deviceId,mMsg.body.motion.source);
if(batchIndex>=0){
Batch&batch=mBatches.editItemAt(batchIndex);
if(canAddSample(batch,&mMsg)){
batch.samples.push(mMsg);
break;
}else{
...
break;
}
}

//Startanewbatchifneeded.
if(mMsg.body.motion.action==AMOTION_EVENT_ACTION_MOVE||
mMsg.body.motion.action==AMOTION_EVENT_ACTION_HOVER_MOVE){
mBatches.push();
Batch&batch=mBatches.editTop();
batch.samples.push(mMsg);
break;
}

//如果是ACTION_DOWN,ACTION_UP等其他事件最终会走到这里
updateTouchState(mMsg);
initializeMotionEvent(motionEvent,&mMsg);
*outSeq=mMsg.body.motion.seq;
*outEvent=motionEvent;
break;
}
...
}
}
returnOK;
}


轮循还是通知

前面我们深入分析了源码,最终发现在分发的路径上,Move类型的事件并没有跟Down和Up事件一样走dispatchInputEvent直接分发到上层。 之前的系统结构图是不完整的!!! 有些同学会认为,触摸事件的处理是由框架层每隔一定的周期(一帧)去调用某个native方法来触发input事件上传消费( 轮循 ),或者是底层接收到触摸事件之后,native调用java主动通知上层进行消费( 通知 ).源码分析到这里,可以发现 在input事件分发消费机制中“轮循”和“通知”是并存的

Batched Consumption机制

首先需要了解下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可供消费。这时候就会通过Choreographerschedules一个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方法, dispatchInputEventonBatchedInputEventPending,这些方法运行在主进程。 我们可以查看java堆栈来查看不同场景下Down,Up和Move事件的分发过程中的Java调用链

使用 AndroidStudio Profile查看Java调用栈 使用AndroidStudio Profile工具,选择CPU,触摸界面并进行record,dump文件之后,可以看到java层的代码调用。(AS也可以进行native调用栈的查看)

那么我们来check下不同场景下,consumeBatchInput的调用情况。 这里罗列几个AS的trace图,可以更直观的看到系统对Down,Up和Move事件的不同处理过程。

实验手机是 oppo find x2 pro (Android 11)

Down和Up

Down和Up事件走dispatchInputEvent分发到上层

系统bug修复了么,系统bug处理

正常情况Consume Batched MoveEvent

系统bug修复了么,系统bug处理

异常情况Consume Batched MoveEvent

系统bug修复了么,系统bug处理

百花齐放的ROM

细心的读者可能会发现,上面正常情况的图中里面并没有出现 onBatchedInputEventPending调用,而是由ViewRootImpl 每隔一帧的时间触发一次消费 consumeBatchedInput.并不是照Android 11源码上的,只有当move事件到来的时候,触发onBatchedInputEventPending,再下一帧绘制的时候触发一次consumeBatchedInput 探究后,发现这手机(Oppo find x2 pro)虽然是Android 11的版本,但在input事件的处理上存在着诸多Android 9的代码调用, Android 9在消费Move事件上是轮循的机制,而Android 11在消费Move事件上是通知的机制

ViewRootImpl

从前面的java堆栈图中,我们可以看到java层是主动调用了一个 doConsumeBatchedInput来进行input事件消费的。而这个doConsumeBatchedInput与两个Runnable有关ConsumeBatchedInputRunnableConsumeBatchedInputImmediatelyRunnable

ConsumeBatchedInputRunnable 和 ConsumeBatchedInputImmediatelyRunnable

ConsumeBatchedInputRunnableConsumeBatchedInputImmediatelyRunnable这两个是ViewRootImpl中定义的Runnable,他们都会调用到native方法nativeConsumeBatchedInputEvents读取inputChannel中的input event,前者是等到下一个Frame绘制的时候再执行input事件消费。后者如其名称immediately,是立即进行input事件的消费, 常用于一些异常场景下的事件清零操作 。 与此对应的有mConsumeBatchInputScheduledmConsumeBatchInputImmediatelyScheduled这两个变量,来标识是否已经将对应的Runnable添加到MessageQueue里面,避免加入重复的Runnable。在对应Runnable的内部执行中又会把这个变量置为false。

Lastest Change

现在压力传递到了ViewRootImpl,Android 11是去年年底发布的, 有可能是最近的提交引入了这个问题。老规矩,甩锅常规操作,点开git history查看源码最近一段时间的改动提交

改动点1: ViewRootImpl#scheduleConsumeBatchedInput

系统bug修复了么,系统bug处理

这里对 ConsumeBatchedInputRunnable的添加新增了一个开关变量mConsumeBatchedImmediatelyScheduled,使得“延时消费input”和“立即消费input”变成两个互斥的操作。

改动点2: ViewRootImpl#setWindowStopped

系统bug修复了么,系统bug处理

可以看到在去年的六月,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对象。 断点之下,一览无余!

系统bug修复了么,系统bug处理

可以看到出问题的界面上的ViewRootImpl对象的 mConsumeBatchedImmediatelyScheduledtrue,与我们的猜想一致。那问题来到了这个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

系统bug修复了么,系统bug处理

这个Runnable并没有设置任何延时,应该是要被立马执行的。 在回到 setWindowStopped下阅读,看下不同参数下的执行路径,当stoppedfalse时,是先执行了scheduleTraversals,之后便调用了scheduleConsumeBatchedInputImmediately

系统bug修复了么,系统bug处理

进入 scheduleTraversals,发现方法内部调用了mHandler.getLooper().getQueue().postSyncBarrier()对MessageQueue直接进行了操作,这个操作很可能是ConsumeBatchedInputImmediatelyRunnable没有运行的关键所在。

//ViewRootImpl.java
voidscheduleTraversals(){
if(!mTraversalScheduled){
mTraversalScheduled=true;
mTraversalBarrier=mHandler.getLooper().getQueue().postSyncBarrier();//⬅️这里对MessageQueue做了一个postSyncBarrier的操作
mChoreographer.postCallback(
Choreographer.CALLBACK_TRAVERSAL,mTraversalRunnable,null);
notifyRendererOfFramePending();
pokeDrawLockIfNeeded();
}
}

Handler之同步屏障

scheduleTraversals中的postSyncBarrier就是往MessageQueue中插入一个同步屏障消息。 MessageQueue中的消息可以分为三种: 普通消息(同步消息) 屏障消息(同步屏障)和异步消息 。我们通常使用的都是普通消息,而屏障消息就是在消息队列中插入一个屏障,在屏障之后的所有普通消息都会被挡着,不能被处理。不过异步消息却例外,屏障不会挡住异步消息,因此可以这样认为:屏障消息就是为了确保异步消息的优先级,设置了屏障后,只能处理其后的异步消息,同步消息会被挡住,除非撤销屏障。

屏障消息

对于一个普通消息来说,它都是存在 target,而屏障信息跟同步消息最大的区别就是没有target,因为屏障消息不需要被执行。

//MessageQueue.java
publicintpostSyncBarrier(){
returnpostSyncBarrier(SystemClock.uptimeMillis());
}
//可以看到下面生成屏障消息的时候并没有设置target
privateintpostSyncBarrier(longwhen){
//Enqueueanewsyncbarriertoken.
//Wedon'tneedtowakethequeuebecausethepurposeofabarrieristostallit.
synchronized(this){
finalinttoken=mNextBarrierToken++;
finalMessagemsg=Message.obtain();
msg.markInUse();
msg.when=when;
msg.arg1=token;
Messageprev=null;
Messagep=mMessages;
if(when!=0){
while(p!=null&&p.when<=when){
prev=p;
p=p.next;
}
}
if(prev!=null){//invariant:p==prev.next
msg.next=p;
prev.next=msg;
}else{
msg.next=p;
mMessages=msg;
}
returntoken;
}
}

ViewRootImpl中的同步屏障

ViewRootImpl#scheduleTraversals方法就使用了同步屏障,以此阻塞其他的同步消息,保证UI绘制优先执行。之后再移除这屏障,让同步消息执行起来。(这也是AOSP源码中唯一一处使用到同步屏障机制的地方)

mTraversalBarrier是用于存放同步屏障的token的变量

//绘制UI之前设置同步屏障,保存token到mTraversalBarrier
voidscheduleTraversals(){
if(!mTraversalScheduled){
mTraversalScheduled=true;
mTraversalBarrier=mHandler.getLooper().getQueue().postSyncBarrier();
mChoreographer.postCallback(
Choreographer.CALLBACK_TRAVERSAL,mTraversalRunnable,null);
notifyRendererOfFramePending();
pokeDrawLockIfNeeded();
}
}
//在performTraversals进行绘制,此时可以根据mTraversalBarrier移除同步屏障
//这里需要知道的是View绘制三大流程measure,Layou,Draw。就发生在performTraversals中,不做展开。
voiddoTraversal(){
if(mTraversalScheduled){
mTraversalScheduled=false;
mHandler.getLooper().getQueue().removeSyncBarrier(mTraversalBarrier);

if(mProfile){
Debug.startMethodTracing("ViewAncestor");
}

performTraversals();

if(mProfile){
Debug.stopMethodTracing();
mProfile=false;
}
}
}

被遗忘的Runnable

结合前面提到同步屏障的机制,可以发现当 Activity#performStart的时候会触发一次ViewRootImpl#scheduleTraversals,与此同时设置了一个同步屏障,并紧随其后添加了ConsumeBatchedInputImmediatelyRunnable这个同步消息。这个同步消息因同步屏障的存在并不会立即被执行,而是被阻塞住直到UI绘制完成。

到这里我们 猜想 是因为 ViewRootImpl中同步屏障出现了问题,设置了多个屏障,但是只移除了一个屏障,仍有屏障没有被移除,导致了后续的 ConsumeBatchedInputImmediatelyRunnable没有执行。

那么怎么验证呢? 将消息队列中所有的消息打印出来!看是否存在 barrier消息和被阻塞的ConsumeBatchedInputImmediatelyRunnable 前面说过AOSP中大多数的核心类都提供了dump方法用于调试,LooperMessageQueue中也有,Looper中的是public可以被调用到

Looper.java
publicvoiddump(@NonNullPrinterpw,@NonNullStringprefix){
pw.println(prefix+toString());
mQueue.dump(pw,prefix+"",null);
}

MessageQueue.java
voiddump(Printerpw,Stringprefix,Handlerh){
synchronized(this){
longnow=SystemClock.uptimeMillis();
intn=0;
for(Messagemsg=mMessages;msg!=null;msg=msg.next){
if(h==null||h==msg.target){
pw.println(prefix+"Message"+n+":"+msg.toString(now));
}
n++;
}
pw.println(prefix+"(Totalmessages:"+n+",polling="+isPollingLocked()
+",quitting="+mQuitting+")");
}
}

ViewRootImpl中的mHandler的Looper即 主线程的Looper ,我们可以调用以下的方法进行打印调试

Looper.getMainLooper().dump(newLogPrinter(intpriority,Stringtag),Stringprefix);

我们在异常的界面上打印 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对象。

publicclassAextendsView{
...
@Override
protectedvoidonDetachedFromWindow(){
super.onDetachedFromWindow();
Handlerhandler=getHandler();//这里调用的是View#getHandler()
if(handler!=null){
handler.removeCallbacksAndMessages(null);
}
}
}

View#getHandler

前面我们提到过 ViewRootImpl 万view之祖 ,这里拿到的getHandler取到对象就是ViewRootImpl$ViewRootHandler,与添加ConsumeBatchedInputImmediatelyRunnable的Handler是同一个,对此handler调用handler.removeCallbacksAndMessages(null);就会将同时处于MessageQueue中的ConsumeBatchedInputImmediatelyRunnable移除,从而造成连锁反应,进而导致我们这个滑动问题!

系统bug修复了么,系统bug处理

View#mAttachInfo

View中的getHandler()为什么会是ViewRootImpl$ViewRootHandler?先看下源码中View中是怎么取到handler的。

//View.java
publicHandlergetHandler(){
finalAttachInfoattachInfo=mAttachInfo;
if(attachInfo!=null){
returnattachInfo.mHandler;
}
returnnull;
}

View是通过在一个mAttachInfo对象取到handler,而View中的mAttachInfo来自于父ViewGroup,ViewGroup在addView和dispatchAttachedToWindow中会将自己的mAttachInfo分发给子view,而ViewGroup的mAttachInfo正是来自于ViewRootImpl,ViewRootImpl在与DecorView的绑定中将mAttachInfo传递给DecorView,进而传递到每一个子View上。详细的可以自行翻看下源码。

系统bug修复了么,系统bug处理

总结

在我们将业务内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事件正常分发,顺带将前面被阻塞的Batched Move事件上传

向AOSP发一个小小的commit

前面分析过, ViewRootImpl#setWindowStoppedActivtity#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

系统bug修复了么,系统bug处理

Google developer's reply

不久后也收到Google developer的回复。 Google内部早已经revert这一次有问题的提交 (was invalid),此外还给出了另外一个解法,并热心的贴出一个 内部的patch和文档来解释ComsumeBatch的机制 。感兴趣的同学可以通过commit链接进行查看。短时间内Android 11依旧会保持现状,我们需要持续注意这个问题。

系统bug修复了么,系统bug处理

应对方案

这个滑动问题,造成的因素有Android 11框架层的一个冗余调用,也有业务侧对View#getHandler().removeCallbacks(null)系列方法的不规范调用。我们业务已经对内部存量的View#getHandler().removeCallbacks(null)调用进行替换和移除。考虑到Android 11框架层这个冗余调用会在短期内一直存在,同时也很难保证所有开发和第三方库在此系列方法上的规范调用,我们会维持临时修复方案。

引用参考

Android Systrace 基础知识 - Input 解读(https://androidperformance.com/2019/11/04/Android-Systrace-Input/)

十分钟了解Android触摸事件原理(https://www.jianshu.com/p/f05d6b05ba17)

招贤纳士

手Q招聘Android开发工程师,感兴趣可前往此页面投递:

https://careers.tencent.com/jobdesc.html?postId=1404731576830402560

或将简历发送至:erainzhong@tencent.com

系统bug修复了么,系统bug处理