Android 螢幕重新整理機制

請叫我大蘇發表於2018-03-30

本篇文章已授權微信公眾號 guolin_blog (郭霖)獨家釋出

這次就來梳理一下 Android 的螢幕重新整理機制,把我這段時間因為研究動畫而梳理出來的一些關於螢幕重新整理方面的知識點分享出來,能力有限,有錯的地方還望指點一下。另外,內容有點多,畢竟要講清楚不容易,所以慢慢看哈。

提問環節

閱讀原始碼還是得帶著問題或目的性的去閱讀,這樣閱讀過程中比較有條理性,不會跟偏或太深入,所以,還是先來幾個問題吧:

大夥都清楚,Android 每隔 16.6ms 會重新整理一次螢幕。

Q1:但是大夥想過沒有,這個 16.6ms 重新整理一次螢幕到底是什麼意思呢?是指每隔 16.6ms 呼叫 onDraw() 繪製一次麼?

Q2:如果介面一直保持沒變的話,那麼還會每隔 16.6ms 重新整理一次螢幕麼?

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

Q4:網上都說避免丟幀的方法之一是保證每次繪製介面的操作要在 16.6ms 內完成,但如果這個 16.6ms 是一個固定的頻率的話,請求繪製的操作在程式碼裡被呼叫的時機是不確定的啊,那麼如果某次使用者點選螢幕導致的介面重新整理操作是在某一個 16.6ms 幀快結束的時候,那麼即使這次繪製操作小於 16.6 ms,按道理不也會造成丟幀麼?這又該如何理解?

Q5:大夥都清楚,主執行緒耗時的操作會導致丟幀,但是耗時的操作為什麼會導致丟幀?它是如何導致丟幀發生的?

本篇主要就是搞清楚這幾個問題,分析的原始碼基本只涉及 ViewRootImplChoreographer 這兩個類。

原始碼分析

ps:本篇分析的原始碼均是 android-25 版本,版本不一樣,原始碼可能會有些許差異,大夥過的時候注意一下。

基本概念

首先,先來過一下一些基本概念,摘抄自網上文章android螢幕重新整理顯示機制

在一個典型的顯示系統中,一般包括CPU、GPU、display三個部分, CPU負責計算資料,把計算好資料交給GPU,GPU會對圖形資料進行渲染,渲染好後放到buffer裡存起來,然後display(有的文章也叫螢幕或者顯示器)負責把buffer裡的資料呈現到螢幕上。

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

上述內容概括一下,大體意思就是說,螢幕的重新整理包括三個步驟:CPU 計算螢幕資料、GPU 進一步處理和快取、最後 display 再將快取中(buffer)的螢幕資料顯示出來。

(ps:開發過程中應該接觸不到 GPU、display 這些層面的東西,所以我把這部分工作都稱作底層的工作了,下文出現的底層指的就是除了 CPU 計算螢幕資料之外的工作。)

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

也就是說,我們常說的 Android 每隔 16.6ms 重新整理一次螢幕其實是指:底層以固定的頻率,比如每 16.6ms 將 buffer 裡的螢幕資料顯示出來。

如果還不清楚,那再看一張網上很常見的圖(摘自上面同一篇文章):

image.png

結合這張圖,再來講講 16.6 ms 螢幕重新整理一次的意思。

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

繼續看圖,Display 黃色的這一行裡有一些數字:0, 1, 2, 3, 4,可以看到每次螢幕重新整理訊號到了的時候,數字就會變化,所以這些數字其實可以理解成每一幀螢幕顯示的畫面。也就是說,螢幕每一幀的畫面可以持續 16.6ms,當過了 16.6ms,底層就會發出一個螢幕重新整理訊號,而螢幕就會去顯示下一幀的畫面。

以上都是一些基本概念,也都是底層的工作,我們瞭解一下就可以了。接下去就還是看這圖,然後講講我們 app 層該乾的事了:

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

