Android 屏幕刷新原理筆記

概述

屏幕刷新包括三個步驟:

  • CPU計算屏幕數據,,把計算好數據交給GPU。
  • GPU會對圖形數據進行渲染,渲染好後放到buffer裏存起來。
  • 接下來display負責把buffer裏的數據呈現到屏幕上。

顯示過程,簡單的說就是CPU/GPU準備好數據,存入buffer,display每隔一段時間去buffer裏取數據,然後顯示出來。display讀取的頻率是固定的,比如每個16ms讀一次,但是CPU/GPU寫數據是完全無規律的。

對於Android而言:CPU 計算屏幕數據指的也就是 View 樹的繪製過程,也就是 Activity 對應的視圖樹從根佈局 DecorView 開始層層遍歷每個 View,分別執行測量、佈局、繪製三個操作的過程。

也就是說,我們常說的 Android 每隔 16.6ms 刷新一次屏幕其實是指:底層以固定的頻率,比如每 16.6ms 將 buffer 裏的屏幕數據顯示出來。

在這裏插入圖片描述

Display 這一行可以理解成屏幕,所以可以看到,底層是以固定的頻率發出 VSync 信號的,而這個固定頻率就是我們常說的每 16.6ms 發送一個 VSync 信號,至於什麼叫 VSync 信號,我們可以不用深入去了解,只要清楚這個信號就是屏幕刷新的信號就可以了。

CPU 藍色的這行,上面也說過了,CPU 這塊的耗時其實就是我們 app 繪製當前 View 樹的時間,而這段時間就跟我們自己寫的代碼有關係了,如果你的佈局很複雜,層次嵌套很多,每一幀內需要刷新的 View 又很多時,那麼每一幀的繪製耗時自然就會多一點。

  • 我們常說的 Android 每隔 16.6 ms 刷新一次屏幕其實是指底層會以這個固定頻率來切換每一幀的畫面。
  • 這個每一幀的畫面也就是我們的 app 繪製視圖樹(View 樹)計算而來的,這個工作是交由 CPU 處理,耗時的長短取決於我們寫的代碼:佈局復不復雜,層次深不深,同一幀內刷新的 View 的數量多不多。
  • CPU 繪製視圖樹來計算下一幀畫面數據的工作是在屏幕刷新信號來的時候纔開始工作的,而當這個工作處理完畢後,也就是下一幀的畫面數據已經全部計算完畢,也不會馬上顯示到屏幕上,而是會等下一個屏幕刷新信號來的時候再交由底層將計算完畢的屏幕畫面數據顯示出來。
  • 當我們的 app 界面不需要刷新時(用戶無操作,界面無動畫),app 就接收不到屏幕刷新信號所以也就不會讓 CPU 再去繪製視圖樹計算畫面數據工作,但是底層仍然會每隔 16.6 ms 切換下一幀的畫面,只是這個下一幀畫面一直是相同的內容。

爲什麼界面不刷新時 app 就接收不到屏幕刷新信號了?爲什麼繪製視圖樹計算下一幀畫面的工作會是在屏幕刷新信號來的時候纔開始的?

源碼

ViewRootImpl 與 DecorView 的綁定.

View#invalidate() 是請求重繪的一個操作,所以我們切入點可以從這個方法開始一步步跟下去。

Android 設備呈現到界面上的大多數情況下都是一個 Activity,真正承載視圖的是一個 Window,每個 Window 都有一個 DecorView,我們調用 setContentView() 其實是將我們自己寫的佈局文件添加到以 DecorView 爲根佈局的一個 ViewGroup 裏,構成一顆 View 樹。

每個 Activity 對應一顆以 DecorView 爲根佈局的 View 樹,但其實 DecorView 還有 mParent,而且就是 ViewRootImpl,而且每個界面上的 View 的刷新,繪製,點擊事件的分發其實都是由 ViewRootImpl 作爲發起者的,由 ViewRootImpl 控制這些操作從 DecorView 開始遍歷 View 樹去分發處理。

ViewRootImpl 與 DecorView 的綁定

跟着 invalidate() 一步步往下走的時候,發現最後跟到了 ViewRootImpl#scheduleTraversals() 就停止了。

Android 設備呈現到界面上的大多數情況下都是一個 Activity,真正承載視圖的是一個 Window,每個 Window 都有一個 DecorView,我們調用 setContentView() 其實是將我們自己寫的佈局文件添加到以 DecorView 爲根佈局的一個 ViewGroup 裏,構成一顆 View 樹。

每個 Activity 對應一顆以 DecorView 爲根佈局的 View 樹,但其實 DecorView 還有 mParent,而且就是 ViewRootImpl,而且每個界面上的 View 的刷新,繪製,點擊事件的分發其實都是由 ViewRootImpl 作爲發起者的,由 ViewRootImpl 控制這些操作從 DecorView 開始遍歷 View 樹去分發處理。

View#invalidate() 時,也可以看到內部其實是有一個 do{}while() 循環來不斷尋找 mParent,所以最終纔會走到 ViewRootImpl 裏去,那麼可能大夥就會疑問了,爲什麼 DecorView 的 mParent 會是 ViewRootImpl 呢?換個問法也就是,在什麼時候將 DevorView 和 ViewRootImpl 綁定起來?

