關于離屏渲染的深入研究

原文地址需翻墻
可以對比YYKIt作者的文章iOS 保持界面流暢的技巧

在平時的iOS面試中,我們經常會考察有關離屏渲染(Offscreen rendering)的知識點。一般來說,絕大多數人都能答出“圓角、mask、陰影會觸發離屏渲染”,但是也僅止于此。如果再問得深入哪怕一點點,比如:

  • 離屏渲染是在哪一步進行的?為什么?
  • 設置cornerRadius一定會觸發離屏渲染嗎?
    90%的候選人都沒法非常確定地說出答案。作為一個客戶端工程師,把控渲染性能是最關鍵、最獨到的技術要點之一,如果僅僅了解表面知識,到了實際應用時往往會失之毫厘謬以千里,無法得到預期的效果。

iOS渲染架構

在WWDC的Advanced Graphics and Animations for iOS Apps(WWDC14 419,關于UIKit和Core Animation基礎的session在早年的WWDC中比較多)中有這樣一張圖:


render1.png

我們可以看到,在Application這一層中主要是CPU在操作,而到了Render Server這一層,CoreAnimation會將具體操作轉換成發送給GPU的draw calls(以前是call OpenGL ES,現在慢慢轉到了Metal),顯然CPU和GPU雙方同處于一個流水線中,協作完成整個渲染工作。

離屏渲染的定義

如果要在顯示屏上顯示內容,我們至少需要一塊與屏幕像素數據量一樣大的frame buffer,作為像素數據存儲區域,而這也是GPU存儲渲染結果的地方。如果有時因為面臨一些限制,無法把渲染結果直接寫入frame buffer,而是先暫存在另外的內存區域,之后再寫入frame buffer,那么這個過程被稱之為離屏渲染。


渲染結果先經過了離屏buffer,再到frame buffer

CPU”離屏渲染“

大家知道,如果我們在UIView中實現了drawRect方法,就算它的函數體內部實際沒有代碼,系統也會為這個view申請一塊內存區域,等待CoreGraphics可能的繪畫操作。

對于類似這種“新開一塊CGContext來畫圖“的操作,有很多文章和視頻也稱之為“離屏渲染”(因為像素數據是暫時存入了CGContext,而不是直接到了frame buffer)。進一步來說,其實所有CPU進行的光柵化操作(如文字渲染、圖片解碼),都無法直接繪制到由GPU掌管的frame buffer,只能暫時先放在另一塊內存之中,說起來都屬于“離屏渲染”。

自然我們會認為,因為CPU不擅長做這件事,所以我們需要盡量避免它,就誤以為這就是需要避免離屏渲染的原因。但是根據蘋果工程師的說法,CPU渲染并非真正意義上的離屏渲染。另一個證據是,如果你的view實現了drawRect,此時打開Xcode調試的“Color offscreen rendered yellow”開關,你會發現這片區域不會被標記為黃色,說明Xcode并不認為這屬于離屏渲染。

我們先來看下GPU是如何渲染的。

畫家算法

在上面的渲染流水線示意圖中我們可以看到,主要的渲染操作都是由CoreAnimation的Render Server模塊,通過調用顯卡驅動所提供的OpenGL/Metal接口來執行的。通常對于每一層layer,Render Server會遵循畫家算法,先把各層按照深度排序,然后由深到淺,按次序輸出到frame buffer,后一層覆蓋前一層,就能得到最終的顯示結果(值得一提的是,與一般桌面架構不同,在iOS中,設備主存和GPU的顯存共享物理內存,這樣可以省去一些數據傳輸開銷)。

”畫家算法“,把每一層依次輸出到畫布,先畫的層會被一定程度覆蓋

這樣一來,每一層前景就很自然遮擋了一部分背景,只是這個過程不可逆,被上層遮住的部分像素數據就永久丟失了。如果此時再想修改當前層的某一部分,讓底下的層再重新顯示出來,顯然是做不到的。

想要突破這個限制也不難。如果我們能在畫布(frame buffer)之外另外開辟一塊內存,把待處理的layer先畫上去,然后在這塊臨時工作區里執行需要的擦除/修改工作,再統一把處理后的結果寫回到畫布,就能得到想要的結果。雖然這個辦法需要額外的空間,但是我們得到了更大的靈活性——如果這些加工操作不需要借助中間buffer,就能一次性完整地畫到frame buffer,何樂而不為呢?

