iOS Rendering 渲染全解析(長文乾貨)

来源:https://www.cnblogs.com/simplepp/archive/2020/05/19/12916972.html

希望通過這篇文章從頭到尾梳理一下 iOS 中涉及到渲染原理相關的內容,會先從電腦渲染原理講起,慢慢說道 iOS 的渲染原理和框架,最後再深入探討一下離屏渲染。 希望能對大家有點幫助~ 1. 電腦渲染原理 CPU 與 GPU 的架構 對於現代電腦系統,簡單來說可以大概視作三層架構:硬體、操作系統 ...


希望通過這篇文章從頭到尾梳理一下 iOS 中涉及到渲染原理相關的內容,會先從電腦渲染原理講起,慢慢說道 iOS 的渲染原理和框架,最後再深入探討一下離屏渲染。

希望能對大家有點幫助~

 

1. 電腦渲染原理

CPU 與 GPU 的架構

對於現代電腦系統,簡單來說可以大概視作三層架構:硬體、操作系統與進程。對於移動端來說,進程就是 app,而 CPU 與 GPU 是硬體層面的重要組成部分。CPU 與 GPU 提供了計算能力,通過操作系統被 app 調用。

 
  • CPU(Central Processing Unit):現代電腦整個系統的運算核心、控制核心。
  • GPU(Graphics Processing Unit):可進行繪圖運算工作的專用微處理器,是連接電腦和顯示終端的紐帶。

CPU 和 GPU 其設計目標就是不同的,它們分別針對了兩種不同的應用場景。CPU 是運算核心與控制核心,需要有很強的運算通用性,相容各種數據類型,同時也需要能處理大量不同的跳轉、中斷等指令,因此 CPU 的內部結構更為複雜。而 GPU 則面對的是類型統一、更加單純的運算,也不需要處理複雜的指令,但也肩負著更大的運算任務。

 

因此,CPU 與 GPU 的架構也不同。因為 CPU 面臨的情況更加複雜,因此從上圖中也可以看出,CPU 擁有更多的緩存空間 Cache 以及複雜的控制單元,計算能力並不是 CPU 的主要訴求。CPU 是設計目標是低時延,更多的高速緩存也意味著可以更快地訪問數據;同時複雜的控制單元也能更快速地處理邏輯分支,更適合串列計算。

而 GPU 擁有更多的計算單元 Arithmetic Logic Unit,具有更強的計算能力,同時也具有更多的控制單元。GPU 基於大吞吐量而設計,每一部分緩存都連接著一個流處理器(stream processor),更加適合大規模的並行計算。

圖像渲染流水線

圖像渲染流程粗粒度地大概分為下麵這些步驟:

 

上述圖像渲染流水線中,除了第一部分 Application 階段,後續主要都由 GPU 負責,為了方便後文講解,先將 GPU 的渲染流程圖展示出來:

 

上圖就是一個三角形被渲染的過程中,GPU 所負責的渲染流水線。可以看到簡單的三角形繪製就需要大量的計算,如果再有更多更複雜的頂點、顏色、紋理信息(包括 3D 紋理),那麼計算量是難以想象的。這也是為什麼 GPU 更適合於渲染流程。

接下來,具體講解渲染流水線中各個部分的具體任務:

Application 應用處理階段:得到圖元

這個階段具體指的就是圖像在應用中被處理的階段,此時還處於 CPU 負責的時期。在這個階段應用可能會對圖像進行一系列的操作或者改變,最終將新的圖像信息傳給下一階段。這部分信息被叫做圖元(primitives),通常是三角形、線段、頂點等。

Geometry 幾何處理階段:處理圖元

進入這個階段之後,以及之後的階段,就都主要由 GPU 負責了。此時 GPU 可以拿到上一個階段傳遞下來的圖元信息,GPU 會對這部分圖元進行處理,之後輸出新的圖元。這一系列階段包括:

  • 頂點著色器(Vertex Shader):這個階段中會將圖元中的頂點信息進行視角轉換、添加光照信息、增加紋理等操作。
  • 形狀裝配(Shape Assembly):圖元中的三角形、線段、點分別對應三個 Vertex、兩個 Vertex、一個 Vertex。這個階段會將 Vertex 連接成相對應的形狀。
  • 幾何著色器(Geometry Shader):額外添加額外的Vertex,將原始圖元轉換成新圖元,以構建一個不一樣的模型。簡單來說就是基於通過三角形、線段和點構建更複雜的幾何圖形。

Rasterization 光柵化階段:圖元轉換為像素

光柵化的主要目的是將幾何渲染之後的圖元信息,轉換為一系列的像素,以便後續顯示在屏幕上。這個階段中會根據圖元信息,計算出每個圖元所覆蓋的像素信息等,從而將像素劃分成不同的部分。

 

一種簡單的劃分就是根據中心點,如果像素的中心點在圖元內部,那麼這個像素就屬於這個圖元。如上圖所示,深藍色的線就是圖元信息所構建出的三角形;而通過是否覆蓋中心點,可以遍歷出所有屬於該圖元的所有像素,即淺藍色部分。

Pixel 像素處理階段:處理像素,得到點陣圖

