一、什么是離屏渲染?
在上一篇中對圖像是如何顯示到屏幕上有了詳細(xì)的解讀 傳送門,這里在簡單回顧下:

主要有以下三步:
- CPU計(jì)算需要顯示的內(nèi)容,然后通過數(shù)據(jù)總線傳給GPU
- GPU拿到數(shù)據(jù)之后開始渲染數(shù)據(jù)并保存在幀緩存區(qū)中
- 隨后視頻控制器會按照
VSync信號逐行讀取幀緩沖區(qū)的數(shù)據(jù),經(jīng)過可能的數(shù)模轉(zhuǎn)換傳遞給顯示器顯示。
iOS 高級圖形和渲染架構(gòu):
在WWDC的Advanced Graphics and Animations for iOS Apps中有一個非常重要的圖:

我們可以看到,在Application這一層中主要是CPU在操作,而到了Render Server這一層,CoreAnimation會將具體操作轉(zhuǎn)換成發(fā)送給GPU的draw calls(以前是OpenGL ES,現(xiàn)在慢慢轉(zhuǎn)到了Metal),顯然CPU和GPU雙方同處于一個流水線中,協(xié)作完成整個渲染工作。
屏幕渲染的兩種方式:
On-Screen Rendering: 即當(dāng)前屏幕渲染,指的是GPU的渲染操作是在當(dāng)前用于顯示的屏幕緩沖區(qū)中進(jìn)行。當(dāng)前屏幕渲染顯示都是直接從幀緩存區(qū)中讀取數(shù)據(jù)然后直接顯示。-
Off-Screen Rendering意為離屏渲染,指的是GPU在當(dāng)前屏幕緩沖區(qū)以外新開辟一個緩沖區(qū)(離屏緩存區(qū))進(jìn)行渲染操作。等所有數(shù)據(jù)都在離屏渲染區(qū)完成渲染后才會提交到幀緩存區(qū),然后再被顯示。
image.png
CPU渲染:
如果我們重寫了drawRect方法,并且使用任何Core Graphics的技術(shù)進(jìn)行了繪制操作,就涉及到了CPU渲染。整個渲染過程由CPU在App內(nèi) 同步地完成,渲染得到的bitmap最后再交由GPU用于顯示。
CoreGraphic通常是線程安全的,所以可以進(jìn)行異步繪制,顯示的時(shí)候再放回主線程,一個簡單的異步繪制過程大致如下:
-(void)display {
dispatch_async(backgroundQueue, ^{
CGContextRef ctx = CGBitmapContextCreate(...); // draw in context...
CGImageRef img = CGBitmapContextCreateImage(ctx);
CFRelease(ctx);
dispatch_async(mainQueue, ^{
layer.contents = img;
});
});
}
但是根據(jù)蘋果工程師的說法,CPU渲染并非真正意義上的離屏渲染。如果你的view實(shí)現(xiàn)drawRect,此時(shí)打開Xcode調(diào)試的Color offscreen rendered yellow開關(guān),你會發(fā)現(xiàn)這片區(qū)域不會被標(biāo)記為黃色,說明Xcode并不認(rèn)為這屬于離屏渲染。
其實(shí)通過CPU渲染就是俗稱的軟件渲染,而真正的離屏渲染發(fā)生在GPU。
GPU離屏渲染
在上面的渲染流水線示意圖中我們可以看到,主要的渲染操作都是由CoreAnimation的Render Server模塊,通過調(diào)用顯卡驅(qū)動所提供的OpenGL/Metal接口來執(zhí)行的。通常對于每一層layer,Render Server會遵循“畫家算法”,按次序輸出到frame buffer,后一層覆蓋前一層,就能得到最終的顯示結(jié)果(值得一提的是,與一般桌面架構(gòu)不同,在iOS中,設(shè)備主存和GPU的顯存共享物理內(nèi)存,這樣可以省去一些數(shù)據(jù)傳輸開銷)。