繼續看圖,CPU 藍色這行裡也有一些數字,其實這些數字跟 Display 黃色的那一行裡的數字是對應的,在 Display 裡我們解釋過這些數字表示的是每一幀的畫面,那麼在 CPU 這一行裡,其實就是在計算對應幀的畫面資料,也叫螢幕資料。也就是說,在當前幀內,CPU 是在計算下一幀的螢幕畫面資料,當螢幕重新整理訊號到的時候,螢幕就去將 CPU 計算的螢幕畫面資料顯示出來;同時 CPU 也接收到螢幕重新整理訊號,所以也開始去計算下一幀的螢幕畫面資料。

CPU 跟 Display 是不同的硬體,它們是可以並行工作的。要理解的一點是,我們寫的程式碼,只是控制讓 CPU 在接收到螢幕重新整理訊號的時候開始去計算下一幀的畫面工作。而底層在每一次螢幕重新整理訊號來的時候都會去切換這一幀的畫面,這點我們是控制不了的,是底層的工作機制。之所以要講這點,是因為,當我們的 app 介面沒有必要再重新整理時(比如使用者不操作了,當前介面也沒動畫),這個時候,我們 app 是接收不到螢幕重新整理訊號的,所以也就不會讓 CPU 去計算下一幀畫面資料,但是底層仍然會以固定的頻率來切換每一幀的畫面,只是它後面切換的每一幀畫面都一樣,所以給我們的感覺就是螢幕沒重新整理。

所以,我覺得上面那張圖還可以再繼續延深幾幀的長度,這樣就更容易理解了:

螢幕重新整理機制.png

我在那張圖的基礎上延長了幾幀,我想這樣應該可以更容易理解點。

看我畫的這張圖,前三幀跟原圖一樣,從第三幀之後,因為我們的 app 介面不需要重新整理了(使用者不操作了,介面也沒有動畫),那麼這之後我們 app 就不會再接收到螢幕重新整理訊號了,所以也就不會再讓 CPU 去繪製檢視樹來計算下一幀畫面了。但是,底層還是會每隔 16.6ms 發出一個螢幕重新整理訊號,只是我們 app 不會接收到而已,Display 還是會在每一個螢幕重新整理訊號到的時候去顯示下一幀畫面,只是下一幀畫面一直是第4幀的內容而已。

好了,到這裡 Q1,Q2,Q3 都可以先回答一半了,那麼我們就先稍微來梳理一下

  1. 我們常說的 Android 每隔 16.6 ms 重新整理一次螢幕其實是指底層會以這個固定頻率來切換每一幀的畫面。

  2. 這個每一幀的畫面也就是我們的 app 繪製檢視樹(View 樹)計算而來的,這個工作是交由 CPU 處理,耗時的長短取決於我們寫的程式碼:佈局復不復雜,層次深不深,同一幀內重新整理的 View 的數量多不多。

  3. CPU 繪製檢視樹來計算下一幀畫面資料的工作是在螢幕重新整理訊號來的時候才開始工作的,而當這個工作處理完畢後,也就是下一幀的畫面資料已經全部計算完畢,也不會馬上顯示到螢幕上,而是會等下一個螢幕重新整理訊號來的時候再交由底層將計算完畢的螢幕畫面資料顯示出來。

  4. 當我們的 app 介面不需要重新整理時(使用者無操作,介面無動畫),app 就接收不到螢幕重新整理訊號所以也就不會讓 CPU 再去繪製檢視樹計算畫面資料工作,但是底層仍然會每隔 16.6 ms 切換下一幀的畫面,只是這個下一幀畫面一直是相同的內容。

這部分雖然說是一些基本概念,但其實也包含了一些結論了,所以可能大夥看著會有些困惑:**為什麼介面不重新整理時 app 就接收不到螢幕重新整理訊號了?為什麼繪製檢視樹計算下一幀畫面的工作會是在螢幕重新整理訊號來的時候才開始的?**等等。