Activity 的啓動是在 ActivityThread 裏完成的,handleLaunchActivity() 會依次間接的執行到 Activity 的 onCreate(), onStart(), onResume()。在執行完這些後 ActivityThread 會調用 WindowManager#addView(),而這個 addView() 最終其實是調用了 WindowManagerGlobal 的 addView() 方法,我們就從這裏開始看:

//WindowManagerGlobal#addView
public void addView(View view, ViewGroup.LayoutParams params, Display display, Window parentWindow) {
    ...
    ViewRootImpl root;
    ...
    synchronized (mLock) {
        ...
        //1. 實例化一個 ViewRootImpl對象
        root = new ViewRootImpl(view.getContext(), display);
        ...
        mViews.add(view);
        mRoots.add(root);
        ...
    }
    try {
        //2. 調用ViewRootImpl的setView(),並將DecorView作爲參數傳遞進去
        root.setView(view, wparams, panelParentView);
    }...  
}

WindowManager 維護着所有 Activity 的 DecorView 和 ViewRootImpl。這裏初始化了一個 ViewRootImpl,然後調用了它的 setView() 方法,將 DevorView 作爲參數傳遞了進去。所以看看 ViewRootImpl 中的 setView() 做了什麼:

//ViewRootImpl#setView
public void setView(View view, WindowManager.LayoutParams attrs, View panelParentView) {
    synchronized (this) {
        if (mView == null) {
            //1. view 是 DecorView
            mView = view;
            ...
            //2.發起佈局請求
            requestLayout();
            ...
            //3.將當前ViewRootImpl對象this,作爲參數調用了DecorView的assignParent
            view.assignParent(this);
            ...
        }
    }
}

在 setView() 方法裏調用了 DecorView 的 assignParent() 方法,所以去看看 View 的這個方法:

//View#assignParent
void assignParent(ViewParent parent) {
    if (mParent == null) {
        mParent = null;
    } else if (parent == null) {
        mParent = null;
    } else {
        throw new RunTimeException("view " + this + " is already has a parent")
    }
}

參數是 ViewParent,而 ViewRootImpl 是實現了 ViewParent 接口的,所以在這裏就將 DecorView 和 ViewRootImpl 綁定起來了。每個Activity 的根佈局都是 DecorView,而 DecorView 的 parent 又是 ViewRootImpl,所以在子 View 裏執行 invalidate() 之類的操作,循環找 parent 時,最後都會走到 ViewRootImpl 裏來。

跟界面刷新相關的方法裏應該都會有一個循環找 parent 的方法,或者是不斷調用 parent 的方法,這樣最終才都會走到 ViewRootImpl 裏,也就是說實際上 View 的刷新都是由 ViewRootImpl 來控制的。

即使是界面上一個小小的 View 發起了重繪請求時,都要層層走到 ViewRootImpl,由它來發起重繪請求,然後再由它來開始遍歷 View 樹,一直遍歷到這個需要重繪的 View 再調用它的 onDraw() 方法進行繪製。

重新看回 ViewRootImpl 的 setView() 這個方法,這個方法裏還調用了一個 requestLayout() 方法:

//ViewRootImpl#requestLayout
@Override
public void requestLayout() {
    if (!mHandingLayoutInLayoutRequest) {
        //1.檢查該操作是否是在主線程中執行
        checkThread();
        mLayoutRequested = true;
        //2.安排一次遍歷繪製View樹的任務
        scheduleTraversals();
    }
}

這裏調用了一個 scheduleTraversals(),還記得當 View 發起重繪操作 invalidate() 時,最後也調用了 scheduleTraversals() 這個方法麼。其實這個方法就是屏幕刷新的關鍵,它是安排一次繪製 View 樹的任務等待執行,具體後面再說。

也就是說,其實打開一個 Activity,當它的 onCreate—onResume 生命週期都走完後,纔將它的 DecoView 與新建的一個 ViewRootImpl 對象綁定起來,同時開始安排一次遍歷 View 任務也就是繪製 View 樹的操作等待執行,然後將 DecoView 的 parent 設置成 ViewRootImpl 對象。

這也就是爲什麼在 onCreate—onResume 裏獲取不到 View 寬高的原因,因爲在這個時刻 ViewRootImpl 甚至都還沒創建,更不用說是否已經執行過測量操作了。

還可以得到一點信息是,一個 Activity 界面的繪製,其實是在 onResume() 之後纔開始的。

ViewRootImpl # scheduleTraversals

調用一個 View 的 invalidate() 請求重繪操作,內部原來是要層層通知到 ViewRootImpl 的 scheduleTraversals() 裏去。

//ViewRootImpl#scheduleTraversals
void scheduleTraversals() {
    if (!mTraversalScheduled) {
        mTraversalScheduled = true;
        mTraversalBarrier = mHandler.getLooper().getQueue().postSyncBarrier();
        mChoreographer.postCallback(
            Choreograhper.CALLBACK_TRAVERSAL, mTraversalRunnable, null);
        ...
    }
}