GPU離屏渲染

以上提到的工作區是獨立于frame buffer之外的,因此很自然地被稱為“離屏buffer”,而整個過程就稱為離屏渲染。對于每一層layer,我們肯定希望優先找一種通過單次遍歷就能完成渲染的算法(效率最高),不然的話就只能另申請一塊離屏buffer,借助這個臨時中轉區域來完成一些復雜的、多次的修改/剪裁操作。

例如,如果要繪制一個帶有圓角并剪切圓角以外內容的容器,就會觸發離屏渲染。我的猜想是(如果讀者中有圖形學專家希望能指正):

  • 將一個layer的內容裁剪成圓角,可能不存在一次遍歷就能完成的方法
  • 容器的子layer因為父容器有圓角,那么也會需要被裁剪,而這時它們還在渲染隊列中排隊,尚未被合成到同一塊畫布上,自然也無法統一裁剪

此時我們就不得不開辟一塊獨立于frame buffer的空白內存,先把容器以及其所有子layer依次畫好,然后把四個角“剪”成圓形,再把結果畫到frame buffer中。

常見離屏渲染場景分析

  • cornerRadius+clipsToBounds,原因就如同上面提到的,不得已只能另開一塊內存來操作。而如果只是設置cornerRadius(如不需要剪切內容,只需要一個帶圓角的邊框),或者只是需要裁掉矩形區域以外的內容(雖然也是剪切,但是稍微想一下就可以發現,對于純矩形而言,實現這個算法似乎并不需要另開內存),并不會觸發離屏渲染。關于剪切圓角的性能優化,根據場景不同有幾個方案可供選擇,非常推薦閱讀AsyncDisplayKit中的一篇文檔

    ASDK中對于如何選擇圓角渲染策略的流程圖,非常實用

  • shadow,其原因在于,雖然layer本身是一塊矩形區域,但是陰影的形狀卻未必是矩形,而是與layer中”非透明區域“的形狀一致。這就意味著需要先知道這個形狀是什么樣的(由layer與其所有子結構合成后所決定),陰影只能在這之后得到。但矛盾的是,陰影需要顯示在所有layer內容的下方,那么根據畫家算法,下層的陰影又必須先被渲染。因為這個矛盾無法被調和,這樣一來又只能另外申請一塊內存,把本體內容都先畫好,再根據渲染結果的形狀,添加陰影到frame buffer,最后把內容畫上去(這只是我的猜測,實際情況可能更復雜)。不過如果我們能夠預先告訴CoreAnimation(通過shadowPath屬性)陰影的幾何形狀,那么陰影當然可以先被獨立渲染出來,不需要依賴layer本體,也就不再需要離屏渲染了。


    陰影會作用在所有子layer所組成的形狀上,那就只能等全部子layer畫完才能得到
  • group opacity,其實從名字就可以猜到,alpha并不是分別應用在每一層之上,而是只有到整個layer樹畫完之后,再統一加上alpha,最后和底下其他layer的像素進行合成。顯然也無法通過一次遍歷就得到最終結果。將一對藍色和紅色layer疊在一起,然后在父layer上設置opacity=0.5,并復制一份在旁邊作對比。左邊關閉group opacity,右邊保持默認(從iOS7開始,如果沒有顯式指定,group opacity會默認打開),然后打開offscreen rendering的調試,我們會發現右邊的那一組確實是離屏渲染了。


    同樣的兩個view,右邊打開group opacity(默認行為)的被標記為Offscreen rendering
  • mask,我們知道mask是應用在layer和其所有子layer的合成結果之上的,而且可能帶有透明度,那么其實和group opacity的原理類似,不得不在離屏渲染中完成。


    WWDC中蘋果的解釋,mask需要遍歷至少三次
  • UIBlurEffect,同樣無法通過一次遍歷完成,其原理在WWDC中提到:


    render8.png
  • 其他還有一些,類似allowsEdgeAntialiasing等等也可能會觸發離屏渲染,原理也都是類似:如果你無法僅僅使用frame buffer來畫出最終結果,那就只能另開一塊內存空間來儲存中間結果。這些原理并不神秘。

