迎來到我的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小時內與您取得聯系。