然而有些場景并沒有那么簡單。作為“畫家”的GPU雖然可以一層一層往畫布上進(jìn)行輸出,但是無法在某一層渲染完成之后,再回過頭來擦除/改變其中的某個部分——因?yàn)樵谶@一層之前的若干層layer像素?cái)?shù)據(jù),已經(jīng)在渲染中被永久覆蓋了。這就意味著,對于每一層layer,要么能找到一種通過單次遍歷就能完成渲染的算法,要么就不得不另開一塊內(nèi)存,借助這個臨時(shí)中轉(zhuǎn)區(qū)域來完成一些更復(fù)雜的、多次的修改/剪裁操作。
如果要繪制一個帶有圓角并剪切圓角以外內(nèi)容的容器,就會觸發(fā)離屏渲染。其流程如下:
- 開辟一塊獨(dú)立于
frame buffer的空白內(nèi)存, - 把容器以及其所有子
layer依次畫好 - 把四個角“剪”成圓形
- 把結(jié)果畫到
frame buffer
二、觸發(fā)離屏渲染的常見場景
1.需要進(jìn)行裁剪的 layer
使用圓角時(shí)候搭配使用layer.masksToBounds / view.clipsToBounds:
下圖為CALayer的結(jié)構(gòu)圖:

如果你只是使用layer.cornerRadius:
layer.cornerRadius = 4;
只會設(shè)置backguroundColor和border的圓角,不會設(shè)置content的圓角,除非同時(shí)設(shè)置layer.masksToBounds或者view.clipsToBounds:
//二者效果相同,使用任意一個即可
layer.masksToBounds = YES;
view.clipsToBounds = YES;
這個時(shí)候才會觸發(fā)離屏渲染,所以并不是多個層級就一定會觸發(fā)離屏渲染。
2.設(shè)置了group opacity(組透明度)
開啟組透明度,并且透明度不為 1 的layer(layer.allowsGroupOpacity/ layer.opacity);
其實(shí)從名字就可以猜到,alpha并不是分別應(yīng)用在每一層之上,而是只有到整個layer樹畫完之后,再統(tǒng)一加上alpha,最后和底下其他layer的像素進(jìn)行組合。顯然也無法通過一次遍歷就得到最終結(jié)果。
3.使用了mask的layer(layer.mask);
和group opacity的原理類似,在使用 mask的時(shí)候,會分別繪制mask的內(nèi)容和layer的內(nèi)容并放入離屏緩存區(qū),然后將二者混合給幀緩存區(qū),然后再進(jìn)行顯示。
WWDC中蘋果的解釋,mask需要遍歷至少三次。

4.添加了投影的layer (layer.shadow);
其原因在于,雖然layer本身是一塊矩形區(qū)域,但是陰影默認(rèn)是作用在其中”非透明區(qū)域“的,而且需要顯示在所有layer內(nèi)容的下方,因此根據(jù)畫家算法必須被渲染在先。但矛盾在于此時(shí)陰影的本體(layer和其子layer)都還沒有被組合到一起,怎么可能在第一步就畫出只有完成最后一步之后才能知道的形狀呢?這樣一來又只能另外申請一塊內(nèi)存,把本體內(nèi)容都先畫好,再根據(jù)渲染結(jié)果的形狀,添加陰影到frame buffer,最后把內(nèi)容畫上去(這只是我的猜測,實(shí)際情況可能更復(fù)雜)。不過如果我們能夠預(yù)先告訴CoreAnimation(通過shadowPath屬性)陰影的幾何形狀,那么陰影當(dāng)然可以先被獨(dú)立渲染出來,不需要依賴layer本體,也就不再需要離屏渲染了。

5. UIBlurEffect
同樣無法通過一次遍歷完成,其原理在WWDC中提到:

