性能優化系列(一)找出卡頓的元兇 —— 渲染性能優化

文章首發「Android波斯灣」公衆號,更新地址:https://github.com/jeanboydev/Android-ReadTheFuckingSourceCode

一個 Android 應用是否流暢,或者說是否存在卡頓、丟幀現象,都與 60fps 和 16ms 有關。那麼這兩個值是怎麼來的呢?爲什麼以這兩個值爲衡量標準呢?本文主要討論下渲染性能方面決定 Android 應用流暢性的因素。

爲什麼是 60fps?

  • 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

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 開發文檔 - ConstraintLayoutConstraintLayout,看完一篇真的就夠了麼?

優化自定義 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 的運算壓力。

所以我們在開發過程中應儘量避免使用不支持硬件加速的 API,來提升 UI 的渲染性能。

我的 GitHub

github.com/jeanboydev

技術交流羣

歡迎加入技術交流羣,來一起交流學習。

QQ 技術交流羣
微信技術交流羣

我的公衆號

歡迎關注我的公衆號,分享各種技術乾貨,各種學習資料,職業發展和行業動態。

Android 波斯灣

參考資料

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