GPU離屏渲染的性能影響

GPU的操作是高度流水線化的。本來所有計算工作都在有條不紊地正在向frame buffer輸出,此時突然收到指令,需要輸出到另一塊內存,那么流水線中正在進行的一切都不得不被丟棄,切換到只能服務于我們當前的“切圓角”操作。等到完成以后再次清空,再回到向frame buffer輸出的正常流程。

在tableView或者collectionView中,滾動的每一幀變化都會觸發每個cell的重新繪制,因此一旦存在離屏渲染,上面提到的上下文切換就會每秒發生60次,并且很可能每一幀有幾十張的圖片要求這么做,對于GPU的性能沖擊可想而知(GPU非常擅長大規模并行計算,但是我想頻繁的上下文切換顯然不在其設計考量之中)

每16ms就需要根據當前滾動位置渲染整個tableView,是個不小的性能挑戰

善用離屏渲染

盡管離屏渲染開銷很大,但是當我們無法避免它的時候,可以想辦法把性能影響降到最低。優化思路也很簡單:既然已經花了不少精力把圖片裁出了圓角,如果我能把結果緩存下來,那么下一幀渲染就可以復用這個成果,不需要再重新畫一遍了。

CALayer為這個方案提供了對應的解法:shouldRasterize。一旦被設置為true,Render Server就會強制把layer的渲染結果(包括其子layer,以及圓角、陰影、group opacity等等)保存在一塊內存中,這樣一來在下一幀仍然可以被復用,而不會再次觸發離屏渲染。有幾個需要注意的點:

  • shouldRasterize的主旨在于降低性能損失,但總是至少會觸發一次離屏渲染。如果你的layer本來并不復雜,也沒有圓角陰影等等,打開這個開關反而會增加一次不必要的離屏渲染
  • 離屏渲染緩存有空間上限,最多不超過屏幕總像素的2.5倍大小
  • 一旦緩存超過100ms沒有被使用,會自動被丟棄
  • layer的內容(包括子layer)必須是靜態的,因為一旦發生變化(如resize,動畫),之前辛苦處理得到的緩存就失效了。如果這件事頻繁發生,我們就又回到了“每一幀都需要離屏渲染”的情景,而這正是開發者需要極力避免的。針對這種情況,Xcode提供了“Color Hits Green and Misses Red”的選項,幫助我們查看緩存的使用是否符合預期
  • 其實除了解決多次離屏渲染的開銷,shouldRasterize在另一個場景中也可以使用:如果layer的子結構非常復雜,渲染一次所需時間較長,同樣可以打開這個開關,把layer繪制到一塊緩存,然后在接下來復用這個結果,這樣就不需要每次都重新繪制整個layer樹了

什么時候需要CPU渲染

渲染性能的調優,其實始終是在做一件事:平衡CPU和GPU的負載,讓他們盡量做各自最擅長的工作。

平衡CPU和GPU的負載

絕大多數情況下,得益于GPU針對圖形處理的優化,我們都會傾向于讓GPU來完成渲染任務,而給CPU留出足夠時間處理各種各樣復雜的App邏輯。為此Core Animation做了大量的工作,盡量把渲染工作轉換成適合GPU處理的形式(也就是所謂的硬件加速,如layer composition,設置backgroundColor等等)。

但是對于一些情況,如文字(CoreText使用CoreGraphics渲染)和圖片(ImageIO)渲染,由于GPU并不擅長做這些工作,不得不先由CPU來處理好以后,再把結果作為texture傳給GPU。除此以外,有時候也會遇到GPU實在忙不過來的情況,而CPU相對空閑(GPU瓶頸),這時可以讓CPU分擔一部分工作,提高整體效率。


來自WWDC18 session 221,可以看到Core Text基于Core Graphics

一個典型的例子是,我們經常會使用CoreGraphics給圖片加上圓角(將圖片中圓角以外的部分渲染成透明)。整個過程全部是由CPU完成的。這樣一來既然我們已經得到了想要的效果,就不需要再另外給圖片容器設置cornerRadius。另一個好處是,我們可以靈活地控制裁剪和緩存的時機,巧妙避開CPU和GPU最繁忙的時段,達到平滑性能波動的目的。

