一個 Android 應用是否流暢,或者說是否存在卡頓、丟幀現象,都與 60fps 和 16ms 有關。那么這兩個值是怎么來的呢?為什么以這兩個值為衡量標準呢?本文主要討論下渲染性能方面決定 Android 應用流暢性的因素。
- 12fps(幀/秒)
由于人類眼睛的特殊生理結構,如果所看畫面之幀率高于每秒約 10 - 12fps 的時候,就會認為是連貫的。早期的無聲電影的幀率介于 16 - 24fps 之間,雖然幀率足以讓人感覺到運動,但往往被認為是在快放幻燈片。在 1920 年代中后期,無聲電影的幀率提高到 20 - 26fps 之間。
- 24fps
1926 年有聲電影推出,人耳對音頻的變化更敏感,反而削弱了人對電影幀率的關注。因為許多無聲電影使用 20 - 26fps 播放,所以選擇了中間值 24fps 作為有聲電影的幀率。之后 24fps 成為 35mm 有聲電影的標準。
- 30fps
早期的高動態電子游戲,幀率少于每秒 30fps 的話就會顯得不連貫。這是因為沒有動態模糊使流暢度降低。(注:如果需要了解動態模糊技術相關知識,可以查閱 這里)
- 60fps
在實際體驗中,60fps 相對于 30fps 有著更好的體驗。
- 85fps
一般而言,大腦處理視頻的極限。
所以,總體而言,幀率越高體驗越好。一般的電影拍攝及播放幀率均為每秒 24 幀,但是據稱《霍比特人:意外旅程》是第一部以每秒 48 幀拍攝及播放的電影,觀眾認為其逼真度得到了顯著的提示。
目前,大多數顯示器根據其設定按 30Hz、 60Hz、 120Hz 或者 144Hz 的頻率進行刷新。而其中最常見的刷新頻率是 60Hz。
這樣做是為了繼承以前電視機刷新頻率為 60Hz 的設定。而 60Hz 是美國交流電的頻率,電視機如果匹配交流電的刷新頻率就可以有效的預防屏幕中出現滾動條,即互調失真。
16 ms
正如上面所述目前大多數顯示器的刷新率是 60Hz,Android 設備的刷新率也是 60Hz。只有當畫面達到 60fps 時 App 應用才不會讓用戶感覺到卡頓。那么 60fps 也就意味著 1000ms/60Hz = 16ms。也就是說 16ms 渲染一次畫面才不會卡頓。
CPU vs GPU
渲染操作通常依賴于兩個核心組件:CPU 與 GPU。CPU 負責包括 Measure、Layout、Record、Execute 的計算操作,GPU 負責 Rasterization (柵格化)操作。
CPU 通常存在的問題的原因是存在非必需的視圖組件,它不僅僅會帶來重復的計算操作,而且還會占用額外的 GPU 資源。

CPU vs GPU
Android UI 與 GPU
了解 Android 是如何利用 GPU 進行畫面渲染有助于我們更好的理解性能問題。
那么一個最實際的問題是:Activity 的畫面是如何繪制到屏幕上的?那些復雜的 XML 布局文件又是如何能夠被識別并繪制出來的?

Resterization 柵格化是繪制那些 Button、Shape、Path、String、Bitmap 等組件最基礎的操作。它把那些組件拆分到不同的像素上進行顯示。
這是一個很費時的操作,GPU 的引入就是為了加快柵格化的操作。
CPU 負責把 UI 組件計算成 Polygons,Texture 紋理,然后交給 GPU 進行柵格化渲染。

