1. 程式人生 > IOS開發 >iOS - 渲染原理

iOS - 渲染原理

Head

在效能優化中,有一個重要的知識點就是卡頓優化,我們以FPS(每秒傳輸幀數(Frames Per Second))來衡量它的流暢度,蘋果的iPhone推薦的重新整理率是60Hz,也就是說GPU每秒鐘重新整理螢幕60次,這每重新整理一次就是一幀frame,每一幀大概在1/60 = 16.67ms畫面最佳,靜止不變的頁面FPS值是0,這個值是沒有參考意義的,只有當頁面在執行動畫或者滑動的時候,FPS值才具有參考價值,FPS值的大小體現了頁面的流暢程度高低,當低於45的時候卡頓會比較明顯

螢幕呈像原理

我們所看到的動態的螢幕的成像其實和視訊一樣也是一幀一幀組成的。為了把顯示器的顯示過程和系統的視訊控制器進行同步,顯示器(或者其他硬體)會用硬體時鐘產生一系列的定時訊號。當電子槍換行進行掃描時,顯示器會發出一個水平同步訊號(horizonal synchronization),簡稱 HSync

;而當一幀畫面繪製完成後,電子槍回覆到原位,準備畫下一幀前,顯示器會發出一個垂直同步訊號(vertical synchronization),簡稱 VSync。顯示器通常以固定頻率進行重新整理,這個重新整理率就是 VSync 訊號產生的頻率。

螢幕呈像原理

卡頓的產生

接下來介紹完成顯示資訊的過程是:CPU 計算資料 -> GPU 進行渲染 -> 渲染結果存入幀緩衝區 -> 視訊控制器會按照 VSync 訊號逐幀讀取幀緩衝區的資料 -> 成像,假如螢幕已經發出了 VSync 但 GPU 還沒有渲染完成,則只能將上一次的資料顯示出來,以致於當前計算的幀資料丟失,這樣就產生了卡頓,當前的幀資料計算好後只能等待下一個週期去渲染。

整體流程

卡頓原因

卡頓的優化

那麼,解決卡頓的方案就很是要在下一次VSync到來之前,儘可能減少這一幀 CPU 和 GPU 資源的消耗,要減少的話我們就得先了解這兩者在渲染中的具體分工是什麼,和iOS中檢視的產生過程

UIView 和 CALayer

我們都知道,檢視的職責是 建立並管理 圖層,以確保當子檢視在層級關係中 新增或被移除 時,其關聯的圖層在圖層樹中也有相同的操作,即保證檢視樹和圖層樹在結構上的一致性,那麼為什麼 iOS 要基於 UIViewCALayer 提供兩個平行的層級關係呢?其原因在於要做 職責分離,這樣也能避免很多重複程式碼。在 iOS 和 Mac OS X 兩個平臺上,事件和使用者互動有很多地方的不同,基於多點觸控的使用者介面和基於滑鼠鍵盤的互動有著本質的區別,這就是為什麼 iOS 有 UIKit

UIView,對應 Mac OS X 有 AppKitNSView 的原因。它們在功能上很相似,但是在實現上有著顯著的區別。

CALayer

那麼為什麼 CALayer 可以呈現視覺化內容呢?因為 CALayer 基本等同於一個 紋理。紋理是 GPU 進行影象渲染的重要依據,紋理本質上就是一張圖片,因此 CALayer 也包含一個 contents 屬性指向一塊快取區,稱為 backing store,可以存放點陣圖(Bitmap)。iOS 中將該快取區儲存的圖片稱為 寄宿圖

CALayer
在實際開發中,繪製介面有兩種方式:一種是 手動繪製;另一種是 使用圖片。 對此,iOS 中也有兩種相應的實現方式:

  • 使用圖片:contents image
  • 手動繪製:custom drawing

Contents Image

Contents Image 是指通過 CALayer 的 contents 屬性來配置圖片。然而,contents 屬性的型別為 id。在這種情況下,可以給 contents 屬性賦予任何值,app 仍可以編譯通過。但是在實踐中,如果 content 的值不是 CGImage ,得到的圖層將是空白的

    // Contents Image
    UIImage *image = [UIImage imageNamed:@"cat.JPG"];
    UIView *v = [UIView new];
    v.layer.contents = (__bridge id _Nullable)(image.CGImage);
    v.frame = CGRectMake(100,100,100);
    [self.view addSubview:v];
複製程式碼

Contents Image
我們可以看到,這樣就可以使用圖片繪製到view上面去

Custom Drawing

