從一次實際經歷來說說IdleHandler的坑 1. 概述 2. 說說坑在哪裏? 3. 怎麼排查問題的原因?

  最近樓主都在做性能優化相關的事,性能優化一般都會跟IdleHandler打交道。本文將介紹,樓主在實際開發過程中使用IdleHandler遇到的坑,主要包括自定義View以及View的動畫。
  本文參考資料:

  1. View 動畫 Animation 運行原理解析
  2. 屬性動畫 ValueAnimator 運行原理全解析
  3. Android 源碼分析 - Handler的同步屏障機制

  注意,本文源碼均來自於API 29。

1. 概述

  我們都知道IdleHandler的含義,一般表示當前主線程在不忙碌的時候會執行IdleHandler裏面的任務。具體的內容是,當Looper在從MessageQueue中獲取當前需要執行的Message時,如果當前MessageQueue中沒有Message或者還沒有到第一條Message執行的時間,此時MessageQueue會嘗試執行IdleHandler的裏面的任務,這個我們可以從MessageQueuenext方法裏面得到應證:

    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永遠不會被回調。

  1. 在View的onDraw方法裏面無限制的直接或者間接調用Viewinvalidate方法。
  2. 做一個無限輪詢的View動畫。

2. 說說坑在哪裏?

  上面我枚舉了queueIdle方法不會被回調的兩種場景,接下來,我們就分開來看一下。

(1). View的invalidate方法

  我們在自定義View的時候,經常會手動的調用Viewinvalidate方法,用來保證我們的某些設置能夠立即生效。但是在很多的時候,我們非常容易錯誤的調用了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的方法(例如上面的介紹的setImageDrawablesetImageBitmap方法),最終又會執行onDraw方法,從而形成了一個類似於死遞歸的情形,即不斷的向任務隊列裏面增加任務。
  有人可能會想,就算不斷的往任務隊列裏面增加任務,但是一幀的時間有那麼長--16ms,不可能都在執行重繪的任務,應該總有機會idle的啊?相信很多人都會這麼想,包括我在最開始的時候也是這麼想的。但是仔細看了源碼之後,發現自己的Android還是沒有學到家。
  Viewinvalidate方法不斷的向上調用,最終會調用到ViewRootImplscheduleTraversals方法裏面去,而在scheduleTraversals方法裏面做了一件容易讓人忽視的事,我們先來看一下源碼:

    void scheduleTraversals() {
        if (!mTraversalScheduled) {
            mTraversalScheduled = true;
            mTraversalBarrier = mHandler.getLooper().getQueue().postSyncBarrier();
            mChoreographer.postCallback(
                    Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null);
            if (!mUnbufferedInputDispatch) {
                scheduleConsumeBatchedInput();
            }
            notifyRendererOfFramePending();
            pokeDrawLockIfNeeded();
        }
    }

  這裏面,我們先忽略其他的操作,只看兩件事:

  1. 調用了MessageQueuepostSyncBarrier方法,向任務隊列post了一個同步屏障。這一步非常的重要,也是因爲有這麼一個同步屏障的存在,導致了MessageQueue不會idle。
  2. 往Choreographer裏面post了一個traversal類型的任務,保證在下一個垂直信號到來時,可以正常的重繪View的內容。這裏的mTraversalRunnable執行,最終會到CutomImageViewonDraw方法。

  上面提到了同步屏障,相信大家都比較熟悉它的作用,但是我在這裏還是要說明一下:

  Message有一個標記位,名爲FLAG_ASYNCHRONOUS,用來表示當前Message是否是異步的。而同步屏障的作用用來擋住所有同步的Message,只允許異步的Message通過。如果當前任務隊列中沒有異步Message,那麼主線程就會休眠,直到任務隊列中添加了一個異步Message,或者同步屏障被移除。
  同步屏障跟普通的消息比較類似,都是一個Message對象,只是同步屏障Message的Target爲空而已。

  上面知道了,當調用Viewinvalidate時,會向任務隊列裏面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)是一個同步屏障,這個方法裏面執行流程就變得非常有意思:

  1. mMessages是同步屏障,且後續沒有異步消息,那麼獲取異步消息獲取同步消息這兩步都會失敗了,即nextPollTimeoutMillis會被賦值爲-1,表示無限制的休眠。
  2. pendingIdleHandlerCount 默認是-1,所以會嘗試着賦值。其中,由於同步屏障的存在,所以mMessages肯定不爲空,同時now < mMessages.when 肯定是不成立的,因爲同步屏障在ViewRootImplscheduleTraversals方法裏面就添加進去,所以這個時間肯定比當前時間要早很多。

  因此,結合上面兩點,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)
    }

  上面的代碼有兩個特點:

  1. 是普通的View動畫。
  2. 是無限輪詢的動畫。

  如果你寫了類似上面的代碼,那麼恭喜你,你的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,並且重寫相關的方法,比如說invalidaterequestLayout等方法,可以在這些方法裏面打印相關堆棧,來監聽哪些地方出現了問題。
  之所以可以在父ViewGroup可以監聽到調用,是因爲子View在調用invalidaterequestLayout等方法,最終都會走到父ViewGroup對應方法裏面去。

  我這裏只拋出兩種比較簡單的解決方法,不一定適用於所有場景,因爲具體的問題還需要依賴於具體的場景來看待。不管怎麼樣,大家在開發過程中儘量不要書寫上面兩種類型的代碼。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章