emmm,有這些困惑很棒,這樣,我們下面一起過原始碼時,大夥就更有目的性了,這樣過原始碼我覺得效率是比較高一點的。繼續看下去,跟著過完原始碼,你就清楚為什麼了。好了,那我們下面就開始過原始碼了。

ViewRootImpl 與 DecorView 的繫結

閱讀原始碼從哪開始看起一直都是個頭疼的問題,所以找一個合適的切入點來跟的話,整個梳理的過程可能會順暢一點。本篇是研究螢幕的重新整理,那麼建議就是從某個會導致螢幕重新整理的方法入手,比如 View#invalidate()

View#invalidate() 是請求重繪的一個操作,所以我們切入點可以從這個方法開始一步步跟下去。我們在上一篇部落格View 動畫 Animation 執行原理解析已經分析過 View#invalidate() 這個方法了。

想再過一遍的可以再去看看,我們這裡就直接說結論了。我們跟著 invalidate() 一步步往下走的時候,發現最後跟到了 ViewRootImpl#scheduleTraversals() 就停止了。而 ViewRootImpl 就是今天我們要介紹的重點物件了。

大夥都清楚,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

WindowManager 維護著所有 Activity 的 DecorView 和 ViewRootImpl。這裡初始化了一個 ViewRootImpl,然後呼叫了它的 setView() 方法,將 DevorView 作為引數傳遞了進去。所以看看 ViewRootImpl 中的 setView() 做了什麼:

ViewRootImpl#setView

setView() 方法裡呼叫了 DecorView 的 assignParent() 方法,所以去看看 View 的這個方法:

View#assignParent

引數是 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

這裡呼叫了一個 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() 裡去。而且開啟一個新的 Activity,它的介面繪製原來是在 onResume() 之後也層層通知到 ViewRootImpl 的 scheduleTraversals() 裡去。雖然其他關於 View 的重新整理操作,比如 requestLayout() 等等之類的方法我們還沒有去看,但我們已經可以大膽猜測,這些跟 View 重新整理有關的操作最終也都會層層走到 ViewRootImpl 中的 scheduleTraversals() 方法裡去的。

那麼這個方法究竟幹了些什麼,我們就要好好來分析了:

ViewRootImpl#scheduleTraversals

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

TraversalRunnable

這個 Runnable 做的事很簡單,就呼叫了一個方法,doTraversal():

ViewRootImpl#doTraversal

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

ViewRootImpl#performTraversals

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

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

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

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

Choreographer

scheduleTraversals() 裡呼叫了 Choreographer 的 postCallback() 將 Runnable 作為引數傳了進去,所以跟進去看看:

Choreographer#postCallback
Choreographer#postCallbackDelayedInternal

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

Choreographer#scheduleFrameLocked

如果程式碼走了 else 這邊來傳送一個訊息,那麼這個訊息做的事肯定很重要,因為對這個 Message 設定了非同步的標誌而且用了sendMessageAtFrontOfQueue() 方法,這個方法是將這個 Message 直接放到 MessageQueue 佇列裡的頭部,可以理解成設定了這個 Message 為最高優先順序,那麼先看看這個 Message 做了些什麼:

Choreograhper$FrameHandler#handleMessage
Choreographer#doScheduleVsync

所以這個 Message 最後做的事就是 scheduleVsyncLocked()。我們回到 scheduleFrameLocked() 這個方法裡,當走 if 裡的程式碼時,直接呼叫了 scheduleVsyncLocked(),當走 else 裡的程式碼時,發了一個最高優先順序的 Message,這個 Message 也是執行 scheduleVsyncLocked()。既然兩邊最後呼叫的都是同一個方法,那麼為什麼這麼做呢?

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

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

Choreographer#scheduleVsyncLocked
DisplayEventReceiver#scheduleVsync