經過上述光柵化階段,我們得到了圖元所對應的像素,此時,我們需要給這些像素填充顏色和效果。所以最後這個階段就是給像素填充正確的內容,最終顯示在屏幕上。這些經過處理、蘊含大量信息的像素點集合,被稱作點陣圖(bitmap)。也就是說,Pixel 階段最終輸出的結果就是點陣圖,過程具體包含:

這些點可以進行不同的排列和染色以構成圖樣。當放大點陣圖時,可以看見賴以構成整個圖像的無數單個方塊。只要有足夠多的不同色彩的像素,就可以製作出色彩豐富的圖象,逼真地表現自然界的景象。縮放和旋轉容易失真,同時文件容量較大。

  • 片段著色器(Fragment Shader):也叫做 Pixel Shader,這個階段的目的是給每一個像素 Pixel 賦予正確的顏色。顏色的來源就是之前得到的頂點、紋理、光照等信息。由於需要處理紋理、光照等複雜信息,所以這通常是整個系統的性能瓶頸。
  • 測試與混合(Tests and Blending):也叫做 Merging 階段,這個階段主要處理片段的前後位置以及透明度。這個階段會檢測各個著色片段的深度值 z 坐標,從而判斷片段的前後位置,以及是否應該被捨棄。同時也會計算相應的透明度 alpha 值,從而進行片段的混合,得到最終的顏色。

2. 屏幕成像與卡頓

在圖像渲染流程結束之後,接下來就需要將得到的像素信息顯示在物理屏幕上了。GPU 最後一步渲染結束之後像素信息,被存在幀緩衝器(Framebuffer)中,之後視頻控制器(Video Controller)會讀取幀緩衝器中的信息,經過數模轉換傳遞給顯示器(Monitor),進行顯示。完整的流程如下圖所示:

 

經過 GPU 處理之後的像素集合,也就是點陣圖,會被幀緩衝器緩存起來,供之後的顯示使用。顯示器的電子束會從屏幕的左上角開始逐行掃描,屏幕上的每個點的圖像信息都從幀緩衝器中的點陣圖進行讀取,在屏幕上對應地顯示。掃描的流程如下圖所示:

 

電子束掃描的過程中,屏幕就能呈現出對應的結果,每次整個屏幕被掃描完一次後,就相當於呈現了一幀完整的圖像。屏幕不斷地刷新,不停呈現新的幀,就能呈現出連續的影像。而這個屏幕刷新的頻率,就是幀率(Frame per Second,FPS)。由於人眼的視覺暫留效應,當屏幕刷新頻率足夠高時(FPS 通常是 50 到 60 左右),就能讓畫面看起來是連續而流暢的。對於 iOS 而言,app 應該儘量保證 60 FPS 才是最好的體驗。

屏幕撕裂 Screen Tearing

在這種單一緩存的模式下,最理想的情況就是一個流暢的流水線:每次電子束從頭開始新的一幀的掃描時,CPU+GPU 對於該幀的渲染流程已經結束,渲染好的點陣圖已經放入幀緩衝器中。但這種完美的情況是非常脆弱的,很容易產生屏幕撕裂:

 

CPU+GPU 的渲染流程是一個非常耗時的過程。如果在電子束開始掃描新的一幀時,點陣圖還沒有渲染好,而是在掃描到屏幕中間時才渲染完成,被放入幀緩衝器中 ---- 那麼已掃描的部分就是上一幀的畫面,而未掃描的部分則會顯示新的一幀圖像,這就造成屏幕撕裂。

垂直同步 Vsync + 雙緩衝機制 Double Buffering

解決屏幕撕裂、提高顯示效率的一個策略就是使用垂直同步信號 Vsync 與雙緩衝機制 Double Buffering。根據蘋果的官方文檔描述,iOS 設備會始終使用 Vsync + Double Buffering 的策略。

垂直同步信號(vertical synchronisation,Vsync)相當於給幀緩衝器加鎖:當電子束完成一幀的掃描,將要從頭開始掃描時,就會發出一個垂直同步信號。只有當視頻控制器接收到 Vsync 之後,才會將幀緩衝器中的點陣圖更新為下一幀,這樣就能保證每次顯示的都是同一幀的畫面,因而避免了屏幕撕裂。

但是這種情況下,視頻控制器在接受到 Vsync 之後,就要將下一幀的點陣圖傳入,這意味著整個 CPU+GPU 的渲染流程都要在一瞬間完成,這是明顯不現實的。所以雙緩衝機制會增加一個新的備用緩衝器(back buffer)。渲染結果會預先保存在 back buffer 中,在接收到 Vsync 信號的時候,視頻控制器會將 back buffer 中的內容置換到 frame buffer 中,此時就能保證置換操作幾乎在一瞬間完成(實際上是交換了記憶體地址)。

 

掉幀 Jank

啟用 Vsync 信號以及雙緩衝機制之後,能夠解決屏幕撕裂的問題,但是會引入新的問題:掉幀。如果在接收到 Vsync 之時 CPU 和 GPU 還沒有渲染好新的點陣圖,視頻控制器就不會去替換 frame buffer 中的點陣圖。這時屏幕就會重新掃描呈現出上一幀一模一樣的畫面。相當於兩個周期顯示了同樣的畫面,這就是所謂掉幀的情況。

 

如圖所示,A、B 代表兩個幀緩衝器,當 B 沒有渲染完畢時就接收到了 Vsync 信號,所以屏幕只能再顯示相同幀 A,這就發生了第一次的掉幀。