mTraversalScheduled 這個 boolean 變量的作用等會再來看,先看看 mChoreographer.postCallback() 這個方法,傳入了三個參數,第二個參數是一個 Runnable 對象,先來看看這個 Runnable:

//ViewRootImpl$TraversalRunnable
final class TraversalRunnable implements Runnable {
    @Override
    public void run() {
        doTraversal();
    }
}
//ViewRootImpl成員變量
final TraversalRunnable mTraversalRunnable = new TraversalRunnable();

做的事很簡單,就調用了一個方法,doTraversal():

//ViewRootImpl#doTraversal
void doTraversal() {
    if (mTraversalScheduled) {
        mTraversalScheduled = false;
        mHandler.getLooper().getQueue().removeSyncBarrier(mTraversalBarrier);
        ...

        //1. 遍歷繪製View樹
        performTraversals();
        ...
    }
}

看看這個方法做的事,跟 scheduleTraversals() 正好相反,一個將變量置成 true,這裏置成 false,一個是 postSyncBarrier(),這裏是 removeSyncBarrier(),具體作用等會再說,繼續先看看 performTraversals(),這個方法也是屏幕刷新的關鍵:

//ViewRootImpl#performTraversals
private void performTraversals() {
    //該方法實在太過複雜,所以將無關代碼全部都省略掉,只留下關鍵代碼和代碼結構
    ...
    if (...) {
        ...
        if (...) {
            if (...) {
                ...
                //1.測量
                performMeasure(childWidthMeasureSpec, childHeightMeasureSpec);
                ...
                layoutRequested = true;
            }
        }
    } ...
    final boolean didLayout = layoutRequested && (!mStopped || mReportNextDraw);
    ...
    if (didLayout) {
        //2.佈局
        performLayout(lp, mWidth, mHeight);
        ...
    }
    ...
    boolean cancelDraw = mAttachInfo.mTreeObserver.dispatchOnPreDraw() || !isViewVisible;
    if (!cancelDraw && !newSurface) {
        ...
        //3.繪製
        performDraw();
    }...

    ...
}

View 的測量、佈局、繪製三大流程都是交由 ViewRootImpl 發起,而且還都是在 performTraversals() 方法中發起的,所以這個方法的邏輯很複雜,因爲每次都需要根據相應狀態判斷是否需要三個流程都走,有時可能只需要執行 performDraw() 繪製流程,有時可能只執行 performMeasure() 測量和 performLayout() 佈局流程(一般測量和佈局流程是一起執行的)。不管哪個流程都會遍歷一次 View 樹,所以其實界面的繪製是需要遍歷很多次的,如果頁面層次太過複雜,每一幀需要刷新的 View 又很多時,耗時就會長一點。

測量、佈局、繪製這些流程在遍歷時並不一定會把整顆 View 樹都遍歷一遍,ViewGroup 在傳遞這些流程時,還會再根據相應狀態判斷是否需要繼續往下傳遞。

瞭解了 performTraversals() 是刷新界面的源頭後,接下去就需要了解下它是什麼時候執行的,和 scheduleTraversals() 又是什麼關係?

performTraversals() 是在 doTraversal() 中被調用的,而 doTraversal() 又被封裝到一個 Runnable 裏,那麼關鍵就是這個 Runnable 什麼時候被執行了?

Choreographer

scheduleTraversals() 裏調用了 Choreographer 的 postCallback() 將 Runnable 作爲參數傳了進去

//Choreograhper#postCallback
public void postCallback(int callbackType, Runnable action, Object token) {
    postCallbackDelayed(callbackType, action, token, 0);
}
//Choreograhper#postCallbackDelayed
pubic void postCallbackDelayed(int callbackType, Runnable action, Object token, long delayMillis) {
    ...  

    postCallbackDelayedInternal(callbackType, action, token, delayMillis);
}

//Choreograhper#postCallbackDelayedInternal
private void postCallbackDelayedInternal(int callbackType, Object action, Object token, long delayMillis) {
    ...

    synchronized (mLock) {
        //1.獲取當前時間戳
        final long now = SystemClock.uptimeMillis();
        final long dueTime = now + delayMillis;
        //2.根據時間戳將Runnable任務添加到指定的隊列中
        mCallbackQueues[callbackType].addCallbackLocked(dueTime, action, token);

        //3.因爲postCallback默認傳入delay = 0,所以代碼會走進if裏面
        if (dueTime <= now) {
            scheduleFrameLocked(now);
        } else {...}
    }
}

因爲 postCallback() 調用 postCallbackDelayed() 時傳了 delay = 0 進去,所以在 postCallbackDelayedInternal() 裏面會先根據當前時間戳將這個 Runnable 保存到一個 mCallbackQueue 隊列裏,這個隊列跟 MessageQueue 很相似,裏面待執行的任務都是根據一個時間戳來排序。然後走了 scheduleFrameLocked() 方法這邊,看看做了些什麼:

