最近樓主都在做性能優化相關的事,性能優化一般都會跟IdleHandler打交道。本文將介紹,樓主在實際開發過程中使用IdleHandler遇到的坑,主要包括自定義View以及View的動畫。
本文參考資料:
注意,本文源碼均來自於API 29。
1. 概述
我們都知道IdleHandler的含義,一般表示當前主線程在不忙碌的時候會執行IdleHandler裏面的任務。具體的內容是,當Looper
在從MessageQueue
中獲取當前需要執行的Message
時,如果當前MessageQueue
中沒有Message或者還沒有到第一條Message執行的時間,此時MessageQueue
會嘗試執行IdleHandler的裏面的任務,這個我們可以從MessageQueue
的next
方法裏面得到應證:
Message next() {
//······
// 當Message爲空,或者當前Message執行時間還未到
if (pendingIdleHandlerCount < 0
&& (mMessages == null || now < mMessages.when)
) {
pendingIdleHandlerCount = mIdleHandlers.size();
}
if (pendingIdleHandlerCount <= 0) {
// No idle handlers to run. Loop and wait some more.
mBlocked = true;
continue;
}
if (mPendingIdleHandlers == null) {
mPendingIdleHandlers = new IdleHandler [Math.max(pendingIdleHandlerCount, 4)];
}
mPendingIdleHandlers = mIdleHandlers.toArray(mPendingIdleHandlers);
// Run the idle handlers.
// We only ever reach this code block during the first iteration.
for (int i = 0; i < pendingIdleHandlerCount; i++) {
final IdleHandler idler = mPendingIdleHandlers[i];
mPendingIdleHandlers[i] = null; // release the reference to the handler
boolean keep = false;
try {
keep = idler.queueIdle();
} catch (Throwable t) {
Log.wtf(TAG, "IdleHandler threw exception", t);
}
if (!keep) {
synchronized(this) {
mIdleHandlers.remove(idler);
}
}
}
// Reset the idle handler count to 0 so we do not run them again.
pendingIdleHandlerCount = 0;
// While calling an idle handler, a new message could have been delivered
// so go back and look again for a pending message without waiting.
nextPollTimeoutMillis = 0;
}
一般來說,我們正常使用IdleHandler都沒有什麼問題,queueIdle
方法也會被正常的回調。但是當你做了如下操作的時候,你會發現,不管等多久,queueIdle
永遠不會被回調。
- 在View的onDraw方法裏面無限制的直接或者間接調用
View
的invalidate
方法。- 做一個無限輪詢的View動畫。
2. 說說坑在哪裏?
上面我枚舉了queueIdle
方法不會被回調的兩種場景,接下來,我們就分開來看一下。
(1). View的invalidate方法
我們在自定義View的時候,經常會手動的調用View
的invalidate
方法,用來保證我們的某些設置能夠立即生效。但是在很多的時候,我們非常容易錯誤的調用了invalidate
方法,從而導致陷入一種無限制重繪的狀態。
舉一個簡單的例子,ImageView
內部有setImageDrawable
,setImageBitmap
等設置Drawable的方法,我們在日常的開發中,也會經常用到這些方法,用來展示某些特殊的內容。但是,一旦我們在自定義View時,在onDraw方法裏面調用這些方法就會讓主線程的任務隊列永遠不會idle。大家可以嘗試一下如下的代碼:
class CustomImageView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : AppCompatImageView(context, attrs, defStyleAttr) {
@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
override fun onDraw(canvas: Canvas?) {
setImageDrawable(context.getDrawable(R.drawable.ic_launcher_background))
super.onDraw(canvas)
}
}
在這裏,我將解釋爲什麼如上的操作會導致主線程任務隊列永遠不會idle。我們都知道,View的重繪流程是:invalidate
-> onDraw
。也就是說,當我們在View的onDraw方法裏面調用invalidate
時或者其他會調用invalidate
的方法(例如上面的介紹的setImageDrawable
和setImageBitmap
方法),最終又會執行onDraw
方法,從而形成了一個類似於死遞歸的情形,即不斷的向任務隊列裏面增加任務。
有人可能會想,就算不斷的往任務隊列裏面增加任務,但是一幀的時間有那麼長--16ms,不可能都在執行重繪的任務,應該總有機會idle的啊?相信很多人都會這麼想,包括我在最開始的時候也是這麼想的。但是仔細看了源碼之後,發現自己的Android還是沒有學到家。
View
的invalidate
方法不斷的向上調用,最終會調用到ViewRootImpl
的scheduleTraversals
方法裏面去,而在scheduleTraversals
方法裏面做了一件容易讓人忽視的事,我們先來看一下源碼:
void scheduleTraversals() {
if (!mTraversalScheduled) {
mTraversalScheduled = true;
mTraversalBarrier = mHandler.getLooper().getQueue().postSyncBarrier();
mChoreographer.postCallback(
Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null);
if (!mUnbufferedInputDispatch) {
scheduleConsumeBatchedInput();
}
notifyRendererOfFramePending();
pokeDrawLockIfNeeded();
}
}
這裏面,我們先忽略其他的操作,只看兩件事:
- 調用了
MessageQueue
的postSyncBarrier
方法,向任務隊列post了一個同步屏障。這一步非常的重要,也是因爲有這麼一個同步屏障的存在,導致了MessageQueue不會idle。- 往Choreographer裏面post了一個traversal類型的任務,保證在下一個垂直信號到來時,可以正常的重繪View的內容。這裏的
mTraversalRunnable
執行,最終會到CutomImageView
的onDraw
方法。
上面提到了同步屏障,相信大家都比較熟悉它的作用,但是我在這裏還是要說明一下:
Message有一個標記位,名爲
FLAG_ASYNCHRONOUS
,用來表示當前Message是否是異步的。而同步屏障的作用用來擋住所有同步的Message,只允許異步的Message通過。如果當前任務隊列中沒有異步Message,那麼主線程就會休眠,直到任務隊列中添加了一個異步Message,或者同步屏障被移除。
同步屏障跟普通的消息比較類似,都是一個Message對象,只是同步屏障Message的Target爲空而已。
上面知道了,當調用View
的invalidate
時,會向任務隊列裏面post一個同步屏障。接下來,我們來看一下MessageQueue對同步屏障是怎麼處理的,同時看一下爲啥idle永遠不會被調用。
Message next() {
// ······
for (;;) {
// ······
// 休眠指定時間
// ······
synchronized (this) {
// ······
final long now = SystemClock.uptimeMillis();
Message prevMsg = null;
Message msg = mMessages;
// 獲取異步消息
if (msg != null && msg.target == null) {
// Stalled by a barrier. Find the next asynchronous message in the queue.
do {
prevMsg = msg;
msg = msg.next;
} while (msg != null && !msg.isAsynchronous());
}
//······
// 獲取同步消息
//······
// 處理idle
if (pendingIdleHandlerCount < 0
&& (mMessages == null || now < mMessages.when)) {
pendingIdleHandlerCount = mIdleHandlers.size();
}
// ·······
}
// ······
}
}
我簡化了一下next方法,next方法最終的目的就是獲取下一個需要被執行Message,如果獲取不到的話,就會休眠。但是,當任務隊列的隊頭(即mMessages
)是一個同步屏障,這個方法裏面執行流程就變得非常有意思:
- 當
mMessages
是同步屏障,且後續沒有異步消息,那麼獲取異步消息
和獲取同步消息
這兩步都會失敗了,即nextPollTimeoutMillis
會被賦值爲-1,表示無限制的休眠。- pendingIdleHandlerCount 默認是-1,所以會嘗試着賦值。其中,由於同步屏障的存在,所以
mMessages
肯定不爲空,同時now < mMessages.when
肯定是不成立的,因爲同步屏障在ViewRootImpl
的scheduleTraversals
方法裏面就添加進去,所以這個時間肯定比當前時間要早很多。
因此,結合上面兩點,idle是不會回調的,並且會讓主線程休眠,直到一個異步Message添加到隊列中。這個Messgae就是Choreographer$FrameDisplayEventReceiver
,我們可以簡單的看一下源碼:
@Override
public void onVsync(long timestampNanos, long physicalDisplayId, int frame) {
// ······
Message msg = Message.obtain(mHandler, this);
msg.setAsynchronous(true);
mHandler.sendMessageAtTime(msg, timestampNanos / TimeUtils.NANOS_PER_MS);
}
這個Message的作用就是把Choreographer
四種類型的任務全部執行,其中前面invalidate
方法添加的任務就包含在這裏。
到這裏,我們就知道了爲啥Idle永遠不會回調了,我做一個總結,方便大家理解:
當我們在onDraw方法直接或者間接調用
invalidate
方法,ViewRootImpl會向MessageQueue
裏面post 一個同步屏障。當MessageQueue
輪詢到這個同步屏障時,會等到Choreographer$FrameDisplayEventReceiver
這個異步任務執行之後,纔會執行其他任務,即纔有可能觸發idle。但是Choreographer$FrameDisplayEventReceiver
這個任務裏面又會執行View
的onDraw方法,從而形成了一個無限循環。進而,idle永遠不會回調。
那麼我們知道了原因所在,怎麼來解決這種類似的問題呢?原則是:儘量不在onDraw方法裏面直接或者間接調用invalidate
方法。如果真的要這麼做,應該怎麼做呢?可以過濾無效的重繪。就拿上面的例子來說,我們可以將Drawable緩存成一個成員變量:
class CustomImageView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : AppCompatImageView(context, attrs, defStyleAttr) {
private var mDrawable: Drawable? = null
@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
override fun onDraw(canvas: Canvas?) {
if (mDrawable == null) {
mDrawable = context.getDrawable(R.drawable.ic_launcher_background)
}
setImageDrawable(mDrawable)
super.onDraw(canvas)
}
}
這裏還有一個疑問,我們發現了,就算我們在onDraw
方法直接或者間接調用invalidate
方法,但是並不會影響我們正常的使用App,比如說使用上不能感受到明顯的卡頓,這也是我們難以發現這種問題的原因。那麼這是爲什麼呢?
其實,簡單的來說,如上的操作只是每一幀的時間裏面多了一個任務而已,從體驗上來說,幾乎沒法區分這裏面的差別。除非,你想要在IdleHandler處理一些事情,在這種情形下是永遠不會被執行的。
(2) 無限輪詢的View動畫
在一個App,動畫是一件再正常不過的事情,而錯誤的使用動畫也會導致IdleHandler永遠不會回調。注意,這裏指的是動畫是普通的View動畫,而不是屬性動畫,屬性動畫沒有這個問題,而針對屬性動畫的分析,後文會有內容介紹。
通常來說,我們會寫類似如下的代碼來展示一個動畫:
private fun startAnimation() {
val animation = AlphaAnimation(1f, 0.5f)
animation.duration = 100
animation.repeatCount = -1
animation.repeatMode = Animation.REVERSE
val view = findViewById<View>(R.id.view)
view.startAnimation(animation)
}
上面的代碼有兩個特點:
- 是普通的View動畫。
- 是無限輪詢的動畫。
如果你寫了類似上面的代碼,那麼恭喜你,你的IdleHandler永遠不會被回調。究其原因,其實還是因爲無限制的調用invalidate
方法,有興趣的可以參考View 動畫 Animation 運行原理解析這篇文章,瞭解一下View動畫的實現原理,這裏就不過多的介紹了。
那麼怎麼解決這種問題,最簡單的辦法就是換成屬性動畫,那麼肯定又有人要問了,爲什麼屬性動畫無限輪詢不會影響的IdleHandler的調用呢?這就要了解屬性動畫的原理了,這裏我簡單的介紹一下,有興趣的同學可以參考 屬性動畫 ValueAnimator 運行原理全解析這篇文章。
屬性動畫的實現原理不同於View動畫。View動畫的每一幀都是通過
invalidate
方法來觸發重繪,而屬性動畫每一幀的繪製都是通過Choreographer
的回調實現,本質上就是當動畫開始時,會向Choreographer
的任務隊列裏面post 一個動畫類型的任務,當垂直信號到來時,會執行這裏面的任務,從而回調我們的任務;同時爲了保證動畫能夠流暢的進行,噹噹前幀繪製完成,會再向Choreographer的任務隊列post一個任務,保證下一幀動畫能夠正常繪製,從而實現了動畫。
從本質上來說,屬性動畫少了一個很重要的步驟,就是post 一個同步屏障。在屬性動畫中,沒有同步屏障,那麼後續的任務能夠繼續執行,當隊列中沒有任務時,自然就會回調IdleHandler。
3. 怎麼排查問題的原因?
當我們使用IdleHandler時,發現Idle永遠不會被回調,應該要積極排查代碼上是否有類似上面的問題。但是,在實際項目中,業務代碼成千上萬,不可能每一個人都會看完,所以我們需要有一個更爲高效的方式來排查這種問題。
(1). 給Looper設置一個Printer
在Looper的loop方法裏面,Message在執行前後,都會通過一個Printer
對象,打印當前執行的Message信息。我們可以通過Message的相關信息找到對應位置上的問題。
public static void loop() {
// ······
for (;;) {
// ······
if (logging != null) {
logging.println(">>>>> Dispatching to " + msg.target + " " +
msg.callback + ": " + msg.what);
}
// ······
if (logging != null) {
logging.println("<<<<< Finished to " + msg.target + " " + msg.callback);
}
// ······
}
(2). 自定義頂級ViewGroup,並且重寫相關方法
當我們在實際場景種發現了這種問題,還有一種排查的方法,就是將界面最頂級的ViewGroup換成我們自定義的ViewGroup,並且重寫相關的方法,比如說invalidate
和requestLayout
等方法,可以在這些方法裏面打印相關堆棧,來監聽哪些地方出現了問題。
之所以可以在父ViewGroup可以監聽到調用,是因爲子View在調用invalidate
,requestLayout
等方法,最終都會走到父ViewGroup對應方法裏面去。
我這裏只拋出兩種比較簡單的解決方法,不一定適用於所有場景,因爲具體的問題還需要依賴於具體的場景來看待。不管怎麼樣,大家在開發過程中儘量不要書寫上面兩種類型的代碼。