呼叫了 native 層的一個方法,那跟到這裡就跟不下去了。

那到這裡,我們先來梳理一下:

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

接下去只能換種方式繼續跟了,既然這個 Runnable 操作被放在一個 mCallbackQueue 佇列裡,那就從這個佇列著手,看看這個佇列的取操作在哪被執行了:

Choreographer$CallbackQueue

Choreographer#doCallbacks

Choreographer#doFrame

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

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

有幾個呼叫的地方,但有個地方很關鍵:

Choreographer$FrameDisplayEventReceiver

關鍵的地方來了,這個繼承自 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

英文不大理解,大體上可能是說安排接收一個 vsync 訊號。而根據我們的分析,如果這個 vsync 訊號發出的話,底層就會回撥 DisplayEventReceiver 的 onVsync() 方法。

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

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

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

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

雖然現在能力還不足以跟蹤到 native 層,這些結論雖然是猜測的,但都經過除錯,對註釋、程式碼理解之後梳理出來的結論,跟原理應該不會偏差太多,這樣子的理解應該是可以的。

本篇內容確實有點多,所以到這裡還是繼續來先來梳理一下目前的資訊,防止都忘記上面講了些什麼:

  1. 我們知道一個 View 發起重新整理的操作時,最終是走到了 ViewRootImpl 的 scheduleTraversals() 裡去,然後這個方法會將遍歷繪製 View 樹的操作 performTraversals() 封裝到 Runnable 裡,傳給 Choreographer,以當前的時間戳放進一個 mCallbackQueue 佇列裡,然後呼叫了 native 層的方法向底層註冊監聽下一個螢幕重新整理訊號事件。

  2. 當下一個螢幕重新整理訊號發出的時候,如果我們 app 有對這個事件進行監聽,那麼底層它就會回撥我們 app 層的 onVsync() 方法來通知。當 onVsync() 被回撥時,會發一個 Message 到主執行緒,將後續的工作切到主執行緒來執行。

  3. 切到主執行緒的工作就是去 mCallbackQueue 佇列里根據時間戳將之前放進去的 Runnable 取出來執行,而這些 Runnable 有一個就是遍歷繪製 View 樹的操作 performTraversals()。在這次的遍歷操作中,就會去繪製那些需要重新整理的 View。

  4. 所以說,當我們呼叫了 invalidate(),requestLayout(),等之類重新整理介面的操作時,並不是馬上就會執行這些重新整理的操作,而是通過 ViewRootImpl 的 scheduleTraversals() 先向底層註冊監聽下一個螢幕重新整理訊號事件,然後等下一個螢幕重新整理訊號來的時候,才會去通過 performTraversals() 遍歷繪製 View 樹來執行這些重新整理操作。

過濾一幀內重複的重新整理請求

整體上的流程我們已經梳理出來的,但還有幾點問題需要解決。我們在一個 16.6ms 的一幀內,程式碼裡可能會有多個 View 發起了重新整理請求,這是非常常見的場景了,比如某個動畫是有多個 View 一起完成,比如介面發生了滑動等等。

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

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

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

ViewRootImpl#scheduleTraversals2

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

ViewRootImpl#doTraversal2

只有三個被賦值為 false 的地方,一個是上圖的 doTraversal(),還有就是宣告時預設為 false,剩下一個是在取消遍歷繪製 View 操作 unscheduleTraversals() 裡。這兩個可以先不去看,就看看 doTraversal()。還記得這個方法吧,就是在 scheduleTraversals() 中封裝到 Runnable 裡的那個方法。

也就是說,當我們呼叫了一次 scheduleTraversals()之後,直到下一個螢幕重新整理訊號來的時候,doTraversal() 被取出來執行。在這期間重複呼叫 scheduleTraversals() 都會被過濾掉的。那麼為什麼需要這樣呢?

