原文地址需翻墻
可以對(duì)比YYKIt作者的文章iOS 保持界面流暢的技巧
在平時(shí)的iOS面試中,我們經(jīng)常會(huì)考察有關(guān)離屏渲染(Offscreen rendering)的知識(shí)點(diǎn)。一般來說,絕大多數(shù)人都能答出“圓角、mask、陰影會(huì)觸發(fā)離屏渲染”,但是也僅止于此。如果再問得深入哪怕一點(diǎn)點(diǎn),比如:
- 離屏渲染是在哪一步進(jìn)行的?為什么?
- 設(shè)置cornerRadius一定會(huì)觸發(fā)離屏渲染嗎?
90%的候選人都沒法非常確定地說出答案。作為一個(gè)客戶端工程師,把控渲染性能是最關(guān)鍵、最獨(dú)到的技術(shù)要點(diǎn)之一,如果僅僅了解表面知識(shí),到了實(shí)際應(yīng)用時(shí)往往會(huì)失之毫厘謬以千里,無法得到預(yù)期的效果。
iOS渲染架構(gòu)
在WWDC的Advanced Graphics and Animations for iOS Apps(WWDC14 419,關(guān)于UIKit和Core Animation基礎(chǔ)的session在早年的WWDC中比較多)中有這樣一張圖:

我們可以看到,在Application這一層中主要是CPU在操作,而到了Render Server這一層,CoreAnimation會(huì)將具體操作轉(zhuǎn)換成發(fā)送給GPU的draw calls(以前是call OpenGL ES,現(xiàn)在慢慢轉(zhuǎn)到了Metal),顯然CPU和GPU雙方同處于一個(gè)流水線中,協(xié)作完成整個(gè)渲染工作。
離屏渲染的定義
如果要在顯示屏上顯示內(nèi)容,我們至少需要一塊與屏幕像素?cái)?shù)據(jù)量一樣大的frame buffer,作為像素?cái)?shù)據(jù)存儲(chǔ)區(qū)域,而這也是GPU存儲(chǔ)渲染結(jié)果的地方。如果有時(shí)因?yàn)槊媾R一些限制,無法把渲染結(jié)果直接寫入frame buffer,而是先暫存在另外的內(nèi)存區(qū)域,之后再寫入frame buffer,那么這個(gè)過程被稱之為離屏渲染。

CPU”離屏渲染“
大家知道,如果我們?cè)赨IView中實(shí)現(xiàn)了drawRect方法,就算它的函數(shù)體內(nèi)部實(shí)際沒有代碼,系統(tǒng)也會(huì)為這個(gè)view申請(qǐng)一塊內(nèi)存區(qū)域,等待CoreGraphics可能的繪畫操作。
對(duì)于類似這種“新開一塊CGContext來畫圖“的操作,有很多文章和視頻也稱之為“離屏渲染”(因?yàn)橄袼財(cái)?shù)據(jù)是暫時(shí)存入了CGContext,而不是直接到了frame buffer)。進(jìn)一步來說,其實(shí)所有CPU進(jìn)行的光柵化操作(如文字渲染、圖片解碼),都無法直接繪制到由GPU掌管的frame buffer,只能暫時(shí)先放在另一塊內(nèi)存之中,說起來都屬于“離屏渲染”。
自然我們會(huì)認(rèn)為,因?yàn)镃PU不擅長(zhǎng)做這件事,所以我們需要盡量避免它,就誤以為這就是需要避免離屏渲染的原因。但是根據(jù)蘋果工程師的說法,CPU渲染并非真正意義上的離屏渲染。另一個(gè)證據(jù)是,如果你的view實(shí)現(xiàn)了drawRect,此時(shí)打開Xcode調(diào)試的“Color offscreen rendered yellow”開關(guān),你會(huì)發(fā)現(xiàn)這片區(qū)域不會(huì)被標(biāo)記為黃色,說明Xcode并不認(rèn)為這屬于離屏渲染。
我們先來看下GPU是如何渲染的。
畫家算法
在上面的渲染流水線示意圖中我們可以看到,主要的渲染操作都是由CoreAnimation的Render Server模塊,通過調(diào)用顯卡驅(qū)動(dòng)所提供的OpenGL/Metal接口來執(zhí)行的。通常對(duì)于每一層layer,Render Server會(huì)遵循畫家算法,先把各層按照深度排序,然后由深到淺,按次序輸出到frame buffer,后一層覆蓋前一層,就能得到最終的顯示結(jié)果(值得一提的是,與一般桌面架構(gòu)不同,在iOS中,設(shè)備主存和GPU的顯存共享物理內(nèi)存,這樣可以省去一些數(shù)據(jù)傳輸開銷)。