//Choreograhper#scheduleFrameLocked
private void scheduleFrameLocked(long now) {
    if (!mFrameScheduled) {
        mFrameScheduled = true;
        //1.系統4.0之後該變量默認爲true,所以會走進if裏
        if (USE_VSYNC) {
            ...

            if (isRunningOnLooperThreadLocked()) {
                scheduleVsyncLocked();
            } else {
                Message msg = mHandler.obtainMessage(MSG_DO_SCHEDULE_VSYNC);
                msg.setAsynchronous(true);
                mHandler.sendMessageAtFrontOfQueue(msg);
            }
        } ...
    }
}

如果代碼走了 else 這邊來發送一個消息,那麼這個消息做的事肯定很重要,因爲對這個 Message 設置了異步的標誌而且用了sendMessageAtFrontOfQueue() 方法,這個方法是將這個 Message 直接放到 MessageQueue 隊列裏的頭部,可以理解成設置了這個 Message 爲最高優先級,那麼先看看這個 Message 做了些什麼:

//Choreograhper#scheduleFrameLocked
private void scheduleFrameLocked(long now) {
    if (!mFrameScheduled) {
        mFrameScheduled = true;
        //1.系統4.0之後該變量默認爲true,所以會走進if裏
        if (USE_VSYNC) {
            ...

            if (isRunningOnLooperThreadLocked()) {
                scheduleVsyncLocked();
            } else {
                Message msg = mHandler.obtainMessage(MSG_DO_SCHEDULE_VSYNC);
                msg.setAsynchronous(true);
                mHandler.sendMessageAtFrontOfQueue(msg);
            }
        } ...
    }
}

這個 Message 設置了異步的標誌而且用了sendMessageAtFrontOfQueue() 方法,這個方法是將這個 Message 直接放到 MessageQueue 隊列裏的頭部,可以理解成設置了這個 Message 爲最高優先級,先看看這個 Message :

//Choreograhper$FrameHandler#handleMessage
private final class FrameHandler extends Handler {
    public FrameHandler(Looper looper) {
        super(looper);
    }

    @Override
    public void handleMessage(Message msg) {
        switch (msg.what) {
            ...
            case MSG_DO_SCHEDULE_VSYNC:
                doScheduleVsync();
                break;
            ...
        }
    }
}

//Choreographer#doScheduleVsync
void doScheduleVsync() {
    synchronized (mLock) {
        if (mFrameScheduled) {
            scheduleVsyncLocked();
        }
    }
}

所以這個 Message 最後做的事就是 scheduleVsyncLocked()。我們回到 scheduleFrameLocked() 這個方法裏,當走 if 裏的代碼時,直接調用了 scheduleVsyncLocked(),當走 else 裏的代碼時,發了一個最高優先級的 Message,這個 Message 也是執行 scheduleVsyncLocked()。既然兩邊最後調用的都是同一個方法,那麼爲什麼這麼做呢?

關鍵在於 if 條件裏那個方法是用來判斷當前是否是在主線程的,我們知道主線程也是一直在執行着一個個的 Message,那麼如果在主線程的話,直接調用這個方法,那麼這個方法就可以直接被執行了,如果不是在主線程,那麼 post 一個最高優先級的 Message 到主線程去,保證這個方法可以第一時間得到處理。

那麼這個方法是幹嘛的呢,爲什麼需要在最短時間內被執行呢,而且只能在主線程?

//Choreographer#scheduleVsyncLocked
private void scheduleVsyncLocked() {
    mDisplayEventReceiver.scheduleVsync();
}
//DisplayEventReceiver#scheduleVsync
/**
 * Schedules a single vertical sync pulse to be delivered when the next
 * display frame begins.
 */
public void scheduleVsync() {
    if (mReceiverPtr == 0) {
        Log.w(TAG, "Attempted to schedule a vertical sync pulse but the display event " + "receiver has already been disposed.");
    } else {
        nativeScheduleVsync(mReceiverPtr);
    }
}

調用了 native 層的一個方法。

到這裏爲止,我們知道一個 View 發起刷新的操作時,會層層通知到 ViewRootImpl 的 scheduleTraversals() 裏去,然後這個方法會將遍歷繪製 View 樹的操作 performTraversals() 封裝到 Runnable 裏,傳給 Choreographer,以當前的時間戳放進一個 mCallbackQueue 隊列裏,然後調用了 native 層的一個方法就跟不下去了。所以這個 Runnable 什麼時候會被執行還不清楚。那麼,下去的重點就是搞清楚它什麼時候從隊列裏被拿出來執行了?

既然這個 Runnable 操作被放在一個 mCallbackQueue 隊列裏,那就從這個隊列着手,看看這個隊列的取操作在哪被執行了:

//Choreographer$CallbackQueue
private final class CallbackQueue {
    private CallbackRecord mHead;
    ...
    //1.取操作
    public CallbackRecord extractDueCallbacksLocked(long now){...}  
    //2.入隊列操作
    public void addCallbackLocked(long dueTime, Object action, Object token) {...}
    ...  
}