其實,想想就能明白了。View 最終是怎麼重新整理的呢,就是在執行 performTraversals() 遍歷繪製 View 樹過程中層層遍歷到需要重新整理的 View,然後去繪製它的吧。既然是遍歷,那麼不管上一幀內有多少個 View 發起了重新整理的請求,在這一次的遍歷過程中全部都會去處理的吧。這也是我們從程式碼上看到的,每一個螢幕重新整理訊號來的時候,只會去執行一次 performTraversals(),因為只需遍歷一遍,就能夠重新整理所有的 View 了。

performTraversals() 會被執行的前提是呼叫了 scheduleTraversals() 來向底層註冊監聽了下一個螢幕重新整理訊號事件,所以在同一個 16.6ms 的一幀內,只需要第一個發起重新整理請求的 View 來走一遍 scheduleTraversals() 乾的事就可以了,其他不管還有多少 View 發起了重新整理請求,沒必要再去重複向底層註冊監聽下一個螢幕重新整理訊號事件了,反正只要有一次遍歷繪製 View 樹的操作就可以對它們進行重新整理了。

postSyncBarrier()---同步屏障訊息

還剩最後一個問題,scheduleTraversals() 裡我們還有一行程式碼沒分析。這個問題是這樣的:

我們清楚主執行緒其實是一直在處理 MessageQueue 訊息佇列裡的 Message,每個操作都是一個 Message,開啟 Activity 是一個 Message,遍歷繪製 View 樹來重新整理螢幕也是一個 Message。

而且,上面梳理完我們也清楚,遍歷繪製 View 樹的操作是在螢幕重新整理訊號到的時候,底層回撥我們 app 的 onVsync(),這個方法再去將遍歷繪製 View 樹的操作 post 到主執行緒的 MessageQueue 中去等待執行。主執行緒同一時間只能處理一個 Message,這些 Message 就肯定有先後的問題,那麼會不會出現下面這種情況呢:

同步分隔欄.png

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

所以我們繼續回來看 scheduleTraversals()

ViewRootImpl#scheduleTraversals3
ViewRootImpl#doTraversal2

在邏輯走進 Choreographer 前會先往佇列裡傳送一個同步屏障,而當 doTraversal() 被呼叫時才將同步屏障移除。這個同步屏障又涉及到訊息機制了,不深入了,這裡就只給出結論。

這個同步屏障的作用可以理解成攔截同步訊息的執行,主執行緒的 Looper 會一直迴圈呼叫 MessageQueue 的 next() 來取出隊頭的 Message 執行,當 Message 執行完後再去取下一個。當 next() 方法在取 Message 時發現隊頭是一個同步屏障的訊息時,就會去遍歷整個佇列,只尋找設定了非同步標誌的訊息,如果有找到非同步訊息,那麼就取出這個非同步訊息來執行,否則就讓 next() 方法陷入阻塞狀態。如果 next() 方法陷入阻塞狀態,那麼主執行緒此時就是處於空閒狀態的,也就是沒在幹任何事。所以,如果隊頭是一個同步屏障的訊息的話,那麼在它後面的所有同步訊息就都被攔截住了,直到這個同步屏障訊息被移除出佇列,否則主執行緒就一直不會去處理同步螢幕後面的同步訊息。

而所有訊息預設都是同步訊息,只有手動設定了非同步標誌,這個訊息才會是非同步訊息。另外,同步屏障訊息只能由內部來傳送,這個介面並沒有公開給我們使用。

最後,仔細看上面 Choreographer 裡所有跟 message 有關的程式碼,你會發現,都手動設定了非同步訊息的標誌,所以這些操作是不受到同步屏障影響的。這樣做的原因可能就是為了儘可能保證上層 app 在接收到螢幕重新整理訊號時,可以在第一時間執行遍歷繪製 View 樹的工作。