CPU 與 GPU 工作流程
然而,每次從 CPU 轉移到 GPU 是一件很麻煩的事情,所幸的是 OpenGL ES 可以把那些需要渲染的紋理緩存在 GPU Memory 里面,在下次需要渲染的時候可以直接使用。但是,如果你更新了 GPU 緩存的紋理內容,那么之前保存的狀態就丟失了。
在 Android 里面那些由主題所提供的資源(例如:Bitmaps、Drawables)都是一起打包到統一的 Texture 紋理當中,然后再傳遞到GPU里面,這意味著每次你需要使用這些資源的時候,都是直接從紋理里面進行獲取渲染的。
當然,隨著 UI 組件的越來越豐富,有了更多演變的形態。例如,顯示圖片的時候,需要先經過 CPU 的計算加載到內存中,然后傳遞給 GPU 進行渲染。文字的顯示更加復雜,需要先經過 CPU 換算成紋理,然后再交給 GPU 進行渲染,回到 CPU 繪制單個字符的時候,再重新引用經過 GPU 渲染的內容。動畫則是一個更加復雜的操作流程。
為了能夠使得 App 流暢,我們需要在每一幀 16ms 以內處理完所有的 CPU 與 GPU 計算,繪制,渲染等等操作。
UI 組件的更新
通常來說,Android 需要把 XML 布局文件轉換成 GPU 能夠識別并繪制的對象。這個操作是在 DisplayList 的幫助下完成的。DisplayList 持有所有將要交給 GPU 繪制到屏幕上的數據信息。
在某個 View 第一次需要被渲染時,DisplayList 會因此而被創建。當這個 View 要顯示到屏幕上時,我們會執行 GPU 的繪制指令來進行渲染。
如果你在后續有執行類似移動這個 View 的位置等操作而需要再次渲染這個 View 時,我們就僅僅需要額外操作一次渲染指令就夠了。然而如果你修改了 View 中的某些可見組件,那么之前的 DisplayList 就無法繼續使用了,我們需要回頭重新創建一個 DisplayList 并且重新執行渲染指令并更新到屏幕上。
需要注意的是:任何時候 View 中的繪制內容發生變化時,都會重新執行創建 DisplayList,渲染 DisplayList,更新到屏幕上等一系列操作。這個流程的表現性能取決于你的 View 的復雜程度,View 的狀態變化以及渲染管道的執行性能。

UI 組件的更新
舉個例子,假設某個 Button 的大小需要增大到目前的兩倍,在增大 Button 大小之前,需要通過父 View 重新計算并擺放其他子 View 的位置。修改 View 的大小會觸發整個 HierarcyView 的重新計算大小的操作。如果是修改 View 的位置則會觸發 HierarchView 重新計算其他 View 的位置。如果布局很復雜,這就會很容易導致嚴重的性能問題。
垂直同步
為了理解 App 是如何進行渲染的,我們必須了解手機硬件是如何工作,那么就必須理解什么是垂直同步(VSYNC)。
在講解 VSYNC 之前,我們需要了解兩個相關的概念:
刷新率
刷新率(Refresh Rate)代表了屏幕在一秒內刷新屏幕的次數,這取決于硬件的固定參數,例如 60Hz。
幀率
幀率(Frame Rate)代表了 GPU 在一秒內繪制操作的幀數,例如 30fps,60fps。
GPU 會獲取圖形數據進行渲染,然后硬件負責把渲染后的內容呈現到屏幕上,他們兩者不停的進行協作。

GPU 渲染
玩游戲的同學,尤其是大型 FPS 游戲應該都見過「垂直同步」這個選項。因為 GPU 的生成圖像的頻率與顯示器的刷新頻率是相互獨立的,所以就涉及到了一個配合的問題。
最理想的情況是兩者之間的頻率是相同且協同進行工作的,在這樣的理想條件下,達到了最優解。

GPU 幀率
但實際中 GPU 的生成圖像的頻率是變化的,如果沒有有效的技術手段進行保證,兩者之間很容易出現這樣的情況。
當 GPU 還在渲染下一幀圖像時,顯示器卻已經開始進行繪制,這樣就會導致屏幕撕裂(Tear)。這會使得屏幕的一部分顯示的是前一幀的內容,而另一部分卻在顯示下一幀的內容。如下圖所示:

撕裂的圖像
屏幕撕裂(Tear)的問題,早在 PC 游戲時代就被發現, 并不停的在嘗試進行解決。其中最知名可能也是最古老的解決方案就是 VSYNC 技術。
VSYNC 的原理簡單而直觀:產生屏幕撕裂的原因是 GPU 在屏幕刷新時進行了渲染,而 VSYNC 通過同步渲染/刷新時間的方式來解決這個問題。
顯示器的刷新頻率為 60Hz,若此時開啟 VSYNC,將控制 GPU 渲染速度在 60Hz 以內以匹配顯示器刷新頻率。這也意味著,在 VSYNC 的限制下,GPU 顯示性能的極限就限制為 60Hz 以內。這樣就能很好的避免圖像撕裂的問題。
通常來說,幀率超過刷新頻率只是一種理想的狀況,在超過 60fps 的情況下,GPU 所產生的幀數據會因為等待 VSYNC 的刷新信息而被 Hold 住,這樣能夠保持每次刷新都有實際的新的數據可以顯示。但是我們遇到更多的情況是幀率小于刷新頻率。