三緩衝 Triple Buffering

事實上上述策略還有優化空間。我們註意到在發生掉幀的時候,CPU 和 GPU 有一段時間處於閑置狀態:當 A 的內容正在被掃描顯示在屏幕上,而 B 的內容已經被渲染好,此時 CPU 和 GPU 就處於閑置狀態。那麼如果我們增加一個幀緩衝器,就可以利用這段時間進行下一步的渲染,並將渲染結果暫存於新增的幀緩衝器中。

 

如圖所示,由於增加了新的幀緩衝器,可以一定程度上地利用掉幀的空檔期,合理利用 CPU 和 GPU 性能,從而減少掉幀的次數。

屏幕卡頓的本質

手機使用卡頓的直接原因,就是掉幀。前文也說過,屏幕刷新頻率必須要足夠高才能流暢。對於 iPhone 手機來說,屏幕最大的刷新頻率是 60 FPS,一般只要保證 50 FPS 就已經是較好的體驗了。但是如果掉幀過多,導致刷新頻率過低,就會造成不流暢的使用體驗。

這樣看來,可以大概總結一下

  • 屏幕卡頓的根本原因:CPU 和 GPU 渲染流水線耗時過長,導致掉幀。
  • Vsync 與雙緩衝的意義:強制同步屏幕刷新,以掉幀為代價解決屏幕撕裂問題。
  • 三緩衝的意義:合理使用 CPU、GPU 渲染性能,減少掉幀次數。

3. iOS 中的渲染框架

 

iOS 的渲染框架依然符合渲染流水線的基本架構,具體的技術棧如上圖所示。在硬體基礎之上,iOS 中有 Core Graphics、Core Animation、Core Image、OpenGL 等多種軟體框架來繪製內容,在 CPU 與 GPU 之間進行了更高層地封裝。

GPU Driver:上述軟體框架相互之間也有著依賴關係,不過所有框架最終都會通過 OpenGL 連接到 GPU Driver,GPU Driver 是直接和 GPU 交流的代碼塊,直接與 GPU 連接。

OpenGL:是一個提供了 2D 和 3D 圖形渲染的 API,它能和 GPU 密切的配合,最高效地利用 GPU 的能力,實現硬體加速渲染。OpenGL的高效實現(利用了圖形加速硬體)一般由顯示設備廠商提供,而且非常依賴於該廠商提供的硬體。OpenGL 之上擴展出很多東西,如 Core Graphics 等最終都依賴於 OpenGL,有些情況下為了更高的效率,比如游戲程式,甚至會直接調用 OpenGL 的介面。

Core Graphics:Core Graphics 是一個強大的二維圖像繪製引擎,是 iOS 的核心圖形庫,常用的比如 CGRect 就定義在這個框架下。

Core Animation:在 iOS 上,幾乎所有的東西都是通過 Core Animation 繪製出來,它的自由度更高,使用範圍也更廣。

Core Image:Core Image 是一個高性能的圖像處理分析的框架,它擁有一系列現成的圖像濾鏡,能對已存在的圖像進行高效的處理。

Metal:Metal 類似於 OpenGL ES,也是一套第三方標準,具體實現由蘋果實現。Core Animation、Core Image、SceneKit、SpriteKit 等等渲染框架都是構建於 Metal 之上的。

Core Animation 是什麼

Render, compose, and animate visual elements. ---- Apple

Core Animation,它本質上可以理解為一個複合引擎,主要職責包含:渲染、構建和實現動畫。

通常我們會使用 Core Animation 來高效、方便地實現動畫,但是實際上它的前身叫做 Layer Kit,關於動畫實現只是它功能中的一部分。對於 iOS app,不論是否直接使用了 Core Animation,它都在底層深度參與了 app 的構建。而對於 OS X app,也可以通過使用 Core Animation 方便地實現部分功能。

 

Core Animation 是 AppKit 和 UIKit 完美的底層支持,同時也被整合進入 Cocoa 和 Cocoa Touch 的工作流之中,它是 app 界面渲染和構建的最基礎架構。 Core Animation 的職責就是儘可能快地組合屏幕上不同的可視內容,這個內容是被分解成獨立的 layer(iOS 中具體而言就是 CALayer),並且被存儲為樹狀層級結構。這個樹也形成了 UIKit 以及在 iOS 應用程式當中你所能在屏幕上看見的一切的基礎。

簡單來說就是用戶能看到的屏幕上的內容都由 CALayer 進行管理。那麼 CALayer 究竟是如何進行管理的呢?另外在 iOS 開發過程中,最大量使用的視圖控制項實際上是 UIView 而不是 CALayer,那麼他們兩者的關係到底如何呢?

CALayer 是顯示的基礎:存儲 bitmap

簡單理解,CALayer 就是屏幕顯示的基礎。那 CALayer 是如何完成的呢?讓我們來從源碼向下探索一下,在 CALayer.h 中,CALayer 有這樣一個屬性 contents:

/
/** Layer content properties and methods. **/

/* An object providing the contents of the layer, typically a CGImageRef,
 * but may be something else. (For example, NSImage objects are
 * supported on Mac OS X 10.6 and later.) Default value is nil.
 * Animatable. */

@property(nullable, strong) id contents;

An object providing the contents of the layer, typically a CGImageRef.

