「Android渲染」圖像是怎樣顯示到屏幕上的?

我們每天花很多時間盯著手機屏幕,不知道你有沒有好奇過:

手機屏幕上的這些東西是怎么顯示出來的?

這時候來了一位Android程序員(當然也可以是iOS或者是前端程序員)說: 這里顯示的其實是一個View樹,我們看到的都是大大小小的View。

。。。聽起來很有道理,我們也經常指著屏幕說這個View怎么怎么樣,可問題又來了:

屏幕認識View嗎?
我們把一個View發給屏幕,它就顯示出來了?

程序員老兄又來了: 屏幕當然不能識別View,它作為一個硬件,只能根據收到的數據改變每個像素單元的數據,這樣整體來看,用戶就發現屏幕上的內容變化了。至于View的內容是如何一步一步轉化成屏幕可是識別的數據的,簡單講可以分成三步:

  1. 準備材料
  2. 畫出來
  3. 顯示到屏幕

。。。聽起來很有道理,可問題又來了:

這也太簡單了吧,能詳細一點嗎?

那可就說來話長了。。。

1. 準備材料

對于measure layoutdraw,Android工程師(大都)非常熟悉,我們常常在執行了onDraw()方法后,一個讓人自豪的自定義View就顯示出來了。在實際的Android繪制流程中,第一步就是通過measure layoutdraw這些步驟準備了下面的材料:

  • 畫什么
  • 畫的參數

畫什么

在Android的繪制中,我們使用Canvas API進行來告訴表示畫的內容,如drawCircle() drawColor() drawText() drawBitmap()等,也是這些內容最終呈現在屏幕上。

畫的參數

  • 畫的坐標

    坐標系: Android圖像坐標系以左上角為0點,x軸左負右正,y軸上負下正,z軸內負外正;

    Viewlayout基準點是父容器的左上角,View的draw內容基準點是View的左上角。

    根節點父容器是當前WindowDecorView,它的布局信息由WindowManger來管理。

    到此,當前應用所有View放在哪個位置就確定了。

  • 畫的層級(重疊時的覆蓋關系)

    View之間并不是井水不犯河水,經常出現重疊的情況,重疊時該怎樣覆蓋和顯示正確的View大體遵循以下規則:

    • 指定z-order情況下,數值最大的顯示在最上層,剩下的降序顯示。
    • 在沒有指定z-order的情況下,子View覆蓋父容器,相同父容器View后添加的顯示在最上層。
  • 特定參數

    不同的方法需要的參數不同,比如drawCircle()會有圓心和半徑,drawText()需要對應的text資源,drawBitmap()需要對應的Bitmap資源等等。

在當前應用中,View樹中所有元素的材料最終會封裝到DisplayList對象中(后期版本有用RenderNodeDisplayList又做了一層封裝,實現了更好的性能),然后發送出去,這樣第一階段就完成了。

當然就有一個重要的問題:

這個階段怎么處理Bitmap呢?

會將Bitmap復制到下一個階段(準確地講就是復制到GPU的內存中)。
現在大多數設備使用了GPU硬件加速,而GPU在渲染來自Bitmap的數據時只能讀取GPU內存中的數據, 所以需要賦值Bitmap到GPU內存,這個階段對應的名稱叫Sync&upload。另外,硬件加速并不支持所有Canvas API,如果自定義View使用了不支持硬件加速的Canvas API(參考Android硬件加速文檔),為了避免出錯就需要對View進行軟件繪制,其處理方式就是生成一個Bitmap,然后復制到GPU進行處理。

這時可能會有問題:如果Bitmap很多或者單個Bitmap尺寸很大,這個過程可能會時間比較久,那有什么辦法嗎?