Custom Drawing 是指使用 Core Graphics 直接繪製寄宿圖。實際開發中,一般通過繼承 UIView 並實現 -drawRect: 方法來自定義繪製。

  • UIView 有一個關聯圖層,即 CALayer
  • CALayer 有一個可選的 delegate 屬性,實現了 CALayerDelegate 協議。UIView 作為 CALayer 的代理實現了CALayerDelegae 協議。
  • 當需要重繪時,即呼叫 -drawRect:,CALayer 請求其代理給予一個寄宿圖來顯示。
  • CALayer 首先會嘗試呼叫 -displayLayer: 方法,此時代理可以直接設定 contents 屬性。
- (void)displayLayer:(CALayer *)layer;
複製程式碼
  • 如果代理沒有實現 -displayLayer: 方法,CALayer 則會嘗試呼叫 -drawLayer:inContext:方法。在呼叫該方法前,CALayer 會建立一個空的寄宿圖(尺寸由 bounds 和 contentScale 決定)和一個 Core Graphics 的繪製上下文,為繪製寄宿圖做準備,作為 ctx 引數傳入。
- (void)drawLayer:(CALayer *)layer inContext:(CGContextRef)ctx;
複製程式碼
  • 最後,由 Core Graphics 繪製生成的寄宿圖會存入 backing store

    Custom Drawing
    若UIView的子類重寫了drawRect,則UIView執行完drawRect後,系統會為器layer的content開闢一塊快取,用來存放drawRect繪製的內容。 即使重寫的drawRect啥也沒做,也會開闢快取,消耗記憶體,所以儘量不要隨便重寫drawRect卻啥也不做

  • 其實,當在操作 UI 時,比如改變了 Frame、更新了 UIView/CALayer 的層次時,或者手動呼叫了 UIView/CALayer 的 setNeedsLayout/setNeedsDisplay方法後,在此過程中 app 可能需要更新 檢視樹,相應地,圖層樹 也會被更新

  • 其次,CPU計算要顯示的內容,包括佈局計算(Layout)檢視繪製(Display)圖片解碼(Prepare)runloopBeforeWaiting(即將進入休眠)Exit (即將退出Loop) 時,會通知註冊的監聽,然後對圖層打包(Commit),打包完後,將打包的資料(backing store)傳送給一個獨立負責渲染的程序 Render Server

  • 資料到達Render Server 後會被反序列化,得到圖層樹,按照圖層樹中圖層順序、RBGA值、圖層frame過濾圖中被遮擋的部分,過濾後將圖層樹轉成渲染樹,渲染樹的資訊會轉給 OpenGL ES/Metal

至此,前面CPU 所處理的這些事情統稱為 Commit Transaction

Render Server

Render Server 會呼叫 GPU,GPU 開始進行頂點著色器形狀裝配幾何著色器光柵化片段著色器測試與混合六個階段。完成這六個階段的工作後,再將 CPU 和 GPU 計算後的資料顯示在螢幕的每個畫素點上

  • 頂點著色器(Vertex Shader)
  • 形狀裝配(Shape Assembly),又稱 圖元裝配
  • 幾何著色器(Geometry Shader)
  • 光柵化(Rasterization)
  • 片段著色器(Fragment Shader)
  • 測試與混合(Tests and Blending)

GPU
第一階段,頂點著色器。該階段的輸入是 頂點資料(Vertex Data) 資料,比如以陣列的形式傳遞 3 個 3D 座標用來表示一個三角形。頂點資料是一系列頂點的集合。頂點著色器主要的目的是把 3D 座標轉為另一種 3D 座標,同時頂點著色器可以對頂點屬性進行一些基本處理。

第二階段,形狀(圖元)裝配。該階段將頂點著色器輸出的所有頂點作為輸入,並將所有的點裝配成指定圖元的形狀。圖中則是一個三角形。圖元(Primitive) 用於表示如何渲染頂點資料,如:點、線、三角形。

第三階段,幾何著色器。該階段把圖元形式的一系列頂點的集合作為輸入,它可以通過產生新頂點構造出新的(或是其它的)圖元來生成其他形狀。例子中,它生成了另一個三角形。

第四階段,光柵化。該階段會把圖元對映為最終螢幕上相應的畫素,生成片段。片段(Fragment) 是渲染一個畫素所需要的所有資料。

第五階段,片段著色器。該階段首先會對輸入的片段進行 裁切(Clipping)。裁切會丟棄超出檢視以外的所有畫素,用來提升執行效率。