6.采用了光柵化的layer (layer.shouldRasterize):
在蘋果官方文檔中對layer.shouldRasterize有如下解釋:
When the value of this property is
YES, the layer is rendered as a bitmap in its local coordinate space and then composited to the destination with any other content.
意思簡單理解就是說,當(dāng)開啟這個屬性后, 這個layer會渲染成位圖存起來,需要等所有的要顯示的內(nèi)容全部渲染完畢后,再混合才能夠顯示到屏幕上。那layer的位圖和其它渲染完成的內(nèi)容放在哪里呢?只能放在離屏緩存區(qū)中了,所以光柵化也會觸發(fā)離屏渲染。
layer.shouldRasterize使用建議如下:
- 如果layer不能被復(fù)用,則沒有必要打開光柵化;
- 如果layer不是靜態(tài)的,需要被頻繁修改,比如處于動畫之中。這樣的情況下開啟光柵化反而影響效率。在比如:我們?nèi)粘探?jīng)常打交道的
TableViewCell,因?yàn)?code>TableViewCell的重繪是很頻繁的(因?yàn)?code>Cell的復(fù)用),如果Cell的內(nèi)容不斷變化,則Cell需要不斷重繪,如果此時(shí)設(shè)置了cell.layer可光柵化。則會造成大量的離屏渲染,降低圖形性能。 - 離屏渲染緩存的內(nèi)容是有時(shí)間限制的,緩存的內(nèi)容
100ms內(nèi)如果沒有使用,那么它就會被丟棄,無法進(jìn)行復(fù)用了; - 離屏渲染的緩存空間有限,超過屏幕像素大小
2.5倍的話,也會失效,且無法進(jìn)行復(fù)用了。
7.繪制了文字的layer (UILabel, CATextLayer, Core Text 等)
三、GPU離屏渲染的性能影響
相比于當(dāng)前屏幕渲染,離屏渲染的代價(jià)是很高的,主要體現(xiàn)在兩個方面:
- 創(chuàng)建新緩沖區(qū):要想進(jìn)行離屏渲染,首先要創(chuàng)建一個新的緩沖區(qū)。
- 上下文切換:離屏渲染的整個過程,需要多次切換上下文環(huán)境:先是從當(dāng)前屏幕(
On-Screen)切換到離屏(Off-Screen);等到離屏渲染結(jié)束以后,將離屏緩沖區(qū)的渲染結(jié)果顯示到屏幕上有需要將上下文環(huán)境從離屏切換到當(dāng)前屏幕。而上下文環(huán)境的切換是要付出很大代價(jià)的。
GPU的操作是高度流水線化的。本來所有計(jì)算工作都在有條不紊地正在向frame buffer輸出,此時(shí)突然收到指令,需要輸出到另一塊內(nèi)存,那么流水線中正在進(jìn)行的一切都不得不被丟棄,切換到只能服務(wù)于我們當(dāng)前的“切圓角”操作。等到完成以后再次清空,再回到向frame buffer輸出的正常流程。
在tableView或者collectionView中,滾動的每一幀變化都會觸發(fā)每個cell的重新繪制,因此一旦存在離屏渲染,上面提到的上下文切換就會每秒發(fā)生60次,并且很可能每一幀有幾十張的圖片要求這么做,對于GPU的性能沖擊可想而知(GPU非常擅長大規(guī)模并行計(jì)算,但是我想頻繁的上下文切換顯然不在其設(shè)計(jì)考量之中)
四、善用離屏渲染:
盡管離屏渲染開銷很大,但是部分場景仍然需要使用。當(dāng)我們無法避免它的時(shí)候,可以想辦法把性能影響降到最低。
針對部分常見場景,可以使用以下方式嘗試解決:
- 對于圖片的圓角,使用
CoreGraphics為圖片裁剪圓角,而不是由當(dāng)前的容器去執(zhí)行裁剪; - 對于視頻的圓角,可以使用四個白色弧形的layer后者UI切圖蓋住四個角,從視覺上制造圓角的效果;
- 對于View的圓形邊框,如果沒有
BackgroundColor,可以放心使用CornerRadius來做 - 對于所有的陰影,使用
shadowPath來規(guī)避離屏渲染 - 對于特殊形狀的View,使用
layer mask并打開shouldRasterize來對渲染結(jié)果進(jìn)行緩存 - 對于模糊效果,不采用系統(tǒng)提供的
UIVisualEffect,而是另外實(shí)現(xiàn)模糊效果(CIGaussianBlur)