VSYNC
在這種情況下,某些幀顯示的畫面內容就會與上一幀的畫面相同。糟糕的事情是,幀率從超過 60fps 突然掉到 60fps 以下,這樣就會發生 LAG、JANK、HITCHING 等卡頓掉幀的不順滑的情況。這也是用戶感受不好的原因所在。
渲染性能
大多數用戶感知到的卡頓等性能問題的最主要根源都是因為渲染性能(Rendering Performance)。
從設計師的角度,他們希望 App 能夠有更多的動畫,圖片等時尚元素來實現流暢的用戶體驗。但是 Android 系統很有可能無法及時完成那些復雜的界面渲染操作。
Android 系統每隔 16ms 發出 VSYNC 信號,觸發對 UI 進行渲染,如果每次渲染都成功,這樣就能夠達到流暢的畫面所需要的 60fps,為了能夠實現 60fps,這意味著程序的大多數操作都必須在 16ms 內完成。

VSYNC 信號
如果你的某個操作花費時間是 24ms,系統在得到 VSYNC 信號的時候就無法進行正常渲染,這樣就發生了丟幀現象。那么用戶在 32ms 內看到的會是同一幀畫面。

丟幀
用戶容易在 UI 執行動畫或者滑動 ListView 的時候感知到卡頓不流暢,是因為這里的操作相對復雜,容易發生丟幀的現象,從而感覺卡頓。
有很多原因可以導致丟幀,也許是因為你的 layout 太過復雜,無法在 16ms 內完成渲染,有可能是因為你的 UI 上有層疊太多的繪制單元,還有可能是因為動畫執行的次數過多。這些都會導致 CPU 或者 GPU 負載過重。
過度重繪
過度重繪(Overdraw)描述的是屏幕上的某個像素在同一幀的時間內被繪制了多次。在多層次的UI結構里面,如果不可見的 UI 也在做繪制的操作,這就會導致某些像素區域被繪制了多次。這就浪費大量的 CPU 以及 GPU 資源。

Overdraw
當設計上追求更華麗的視覺效果的時候,我們就容易陷入采用越來越多的層疊組件來實現這種視覺效果的怪圈。這很容易導致大量的性能問題,為了獲得最佳的性能,我們必須盡量減少 Overdraw 的情況發生。
如何找出過度重繪?
很榮幸 Android 系統的開發者模式中,提供了一些工具可以幫助我們找出過度重繪。
首先,打開手機里面的開發者選項(這個都找不到,那還開發什么 Android?),可以找到下面幾個選項:
調試 GPU 過度重繪(Debug GPU overdraw)
我們可以通過手機設置里面的 開發者選項 ,打開 顯示過渡繪制區域(Show GPU Overdraw)的選項,可以觀察 UI 上的 Overdraw 情況。

Debug GPU overdraw
藍色,淡綠,淡紅,深紅代表了 4 種不同程度的 Overdraw 情況,我們的目標就是盡量減少紅色 Overdraw,看到更多的藍色區域。
- 真彩色:沒有過度繪制
- 藍色:過度重繪 1 次
像素繪制了 2 次。大片的藍色還是可以接受的(若整個窗口是藍色的,可以擺脫一層)。
- 綠色:過度重繪 2 次
像素繪制了 3 次。中等大小的綠色區域是可以接受的但你應該嘗試優化、減少它們。
- 淡紅:過度重繪 3 次
像素繪制了 4 次,小范圍可以接受。
- 深紅:過度重繪 4 次或更多
像素繪制了 5 次或者更多。這是錯誤的,要修復它們。
Overdraw 有時候是因為你的UI布局存在大量重疊的部分,還有的時候是因為非必須的重疊背景。
例如:某個 Activity 有一個背景,然后里面的 Layout 又有自己的背景,同時子 View 又分別有自己的背景。僅僅是通過移除非必須的背景圖片,這就能夠減少大量的紅色 Overdraw 區域,增加藍色區域的占比。這一措施能夠顯著提升程序性能。