因為主執行緒中如果有太多訊息要執行,而這些訊息又是根據時間戳進行排序,如果不加一個同步屏障的話,那麼遍歷繪製 View 樹的工作就可能被迫延遲執行,因為它也需要排隊,那麼就有可能出現當一幀都快結束的時候才開始計算螢幕資料,那即使這次的計算少於 16.6ms,也同樣會造成丟幀現象。

那麼,有了同步屏障訊息的控制就能保證每次一接收到螢幕重新整理訊號就第一時間處理遍歷繪製 View 樹的工作麼?

只能說,同步屏障是儘可能去做到,但並不能保證一定可以第一時間處理。因為,同步屏障是在 scheduleTraversals() 被呼叫時才傳送到訊息佇列裡的,也就是說,只有當某個 View 發起了重新整理請求時,在這個時刻後面的同步訊息才會被攔截掉。如果在 scheduleTraversals() 之前就傳送到訊息佇列裡的工作仍然會按順序依次被取出來執行。

介面重新整理控制者--ViewRootImpl

最後,就是上文經常說的一點,所有跟介面重新整理相關的操作,其實最終都會走到 ViewRootImpl 中的 scheduleTraversals() 去的。

大夥可以想想,跟介面重新整理有關的操作有哪些,大概就是下面幾種場景吧:

  1. invalidate(請求重繪)
  2. requestLayout(重新佈局)
  3. requestFocus(請求焦點)
  4. startActivity(開啟新介面)
  5. onRestart(重新開啟介面)
  6. KeyEvent(遙控器事件,本質上是焦點導致的重新整理)
  7. Animation(各種動畫,本質上是請求重繪導致的重新整理)
  8. RecyclerView滑動(頁面滑動,本質上是動畫導致的重新整理)
  9. setAdapter(各種adapter的更新)
  10. ...

在上一篇分析動畫的部落格裡,我們跟蹤了 invalidate(),確實也是這樣,至於其他的我並沒有一一去驗證,大夥有興趣可以看看,我猜測,這些跟介面重新整理有關的方法內部要麼就是一個 do{}while() 迴圈尋找 mParent,要麼就是直接不斷的呼叫 mParent 的方法。而一顆 View 樹最頂端的 mParent 就是 ViewRootImpl,所以這些跟介面重新整理相關的方法,在 ViewRootImpl 肯定也是可以找到的:

ViewRootImpl#requestChildFocus

ViewRootImpl#clearChildFocus

ViewRootImpl#requestLayout

其實,以前我一直以為如果介面上某個小小的 View 發起了 invalidate() 重繪之類的操作,那麼應該就只是它自己的 onLayout(), onDraw() 被呼叫來重繪而已。最後才清楚,原來,即使再小的 View,如果發起了重繪的請求,那麼也需要先層層走到 ViewRootImpl 裡去,而且還不是馬上就執行重繪操作,而是需要等待下一個螢幕重新整理訊號來的時候,再從 DecorView 開始層層遍歷到這些需要重新整理的 View 裡去重繪它們。

總結

本篇篇幅確實很長,因為這部分內容要理清楚不容易,要講清楚更不容易,大夥如果有時間,可以靜下心來慢慢看,從頭看下來,我相信,多少會有些收穫的。如果沒時間,那麼也可以直接看看總結。

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

再來一張時序圖結尾,大夥想自己過原始碼時可以跟著時序圖來,建議在電腦上閱讀:

View重新整理流程時序圖.png

QA

Q1:Android 每隔 16.6 ms 重新整理一次螢幕到底指的是什麼意思?是指每隔 16.6ms 呼叫 onDraw() 繪製一次麼?
Q2:如果介面一直保持沒變的話,那麼還會每隔 16.6ms 重新整理一次螢幕麼?
答:我們常說的 Android 每隔 16.6 ms 重新整理一次螢幕其實是指底層會以這個固定頻率來切換每一幀的畫面,而這個每一幀的畫面資料就是我們 app 在接收到螢幕重新整理訊號之後去執行遍歷繪製 View 樹工作所計算出來的螢幕資料。而 app 並不是每隔 16.6ms 的螢幕重新整理訊號都可以接收到,只有當 app 向底層註冊監聽下一個螢幕重新整理訊號之後,才能接收到下一個螢幕重新整理訊號到來的通知。而只有當某個 View 發起了重新整理請求時,app 才會去向底層註冊監聽下一個螢幕重新整理訊號。

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