contents 提供了 layer 的內容,是一個指針類型,在 iOS 中的類型就是 CGImageRef(在 OS X 中還可以是 NSImage)。而我們進一步查到,Apple 對 CGImageRef 的定義是:

A bitmap image or image mask.

看到 bitmap,這下我們就可以和之前講的的渲染流水線聯繫起來了:實際上,CALayer 中的 contents 屬性保存了由設備渲染流水線渲染好的點陣圖 bitmap(通常也被稱為 backing store),而當設備屏幕進行刷新時,會從 CALayer 中讀取生成好的 bitmap,進而呈現到屏幕上。

所以,如果我們在代碼中對 CALayer 的 contents 屬性進行了設置,比如這樣:

// 註意 CGImage 和 CGImageRef 的關係:
// typedef struct CGImage CGImageRef;
layer.contents = (__bridge id)image.CGImage;

那麼在運行時,操作系統會調用底層的介面,將 image 通過 CPU+GPU 的渲染流水線渲染得到對應的 bitmap,存儲於 CALayer.contents 中,在設備屏幕進行刷新的時候就會讀取 bitmap 在屏幕上呈現。

也正因為每次要被渲染的內容是被靜態的存儲起來的,所以每次渲染時,Core Animation 會觸發調用 drawRect: 方法,使用存儲好的 bitmap 進行新一輪的展示。

CALayer 與 UIView 的關係

UIView 作為最常用的視圖控制項,和 CALayer 也有著千絲萬縷的聯繫,那麼兩者之間到底是個什麼關係,他們有什麼差異?

當然,兩者有很多顯性的區別,比如是否能夠響應點擊事件。但為了從根本上徹底搞懂這些問題,我們必須要先搞清楚兩者的職責。

UIView - Apple

Views are the fundamental building blocks of your app's user interface, and the UIView class defines the behaviors that are common to all views. A view object renders content within its bounds rectangle and handles any interactions with that content.

根據 Apple 的官方文檔,UIView 是 app 中的基本組成結構,定義了一些統一的規範。它會負責內容的渲染以及,處理交互事件。具體而言,它負責的事情可以歸為下麵三類

  • Drawing and animation:繪製與動畫
  • Layout and subview management:佈局與子 view 的管理
  • Event handling:點擊事件處理

CALayer - Apple

Layers are often used to provide the backing store for views but can also be used without a view to display content. A layer’s main job is to manage the visual content that you provide...

If the layer object was created by a view, the view typically assigns itself as the layer’s delegate automatically, and you should not change that relationship.

而從 CALayer 的官方文檔中我們可以看出,CALayer 的主要職責是管理內部的可視內容,這也和我們前文所講的內容吻合。當我們創建一個 UIView 的時候,UIView 會自動創建一個 CALayer,為自身提供存儲 bitmap 的地方(也就是前文說的 backing store),並將自身固定設置為 CALayer 的代理。

 

從這兒我們大概總結出下麵兩個核心關係:

  1. CALayer 是 UIView 的屬性之一,負責渲染和動畫,提供可視內容的呈現。
  2. UIView 提供了對 CALayer 部分功能的封裝,同時也另外負責了交互事件的處理。

有了這兩個最關鍵的根本關係,那麼下麵這些經常出現在面試答案里的顯性的異同就很好解釋了。舉幾個例子:

  • 相同的層級結構:我們對 UIView 的層級結構非常熟悉,由於每個 UIView 都對應 CALayer 負責頁面的繪製,所以 CALayer 也具有相應的層級結構。

  • 部分效果的設置:因為 UIView 只對 CALayer 的部分功能進行了封裝,而另一部分如圓角、陰影、邊框等特效都需要通過調用 layer 屬性來設置。

  • 是否響應點擊事件:CALayer 不負責點擊事件,所以不響應點擊事件,而 UIView 會響應。

  • 不同繼承關係:CALayer 繼承自 NSObject,UIView 由於要負責交互事件,所以繼承自 UIResponder。

當然還剩最後一個問題,為什麼要將 CALayer 獨立出來,直接使用 UIView 統一管理不行嗎?為什麼不用一個統一的對象來處理所有事情呢?

這樣設計的主要原因就是為了職責分離,拆分功能,方便代碼的復用。通過 Core Animation 框架來負責可視內容的呈現,這樣在 iOS 和 OS X 上都可以使用 Core Animation 進行渲染。與此同時,兩個系統還可以根據交互規則的不同來進一步封裝統一的控制項,比如 iOS 有 UIKit 和 UIView,OS X 則是AppKit 和 NSView。

4. Core Animation 渲染全內容

Core Animation Pipeline 渲染流水線

當我們瞭解了 Core Animation 以及 CALayer 的基本知識後,接下來我們來看下 Core Animation 的渲染流水線。

 

整個流水線一共有下麵幾個步驟:

Handle Events:這個過程中會先處理點擊事件,這個過程中有可能會需要改變頁面的佈局和界面層次。

Commit Transaction:此時 app 會通過 CPU 處理顯示內容的前置計算,比如佈局計算、圖片解碼等任務,接下來會進行詳細的講解。之後將計算好的圖層進行打包發給 Render Server

Decode:打包好的圖層被傳輸到 Render Server 之後,首先會進行解碼。註意完成解碼之後需要等待下一個 RunLoop 才會執行下一步 Draw Calls