優化過度重繪
GPU 呈現模式分析(Profile GPU Rendering)
我們可以通過手機設置里面的 開發者選項 中找到 GPU 呈現模式分析(Peofile GPU Rendering tool) ,然后選擇 在屏幕上顯示為條形圖(On screen as bars)。

Profile GPU Rendering
在 Android 系統中是以 60fps 為滿幀,綠色橫線為 16ms 分界線,低于綠線即為流暢。
屏幕下方的柱狀圖每一根代表一幀,其高度表示「渲染這一幀耗時」,隨著手機屏幕界面的變化,柱狀圖會持續刷新每幀用時的具體情況(通過高度表示)。
那么,當柱狀圖高于綠線,是不是就說明我卡了呢?其實這不完全正確,這里就要開始分析組成每一根柱狀圖不同顏色所代表的含義了。

gpu 16ms
- 紅色
代表了「執行時間」,它指的是 Android 渲染引擎執行盒子中這些繪制命令的時間。
假如當前界面的視圖越多,那么紅色便會「跳」得越高。實際使用中,比如我們平時刷淘寶 App 時遇到出現多張縮略圖需要加載時,那么紅色會突然跳很高,但是此時你的頁面滑動其實是流暢的,雖然等了零點幾秒圖片才加載出來,但其實這可能并不意味著你卡住了。
- 黃色
通常較短,它代表著 CPU 通知 GPU 你已經完成視圖渲染了,不過在這里 CPU 會等待 GPU 的回話,當 GPU 說「好的知道了」,才算完事兒。
假如橙色部分很高的話,說明當前 GPU 過于忙碌,有很多命令需要去處理,比如 Android 淘寶客戶端,紅色黃色通常會很高。
- 藍色
假如想通過玄學曲線來判斷流暢度的話,其實藍色的參考意義是較大的。藍色代表了視圖繪制所花費的時間,表示視圖在界面發生變化(更新)的用時情況。
當它越短時,即便是體驗上更接近「絲滑」,當他越長時,說明當前視圖較復雜或者無效需要重繪,即我們通常說的「卡了」。
理解了玄學曲線不同顏色代表的意義,看懂玄學曲線就不難了。一般情況下,當藍色低于綠線時都不會出現卡頓,但是想要追求真正的絲般順滑那當然還是三色全部處于綠線以下最為理想。
Hierarchy Viewer
Hierarchy Viewer 是 Android Device Monitor 中的一個工具,它可以幫助我們檢測布局層次結構中每個視圖的布局速度。
它的界面如下:

Hierarchy Viewer
有一定開發經驗的小伙伴應該使用過它,不過現在已經被「棄用了」,google 推薦我們使用 Layout Inspector 來檢查應用程序的視圖層次結構。
Layout Inspector
Layout Inspector 集成在 Android Studio 中,點擊 Tools > Layout Inspector,在出現的Choose Process 對話框中,選擇您想要檢查的應用進程,然后點擊 OK。

Layout Inspector
默認情況下,Choose Process 對話框僅會為 Android Studio 中當前打開的項目列出進程,并且該項目必須在設備上運行。
如果您想要檢查設備上的其他應用,請點擊 Show all processes。如果您正在使用已取得 root 權限的設備或者沒有安裝 Google Play 商店的模擬器,那么您會看到所有正在運行的應用。否則,您只能看到可以調試的運行中應用。
布局檢查器會捕獲快照,將它保存為 .li 文件并打開。如圖下圖所示,布局檢查器將顯示以下內容:

Layout Inspector
優化布局
使用上面的工具找到了過度重繪的地方,就需要優化自己的代碼,我們可以通過下面幾個方式進行優化。
include
include 標簽常用于將布局中的公共部分提取出來供其他 layout 共用,以實現布局模塊化。
merge
merge 標簽主要用于輔助 include 標簽,在使用 include 后可能導致布局嵌套過多,多余的 layout 節點或導致解析變慢。
例如:根布局是 Linearlayout,那么我們又 include 一個 LinerLayout 布局就沒意義了,反而會減慢 UI 加載速度。
ViewStub
ViewStub 標簽最大的優點是當你需要時才會加載,使用它并不會影響UI初始化時的性能。
例如:不常用的布局像進度條、顯示錯誤消息等可以使用 ViewStub 標簽,以減少內存使用量,加快渲染速度.。
ViewStub 是一個不可見的,實際上是把寬高設置為 0 的 View。效果有點類似普通的 view.setVisible(),但性能體驗提高不少。
ConstraintLayout
約束布局 ConstraintLayout 是一個 ViewGroup,可以在 API 9 以上的 Android 系統使用它,它的出現主要是為了解決布局嵌套過多的問題,以靈活的方式定位和調整小部件。從 Android Studio 2.3 起,官方的模板默認使用 ConstraintLayout。
更多使用細節詳見:Android 開發文檔 - ConstraintLayout、ConstraintLayout,看完一篇真的就夠了么?。
優化自定義 View
onDraw()
減少 onDraw() 耗時操作。
clipRect() 與 quickReject()
我們可以通過 canvas.clipRect() 來幫助系統識別那些可見的區域。這個方法可以指定一塊矩形區域,只有在這個區域內才會被繪制,其他的區域會被忽視。
這個API可以很好的幫助那些有多組重疊組件的自定義 View 來控制顯示的區域。同時 clipRect 方法還可以幫助節約 CPU 與 GPU 資源,在 clipRect 區域之外的繪制指令都不會被執行,那些部分內容在矩形區域內的組件,仍然會得到繪制。

clipRect
除了 clipRect 方法之外,我們還可以使用 canvas.quickReject() 來判斷是否沒和某個矩形相交,從而跳過那些非矩形區域內的繪制操作。

quickReject
上面的示例圖中顯示了一個自定義的View,主要效果是呈現多張重疊的卡片。這個 View 的 onDraw 方法如下圖所示:
protected void onDraw(Canvas canvas) { super.onDraw(canvas); if (mDroids.length > 0 && mDroidCards.size() == mDroids.length) { // 過度重繪代碼 int i; for (i = 0; i < mDroidCards.size(); i++) { // 每張卡片都放在前一張卡片的右側 mCardLeft = i * mCardSpacing; drawDroidCard(canvas, mDroidCards.get(i), mCardLeft, 0); } } invalidate(); }
打開「開發者選項」中的「顯示過度渲染」,可以看到我們這個自定義的 View 部分區域存在著過度繪制。
下面的代碼顯示了如何通過 clipRect 來解決自定義 View 的過度繪制,提高自定義 View 的繪制性能:
protected void onDraw(Canvas canvas) { super.onDraw(canvas); if (mDroids.length > 0 && mDroidCards.size() == mDroids.length) { int i; for (i = 0; i < mDroidCards.size() - 1; i++) { // 每張卡片都放在前一張卡片的右側 mCardLeft = i * mCardSpacing; // 保存 canvas 的狀態 canvas.save(); // 將繪圖區域限制為可見的區域 canvas.clipRect(mCardLeft, 0, mCardLeft + mCardSpacing, mDroidCards.get(i).getHeight()); drawDroidCard(canvas, mDroidCards.get(i), mCardLeft, 0); // 將畫布恢復到非剪切狀態 canvas.restore(); } // 繪制最后沒有剪裁的卡片 drawDroidCard(canvas, mDroidCards.get(i), mCardLeft + mCardSpacing, 0); } invalidate(); }
避免使用不支持硬件加速的 API
Android 系統中圖形繪制分為兩種方式,純軟件繪制和使用硬件加速繪制。
大家可以查看 美團技術團隊 - Android 硬件加速原理與實現簡介 這篇文章了解下硬件加速的實現原理。
簡單來說在 Android 3.0(API 11)之前沒有硬件加速,圖形繪制是純軟件的方式,DisplayList 的生成和繪制都需要 CPU 來完成。之后加入的硬件加速(默認開啟)將一部分圖形相關的操作交給 GPU 來處理,這樣大大減少了 CPU 的運算壓力。
今年金九銀十我花一個月的時間收錄整理了一套知識體系,如果有想法深入的系統化的去學習的,可以私信我【安卓】,我會把我收錄整理的資料都送給大家,幫助大家更快的進階。