网易首页 > 网易号 > 正文 申请入驻

系统级bug解决分享:腾讯开发工程师刨根问底安卓端滑动异常

0
分享至

作者: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:这里的流程是片面的)

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

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中被调用。

寻找消失的MotionEventInputReader和InputDispatcher

InputReader 和 InputDispatcher 是跑在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

// All registered connections mapped by channel file descriptor.std::unordered_map> mConnectionsByFd GUARDED_BY(mLock);

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 python systrace.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 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中。

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

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

//以下代码经过裁剪,去除了一些日志打印和非关键路径的代码status_t NativeInputEventReceiver::consumeEvents(JNIEnv* env, bool consumeBatches, nsecs_t frameTime, bool* outConsumedBatch) { if (consumeBatches) { mBatchedInputEventPending = false; } if (outConsumedBatch) { *outConsumedBatch = false; } ScopedLocalRef receiverObj(env, nullptr); bool skipCallbacks = false; for (;;) { uint32_t seq; InputEvent* inputEvent; status_t status = mInputConsumer.consume(&mInputEventFactory, consumeBatches, frameTime, &seq, &inputEvent); if (status == WOULD_BLOCK) { //收到socket传来的input event时,以下条件为true if (!skipCallbacks && !mBatchedInputEventPending && mInputConsumer.hasPendingBatch()) { // There is a pending batch. Come back later. if (!receiverObj.get()) { receiverObj.reset(jniGetReferent(env, mReceiverWeakGlobal)); } mBatchedInputEventPending = true; env->CallVoidMethod(receiverObj.get(), gInputEventReceiverClassInfo.onBatchedInputEventPending, mInputConsumer.getPendingBatchSource()); } return OK; } if (!skipCallbacks) { jobject inputEventObj; switch (inputEvent->getType()) { case AINPUT_EVENT_TYPE_MOTION: { MotionEvent* motionEvent = static_cast(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_t InputConsumer::consume(InputEventFactoryInterface* factory, bool consumeBatches, nsecs_t frameTime, uint32_t* outSeq, InputEvent** outEvent) { *outSeq = 0; *outEvent = nullptr; // Fetch the next input message. // Loop until an event can be returned or no additional events are received. while (!*outEvent) { if (mMsgDeferred) { // mMsg contains a valid input message from the previous call to consume // that has not yet been processed. mMsgDeferred = false; } else { // Receive a fresh message. status_t result = mChannel->receiveMessage(&mMsg); if (result) { // Consume the next batched event unless batches are being held for later. if (consumeBatches || result != WOULD_BLOCK) { result = consumeBatch(factory, frameTime, outSeq, outEvent); if (*outEvent) { break; } } return result; } } switch (mMsg.header.type) { ... case InputMessage::Type::MOTION: { ssize_t batchIndex = 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; } } // Start a new batch if needed. 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; } ... } } return OK;}轮循还是通知

前面我们深入分析了源码,最终发现在分发的路径上,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方法,dispatchInputEvent和onBatchedInputEventPending,这些方法运行在主进程。我们可以查看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分发到上层

正常情况Consume Batched MoveEvent

异常情况Consume Batched MoveEvent

百花齐放的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有关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没有运行的关键所在。

//ViewRootImpl.javavoid scheduleTraversals() { 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.javapublic int postSyncBarrier() { return postSyncBarrier(SystemClock.uptimeMillis());}//可以看到下面生成屏障消息的时候并没有设置 targetprivate int postSyncBarrier(long when) { // Enqueue a new sync barrier token. // We don't need to wake the queue because the purpose of a barrier is to stall it. synchronized (this) { final int token = mNextBarrierToken++; final Message msg = Message.obtain(); msg.markInUse(); msg.when = when; msg.arg1 = token; Message prev = null; Message p = 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; } return token; }}ViewRootImpl中的同步屏障

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

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

//绘制UI之前设置同步屏障,保存 token 到 mTraversalBarriervoid scheduleTraversals() { 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中,不做展开。void doTraversal() { 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方法用于调试,Looper和MessageQueue中也有,Looper中的是public可以被调用到

Looper.javapublic void dump(@NonNull Printer pw, @NonNull String prefix) { pw.println(prefix + toString()); mQueue.dump(pw, prefix + " ", null);}MessageQueue.javavoid dump(Printer pw, String prefix, Handler h) { synchronized (this) { long now = SystemClock.uptimeMillis(); int n = 0; for (Message msg = mMessages; msg != null; msg = msg.next) { if (h == null || h == msg.target) { pw.println(prefix + "Message " + n + ": " + msg.toString(now)); } n++; } pw.println(prefix + "(Total messages: " + n + ", polling=" + isPollingLocked() + ", quitting=" + mQuitting + ")"); } }

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

Looper.getMainLooper().dump(new LogPrinter(int priority,String tag),String prefix);

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