//Choreographer#doCallbacks
void doCallbacks(int callbackType, long frameTimeNanos) {
    CallbackRecord callbacks;
    synchronized(mLock) {
        ...
        //1.這個隊列跟MessageQueue很相似,所以取的時候需要傳入一個時間戳,因爲隊頭的任務可能還沒到設定的執行時間
        callback = mCallbackQueues[callbackType].extractDueCallbacksLocked(now / TimeUtils.NANOS_PER_MS);
        ...
    }
}

//Choreographer#doFrame
void doFrame(long frameTimeNanos, int frame) {
    ...
    try {
        ...
        //1.這個參數跟 ViewRootImpl調用mChoreographer.postCallback()時傳進的第一個參數是一致的
        doCallbacks(Choreograhper.CALLBACK_TRAVERSAL, frameTimeNanos);
        ...
    }...
}

我們說過在 ViewRootImpl 的 scheduleTraversals() 裏會將遍歷 View 樹繪製的操作封裝到 Runnable 裏,然後調用 Choreographer 的 postCallback() 將這個 Runnable 放進隊列裏麼,而當時調用 postCallback() 時傳入了多個參數,這是因爲 Choreographer 裏有多個隊列,而第一個參數 Choreographer.CALLBACK_TRAVERSAL 這個參數是用來區分隊列的,可以理解成各個隊列的 key 值。

那麼這樣一來,就找到關鍵的方法了:doFrame(),這個方法裏會根據一個時間戳去隊列裏取任務出來執行,而這個任務就是 ViewRootImpl 封裝起來的 doTraversal() 操作,而 doTraversal() 會去調用 performTraversals() 開始根據需要測量、佈局、繪製整顆 View 樹。所以剩下的問題就是 doFrame() 這個方法在哪裏被調用了。

//Choreographer$FrameDisplayEventReceiver
private final class FrameDisplayEventReceiver extends DisplayEventReceiver implements Runnable {
    ...
    @Override
    public void onVsync(long timestampNanos, int builtInDisplayId, int frame) {
        ...
        //1.這個這裏的this,該message做的事其實是下面的run()方法
        Message msg = Message.obtain(mHandler, this);
        msg.setAsynchronous(true);
        mHandler.sendMessageAtTime(msg, timestampNanos / TimeUtils.NANOS_PER_MS);
    }

    @Override
    public void run() {
        mHavePendingVsync = false;
        doFrame(mTimestampNanos, mFrame);
    }
}

這個繼承自 DisplayEventReceiver 的 FrameDisplayEventReceiver 類的作用很重要。

FrameDisplayEventReceiver繼承自DisplayEventReceiver接收底層的VSync信號開始處理UI過程。VSync信號由SurfaceFlinger實現並定時發送。FrameDisplayEventReceiver收到信號後,調用onVsync方法組織消息發送到主線程處理。這個消息主要內容就是run方法裏面的doFrame了,這裏mTimestampNanos是信號到來的時間參數。

也就是說,onVsync() 是底層會回調的,可以理解成每隔 16.6ms 一個幀信號來的時候,底層就會回調這個方法,當然前提是我們得先註冊,這樣底層才能找到我們 app 並回調。當這個方法被回調時,內部發起了一個 Message,注意看代碼對這個 Message 設置了 callback 爲 this,Handler 在處理消息時會先查看 Message 是否有 callback,有則優先交由 Message 的 callback 處理消息,沒有的話再去看看Handler 有沒有 callback,如果也沒有才會交由 handleMessage() 這個方法執行。

onVsync() 是由底層回調的,那麼它就不是運行在我們 app 的主線程上,畢竟上層 app 對底層是隱藏的。但這個 doFrame() 是個 ui 操作,它需要在主線程中執行,所以才通過 Handler 切到主線程中。

前面分析 scheduleTraversals() 方法時,最後跟到了一個 native 層方法就跟不下去了,現在再回過來想想這個 native 層方法的作用是什麼,應該就比較好猜測了。

//DisplayEventReceiver#scheduleVsync
/**
 * Schedules a single vertical sync pulse to be delivered when the next
 * display frame begins.
 */
public void scheduleVsync() {
    if (mReceiverPtr == 0) {
        Log.w(TAG, "Attempted to schedule a vertical sync pulse but the display event " + "receiver has already been disposed.");
    } else {
        nativeScheduleVsync(mReceiverPtr);
    }
}

大體上是說安排接收一個 vsync 信號。而根據我們的分析,如果這個 vsync 信號發出的話,底層就會回調 DisplayEventReceiver 的 onVsync() 方法。

如果只是這樣的話,就有一點說不通了,首先上層 app 對於這些發送 vsync 信號的底層來說肯定是隱藏的,也就是說底層它根本不知道上層 app 的存在,那麼在它的每 16.6ms 的幀信號來的時候,它是怎麼找到我們的 app,並回調它的方法呢?

這就有點類似於觀察者模式,或者說發佈-訂閱模式。既然上層 app 需要知道底層每隔 16.6ms 的幀信號事件,那麼它就需要先註冊監聽纔對,這樣底層在發信號的時候,直接去找這些觀察者通知它們就行了。