當然有(做作。。。)

  • 預上傳: Bitmap.prepareToDraw()(from Android 7.0 - Nougat)

  • 使用Hardware-Only Bitmap(from Android 8.0 - Oreo)

    從Android 8.0 開始,支持了Hardware-Only Bitmap類型,這種類型的Bitmap的數據只存放在GPU內存中,這樣在Sync&upload階段就不需要upload這個Bitmap了。使用很簡單,只需要將Options.inPreferredConfig賦值為Bitmap.Config.HARDWARE即可。

    這種方式能實現特定場景的極致性能,提供便利的同時,這種Bitmap的某些操作是受限的(畢竟數據存儲只存儲在GPU內存中),可以查看Glide的總結(為啥不是google?。。。)

關于Bitmap這里再多說一句:
Bitmap的內存管理一直是Android程序員很關心的問題,畢竟它是個很占內存的大胖子,在Android3.0~Android7.0,Bitmap內存放在Java堆中,而android系統中每個進程的Java堆是有嚴格限制的,處理不好這些Bitmap內存,容易導致頻繁GC,甚至觸發Java堆的OutOfMemoryError。從Android8.0開始,bitmap的像素數據放入了native內存,于是Java Heap的內存問題暫時緩解了。

Tip:

第一步的所有操作都在應用進程的UI Thread中執行。

2. 畫出來

現在材料已經備好,我們要真正地畫東西了。

誰來畫

接下來就要把東西畫出來了,畫出來的過程就是把前面的材料轉化成一個堆像素數據的過程,也叫柵格化,那這個活兒誰來干呢?

候選人只有兩個:

  • CPU: 軟件繪制,使用Skia方案實現,繪制慢。
  • GPU: 硬件加速繪制,使用OpenGL ESVulkan方案實現,繪制快很多。

大部分情況下,都是GPU來干這個活兒,因為GPU真的特別快!!!

怎么畫

所謂的“畫”,對于計算機來講就是處理圖像,其實就是根據需要(就是DisplayList中的命令)對數據做一些特定類型的數學運算,最后輸出結果的過程。我們看到的每一幀精美界面,(幾乎)都是GPU吭哧吭哧"算"出來的,這個就有疑問了:

既然是運算,CPU也能算啊,為什么GPU更快呢?

我們簡單地聊聊CPU與GPU的區別:
CPU的核心數通常是幾個,單個核心的主頻高,功能強大,擅長串行處理復雜的流程;
GPU (Graphics Processing Unit) 有成百上千個核心,單個核心主頻低,功能有限,擅長(利用超多核心)大量并行簡單運算;正如它的名字一樣,GPU就是為圖像繪制這個場景量身定做的硬件(所以使用GPU也叫硬件加速),后來也被用到挖礦和神經網絡中。

圖片肯定沒有視頻直觀,我們從感性的角度感受一下GPU到底有多快,我想下面的視頻看過就不會忘掉,你會被GPU折服:
Mythbusters Demo GPU versus CPU

看這個視頻,我們對于“加速”應該有了更深刻的印象,這里不再進一步分析CPU和GPU更微觀的差別(因為不懂),我想已經講明白為什們GPU更快了。

另外,在GPU開始繪制之前,系統也做了一些優化(對DisplayList中的命令進行預處理),讓整個繪制流程更加高效:

  • 增量更新:兩幀圖像之間只是個別View改變了,那么只繪制更新的View即可,實現方案是DisplayListDamaged Area

  • 指令重排序(reordering)、指令合并(merging)、批處理(batching)

    在硬件繪制之前,第一步中輸出的信息會轉化成 OpenGL ES中對應的繪制命令( gl commands ),這些命令原本是按照View樹的層級關系來遞歸輸出的。可這些命令中有很多(看起來)相同的操作,比如我們在繪制一個列表時,同樣屬性的文字(標題,內容,昵稱等)要繪制十幾次,這時候如果把繪制命令重新排序、進行一定的合并和批處理,性能會提升很多。如下圖:

    • 未經優化, 按順序繪制


    • 優化后,一次繪制出所有的文字


第二步的具體過程還是很復雜的,比如涉及到Alpha繪制,相關的優化會失效,詳情查看文章為什么alpha渲染性能低.

畫在哪里

至于畫在哪里,我們現在理解為一個緩沖(Buffer)中就可以了,具體的機制放在第三步講。