Draw Calls:解碼完成後,Core Animation 會調用下層渲染框架(比如 OpenGL 或者 Metal)的方法進行繪製,進而調用到 GPU。

Render:這一階段主要由 GPU 進行渲染。

Display:顯示階段,需要等 render 結束的下一個 RunLoop 觸發顯示。

Commit Transaction 發生了什麼

一般開發當中能影響到的就是 Handle Events 和 Commit Transaction 這兩個階段,這也是開發者接觸最多的部分。Handle Events 就是處理觸摸事件,而 Commit Transaction 這部分中主要進行的是:Layout、Display、Prepare、Commit 等四個具體的操作。

Layout:構建視圖

這個階段主要處理視圖的構建和佈局,具體步驟包括:

  1. 調用重載的 layoutSubviews 方法
  2. 創建視圖,並通過 addSubview 方法添加子視圖
  3. 計算視圖佈局,即所有的 Layout Constraint

由於這個階段是在 CPU 中進行,通常是 CPU 限制或者 IO 限制,所以我們應該儘量高效輕量地操作,減少這部分的時間,比如減少非必要的視圖創建、簡化佈局計算、減少視圖層級等。

Display:繪製視圖

這個階段主要是交給 Core Graphics 進行視圖的繪製,註意不是真正的顯示,而是得到前文所說的圖元 primitives 數據:

  1. 根據上一階段 Layout 的結果創建得到圖元信息。
  2. 如果重寫了 drawRect: 方法,那麼會調用重載的 drawRect: 方法,在 drawRect: 方法中手動繪製得到 bitmap 數據,從而自定義視圖的繪製。

註意正常情況下 Display 階段只會得到圖元 primitives 信息,而點陣圖 bitmap 是在 GPU 中根據圖元信息繪製得到的。但是如果重寫了 drawRect: 方法,這個方法會直接調用 Core Graphics 繪製方法得到 bitmap 數據,同時系統會額外申請一塊記憶體,用於暫存繪製好的 bitmap。

由於重寫了 drawRect: 方法,導致繪製過程從 GPU 轉移到了 CPU,這就導致了一定的效率損失。與此同時,這個過程會額外使用 CPU 和記憶體,因此需要高效繪製,否則容易造成 CPU 卡頓或者記憶體爆炸。

Prepare:Core Animation 額外的工作

這一步主要是:圖片解碼和轉換

Commit:打包併發送

這一步主要是:圖層打包併發送到 Render Server。

註意 commit 操作是依賴圖層樹遞歸執行的,所以如果圖層樹過於複雜,commit 的開銷就會很大。這也是我們希望減少視圖層級,從而降低圖層樹複雜度的原因。

Rendering Pass: Render Server 的具體操作

 

Render Server 通常是 OpenGL 或者是 Metal。以 OpenGL 為例,那麼上圖主要是 GPU 中執行的操作,具體主要包括:

  1. GPU 收到 Command Buffer,包含圖元 primitives 信息
  2. Tiler 開始工作:先通過頂點著色器 Vertex Shader 對頂點進行處理,更新圖元信息
  3. 平鋪過程:平鋪生成 tile bucket 的幾何圖形,這一步會將圖元信息轉化為像素,之後將結果寫入 Parameter Buffer 中
  4. Tiler 更新完所有的圖元信息,或者 Parameter Buffer 已滿,則會開始下一步
  5. Renderer 工作:將像素信息進行處理得到 bitmap,之後存入 Render Buffer
  6. Render Buffer 中存儲有渲染好的 bitmap,供之後的 Display 操作使用

使用 Instrument 的 OpenGL ES,可以對過程進行監控。OpenGL ES tiler utilization 和 OpenGL ES renderer utilization 可以分別監控 Tiler 和 Renderer 的工作情況

5. Offscreen Rendering 離屏渲染

離屏渲染作為一個面試高頻問題,時常被提及,下麵來從頭到尾講一下離屏渲染。

離屏渲染具體過程

根據前文,簡化來看,通常的渲染流程是這樣的:

 

App 通過 CPU 和 GPU 的合作,不停地將內容渲染完成放入 Framebuffer 幀緩衝器中,而顯示屏幕不斷地從 Framebuffer 中獲取內容,顯示實時的內容。

而離屏渲染的流程是這樣的:

 

與普通情況下 GPU 直接將渲染好的內容放入 Framebuffer 中不同,需要先額外創建離屏渲染緩衝區 Offscreen Buffer,將提前渲染好的內容放入其中,等到合適的時機再將 Offscreen Buffer 中的內容進一步疊加、渲染,完成後將結果切換到 Framebuffer 中。

離屏渲染的效率問題

從上面的流程來看,離屏渲染時由於 App 需要提前對部分內容進行額外的渲染並保存到 Offscreen Buffer,以及需要在必要時刻對 Offscreen Buffer 和 Framebuffer 進行內容切換,所以會需要更長的處理時間(實際上這兩步關於 buffer 的切換代價都非常大)。

並且 Offscreen Buffer 本身就需要額外的空間,大量的離屏渲染可能早能記憶體的過大壓力。與此同時,Offscreen Buffer 的總大小也有限,不能超過屏幕總像素的 2.5 倍。

可見離屏渲染的開銷非常大,一旦需要離屏渲染的內容過多,很容易造成掉幀的問題。所以大部分情況下,我們都應該儘量避免離屏渲染。