這是我的理解,所以,這樣一來,scheduleVsync() 這個調用到了 native 層方法的作用大體上就可以理解成註冊監聽了,這樣底層也才找得到上層 app,並在每 16.6ms 刷新信號發出的時候回調上層 app 的 onVsync() 方法。這樣一來,應該就說得通了。

還有一點,scheduleVsync() 註冊的監聽應該只是監聽下一個屏幕刷新信號的事件而已,而不是監聽所有的屏幕刷新信號。比如說當前監聽了第一幀的刷新信號事件,那麼當第一幀的刷新信號來的時候,上層 app 就能接收到事件並作出反應。但如果還想監聽第二幀的刷新信號,那麼只能等上層 app 接收到第一幀的刷新信號之後再去監聽下一幀。

梳理一下目前的信息

  • 我們知道一個 View 發起刷新的操作時,最終是走到了 ViewRootImpl 的 scheduleTraversals() 裏去,然後這個方法會將遍歷繪製 View 樹的操作 performTraversals() 封裝到 Runnable 裏,傳給 Choreographer,以當前的時間戳放進一個 mCallbackQueue 隊列裏,然後調用了 native 層的方法向底層註冊監聽下一個屏幕刷新信號事件。
  • 當下一個屏幕刷新信號發出的時候,如果我們 app 有對這個事件進行監聽,那麼底層它就會回調我們 app 層的 onVsync() 方法來通知。當 onVsync() 被回調時,會發一個 Message 到主線程,將後續的工作切到主線程來執行。
  • 切到主線程的工作就是去 mCallbackQueue 隊列里根據時間戳將之前放進去的 Runnable 取出來執行,而這些 Runnable 有一個就是遍歷繪製 View 樹的操作 performTraversals()。在這次的遍歷操作中,就會去繪製那些需要刷新的 View。
  • 所以說,當我們調用了 invalidate(),requestLayout(),等之類刷新界面的操作時,並不是馬上就會執行這些刷新的操作,而是通過 ViewRootImpl 的 scheduleTraversals() 先向底層註冊監聽下一個屏幕刷新信號事件,然後等下一個屏幕刷新信號來的時候,纔會去通過 performTraversals() 遍歷繪製 View 樹來執行這些刷新操作。

過濾一幀內重複的刷新請求

整體上的流程我們已經梳理出來,但還有幾點問題需要解決。

我們在一個 16.6ms 的一幀內,代碼裏可能會有多個 View 發起了刷新請求,這是非常常見的場景了,比如某個動畫是有多個 View 一起完成,比如界面發生了滑動等等。

按照我們上面梳理的流程,只要 View 發起了刷新請求最終都會走到 ViewRootImpl 中的 scheduleTraversals() 裏去,是吧。而這個方法又會封裝一個遍歷繪製 View 樹的操作 performTraversals() 到 Runnable 然後扔到隊列裏等刷新信號來的時候取出來執行。

那如果多個 View 發起了刷新請求,豈不是意味着會有多次遍歷繪製 View 樹的操作?

其實,這點不用擔心,還記得我們在最開始分析 scheduleTraverslas() 的時候先跳過了一些代碼麼?現在我們回過來繼續看看這些代碼:

//ViewRootImpl#scheduleTraversals
void scheduleTraversals() {
    if (!mTraversalScheduled) {
        //1.注意這個boolean類型的變量
        mTraversalScheduled = true;
        mTraversalBarrier = mHandler.getLooper().getQueue().postSyncBarrier();
        mChoreographer.postCallback(
            Choreograhper.CALLBACK_TRAVERSAL, mTraversalRunnable, null);
        ...
    }
}

我們上面分析的 scheduleTraversals() 乾的那一串工作,前提是 mTraversalScheduled 這個 boolean 類型變量等於 false 纔會去執行。那這個變量在什麼時候被賦值被 false 了呢:

一個是上圖的 doTraversal(),還有就是聲明時默認爲 false,剩下一個是在取消遍歷繪製 View 操作 unscheduleTraversals() 裏。

doTraversal()這個方法,就是在 scheduleTraversals() 中封裝到 Runnable 裏的那個方法。

當我們調用了一次 scheduleTraversals()之後,直到下一個屏幕刷新信號來的時候,doTraversal() 被取出來執行。在這期間重複調用 scheduleTraversals() 都會被過濾掉的。那麼爲什麼需要這樣呢?

View 就是在執行 performTraversals() 遍歷繪製 View 樹過程中層層遍歷到需要刷新的 View,然後去繪製它的。既然是遍歷,那麼不管上一幀內有多少個 View 發起了刷新的請求,在這一次的遍歷過程中全部都會去處理的。這也是我們從代碼上看到的,每一個屏幕刷新信號來的時候,只會去執行一次 performTraversals(),因爲只需遍歷一遍,就能夠刷新所有的 View 了。

同步屏障消息postSyncBarrier()

