迎来到我的JS拖拽专题系列文章,本章节将是拖拽系列的最后一篇。感谢大家的支持^_^
上一章节我们说到了拖拽让图片相互之间交换位置,相对来说是一个比较综合的示例,涉及到了矩形的碰撞检测,勾股定理计算两点间的距离以及最小距离的获取。
在移动端,我们经常会通过手指左滑,右滑,上滑,下滑去触发一些操作,这种手指滑动操作我们称之为swipe相关的事件。
先来看下今天要实现的效果吧!
一个简单的滑动事件的示例
然后不巧的是,这些事件并不是原生就提供给我们使用的。而我们能够使用之,是因为有人造好了轮子。
那么接下来我们也一起去造一下这个轮子吧,看看它和我们的拖拽有着怎样千丝万缕的联系吧~
本次swipe相关事件是基于 伟大的jquery来实现的。所以我们先来了解一下jquery的插件扩展原理吧
熟悉jqeuery的特性的都知道,它是基于面向对象,插件的扩展内部原理其实就是在类为原型上添加自定义的方法。
$.fn.pluginName=function(){ ... do something }
what?不是插件是在原型上扩展的吗???
OK,为了验证我的做法,老规矩,找源码去。
作者在类下直接挂载了一个fn属性,这个属性和jQuery的原型对象相等,我们知道在jquery中,$===jQuery的。所以,我们可以$.fn.pluginName=function(){}进行扩展
分析一下,滑动的动作,手指按下,手指移动,手指抬起,实质是三个事件的合体,刚好和我们的拖拽三大事件不谋而合。
问题1:如何定义滑动的方向?
假如圆心为我们的手指的起始点,那么手指抬起的时候位置落在的区域如图所示,我们就能轻松判断出用户的手指的滑动方向。
问题2:触发最终滑动事件的条件是什么?比如向上滑动的判断条件是什么?
接下来我们用代码实现一下
我们定义扩展的插件名为swipe,于是就有了:
$.fn.swipe=function(type,fn){}
其中type为我们要滑动的方向,比如:up, down ,left,top
依据上面的分析:在手指按下的时候,记录手指的起始X和Y的坐标和起始时间
在手指抬起的时候,再次获取当前的X,Y和时间
再分别计算出差值。
条件判断
执行我们的fn函数
完整的代码请私信我
这是我们封装向上滑动的插件内部实现。同理,其它的方向的滑动只需要做细小的变动即可。这里不再赘述。
说明:插件的扩展还有一些默认的参数的配置,这些在本章节中不是主要,就不再细分下去了。有兴趣的大家可以阅读其它的swipe插件的封装。比我这边要复杂一些,但是原理和我分享的差不了。建议各位项目中还是用成熟的插件哦。
我这里只是想告诉大家这个轮子大概是怎么造出来的
var ul=$('#container ul'); var iNow=0; ul.swipe('up',()=>{ console.log('up'); iNow++; iNow=Math.min(5,iNow) ul.css({transform:'translateY('+-iNow * 100 +'vh)'}); }).swipe('down',e=>{ iNow--; iNow=Math.max(0,iNow) ul.css({transform:'translateY('+-iNow * 100 +'vh)'}); })
这里的swipe就是我们自己扩展的插件的方法。up是我们定义滑动的方向fn是滑动后的回调方法。
为什么我们定义的插件后,可以直接用ul.swipe方法呢?这个我们在文章的最前端已经说到,jquery 是基于面向对象的,var ul=$('#container ul');就相当于new jQuery(),也就是说ul是jQuery类的一个实例,而我们的插件是基于类的原型扩展出来的方法。所以我们可以直接通过ul.swipe来调用。
这里是【畅哥聊技术】JS拖拽专题系列技术文章的最后一个章节,在 web中,有关于拖拽的变形应用还有很多,但是万变不离其踪,掌握原理,其它都是浮云。我的系列文章只是记录了我在工作中经常用到的案例。希望从中能帮助到大家。
最后感谢各位的支持,下一个系列专题分享还在准备中...
敬请期待!
(全文完)
者: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界面都可正常滑动。
大部分的滑动问题都是因为存在着嵌套滑动冲突。为了验证是否是嵌套的问题,我们需要在不同层级的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. 会不会是硬件层的触摸事件采集出现了问题?
结合异常情况出现时,是能同时存在正常界面的。底层的触摸事件采集跟业务的界面属于不同结构层级,业务的一些状态管理问题应该不会反作用于硬件层的触摸采集,因此这个问题与硬件层的关系不是很大。排除
在排查了多个因素无果之后,我们将焦点放到反馈问题的手机上。出现问题的手机有一个共同点是支持高刷新频率(90HZ,120HZ...)。而一般手机的刷新频率是60HZ。难道是高刷新频率机制在某些场景下导致了触摸事件的异常? 此外,高刷机型的聚集也侧面反映了这些反馈问题的都是比较新款的手机,另一个共同点是对应的版本都是Android11。因此对刷新频率和Android版本这两个变量进行交叉组合验证
经过测试:
这意味着滑动问题与Android 11存在着紧密的联系,而Android 10是不存在这个问题的。那么要想彻底探究清楚这个问题,就必须深入了解Android 10和Android 11这两版本在Input系统的事件处理上的差异,源码分析势在必行。
本文许多地方引用到了Android Framework中native,base这两部分的源码,这里提供源码的阅读的一些链接。
由于对Input事件的处理涉及到Android框架的多个结构层次,从native到base层,且为了探究Android 11与之前的版本差异,更需要用到翻看git history对比差异。这里我是同步整个开源仓库的代码,学有余力的同学可以参考下这个Android 开源项目指南 Wiki
这里先放一张结构草图,让大家对Input系统结构层次有个粗略的印象。(PS:这里的流程是片面的)
源码中核心类及文件路径:
c++:
java:、
Android Input系统中Window是接收用户Input事件的基本单位, 它可以是一个Activity,也可以是个Dialog,Toast,StatusBar,NavigationBar等等 ,每个Window都会对应一个ViewRootImpl. 前面分析的问题来说:界面A可以简单理解为Window A,界面B为Window B
Android Input事件的读取和分发是进行在一个System Server进程中的,因此从System Server进程中发送触摸事件到我们App主进程是需要进行跨进称通信,这里选用的通信方式就是socket Activity初始化的时候, 每一个Activity实例都会创建一个用于接收事件的socket通讯通道, 通过对Windows的管理, 找到当前需要接收事件的Windows, 通过socket直接将事件数据发送给对应的Windows, Window内以RootViewImpl为起点, 对事件进行分发处理。
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的根结点,也是我们的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 是跑在System Server进程中的里面的两个 Native 线程,负责读取和分发 Input 事件。要想分析input事件的流向,需要从这里开始入手。
从InputReader和InputDispatcher这两个线程的角度,我们可以将整个input事件的处理流程简单归纳如下:
InputDispatcher内部维护了一个mConnectionsByFd,根据File Descriptor存放了所有的Connection(与每个Window都有一个),Connection持有InputChannel用于发送Intput Message
// All registered connections mapped by channel file descriptor.
std::unordered_map<int, sp<Connection>> mConnectionsByFd GUARDED_BY(mLock);
Android系统中,Dispatch线程与众多APP密切联系,当我们创建一个APP时候,便于Dispatch线程产生联系,这些Connection由窗口管理器(WindowManager)创建的。故Dispatch线程便可通过这些Connection将输入事件发送给对应的APP。
了解了一些Input机制后,我们该怎么对InputReader和InputDispatcher这两个Native线程进行Native调试呢?
这里我们使用的是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的时候做了合并的操作?
源码核心类必能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 dumpsys或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这个步骤就是正常的。
这里需要对源码逐步分析,当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<jobject> 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<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_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机制。一般应用只在每个VSYNC的周期下进行一次绘制。因此,在每一帧的时候应用只能对一次input事件进行响应反馈。如果在一个VSYNC周期中出现了多个input事件,每次input事件到来的时候都立即分发到应用层是比较浪费资源的。为了避免浪费,就有了Batched Consumption机制,input事件会被进行批处理,然后在每个Frame渲染时发送一个batched input事件给到应用层。
对于批量的Move事件,事件从分发到消费对的链路如下:
对于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事件走dispatchInputEvent分发到上层
正常情况Consume Batched MoveEvent
异常情况Consume Batched MoveEvent
细心的读者可能会发现,上面正常情况的图中里面并没有出现onBatchedInputEventPending调用,而是由ViewRootImpl每隔一帧的时间触发一次消费consumeBatchedInput.并不是照Android 11源码上的,只有当move事件到来的时候,触发onBatchedInputEventPending,再下一帧绘制的时候触发一次consumeBatchedInput 探究后,发现这手机(Oppo find x2 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。
现在压力传递到了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.java
void scheduleTraversals() {
if (!mTraversalScheduled) {
mTraversalScheduled = true;
mTraversalBarrier = mHandler.getLooper().getQueue().postSyncBarrier();//??这里对MessageQueue做了一个postSyncBarrier的操作
mChoreographer.postCallback(
Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null);
notifyRendererOfFramePending();
pokeDrawLockIfNeeded();
}
}
scheduleTraversals中的postSyncBarrier就是往MessageQueue中插入一个同步屏障消息。 MessageQueue中的消息可以分为三种:普通消息(同步消息)、屏障消息(同步屏障)和异步消息。我们通常使用的都是普通消息,而屏障消息就是在消息队列中插入一个屏障,在屏障之后的所有普通消息都会被挡着,不能被处理。不过异步消息却例外,屏障不会挡住异步消息,因此可以这样认为:屏障消息就是为了确保异步消息的优先级,设置了屏障后,只能处理其后的异步消息,同步消息会被挡住,除非撤销屏障。
对于一个普通消息来说,它都是存在target,而屏障信息跟同步消息最大的区别就是没有target,因为屏障消息不需要被执行。
//MessageQueue.java
public int postSyncBarrier() {
return postSyncBarrier(SystemClock.uptimeMillis());
}
//可以看到下面生成屏障消息的时候并没有设置 target
private 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#scheduleTraversals方法就使用了同步屏障,以此阻塞其他的同步消息,保证UI绘制优先执行。之后再移除这屏障,让同步消息执行起来。(这也是AOSP源码中唯一一处使用到同步屏障机制的地方)
mTraversalBarrier是用于存放同步屏障的token的变量
//绘制UI之前设置同步屏障,保存 token 到 mTraversalBarrier
void 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;
}
}
}
结合前面提到同步屏障的机制,可以发现当Activity#performStart的时候会触发一次ViewRootImpl#scheduleTraversals,与此同时设置了一个同步屏障,并紧随其后添加了ConsumeBatchedInputImmediatelyRunnable这个同步消息。这个同步消息因同步屏障的存在并不会立即被执行,而是被阻塞住直到UI绘制完成。
到这里我们猜想是因为ViewRootImpl中同步屏障出现了问题,设置了多个屏障,但是只移除了一个屏障,仍有屏障没有被移除,导致了后续的ConsumeBatchedInputImmediatelyRunnable没有执行。
那么怎么验证呢? 将消息队列中所有的消息打印出来!看是否存在barrier消息和被阻塞的ConsumeBatchedInputImmediatelyRunnable 前面说过AOSP中大多数的核心类都提供了dump方法用于调试,Looper和MessageQueue中也有,Looper中的是public可以被调用到
Looper.java
public void dump(@NonNull Printer pw, @NonNull String prefix) {
pw.println(prefix + toString());
mQueue.dump(pw, prefix + " ", null);
}
MessageQueue.java
void 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);
}
}
}
前面我们提到过ViewRootImpl是万view之祖,这里拿到的getHandler取到对象就是ViewRootImpl$ViewRootHandler,与添加ConsumeBatchedInputImmediatelyRunnable的Handler是同一个,对此handler调用handler.removeCallbacksAndMessages(null);就会将同时处于MessageQueue中的ConsumeBatchedInputImmediatelyRunnable移除,从而造成连锁反应,进而导致我们这个滑动问题!
View中的getHandler()为什么会是ViewRootImpl$ViewRootHandler?先看下源码中View中是怎么取到handler的。
//View.java
public 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事件上传
前面分析过,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
不久后也收到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 解读(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
段子手168
方法一:代码破解法
打开你需要复制内容的网页,在浏览器地址栏输入“javascript:void($={});”这串代码,
然后按下回车键,这时候就允许你复制文本了。
方法 二:打印网页法
我们还可以利用打印网页的时候,在预览页面将文本复制下来。按下快捷键【Ctrl+P】,
将会进入打印界面,直接在右侧的预览界面,选中文本进行复制。
方法三:后台控制端
打开网页后,按下功能键【F12】,进入网页后台找到【Console】,
在下面输入这串符号“$=0”,再2按下回车键,
网页文字就能自由复制了。
方法四:查看源代码
你还可以在网页空白处,右击选择【查看页面源代码】,然后一直向下滑动,找到密密麻麻的文本,
选中直接复制提取出来。
方法五:保存本地网页
打开网页鼠标右击,选择【网页另存为】,然后在弹出的窗口中,
将保存类型改为【网页,仅HTML】,接着点击【保存】。
关闭当前网页,回到桌面找到刚刚保存的本地网页文件,双击打开后,就可以随意复制啦。
方法六:截图识别文字
此外,我们还可以利用OCR文字识别技术,将网页文字识别出来。
需要借助掌上识别王工具,找到【文字识别】-【快速截图识别】功能。
方法七:
网址最前面加上 read: (用 Microsoft Edge 浏览器打开)
方法八:
1)按 F12 打开调试框,点击右上角【设置】。
2)往下拉,找到 【Debugger】
3)勾选 【Disable JavaScript】
4)返回页面,按 F5 刷新一下页面,这样网页文字就可以复制了。
*请认真填写需求信息,我们会在24小时内与您取得联系。