public class A extends View { ... @Override protected void onDetachedFromWindow() { super.onDetachedFromWindow(); Handler handler = getHandler();//这里调用的是View#getHandler() if (handler != null) { handler.removeCallbacksAndMessages(null); } }}View#getHandler

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

View#mAttachInfo

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

//View.javapublic Handler getHandler() { final AttachInfo attachInfo = mAttachInfo; if (attachInfo != null) { return attachInfo.mHandler; } return null;}

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

向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

应对方案

这个滑动问题,造成的因素有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

特别声明:以上内容(如有图片或视频亦包括在内)为自媒体平台“网易号”用户上传并发布,本平台仅提供信息存储服务。

Notice: The content above (including the pictures and videos if any) is uploaded and posted by a user of NetEase Hao, which is a social media platform and only provides information storage services.

相关推荐
热点推荐
王艳儿子保送北大!原以为是孽子逆袭,谁想走了奶茶妹妹的老路

王艳儿子保送北大!原以为是孽子逆袭,谁想走了奶茶妹妹的老路

青瓜娱评
2024-06-19 14:56:21
女儿中考亲妈后妈共同来接!网友:看了颜值后,丈夫才是人生赢家

女儿中考亲妈后妈共同来接!网友:看了颜值后,丈夫才是人生赢家

垛垛糖
2024-06-18 12:47:49
马斯克:一面是天才一面是疯子,从小混乱的家庭关系让他无法正常

马斯克:一面是天才一面是疯子,从小混乱的家庭关系让他无法正常

照见古今
2024-01-06 18:43:16
昔日“彩电大王”走向没落,被美公司欠款40亿,如今沦为三线品牌

昔日“彩电大王”走向没落,被美公司欠款40亿,如今沦为三线品牌

李哥三观很正
2024-06-14 17:51:29
女排巴黎抽签分析:打法国力争3-0拼下美塞1场 小组晋级概率大

女排巴黎抽签分析:打法国力争3-0拼下美塞1场 小组晋级概率大

颜小白的篮球梦
2024-06-19 20:11:22
如何看待普京访问朝鲜?网友:历史告诉俄罗斯,只有东方才是救星

如何看待普京访问朝鲜?网友:历史告诉俄罗斯,只有东方才是救星

大东北的小豆包
2024-06-19 14:46:39
媒体人:欧洲杯VAR检查后取消进球,现场大屏会告知原因

媒体人:欧洲杯VAR检查后取消进球,现场大屏会告知原因

直播吧
2024-06-19 16:51:05
枢密院十号:中国车出口墨西哥,美国在担心啥?

枢密院十号:中国车出口墨西哥,美国在担心啥?

环球网资讯
2024-06-18 00:02:51
中国女排抽得上上签,巴黎奥运会女排小组赛分组出炉

中国女排抽得上上签,巴黎奥运会女排小组赛分组出炉

阿牛体育说
2024-06-20 01:08:29
单位里一把手领导更换后,通常这三个部门的负责人是铁定要调整的

单位里一把手领导更换后,通常这三个部门的负责人是铁定要调整的

蘑菇老大
2024-06-18 10:37:09
古利特回应争议:我感到很荣幸,因为没有被遗忘

古利特回应争议:我感到很荣幸,因为没有被遗忘

懂球帝
2024-06-19 07:36:12
噩耗:他于6月19日去世。出身“中国最牛家族”,干出七个第一

噩耗:他于6月19日去世。出身“中国最牛家族”,干出七个第一

华人星光
2024-06-19 16:23:29
中央第一生态环境保护督察组向上海交办第32批群众信访举报件办理情况公开

中央第一生态环境保护督察组向上海交办第32批群众信访举报件办理情况公开

上观新闻
2024-06-19 20:13:31
感谢!夺冠第二天,霍福德做出重要决定,凯尔特人喜忧参半

感谢!夺冠第二天,霍福德做出重要决定,凯尔特人喜忧参半

老王大话体育
2024-06-19 13:11:41
预定总冠军?热火内讧,曝掘金用波特+布劳恩交易巴特勒!

预定总冠军?热火内讧,曝掘金用波特+布劳恩交易巴特勒!

运筹帷幄的篮球
2024-06-20 04:40:02
后续来了,朱芳雨拒绝和郭艾伦拍大合照,主持人透露关键信息

后续来了,朱芳雨拒绝和郭艾伦拍大合照,主持人透露关键信息

林子说事
2024-06-19 19:26:07
不会出口欧美!拜登做梦也没想到,中国出手会这么狠

不会出口欧美!拜登做梦也没想到,中国出手会这么狠

科技龙
2024-06-12 10:23:18
8亿英镑!罗马老板收购埃弗顿即将官宣 英超老牌球队自求多福吧

8亿英镑!罗马老板收购埃弗顿即将官宣 英超老牌球队自求多福吧

雪狼侃体育
2024-06-19 11:16:17
许正宇:正与内地监管磋商,让内地投资者直接用人民币买港股

许正宇:正与内地监管磋商,让内地投资者直接用人民币买港股

财联社
2024-06-19 17:33:09
韩国教头夏窗从鲁能下课走人!跟队记者已确认,替代者浮出水面

韩国教头夏窗从鲁能下课走人!跟队记者已确认,替代者浮出水面

罗掌柜体育
2024-06-19 16:14:52
2024-06-20 06:54:44
腾讯技术工程
腾讯技术工程
不止于技术
1131文章数 598关注度
往期回顾 全部

科技要闻

618观察:谁为高强度的低价竞争买单?

头条要闻

欧洲杯:苏格兰1-1瑞士 沙奇里无解世界波

头条要闻

欧洲杯:苏格兰1-1瑞士 沙奇里无解世界波

体育要闻

欧洲杯最大的混子,非他莫属

娱乐要闻

黄一鸣“杀疯了” 直播间卖大葱养孩子

财经要闻

深化科创板改革 证监会发布八条措施

汽车要闻

双肾格栅变化大/内饰焕新 新一代宝马X3官图发布

态度原创

游戏
本地
家居
手机
公开课

《艾尔登法环:黄金树幽影》是M站评分最高的DLC

本地新闻

中式沙拉宇宙的天花板,它必须有姓名

家居要闻

自然开放 实现灵动可变空间

手机要闻

Android 15将重新定义“快速充电”标准:从7.5W提高到20W

公开课

近视只是视力差?小心并发症

无障碍浏览 进入关怀版