當我們的 app 接收到屏幕刷新信號時,來不及第一時間就去執行刷新屏幕的操作,這樣一來,即使我們將佈局優化得很徹底,保證繪製當前 View 樹不會超過 16ms,但如果不能第一時間優先處理繪製 View 的工作,那等 16.6 ms 過了,底層需要去切換下一幀的畫面了,我們 app 卻還沒處理完,這樣也照樣會出現丟幀了吧。而且這種場景是非常有可能出現的吧,畢竟主線程需要處理的事肯定不僅僅是刷新屏幕的事而已,那麼這個問題是怎麼處理的呢?

//ViewRootImpl#scheduleTraversals
void scheduleTraversals() {
    if (!mTraversalScheduled) {
        mTraversalScheduled = true;
        //1.注意這行代碼,往主線程的消息隊列裏發送了一個同步屏障消息
        mTraversalBarrier = mHandler.getLooper().getQueue().postSyncBarrier();
        mChoreographer.postCallback(
            Choreograhper.CALLBACK_TRAVERSAL, mTraversalRunnable, null);
        ...
    }
}

//ViewRootImpl#doTraversal
void doTraversal() {
    if (mTraversalScheduled) {
        mTraversalScheduled = false;
        //1.注意這行代碼,移除消息隊列裏的同步屏障消息
        mHandler.getLooper().getQueue().removeSyncBarrier(mTraversalBarrier);
        ...

        performTraversals();
        ...
    }
}

邏輯走進 Choreographer 前會先往隊列裏發送一個同步屏障,而當 doTraversal() 被調用時纔將同步屏障移除。

這個同步屏障的作用可以理解成攔截同步消息的執行,主線程的 Looper 會一直循環調用 MessageQueue 的 next() 來取出隊頭的 Message 執行,當 Message 執行完後再去取下一個。

當 next() 方法在取 Message 時發現隊頭是一個同步屏障的消息時,就會去遍歷整個隊列,只尋找設置了異步標誌的消息,如果有找到異步消息,那麼就取出這個異步消息來執行,否則就讓 next() 方法陷入阻塞狀態。

如果 next() 方法陷入阻塞狀態,那麼主線程此時就是處於空閒狀態的,也就是沒在幹任何事。所以,如果隊頭是一個同步屏障的消息的話,那麼在它後面的所有同步消息就都被攔截住了,直到這個同步屏障消息被移除出隊列,否則主線程就一直不會去處理同步屏幕後面的同步消息。

而所有消息默認都是同步消息,只有手動設置了異步標誌,這個消息纔會是異步消息。另外,同步屏障消息只能由內部來發送,這個接口並沒有公開給我們使用。

最後,仔細看上面 Choreographer 裏所有跟 message 有關的代碼,你會發現,都手動設置了異步消息的標誌,所以這些操作是不受到同步屏障影響的。這樣做的原因可能就是爲了儘可能保證上層 app 在接收到屏幕刷新信號時,可以在第一時間執行遍歷繪製 View 樹的工作。

刷新控制者 ViewRootImpl

所有跟界面刷新相關的操作,其實最終都會走到 ViewRootImpl 中的 scheduleTraversals() 去的。

跟界面刷新有關的操作大概就是下面幾種場景吧:

  • invalidate(請求重繪)

  • requestLayout(重新佈局)

  • requestFocus(請求焦點)

  • startActivity(打開新界面)

  • onRestart(重新打開界面)

  • KeyEvent(遙控器事件,本質上是焦點導致的刷新)

  • Animation(各種動畫,本質上是請求重繪導致的刷新)

  • RecyclerView滑動(頁面滑動,本質上是動畫導致的刷新)

  • setAdapter(各種adapter的更新)

//ViewRootImpl#requestChildFocus
@Override
public void requestChildFocus(View child, View focused) {
    if (DEBUG_INPUT_RESIZE) {
        Log.v(mTag, "Request child focus: focus now " + focused);
    }
    checkThread();
    scheduleTraversals();
}

//ViewRootImpl#clearChildFocus
@Override
public void clearChildFocus(View child) {
    if (DEBUG_INPUT_RESIZE) {
        Log.v(mTag, "Clearing child focus");
    }
    checkThread();
    scheduleTraversals();
}

//ViewRootImpl#requestLayout
@Override
public void requestLayout() {
    if (!mHandlingLayoutInLayoutRequest) {
        checkThread();
        mLayoutRequested = true;
        scheduleTraversals();
    }
}