到此,我們已經畫(繪制)完了圖像內容,把這個內容發送出去,第二步的任務就完成了。

Tip:

在Android L 之前,第二步的操作在應用進程的UI Thread中執行;
在Android L 之后, 第二步的操作在應用進程的RenderThread中執行。

3. 顯示到屏幕

我們知道,除了我們的應用界面,手機屏幕上同時顯示著其他內容,比如SystemUI(狀態欄、導航欄)或者另外的懸浮窗等,這些內容都需要顯示到屏幕上。所以要先把這些界面的內容合成,然后再顯示到屏幕

在講合成圖像之前,我們有必要知道這些界面圖像(Buffer)是怎么傳遞的:

BufferQueue

Android圖形架構中,使用生產者消費者模型來處理圖像數據,其中的圖像緩沖隊列叫BufferQueue, 隊列中的元素叫Graphic Buffer,隊列有生產者也有消費者;每個應用通常會對應一個Surface,一個Surface對應著一個緩沖隊列,每個隊列中Graphic Buffer的數量不超過3個, 上面兩步后繪制的圖像數據最終會放入一個Graphic Buffer,應用自身就是隊列的生產者(BufferQueue在Android圖形處理中有廣泛的應用,當前只討論界面繪制的場景)。

每個Graphic Buffer本身體積很大,在從生產者到消費者的傳遞過程中不會進行復制的操作,都是用匿名共享內存的方式,通過句柄來跨進程傳遞。

BufferQueue工作周期

我們可以通過以下命令來查看手機當前用到的Graphic Buffer情況:

adb shell dumpsys SurfaceFlinger


這個命令會輸出很多內容,尾部會有當前正在使用的GraphicBuffer信息,從上圖中我們看到,當前正在使用的微信共有3個Graphic Buffer,所有的Buffer共占用接近90MB的內存,這些內存在應用不再顯示后就馬上回收。

關于上面的命令,你可能會好奇這個SurfaceFlinger是什么東西啊?

SurfaceFlinger

上文提到過每個應用(一般)對應一個Surface,從字面意思看,SurfaceFlinger就是把應用的Surface投射到目的地。

實際上,SurfaceFlinger就是界面(Buffer)合成的負責人,在應用界面繪制的場景,SurfaceFlinger充當了BufferQueue的消費者。繪制好的Graphic Buffer會進入(queue)隊列,SurfaceFlinger會在合適的時機(這個時機下文討論),從隊列中取出(acquire)Buffer數據進行處理。

我們知道,除了我們的應用界面,手機屏幕上同時顯示著其他內容,比如SystemUI(狀態欄、導航欄)或者另外的懸浮窗等,這些部分的都有各自的Surface,當然也會往對應的BufferQueue中生產Graphic Buffer

如下圖所示,SurfaceFlinger獲取到所有Surface的最新Buffer之后,會配合HWComposer進行處理合成,最終把這些Buffer的數據合成到一個FrameBuffer中,而FrameBuffer的數據會在另一個合適的時機(同樣下文討論)迅速地顯示到屏幕上,這時用戶才觀察到屏幕上的變化。

關于上圖中的HWComposer,它是Android HAL接口中的一部分,它定義了上層需要的能力,讓由硬件提供商來實現,因為不同的屏幕硬件差別很大,讓硬件提供商驅動自己的屏幕,上層軟件無需關心屏幕硬件的兼容問題。

事實上,如果你觀察足夠仔細的話,可能對上圖還有疑問:

SurfaceFlinger部分,
為什么有的Buffer是直接發到HWComposer合成,
而有的Buffer需要通過GPU合并成一個新的Buffer才能合成。

同學你觀察很仔細(...),事實上,這是SurfaceFlinger合成過程中重要的細節,對于不同Surface的Buffer, 合成的方法有兩種:

  • 把Buffer發到HWComposer,直接寫到FrameBuffer的對位置
  • 由于某些操作HWComposer不能支持直接寫(但是GPU知道),部分Buffer的內容需要通過寫到一個臨時的Buffer中(HWComposer知道這個臨時的Buffer該怎么寫),最終把這臨時的Buffer寫到FrameBuffer的對應位置。