這樣一來,每一層前景就很自然遮擋了一部分背景,只是這個(gè)過程不可逆,被上層遮住的部分像素?cái)?shù)據(jù)就永久丟失了。如果此時(shí)再想修改當(dāng)前層的某一部分,讓底下的層再重新顯示出來,顯然是做不到的。
想要突破這個(gè)限制也不難。如果我們能在畫布(frame buffer)之外另外開辟一塊內(nèi)存,把待處理的layer先畫上去,然后在這塊臨時(shí)工作區(qū)里執(zhí)行需要的擦除/修改工作,再統(tǒng)一把處理后的結(jié)果寫回到畫布,就能得到想要的結(jié)果。雖然這個(gè)辦法需要額外的空間,但是我們得到了更大的靈活性——如果這些加工操作不需要借助中間buffer,就能一次性完整地畫到frame buffer,何樂而不為呢?
GPU離屏渲染
以上提到的工作區(qū)是獨(dú)立于frame buffer之外的,因此很自然地被稱為“離屏buffer”,而整個(gè)過程就稱為離屏渲染。對(duì)于每一層layer,我們肯定希望優(yōu)先找一種通過單次遍歷就能完成渲染的算法(效率最高),不然的話就只能另申請(qǐng)一塊離屏buffer,借助這個(gè)臨時(shí)中轉(zhuǎn)區(qū)域來完成一些復(fù)雜的、多次的修改/剪裁操作。
例如,如果要繪制一個(gè)帶有圓角并剪切圓角以外內(nèi)容的容器,就會(huì)觸發(fā)離屏渲染。我的猜想是(如果讀者中有圖形學(xué)專家希望能指正):
- 將一個(gè)layer的內(nèi)容裁剪成圓角,可能不存在一次遍歷就能完成的方法
- 容器的子layer因?yàn)楦溉萜饔袌A角,那么也會(huì)需要被裁剪,而這時(shí)它們還在渲染隊(duì)列中排隊(duì),尚未被合成到同一塊畫布上,自然也無法統(tǒng)一裁剪
此時(shí)我們就不得不開辟一塊獨(dú)立于frame buffer的空白內(nèi)存,先把容器以及其所有子layer依次畫好,然后把四個(gè)角“剪”成圓形,再把結(jié)果畫到frame buffer中。
常見離屏渲染場(chǎng)景分析
-
cornerRadius+clipsToBounds,原因就如同上面提到的,不得已只能另開一塊內(nèi)存來操作。而如果只是設(shè)置cornerRadius(如不需要剪切內(nèi)容,只需要一個(gè)帶圓角的邊框),或者只是需要裁掉矩形區(qū)域以外的內(nèi)容(雖然也是剪切,但是稍微想一下就可以發(fā)現(xiàn),對(duì)于純矩形而言,實(shí)現(xiàn)這個(gè)算法似乎并不需要另開內(nèi)存),并不會(huì)觸發(fā)離屏渲染。關(guān)于剪切圓角的性能優(yōu)化,根據(jù)場(chǎng)景不同有幾個(gè)方案可供選擇,非常推薦閱讀AsyncDisplayKit中的一篇文檔。
ASDK中對(duì)于如何選擇圓角渲染策略的流程圖,非常實(shí)用 -
shadow,其原因在于,雖然layer本身是一塊矩形區(qū)域,但是陰影的形狀卻未必是矩形,而是與layer中”非透明區(qū)域“的形狀一致。這就意味著需要先知道這個(gè)形狀是什么樣的(由layer與其所有子結(jié)構(gòu)合成后所決定),陰影只能在這之后得到。但矛盾的是,陰影需要顯示在所有l(wèi)ayer內(nèi)容的下方,那么根據(jù)畫家算法,下層的陰影又必須先被渲染。因?yàn)檫@個(gè)矛盾無法被調(diào)和,這樣一來又只能另外申請(qǐng)一塊內(nèi)存,把本體內(nèi)容都先畫好,再根據(jù)渲染結(jié)果的形狀,添加陰影到frame buffer,最后把內(nèi)容畫上去(這只是我的猜測(cè),實(shí)際情況可能更復(fù)雜)。不過如果我們能夠預(yù)先告訴CoreAnimation(通過shadowPath屬性)陰影的幾何形狀,那么陰影當(dāng)然可以先被獨(dú)立渲染出來,不需要依賴layer本體,也就不再需要離屏渲染了。
陰影會(huì)作用在所有子layer所組成的形狀上,那就只能等全部子layer畫完才能得到
-
group opacity,其實(shí)從名字就可以猜到,alpha并不是分別應(yīng)用在每一層之上,而是只有到整個(gè)layer樹畫完之后,再統(tǒng)一加上alpha,最后和底下其他layer的像素進(jìn)行合成。顯然也無法通過一次遍歷就得到最終結(jié)果。將一對(duì)藍(lán)色和紅色layer疊在一起,然后在父layer上設(shè)置opacity=0.5,并復(fù)制一份在旁邊作對(duì)比。左邊關(guān)閉group opacity,右邊保持默認(rèn)(從iOS7開始,如果沒有顯式指定,group opacity會(huì)默認(rèn)打開),然后打開offscreen rendering的調(diào)試,我們會(huì)發(fā)現(xiàn)右邊的那一組確實(shí)是離屏渲染了。
同樣的兩個(gè)view,右邊打開group opacity(默認(rèn)行為)的被標(biāo)記為Offscreen rendering
-
mask,我們知道m(xù)ask是應(yīng)用在layer和其所有子layer的合成結(jié)果之上的,而且可能帶有透明度,那么其實(shí)和group opacity的原理類似,不得不在離屏渲染中完成。
WWDC中蘋果的解釋,mask需要遍歷至少三次 -
UIBlurEffect,同樣無法通過一次遍歷完成,其原理在WWDC中提到:
render8.png 其他還有一些,類似allowsEdgeAntialiasing等等也可能會(huì)觸發(fā)離屏渲染,原理也都是類似:如果你無法僅僅使用frame buffer來畫出最終結(jié)果,那就只能另開一塊內(nèi)存空間來儲(chǔ)存中間結(jié)果。這些原理并不神秘。
GPU離屏渲染的性能影響
GPU的操作是高度流水線化的。本來所有計(jì)算工作都在有條不紊地正在向frame buffer輸出,此時(shí)突然收到指令,需要輸出到另一塊內(nèi)存,那么流水線中正在進(jìn)行的一切都不得不被丟棄,切換到只能服務(wù)于我們當(dāng)前的“切圓角”操作。等到完成以后再次清空,再回到向frame buffer輸出的正常流程。
在tableView或者collectionView中,滾動(dòng)的每一幀變化都會(huì)觸發(fā)每個(gè)cell的重新繪制,因此一旦存在離屏渲染,上面提到的上下文切換就會(huì)每秒發(fā)生60次,并且很可能每一幀有幾十張的圖片要求這么做,對(duì)于GPU的性能沖擊可想而知(GPU非常擅長(zhǎng)大規(guī)模并行計(jì)算,但是我想頻繁的上下文切換顯然不在其設(shè)計(jì)考量之中)