這里有幾個需要注意的點:

  • 渲染不是CPU的強項,調用CoreGraphics會消耗其相當一部分計算時間,并且我們也不愿意因此阻塞用戶操作,因此一般來說CPU渲染都在后臺線程完成(這也是AsyncDisplayKit的主要思想),然后再回到主線程上,把渲染結果傳回CoreAnimation。這樣一來,多線程間數據同步會增加一定的復雜度
  • 同樣因為CPU渲染速度不夠快,因此只適合渲染靜態的元素,如文字、圖片(想象一下沒有硬件加速的視頻解碼,性能慘不忍睹)
  • 作為渲染結果的bitmap數據量較大(形式上一般為解碼后的UIImage),消耗內存較多,所以應該在使用完及時釋放,并在需要的時候重新生成,否則很容易導致OOM
  • 如果你選擇使用CPU來做渲染,那么就沒有理由再觸發GPU的離屏渲染了,否則會同時存在兩塊內容相同的內存,而且CPU和GPU都會比較辛苦
  • 一定要使用Instruments的不同工具來測試性能,而不是僅憑猜測來做決定

即刻的優化

由于在iOS10之后,系統的設計風格慢慢從扁平化轉變成圓角卡片,即刻的設計風格也隨之發生變化,加入了大量圓角與陰影效果,如果在處理上稍有不慎,就很容易觸發離屏渲染。為此我們采取了以下一些措施:

  • 即刻大量應用AsyncDisplayKit(Texture)作為主要渲染框架,對于文字和圖片的異步渲染操作交由框架來處理。關于這方面可以看我之前的一些介紹
  • 對于圖片的圓角,統一采用“precomposite”的策略,也就是不經由容器來做剪切,而是預先使用CoreGraphics為圖片裁剪圓角
  • 對于視頻的圓角,由于實時剪切非常消耗性能,我們會創建四個白色弧形的layer蓋住四個角,從視覺上制造圓角的效果
  • 對于view的圓形邊框,如果沒有backgroundColor,可以放心使用cornerRadius來做
  • 對于所有的陰影,使用shadowPath來規避離屏渲染
  • 對于特殊形狀的view,使用layer mask并打開shouldRasterize來對渲染結果進行緩存
  • 對于模糊效果,不采用系統提供的UIVisualEffect,而是另外實現模糊效果(CIGaussianBlur),并手動管理渲染結果


    客戶端中有大量的圓角、陰影等效果

總結

  • CPU渲染雖然也是“離屏”,但是通常提到的離屏渲染是發生在GPU
  • 如果一個layer無法在一次遍歷就完成繪制,那么就不得不觸發離屏渲染
  • 離屏渲染的開銷主要在與frame buffer與離屏buffer之間的上下文切換。如果無法避免,也可以通過有效利用shouldRasterize,減少觸發的次數
  • CPU和GPU是相互扶持的關系。CPU渲染效率不高,但是較為通用靈活;GPU擅長并行計算,但也有捉襟見肘之時,此時CPU可以適當給與幫助

離屏渲染牽涉了很多Core Animation、GPU和圖形學等等方面的知識,在實踐中也非常考驗一個工程師排查問題的基本功、經驗和判斷能力——如果在不恰當的時候打開了shouldRasterize,只會弄巧成拙。

從一個更廣闊的視角看,離屏渲染也僅僅是渲染性能優化中的一部分,而能否保證UI性能過關,將會直接影響到用戶日常的操作體驗。渲染技術作為客戶端工程師的關鍵技術能力之一,值得持續研究。

推薦資料

Andy Matuschak關于離屏渲染的解釋

Objc.io: Moving Pixels onto the Screen

Mastering Offscreen Render

WWDC 2011 421 Core Animation Essentials

WWDC 2011 121 Understanding UIKit Rendering

WWDC 2014 419 Advanced Graphics and Animations for iOS Apps

WWDC 2010 135 Advanced Performance Optimization on iPhone OS Part 1

《Core Animation: Advanced Techniques》

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,967評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,273評論 3 415
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,870評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,742評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,527評論 6 407
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,010評論 1 322
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,108評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,250評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,769評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,656評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,853評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,371評論 5 358
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,103評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,472評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,717評論 1 281
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,487評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,815評論 2 372