顯然第一種方法是最高效的,但為了保證正確性,Android系統結合了兩種方法。具體實現上,SurfaceFlinger會詢問(prepare)HWComposer是否支持直接合成,之后按照結果做對應處理。

有的朋友憋不住了:

你上面說合成的觸發、FrameBuffer顯示到屏幕上都需要合適的時機,
到底是什么時機?

Good question! (太做作了。。。)

為了保證最好的渲染性能,上面各個步驟之間并不是串行阻塞運行的關系,所以有一個機制來調度每一步的觸發時機,不過在此之前,我們先講介紹一個更基礎的概念:

屏幕刷新率

刷新率是屏幕的硬件指標,單位是Hz(赫茲),意思是屏幕每秒可以刷新的次數。

這里稍微展開一下,我們之所以在屏幕上看到東西在"動"(看視頻或者滑動列表),其原理是屏幕在快速地播放不同的幀,相鄰幀的圖像只有很小的位移,加上大腦的殘留效應,我們感官上就覺得這個東西在連續地動;有時候我們覺得手機界面卡頓,原因是兩個幀之前的時間太長了,大腦殘留內容消失了。

想要達到(看起來)流暢的效果,就要確保幀率足夠大,一般電影(視頻)的幀率不小于24幀,手機屏幕上不小于40幀,人眼就不易察覺卡頓了。

在2021年的今天,Android旗艦手機通常配置了90Hz~120Hz的高刷新率屏幕,iPhone與其他Android中低端手機會配置60Hz的屏幕。用60Hz來計算,1000 ? 60 ≈ 16.7ms。這就是要求每一幀數據在16ms之內繪制完成的原因。

當然,人眼的分辨能力還是遠超60Hz,如果用一段時間90Hz或者120Hz的設備,再回到60Hz,就會覺得不爽。人眼也能適應越來越高的分辨率,現在大部分手機屏幕的分辨率超過了400ppi,有些上了2k屏已經超過了500ppi,再回到喬幫主在iPhone4定義的視網膜屏幕(指在距離屏幕10inch的距離,超過300ppi的分辨率是人眼難以分辨的,iPhone4/5/6/7/8的分辨率都是326ppi),會察覺到明顯的顆粒感。

回到問題,既然屏幕這個硬件每隔一段時間(如60Hz屏幕是16ms)就刷新一次,最佳的方案就是屏幕刷新時開始新一輪的繪制流程,讓一次繪制的流程盡可能占滿整個刷新周期,這樣掉幀的可能性最小。基于這樣的思考,在Android4.1(JellyBean)引入VSYNC(Vertical Synchronization - 垂直同步信號)

收到系統發出的VSYNC信號后,有三件事會同時執行(并行)

  • (第一步和第二步)應用開始繪制Graphic Buffer
  • (第三步)SurfaceFlinger 開始合成FrameBuffer
  • 屏幕刷新: 顯示FrameBuffer中的數據

下圖描述了沒有掉幀時的VSYNC執行流程,現在我們可以直接回答問題了: 合適的時機就是VSYNC信號

VSYNC信號調度下的Android繪制工作流

從上圖可以看出,在一次VSYNC信號發出后,屏幕立即顯示2個VSYNC周期(60Hz屏幕上就是32ms)之前開始繪制的圖像,這當然是延遲,不過這個延遲非常穩定,只要前面的繪制不掉鏈子,界面也是如絲般順滑。當然,Android還是推出一種機制讓延遲可以縮小到1個VSYNC周期,詳情可參考VSYNC-offset