善用離屏渲染
盡管離屏渲染開銷很大,但是當(dāng)我們無法避免它的時(shí)候,可以想辦法把性能影響降到最低。優(yōu)化思路也很簡(jiǎn)單:既然已經(jīng)花了不少精力把圖片裁出了圓角,如果我能把結(jié)果緩存下來,那么下一幀渲染就可以復(fù)用這個(gè)成果,不需要再重新畫一遍了。
CALayer為這個(gè)方案提供了對(duì)應(yīng)的解法:shouldRasterize。一旦被設(shè)置為true,Render Server就會(huì)強(qiáng)制把layer的渲染結(jié)果(包括其子layer,以及圓角、陰影、group opacity等等)保存在一塊內(nèi)存中,這樣一來在下一幀仍然可以被復(fù)用,而不會(huì)再次觸發(fā)離屏渲染。有幾個(gè)需要注意的點(diǎn):
- shouldRasterize的主旨在于降低性能損失,但總是至少會(huì)觸發(fā)一次離屏渲染。如果你的layer本來并不復(fù)雜,也沒有圓角陰影等等,打開這個(gè)開關(guān)反而會(huì)增加一次不必要的離屏渲染
- 離屏渲染緩存有空間上限,最多不超過屏幕總像素的2.5倍大小
- 一旦緩存超過100ms沒有被使用,會(huì)自動(dòng)被丟棄
- layer的內(nèi)容(包括子layer)必須是靜態(tài)的,因?yàn)橐坏┌l(fā)生變化(如resize,動(dòng)畫),之前辛苦處理得到的緩存就失效了。如果這件事頻繁發(fā)生,我們就又回到了“每一幀都需要離屏渲染”的情景,而這正是開發(fā)者需要極力避免的。針對(duì)這種情況,Xcode提供了“Color Hits Green and Misses Red”的選項(xiàng),幫助我們查看緩存的使用是否符合預(yù)期
- 其實(shí)除了解決多次離屏渲染的開銷,shouldRasterize在另一個(gè)場(chǎng)景中也可以使用:如果layer的子結(jié)構(gòu)非常復(fù)雜,渲染一次所需時(shí)間較長(zhǎng),同樣可以打開這個(gè)開關(guān),把layer繪制到一塊緩存,然后在接下來復(fù)用這個(gè)結(jié)果,這樣就不需要每次都重新繪制整個(gè)layer樹了
什么時(shí)候需要CPU渲染
渲染性能的調(diào)優(yōu),其實(shí)始終是在做一件事:平衡CPU和GPU的負(fù)載,讓他們盡量做各自最擅長(zhǎng)的工作。