為什麼使用離屏渲染

那麼為什麼要使用離屏渲染呢?主要是因為下麵這兩種原因:

  1. 一些特殊效果需要使用額外的 Offscreen Buffer 來保存渲染的中間狀態,所以不得不使用離屏渲染。
  2. 處於效率目的,可以將內容提前渲染保存在 Offscreen Buffer 中,達到復用的目的。

對於第一種情況,也就是不得不使用離屏渲染的情況,一般都是系統自動觸發的,比如陰影、圓角等等。

最常見的情形之一就是:使用了 mask 蒙版。

 

如圖所示,由於最終的內容是由兩層渲染結果疊加,所以必須要利用額外的記憶體空間對中間的渲染結果進行保存,因此系統會預設觸發離屏渲染。

又比如下麵這個例子,iOS 8 開始提供的模糊特效 UIBlurEffectView:

 

整個模糊過程分為多步:Pass 1 先渲染需要模糊的內容本身,Pass 2 對內容進行縮放,Pass 3 4 分別對上一步內容進行橫縱方向的模糊操作,最後一步用模糊後的結果疊加合成,最終實現完整的模糊特效。

而第二種情況,為了復用提高效率而使用離屏渲染一般是主動的行為,是通過 CALayer 的 shouldRasterize 光柵化操作實現的。

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.

開啟光柵化後,會觸發離屏渲染,Render Server 會強制將 CALayer 的渲染點陣圖結果 bitmap 保存下來,這樣下次再需要渲染時就可以直接復用,從而提高效率。

而保存的 bitmap 包含 layer 的 subLayer、圓角、陰影、組透明度 group opacity 等,所以如果 layer 的構成包含上述幾種元素,結構複雜且需要反覆利用,那麼就可以考慮打開光柵化。

圓角、陰影、組透明度等會由系統自動觸發離屏渲染,那麼打開光柵化可以節約第二次及以後的渲染時間。而多層 subLayer 的情況由於不會自動觸發離屏渲染,所以相比之下會多花費第一次離屏渲染的時間,但是可以節約後續的重覆渲染的開銷。

不過使用光柵化的時候需要註意以下幾點:

  1. 如果 layer 不能被覆用,則沒有必要打開光柵化
  2. 如果 layer 不是靜態,需要被頻繁修改,比如處於動畫之中,那麼開啟離屏渲染反而影響效率
  3. 離屏渲染緩存內容有時間限制,緩存內容 100ms 內如果沒有被使用,那麼就會被丟棄,無法進行復用
  4. 離屏渲染緩存空間有限,超過 2.5 倍屏幕像素大小的話也會失效,無法復用

圓角的離屏渲染

通常來講,設置了 layer 的圓角效果之後,會自動觸發離屏渲染。但是究竟什麼情況下設置圓角才會觸發離屏渲染呢?

 

如上圖所示,layer 由三層組成,我們設置圓角通常會首先像下麵這行代碼一樣進行設置:

view.layer.cornerRadius = 2

根據 cornerRadius - Apple 的描述,上述代碼只會預設設置 backgroundColor 和 border 的圓角,而不會設置 content 的圓角,除非同時設置了 layer.masksToBounds 為 true(對應 UIView 的 clipsToBounds 屬性):

Setting the radius to a value greater than 0.0 causes the layer to begin drawing rounded corners on its background. By default, the corner radius does not apply to the image in the layer’s contents property; it applies only to the background color and border of the layer. However, setting the masksToBounds property to true causes the content to be clipped to the rounded corners.

如果只是設置了 cornerRadius 而沒有設置 masksToBounds,由於不需要疊加裁剪,此時是並不會觸發離屏渲染的。而當設置了裁剪屬性的時候,由於 masksToBounds 會對 layer 以及所有 subLayer 的 content 都進行裁剪,所以不得不觸發離屏渲染。

view.layer.masksToBounds = true // 觸發離屏渲染的原因

所以,Texture 也提出在沒有必要使用圓角裁剪的時候,儘量不去觸發離屏渲染而影響效率:

 

離屏渲染的具體邏輯

剛纔說了圓角加上 masksToBounds 的時候,因為 masksToBounds 會對 layer 上的所有內容進行裁剪,從而誘發了離屏渲染,那麼這個過程具體是怎麼回事呢,下麵我們來仔細講一下。

圖層的疊加繪製大概遵循“畫家演算法”,在這種演算法下會按層繪製,首先繪製距離較遠的場景,然後用繪製距離較近的場景覆蓋較遠的部分。

 

在普通的 layer 繪製中,上層的 sublayer 會覆蓋下層的 sublayer,下層 sublayer 繪製完之後就可以拋棄了,從而節約空間提高效率。所有 sublayer 依次繪製完畢之後,整個繪製過程完成,就可以進行後續的呈現了。假設我們需要繪製一個三層的 sublayer,不設置裁剪和圓角,那麼整個繪製過程就如下圖所示:

 

而當我們設置了 cornerRadius 以及 masksToBounds 進行圓角 + 裁剪時,如前文所述,masksToBounds 裁剪屬性會應用到所有的 sublayer 上。這也就意味著所有的 sublayer 必須要重新被應用一次圓角+裁剪,這也就意味著所有的 sublayer 在第一次被繪製完之後,並不能立刻被丟棄,而必須要被保存在 Offscreen buffer 中等待下一輪圓角+裁剪,這也就誘發了離屏渲染,具體過程如下:

 