實際上,系統只會在需要的時候才發出VSYNC信號,這個開關由SurfaceFlinger來管理。應用也只是在需要的時候才接收VSYNC信號,什么時候需要呢?也就是應用界面有變化,需要更新了,具體的流程可以參考View.requestLayout()View.invalidate()Choreographer(編舞者)的調用過程。這個過程會注冊一次VSYNC信號,下一次VSYNC信號發出后應用就能收到了,然后開始新的繪制工作;想要再次接收VSYNC信號就需要重新注冊,可見,應用界面沒有改變的時候是不會進行刷新的。

我們可以看到,無論是VSYNC開關,還是應用對VSYNC信號的單次注冊邏輯,都是秉承著按需分配的原則,這樣的設計能夠帶來Android操作系統更好的性能和更低的功耗。

Tip:

第3步的操作執行在系統進程中

終于。。。說完了

我們簡單回顧一下,

  1. 準備材料
  2. 畫出來
  3. 顯示到屏幕

更形象一點就是:


Android渲染的演進

之所以有這一節,是因為隨著Android版本的更替,渲染方案也發生了很多變化。為了簡化表達,我們前文都以當前最新的方案來講解,事實上,部分流程的實現方式在不同版本可能會有較大的變化,甚至在之前版本沒有實現方案,這里我盡可能詳細地列出Android版本更迭過程中與渲染相關的更新(包括監控工具)。

Android 3.0 (Honeycomb)

  • 硬件加速
  • DisplayList

Android 4.0 (Ice Cream Sandwich)

  • 默認開啟硬件加速

Android 4.1 (Jelly Bean)

  • Project Butter (黃油計劃)

    • VSYNC

    • Triple-Buffering(3緩沖)

      Android應用的BufferQueue中的Graphic Buffer數量經歷了1個到2個再到3個的變化。一次次地提升了性能。

      單Buffer時代,Buffer沒有鎖機制,也沒有VSYNC來協調繪制的節奏,可能Buffer繪制到一半屏幕刷新了,結果就出現屏幕上下兩部分界面錯位的問題(如下圖)。為了解決這個問題,于是鎖機制與雙Buffer就來了。


      雙Buffer時代,鎖機制的加入就帶來了對鎖的爭奪,加上沒有VSYNC機制,界面上下錯位的問題倒是沒有了,但界面卡頓依然嚴重。

      ProjectButter 項目帶來了VSYNC, 與 三Buffer,這是因為CPU(第一步)、GPU(第二步)、與SurfaceFlinger(第三步)都會搶占Buffer,如果上一次的GPU渲染(第二步)比較耗時,此時下一次的VSYNC信號來了,那么系統會分配第三個Buffer給CPU。(有能力三Buffer,但非默認)

  • Systrace: 功能強大的采集工具

Android 4.2 (Jelly Bean)

Android 5.0 (Lollipop)

  • RenderThread

    Android5.0之后,繪制階段移到了單獨線程RenderThread(渲染線程)中,進一步提升渲染性能。

  • RenderNode

Android 7.0 (Nougat)

  • 支持Vulkan

Android 8.0 (Oreo)

  • Bitmap內存轉移至native內存
  • Hardware-Only Bitmap

如果你居然能讀到這里,那我猜你對下面的參考文章也會感興趣:

Reference

https://source.android.com/devices/graphics

https://hencoder.com/tag/hui-zhi/

https://www.youtube.com/watch?v=wIy8g8yNhNk&feature=emb_logo

https://www.youtube.com/watch?v=v9S5EO7CLjo

https://www.youtube.com/watch?v=zdQRIYOST64&t=177s

https://www.youtube.com/watch?v=we6poP0kw6E&index=64&list=PLWz5rJ2EKKc9CBxr3BVjPTPoDPLdPIFCE

https://developer.android.com/topic/performance/rendering

https://developer.android.com/guide/topics/graphics/hardware-accel

https://developer.android.com/topic/performance/rendering/profile-gpu#su

https://mp.weixin.qq.com/s/0OOSmrzSkjG3cSOFxWYWuQ

Android Developer Backstage - Android Rendering

Android Developer Backstage - Graphics Performance

https://elinux.org/images/2/2b/Android_graphics_path--chis_simmonds.pdf

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容