總結

  • 界面上任何一個 View 的刷新請求最終都會走到 ViewRootImpl 中的 scheduleTraversals() 裏來安排一次遍歷繪製 View 樹的任務;
  • scheduleTraversals() 會先過濾掉同一幀內的重複調用,在同一幀內只需要安排一次遍歷繪製 View 樹的任務即可,這個任務會在下一個屏幕刷新信號到來時調用 performTraversals() 遍歷 View 樹,遍歷過程中會將所有需要刷新的 View 進行重繪;
  • 接着 scheduleTraversals() 會往主線程的消息隊列中發送一個同步屏障,攔截這個時刻之後所有的同步消息的執行,但不會攔截異步消息,以此來儘可能的保證當接收到屏幕刷新信號時可以儘可能第一時間處理遍歷繪製 View 樹的工作;
  • 發完同步屏障後 scheduleTraversals() 纔會開始安排一個遍歷繪製 View 樹的操作,作法是把 performTraversals() 封裝到 Runnable 裏面,然後調用 Choreographer 的 postCallback() 方法;
  • postCallback() 方法會先將這個 Runnable 任務以當前時間戳放進一個待執行的隊列裏,然後如果當前是在主線程就會直接調用一個native 層方法,如果不是在主線程,會發一個最高優先級的 message 到主線程,讓主線程第一時間調用這個 native 層的方法;
  • native 層的這個方法是用來向底層註冊監聽下一個屏幕刷新信號,當下一個屏幕刷新信號發出時,底層就會回調 Choreographer 的onVsync() 方法來通知上層 app;
  • onVsync() 方法被回調時,會往主線程的消息隊列中發送一個執行 doFrame() 方法的消息,這個消息是異步消息,所以不會被同步屏障攔截住;
  • doFrame() 方法會去取出之前放進待執行隊列裏的任務來執行,取出來的這個任務實際上是 ViewRootImpl 的 doTraversal() 操作;
  • 上述第4步到第8步涉及到的消息都手動設置成了異步消息,所以不會受到同步屏障的攔截;
  • doTraversal() 方法會先移除主線程的同步屏障,然後調用 performTraversals() 開始根據當前狀態判斷是否需要執行performMeasure() 測量、perfromLayout() 佈局、performDraw() 繪製流程,在這幾個流程中都會去遍歷 View 樹來刷新需要更新的View;

常見問題

Android 每隔 16.6 ms 刷新一次屏幕到底指的是什麼意思?是指每隔 16.6ms 調用 onDraw() 繪製一次麼?

如果界面一直保持沒變的話,那麼還會每隔 16.6ms 刷新一次屏幕麼?

我們常說的 Android 每隔 16.6 ms 刷新一次屏幕其實是指底層會以這個固定頻率來切換每一幀的畫面,而這個每一幀的畫面數據就是我們 app 在接收到屏幕刷新信號之後去執行遍歷繪製 View 樹工作所計算出來的屏幕數據。

而 app 並不是每隔 16.6ms 的屏幕刷新信號都可以接收到,只有當 app 向底層註冊監聽下一個屏幕刷新信號之後,才能接收到下一個屏幕刷新信號到來的通知。而只有當某個 View 發起了刷新請求時,app 纔會去向底層註冊監聽下一個屏幕刷新信號。

也就是說,只有當界面有刷新的需要時,我們 app 纔會在下一個屏幕刷新信號來時,遍歷繪製 View 樹來重新計算屏幕數據。如果界面沒有刷新的需要,一直保持不變時,我們 app 就不會去接收每隔 16.6ms 的屏幕刷新信號事件了,但底層仍然會以這個固定頻率來切換每一幀的畫面,只是後面這些幀的畫面都是相同的而已。

界面的顯示其實就是一個 Activity 的 View 樹裏所有的 View 都進行測量、佈局、繪製操作之後的結果呈現,那麼如果這部分工作都完成後,屏幕會馬上就刷新麼?

我們 app 只負責計算屏幕數據而已,接收到屏幕刷新信號就去計算,計算完畢就計算完畢了。至於屏幕的刷新,這些是由底層以固定的頻率來切換屏幕每一幀的畫面。所以即使屏幕數據都計算完畢,屏幕會不會馬上刷新就取決於底層是否到了要切換下一幀畫面的時機了。

**網上都說避免丟幀的方法之一是保證每次繪製界面的操作要在 16.6ms 內完成,但如果這個 16.6ms 是一個固定的頻率的話,請求繪製的操作在代碼裏被調用的時機是不確定的啊,那麼如果某次用戶點擊屏幕導致的界面刷新操作是在某一個 16.6ms 幀快結束的時候,那麼即使這次繪製操作小於 16.6 ms,按道理不也會造成丟幀麼?這又該如何理解? **

代碼裏調用了某個 View 發起的刷新請求,這個重繪工作並不會馬上就開始,而是需要等到下一個屏幕刷新信號來的時候纔開始。

主線程耗時的操作會導致丟幀,但是耗時的操作爲什麼會導致丟幀?它是如何導致丟幀發生的?

造成丟幀大體上有兩類原因,一是遍歷繪製 View 樹計算屏幕數據的時間超過了 16.6ms;

二是,主線程一直在處理其他耗時的消息,導致遍歷繪製 View 樹的工作遲遲不能開始,從而超過了 16.6 ms 底層切換下一幀畫面的時機。

第一個原因就是我們寫的佈局有問題了,需要進行優化了。而第二個原因則是我們常說的避免在主線程中做耗時的任務。

針對第二個原因,系統已經引入了同步屏障消息的機制,儘可能的保證遍歷繪製 View 樹的工作能夠及時進行,但仍沒辦法完全避免,所以我們還是得儘可能避免主線程耗時工作。

在這裏插入圖片描述

發佈了91 篇原創文章 · 獲贊 63 · 訪問量 5萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章