實際上不只是圓角+裁剪,如果設置了透明度+組透明(layer.allowsGroupOpacity+layer.opacity),陰影屬性(shadowOffset 等)都會產生類似的效果,因為組透明度、陰影都是和裁剪類似的,會作用與 layer 以及其所有 sublayer 上,這就導致必然會引起離屏渲染。

避免圓角離屏渲染

除了儘量減少圓角裁剪的使用,還有什麼別的辦法可以避免圓角+裁剪引起的離屏渲染嗎?

由於剛纔我們提到,圓角引起離屏渲染的本質是裁剪的疊加,導致 masksToBounds 對 layer 以及所有 sublayer 進行二次處理。那麼我們只要避免使用 masksToBounds 進行二次處理,而是對所有的 sublayer 進行預處理,就可以只進行“畫家演算法”,用一次疊加就完成繪製。

那麼可行的實現方法大概有下麵幾種:

  1. 【換資源】直接使用帶圓角的圖片,或者替換背景色為帶圓角的純色背景圖,從而避免使用圓角裁剪。不過這種方法需要依賴具體情況,並不通用。
  2. 【mask】再增加一個和背景色相同的遮罩 mask 覆蓋在最上層,蓋住四個角,營造出圓角的形狀。但這種方式難以解決背景色為圖片或漸變色的情況。
  3. 【UIBezierPath】用貝塞爾曲線繪製閉合帶圓角的矩形,在上下文中設置只有內部可見,再將不帶圓角的 layer 渲染成圖片,添加到貝塞爾矩形中。這種方法效率更高,但是 layer 的佈局一旦改變,貝塞爾曲線都需要手動地重新繪製,所以需要對 frame、color 等進行手動地監聽並重繪。
  4. 【CoreGraphics】重寫 drawRect:,用 CoreGraphics 相關方法,在需要應用圓角時進行手動繪製。不過 CoreGraphics 效率也很有限,如果需要多次調用也會有效率問題。

觸發離屏渲染原因的總結

總結一下,下麵幾種情況會觸發離屏渲染:

  1. 使用了 mask 的 layer (layer.mask)
  2. 需要進行裁剪的 layer (layer.masksToBounds / view.clipsToBounds)
  3. 設置了組透明度為 YES,並且透明度不為 1 的 layer (layer.allowsGroupOpacity/layer.opacity)
  4. 添加了投影的 layer (layer.shadow*)
  5. 採用了光柵化的 layer (layer.shouldRasterize)
  6. 繪製了文字的 layer (UILabelCATextLayerCore Text 等)

不過,需要註意的是,重寫 drawRect: 方法並不會觸發離屏渲染。前文中我們提到過,重寫 drawRect: 會將 GPU 中的渲染操作轉移到 CPU 中完成,並且需要額外開闢記憶體空間。但根據蘋果工程師的說法,這和標準意義上的離屏渲染並不一樣,在 Instrument 中開啟 Color offscreen rendered yellow 調試時也會發現這並不會被判斷為離屏渲染。

6. 自測題目

一般來說做點題才能加深理解和鞏固,所以這裡從文章里簡單提煉了一些,希望能幫到大家:

  1. CPU 和 GPU 的設計目的分別是什麼?
  2. CPU 和 GPU 哪個的 Cache\ALU\Control unit 的比例更高?
  3. 電腦圖像渲染流水線的大致流程是什麼?
  4. Framebuffer 幀緩衝器的作用是什麼?
  5. Screen Tearing 屏幕撕裂是怎麼造成的?
  6. 如何解決屏幕撕裂的問題?
  7. 掉幀是怎麼產生的?
  8. CoreAnimation 的職責是什麼?
  9. UIView 和 CALayer 是什麼關係?有什麼區別?
  10. 為什麼會同時有 UIView 和 CALayer,能否合成一個?
  11. 渲染流水線中,CPU 會負責哪些任務?
  12. 離屏渲染為什麼會有效率問題?
  13. 什麼時候應該使用離屏渲染?
  14. shouldRasterize 光柵化是什麼?
  15. 有哪些常見的觸發離屏渲染的情況?
  16. cornerRadius 設置圓角會觸發離屏渲染嗎?
  17. 圓角觸發的離屏渲染有哪些解決方案?
  18. 重寫 drawRect 方法會觸發離屏渲染嗎?

推薦

您的分享是我們最大的動力!