第六階段,測試與混合。該階段會檢測片段的對應的深度值(z 座標),判斷這個畫素位於其它物體的前面還是後面,決定是否應該丟棄。此外,該階段還會檢查 alpha 值( alpha 值定義了一個物體的透明度),從而對物體進行混合。因此,即使在片段著色器中計算出來了一個畫素輸出的顏色,在渲染多個三角形的時候最後的畫素顏色也可能完全不同。 公式為:

R = S + D * (1 - Sa)
複製程式碼

假設有兩個畫素 S(source) 和 D(destination),S 在 z 軸方向相對靠前(在上面),D 在 z 軸方向相對靠後(在下面),那麼最終的顏色值就是 S(上面畫素) 的顏色 + D(下面畫素) 的顏色 * (1 - S(上面畫素) 顏色的透明度)

所以,才需要我們在做頁面的時候,儘量控制少的圖層數、還有儘量不要使用alpha

  • 最終,GPU通過Frame Buffer(幀緩衝區、 雙緩衝機制)視訊控制器等相關部件,將影象顯示在螢幕上。

至此,原生的渲染流程到此結束。

原生渲染卡頓優化方案

所以解決卡頓現象的主要思路就是:儘可能減少 CPUGPU 資源的消耗。 ######CPU

  • 儘量用輕量級的物件 如:不用處理事件的 UI 控制元件可以考慮使用 CALayer;
  • 不要頻繁地呼叫 UIView 的相關屬性 如:frame、bounds、transform 等;
  • 儘量提前計算好佈局,在有需要的時候一次性調整對應屬性,不要多次修改;
  • Autolayout 會比直接設定 frame 消耗更多的 CPU 資源;
  • 圖片的 size 和 UIImageView 的 size 保持一致;
  • 控制執行緒的最大併發數量;
  • 耗時操作放入子執行緒;如文字的尺寸計算、繪製,圖片的解碼、繪製等; ######GPU
  • 儘量避免短時間內大量圖片顯示;
  • GPU 能處理的最大紋理尺寸是 4096 * 4096,超過這個尺寸就會佔用 CPU 資源,所以紋理不能超過這個尺寸;
  • 儘量減少透檢視的數量和層次;
  • 減少透明的檢視(alpha < 1),不透明的就設定 opaque 為 YES;
  • 儘量避免離屏渲染;

大前端渲染

大前端的開發框架主要分為兩類:第一類是基於 WebView 的,第二類是類似 React Native 的。

對於第一類 WebView 的大前端渲染,主要工作在 WebKit 中完成。WebKit 的渲染層來自以前 macOS 的 Layer Rendering 架構,而 iOS 也是基於這一套架構。所以,從本質上來看,WebKit 和 iOS 原生渲染差別不大。

第二類的類 React Native 更簡單,渲染直接走的是 iOS 原生的渲染。那麼,我們為什麼會感覺 WebView 和類 React Native 比原生渲染得慢呢?

從第一次內容載入來看,即使是本地載入,大前端也要比原生多出指令碼程式碼解析的工作。

WebView 需要額外解析 HTML + CSS + JavaScript 程式碼,而類 React Native 方案則需要解析 JSON + JavaScriptHTML + CSS 的複雜度要高於 JSON,所以解析起來會比 JSON 慢。也就是說,首次內容載入時,WebView會比類 React Native 慢。

從語言本身的解釋執行效能來看,大前端載入後的介面更新會通過 JavaScript解釋執行,而 JavaScript 解釋執行效能要比原生差,特別是解釋執行復雜邏輯或大量計算時。所以,大前端的運算速度,要比原生慢不少。

說完了大前端的渲染,你會發現,相對於原生渲染,無論是 WebView 還是類 React Native 都會因為指令碼語言本身的效能問題而在存在效能差距。那麼,對於 Flutter 這種沒有使用指令碼語言,並且渲染引擎也是全新的框架,其渲染方式有什麼不同,效能又怎樣呢?

Flutter 渲染

Flutter 介面是由 Widget 組成的,所有 Widget 組成 Widget Tree,介面更新時會更新 Widget Tree,然後再更新 Element Tree,最後更新 RenderObject Tree。

接下來的渲染流程,Flutter 渲染在 Framework 層會有 BuildWiget TreeElement TreeRenderObject TreeLayoutPaintComposited Layer 等幾個階段。將 Layer 進行組合,生成紋理,使用 OpenGL 的介面向 GPU 提交渲染內容進行光柵化與合成,是在 Flutter 的 C++ 層,使用的是 Skia 庫。包括提交到 GPU 程序後,合成計算,顯示螢幕的過程和 iOS 原生基本是類似的,因此效能也差不多。