絕大多數(shù)情況下,得益于GPU針對(duì)圖形處理的優(yōu)化,我們都會(huì)傾向于讓GPU來完成渲染任務(wù),而給CPU留出足夠時(shí)間處理各種各樣復(fù)雜的App邏輯。為此Core Animation做了大量的工作,盡量把渲染工作轉(zhuǎn)換成適合GPU處理的形式(也就是所謂的硬件加速,如layer composition,設(shè)置backgroundColor等等)。
但是對(duì)于一些情況,如文字(CoreText使用CoreGraphics渲染)和圖片(ImageIO)渲染,由于GPU并不擅長(zhǎng)做這些工作,不得不先由CPU來處理好以后,再把結(jié)果作為texture傳給GPU。除此以外,有時(shí)候也會(huì)遇到GPU實(shí)在忙不過來的情況,而CPU相對(duì)空閑(GPU瓶頸),這時(shí)可以讓CPU分擔(dān)一部分工作,提高整體效率。

一個(gè)典型的例子是,我們經(jīng)常會(huì)使用CoreGraphics給圖片加上圓角(將圖片中圓角以外的部分渲染成透明)。整個(gè)過程全部是由CPU完成的。這樣一來既然我們已經(jīng)得到了想要的效果,就不需要再另外給圖片容器設(shè)置cornerRadius。另一個(gè)好處是,我們可以靈活地控制裁剪和緩存的時(shí)機(jī),巧妙避開CPU和GPU最繁忙的時(shí)段,達(dá)到平滑性能波動(dòng)的目的。
這里有幾個(gè)需要注意的點(diǎn):
- 渲染不是CPU的強(qiáng)項(xiàng),調(diào)用CoreGraphics會(huì)消耗其相當(dāng)一部分計(jì)算時(shí)間,并且我們也不愿意因此阻塞用戶操作,因此一般來說CPU渲染都在后臺(tái)線程完成(這也是AsyncDisplayKit的主要思想),然后再回到主線程上,把渲染結(jié)果傳回CoreAnimation。這樣一來,多線程間數(shù)據(jù)同步會(huì)增加一定的復(fù)雜度
- 同樣因?yàn)镃PU渲染速度不夠快,因此只適合渲染靜態(tài)的元素,如文字、圖片(想象一下沒有硬件加速的視頻解碼,性能慘不忍睹)
- 作為渲染結(jié)果的bitmap數(shù)據(jù)量較大(形式上一般為解碼后的UIImage),消耗內(nèi)存較多,所以應(yīng)該在使用完及時(shí)釋放,并在需要的時(shí)候重新生成,否則很容易導(dǎo)致OOM
- 如果你選擇使用CPU來做渲染,那么就沒有理由再觸發(fā)GPU的離屏渲染了,否則會(huì)同時(shí)存在兩塊內(nèi)容相同的內(nèi)存,而且CPU和GPU都會(huì)比較辛苦
- 一定要使用Instruments的不同工具來測(cè)試性能,而不是僅憑猜測(cè)來做決定
即刻的優(yōu)化
由于在iOS10之后,系統(tǒng)的設(shè)計(jì)風(fēng)格慢慢從扁平化轉(zhuǎn)變成圓角卡片,即刻的設(shè)計(jì)風(fēng)格也隨之發(fā)生變化,加入了大量圓角與陰影效果,如果在處理上稍有不慎,就很容易觸發(fā)離屏渲染。為此我們采取了以下一些措施:
- 即刻大量應(yīng)用AsyncDisplayKit(Texture)作為主要渲染框架,對(duì)于文字和圖片的異步渲染操作交由框架來處理。關(guān)于這方面可以看我之前的一些介紹
- 對(duì)于圖片的圓角,統(tǒng)一采用“precomposite”的策略,也就是不經(jīng)由容器來做剪切,而是預(yù)先使用CoreGraphics為圖片裁剪圓角
- 對(duì)于視頻的圓角,由于實(shí)時(shí)剪切非常消耗性能,我們會(huì)創(chuàng)建四個(gè)白色弧形的layer蓋住四個(gè)角,從視覺上制造圓角的效果
- 對(duì)于view的圓形邊框,如果沒有backgroundColor,可以放心使用cornerRadius來做
- 對(duì)于所有的陰影,使用shadowPath來規(guī)避離屏渲染
- 對(duì)于特殊形狀的view,使用layer mask并打開shouldRasterize來對(duì)渲染結(jié)果進(jìn)行緩存
-
對(duì)于模糊效果,不采用系統(tǒng)提供的UIVisualEffect,而是另外實(shí)現(xiàn)模糊效果(CIGaussianBlur),并手動(dòng)管理渲染結(jié)果
客戶端中有大量的圓角、陰影等效果
總結(jié)
- CPU渲染雖然也是“離屏”,但是通常提到的離屏渲染是發(fā)生在GPU
- 如果一個(gè)layer無法在一次遍歷就完成繪制,那么就不得不觸發(fā)離屏渲染
- 離屏渲染的開銷主要在與frame buffer與離屏buffer之間的上下文切換。如果無法避免,也可以通過有效利用shouldRasterize,減少觸發(fā)的次數(shù)
- CPU和GPU是相互扶持的關(guān)系。CPU渲染效率不高,但是較為通用靈活;GPU擅長(zhǎng)并行計(jì)算,但也有捉襟見肘之時(shí),此時(shí)CPU可以適當(dāng)給與幫助
離屏渲染牽涉了很多Core Animation、GPU和圖形學(xué)等等方面的知識(shí),在實(shí)踐中也非常考驗(yàn)一個(gè)工程師排查問題的基本功、經(jīng)驗(yàn)和判斷能力——如果在不恰當(dāng)?shù)臅r(shí)候打開了shouldRasterize,只會(huì)弄巧成拙。
從一個(gè)更廣闊的視角看,離屏渲染也僅僅是渲染性能優(yōu)化中的一部分,而能否保證UI性能過關(guān),將會(huì)直接影響到用戶日常的操作體驗(yàn)。渲染技術(shù)作為客戶端工程師的關(guān)鍵技術(shù)能力之一,值得持續(xù)研究。
推薦資料
Objc.io: Moving Pixels onto the Screen
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