更多相關文章
  • utf8mb4是4個位元組。utf8是3個位元組。utf8mb4相容性更好,占用空間更大。 主要從排序準確性和性能兩方面看: 準確性utf8mb4_unicode_ci 是基於標準的Unicode來排序和比較,能夠在各種語言之間精確排序utf8mb4_general_ci 沒有實現Unicode排序規則 ...
  • --ORACLE表被鎖原因:具體操作某一個FORM界面,或者後臺資料庫操作某一個表時發現一直出於"假死"狀態, --可能是該表被某一用戶鎖定,導致其他用戶無法繼續操作 --查詢被鎖的表 select b.owner, b.object_name, a.session_id, a.locked_mod ...
  • 資料庫索引: 索引有單列索引複合索引之說 如何某表的某個欄位有主鍵約束和唯一性約束,則Oracle 則會自動在相應的約束列上建議唯一索引。資料庫索引主要進行提高訪問速度。 建設原則: 1、索引應該經常建在Where 子句經常用到的列上。如果某個大表經常使用某個欄位進行查詢,並且檢索行數小於總表行數的 ...
  • 前言 數據科學部為想從事大數據方向學習的小伙伴總結了一下大數據的學習路線,供大家學習參考。由於大數據是一個基礎門檻較高就業前景較好的學習方向。所以打算學習大數據的小伙伴要加油啦! 大數據學習路線: 首先我要瞭解大數據處理流程: 第一步:數據收集 第二部:數據存儲 第三步:數據分析 第四步:數據應用 ... ...
  • 問題 經常在PG群里看到有人在問“為什麼我對錶賦予了許可權;但是還是不能訪問表” 解析 若你看懂德哥這篇文章PostgreSQL邏輯結構和許可權體系介紹;上面對你就不是困擾你的問題 解決這個問題很簡單;在解決之前;我們要先瞭解PostgreSQL的邏輯結構、以及與用戶之間的關係。盜用德哥的圖;來詮釋下邏 ...
  • #獲取會員的上二級 drop view if exists vwMemberL2Parent; create view vwMemberL2Parent as select m.id, m.parent_id, m.nickname, m.realname, m.avatar, m.mobile, ...
  • 這是大數據入門常識第二篇,主要討論大數據工作的方向問題。第一篇沒看的同學可以補一下: "3000字長文教你大數據該怎麼學!" 有不少剛入門的同學在後臺會問類似這樣的問題 看招聘網站上,大數據相關的方向好多,不知道自己適合哪個怎麼辦? 關註我公眾號的同學應該有不少是剛入門的,所以我把內容好好地整理總結 ...
  • IsEqual與Hash個人理解 isEqual NSObject類的實例方法: 主要是根據對象的記憶體地址來判斷兩個對象是否相等,這裡與 效果相同。 isEqualToString (BOOL)isEqualToString:(NSString )aString 是NSString類的實例方法,它主 ...
一周排行
  • 文章篇幅較長,閱讀完大概20min,建議收藏閱讀, 讀完會有收穫。歡迎點贊關註 原文鏈接:https://www.softwaretestinghelp.com/types-of-software-testing/ 有多少軟體測試類型呢? 我們作為測試人員瞭解很多種不同的軟體測試類型,例如功能測試( ...
  • 過場CG: 接到公司領導的文件指示,“小熊”需要在6月底去海外執行一個行動代號為【定時任務】的營救計劃,這個計劃關係到公司某個項目的生死(數據安全漏洞),作戰部擬定兩個作戰方案: 方案一:使用務定時任務框架quartz; 方案二:使用windows Service服務。 最終的作戰方案為:兩者配套使 ...
  • 為什麼編寫TaskSchedulerEx類? 因為.NET預設線程池只有一個線程池,如果某個批量任務一直占著大量線程,甚至耗盡預設線程池,則會嚴重影響應用程式域中其它任務或批量任務的性能。 特點: 1、使用獨立線程池,線程池中線程分為核心線程和輔助線程,輔助線程會動態增加和釋放,且匯流排程數不大於參數 ...
  • 前幾天,公眾號後臺有朋友在問Core的中間件,所以專門抽時間整理了這樣一篇文章。 一、前言 中間件(Middleware)最初是一個機械上的概念,說的是兩個不同的運動結構中間的連接件。後來這個概念延伸到軟體行業,大家把應用操作系統和電腦硬體之間過渡的軟體或系統稱之為中間件,比方驅動程式,就是一個典型 ...
  • 參考文檔: https://www.cnblogs.com/liaods/p/10101513.html https://www.cnblogs.com/zyz-Notes/p/12030281.html 本示例使用MVC項目做演示(不推薦,推薦直接用WebAPI),框架版本使用 4.6.2 為了支 ...
  • 引用NModbus 在NuGet搜索NModbus,添加引用。 封裝ModbusTcp類 public class ModbusTCP { private ModbusFactory modbusFactory; private IModbusMaster master; private TcpCl ...
  • 系列文章 基於 abp vNext 和 .NET Core 開發博客項目 - 使用 abp cli 搭建項目 基於 abp vNext 和 .NET Core 開發博客項目 - 給項目瘦身,讓它跑起來 基於 abp vNext 和 .NET Core 開發博客項目 - 完善與美化,Swagger登場 ...
  • Microsoft.AspNetCore.Mvc.Versioning //引入程式集 .net core 下麵api的版本控製作用不需要多說,可以查閱https://www.cnblogs.com/dc20181010/p/11313738.html 普通的版本控制一般是通過鏈接、header此類 ...
  • 結合 AOP 輕鬆處理事件發佈處理日誌 Intro 前段時間,實現了 EventBus 以及 EventQueue 基於 Event 的事件處理,但是沒有做日誌(EventLog)相關的部分,原本想增加兩個介面, 處理事件發佈日誌和事件處理日誌,最近用了 AOP 的思想處理了 EntityFrame ...
  • 什麼是sam 轉換 Single Abstract Method 實際上這是java8中提出的概念,你就把他理解為是一個方法的介面的就可以了 看一下我們每天都在使用的線程池 ExecutorService executorService= Executors.newScheduledThreadPo ...