Q3:介面的顯示其實就是一個 Activity 的 View 樹裡所有的 View 都進行測量、佈局、繪製操作之後的結果呈現,那麼如果這部分工作都完成後,螢幕會馬上就重新整理麼?
答:我們 app 只負責計算螢幕資料而已,接收到螢幕重新整理訊號就去計算,計算完畢就計算完畢了。至於螢幕的重新整理,這些是由底層以固定的頻率來切換螢幕每一幀的畫面。所以即使螢幕資料都計算完畢,螢幕會不會馬上重新整理就取決於底層是否到了要切換下一幀畫面的時機了。

Q4:網上都說避免丟幀的方法之一是保證每次繪製介面的操作要在 16.6ms 內完成,但如果這個 16.6ms 是一個固定的頻率的話,請求繪製的操作在程式碼裡被呼叫的時機是不確定的啊,那麼如果某次使用者點選螢幕導致的介面重新整理操作是在某一個 16.6ms 幀快結束的時候,那麼即使這次繪製操作小於 16.6 ms,按道理不也會造成丟幀麼?這又該如何理解?
答:之所以提了這個問題,是因為之前是以為如果某個 View 發起了重新整理請求,比如呼叫了 invalidte(),那麼它的重繪工作就馬上開始執行了,所以以前在看網上那些介紹螢幕重新整理機制的部落格時,經常看見下面這張圖:

image.png

那個時候就是不大理解,為什麼每一次 CPU 計算的工作都剛剛好是在每一個訊號到來的那個瞬間開始的呢?畢竟程式碼裡發起重新整理螢幕的操作是動態的,不可能每次都剛剛好那麼巧。

梳理完螢幕重新整理機制後就清楚了,程式碼裡呼叫了某個 View 發起的重新整理請求,這個重繪工作並不會馬上就開始,而是需要等到下一個螢幕重新整理訊號來的時候才開始,所以現在回過頭來看這些圖就清楚多了。

Q5:大夥都清楚,主執行緒耗時的操作會導致丟幀,但是耗時的操作為什麼會導致丟幀?它是如何導致丟幀發生的?
答:造成丟幀大體上有兩類原因,一是遍歷繪製 View 樹計算螢幕資料的時間超過了 16.6ms;二是,主執行緒一直在處理其他耗時的訊息,導致遍歷繪製 View 樹的工作遲遲不能開始,從而超過了 16.6 ms 底層切換下一幀畫面的時機。

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

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

其實第二個原因,可以拿出來細講的,比如有這種情況, message 不怎麼耗時,但數量太多,這同樣可能會造成丟幀。如果有使用一些圖片框架的,它內部下載圖片都是開執行緒去下載,但當下載完成後需要把圖片載入到繫結的 view 上,這個工作就是發了一個 message 切到主執行緒來做,如果一個介面這種 view 特別多的話,佇列裡就會有非常多的 message,雖然每個都 message 並不怎麼耗時,但經不起量多啊。後面有時間的話,看看要不要專門整理一篇文章來講卡頓和丟幀的事。

推薦閱讀(大神部落格)

破譯Android效能優化中的16ms問題 android螢幕重新整理顯示機制 Android Choreographer 原始碼分析


QQ圖片20180316094923.jpg
最近剛開通了公眾號,想激勵自己堅持寫作下去,初期主要分享原創的Android或Android-Tv方面的小知識,感興趣的可以點一波關注,謝謝支援~~

相關文章