從iOS的圖片圓角想到渲染

圓角是一種很常見的視圖效果,相比於直角,它更加柔和優美,易於接受。設置圓角會帶來一定的性能損耗,如何提高性能是一個需要重點討論的話題。

大家常見的圓角代碼x.layer.cornerRadius = xx; x.clipsToBounds = YES;這兩行確實實現了圓角視覺效果。其實使用x.layer.cornerRadius = xx;已經實現了圓角,只不過在某些控件是不生效的,因爲某些圖層在被切割圓角圖層之上而被顯示出來了。而x.clipsToBounds = YES;帶來的後果就是產生離屏渲染。可以使用instruments中的CoreAnimation工具,打開Color Offscren-Rednered Yellow選項,可見黃色區域部分即是離屏渲染部分。

那麼離屏渲染會帶來什麼?當然後資源損耗,可能產生卡頓。因爲在iPhone設備的硬件資源有差異,當離屏渲染不多時,並不是很明顯感覺到它的缺點。

文章中說到的具體源碼可以轉至github進行star DDCornerRadius 歡迎issue。

什麼是像素

像素,爲視頻顯示的基本單位,譯自英文“pixel”,pix是英語單詞picture的常用簡寫,加上英語單詞“元素”element,就得到pixel,故“像素”表示“畫像元素”之意,有時亦被稱爲pel(picture element)。每個這樣的消息元素不是一個點或者一個方塊,而是一個抽象的取樣。像素是由紅,綠,藍三種顏色組件構成的。因此,位圖數據有時也被叫做 RGB 數據。

顯示機制

一個像素是如何繪製到屏幕上去的?有很多種方式將一些東西映射到顯示屏上,他們需要調用不同的框架、許多功能和方法的結合體。這裏我們大概看一下屏幕之後發生的事情。

圖像想顯示到屏幕上使人肉眼可見都需藉助像素的力量。它們密集的排布在手機屏幕上,將任何圖形通過不同的色值表現出來。計算機顯示的流程大致可以描述爲將圖像轉化爲一系列像素點的排列然後打印在屏幕上,由圖像轉化爲像素點的過程又可以稱之爲光柵化,就是從矢量的點線面的描述,變成像素的描述。

display screen

回溯歷史,可以從過去的 CRT 顯示器原理說起。CRT 的電子槍按照上面方式,從上到下一行行掃描,掃描完成後顯示器就呈現一幀畫面,隨後電子槍回到初始位置繼續下一次掃描。爲了把顯示器的顯示過程和系統的視頻控制器進行同步,顯示器(或者其他硬件)會用硬件時鐘產生一系列的定時信號。當電子槍換到新的一行,準備進行掃描時,顯示器會發出一個水平同步信號(horizonal synchronization),簡稱 HSync;而當一幀畫面繪製完成後,電子槍回覆到原位,準備畫下一幀前,顯示器會發出一個垂直同步信號(vertical synchronization),簡稱 VSync。顯示器通常以固定頻率進行刷新,這個刷新率就是 VSync 信號產生的頻率。儘管現在的設備大都是液晶顯示屏了,但原理仍然沒有變。

關於卡頓的簡單原理解釋

在 VSync 信號到來後,系統圖形服務會通過 CADisplayLink 等機制通知 App,App 主線程開始在 CPU 中計算顯示內容,比如視圖的創建、佈局計算、圖片解碼、文本繪製等。隨後 CPU 會將計算好的內容提交到 GPU 去,由 GPU 進行變換、合成、渲染。隨後 GPU 會把渲染結果提交到幀緩衝區去,等待下一次 VSync 信號到來時顯示到屏幕上。由於垂直同步的機制,如果在一個 VSync 時間內,CPU 或者 GPU 沒有完成內容提交,則那一幀就會被丟棄,等待下一次機會再顯示,而這時顯示屏會保留之前的內容不變。這就是界面卡頓的原因。

CPU 和 GPU 不論哪個阻礙了顯示流程,都會造成掉幀現象。所以開發時,也需要分別對 CPU 和 GPU 壓力進行評估和優化。

渲染機制

當像素映射到屏幕上的時候,後臺發生了很多事情。但一旦它們顯示到屏幕上,每一個像素均由三個顏色組件構成:紅,綠,藍。三個獨立的顏色單元會根據給定的顏色顯示到一個像素上。在 iPhoneSE 的顯示器上有1,136×640=727,040個像素,因此有2,181,120個顏色單元。在一些Retina屏幕上,這一數字將達到百萬以上。所有的圖形堆棧一起工作以確保每次正確的顯示。當你滾動整個屏幕的時候,數以百萬計的顏色單元必須以每秒60次的速度刷新,這就是一個很大的工作量。

簡單來說,iOS的顯示機制大致如此:


pixels software stack

Display 的上一層便是圖形處理單元 GPU,GPU 是一個專門爲圖形高併發計算而量身定做的處理單元。這也是爲什麼它能同時更新所有的像素,並呈現到顯示器上。它的併發本性讓它能高效的將不同紋理合成起來。所以,開發中我們應該儘量讓CPU負責主線程的UI調動,把圖形顯示相關的工作交給GPU來處理。

GPU Driver 是直接和 GPU 交流的代碼塊。不同的GPU是不同的性能怪獸,但是驅動使它們在下一個層級上顯示的更爲統一,典型的下一層級有 OpenGL/OpenGL ES.

OpenGL(Open Graphics Library) 是一個提供了 2D 和 3D 圖形渲染的 API。GPU 是一塊非常特殊的硬件,OpenGL 和 GPU 密切的工作以提高GPU的能力,並實現硬件加速渲染。

OpenGL 之上擴展出很多東西。在 iOS 上,幾乎所有的東西都是通過 Core Animation 繪製出來,然而在 OS X 上,繞過 Core Animation 直接使用 Core Graphics 繪製的情況並不少見。對於一些專門的應用,尤其是遊戲,程序可能直接和 OpenGL/OpenGL ES 交流。

需要強調的是,GPU 是一個非常強大的圖形硬件,並且在顯示像素方面起着核心作用。它連接到 CPU。從硬件上講兩者之間存在某種類型的總線,並且有像 OpenGL,Core Animation 和 Core Graphics 這樣的框架來在 GPU 和 CPU 之間精心安排數據的傳輸。爲了將像素顯示到屏幕上,一些處理將在 CPU 上進行。然後數據將會傳送到 GPU,最終像素顯示到屏幕上。

pixels hardware

正如上圖顯示,GPU 需要將每一個 frame 的紋理(位圖)合成在一起(一秒60次)。每一個紋理會佔用 VRAM(video RAM),所以需要給 GPU 同時保持紋理的數量做一個限制。GPU 在合成方面非常高效,但是某些合成任務卻比其他更復雜,並且 GPU在 16.7ms(1/60s)內能做的工作也是有限的。

另外一個問題就是將數據傳輸到 GPU 上。爲了讓 GPU 訪問數據,需要將數據從 RAM 移動到 VRAM 上。這就是提及到的上傳數據到 GPU。這些看起來貌似微不足道,但是一些大型的紋理卻會非常耗時。

最終,CPU 開始運行程序。你可能會讓 CPU 從 bundle 加載一張 PNG 的圖片並且解壓它。這所有的事情都在 CPU 上進行。然後當你需要顯示解壓縮後的圖片時,它需要以某種方式上傳到 GPU。一些看似平凡的,比如顯示文本,對 CPU 來說卻是一件非常複雜的事情,這會促使 Core Text 和 Core Graphics 框架更緊密的集成來根據文本生成一個位圖。一旦準備好,它將會被作爲一個紋理上傳到 GPU 並準備顯示出來。當你滾動或者在屏幕上移動文本時,同樣的紋理能夠被複用,CPU 只需簡單的告訴 GPU 新的位置就行了,所以 GPU 就可以重用存在的紋理了。CPU 並不需要重新渲染文本,並且位圖也不需要重新上傳到 GPU。

在圖形世界中,合成是一個描述不同位圖如何放到一起來創建你最終在屏幕上看到圖像的過程。屏幕上一切事物皆紋理。一個紋理就是一個包含 RGBA 值的長方形,比如,每一個像素裏面都包含紅、綠、藍和透明度的值。在 Core Animation 世界中這就相當於一個 CALayer。

每一個 layer 是一個紋理,所有的紋理都以某種方式堆疊在彼此的頂部。對於屏幕上的每一個像素,GPU 需要算出怎麼混合這些紋理來得到像素 RGB 的值。這就是合成。

如果我們所擁有的是一個和屏幕大小一樣並且和屏幕像素對齊的單一紋理,那麼屏幕上每一個像素相當於紋理中的一個像素,紋理的最後一個像素也就是屏幕的最後一個像素。

如果我們有第二個紋理放在第一個紋理之上,然後GPU將會把第二個紋理合成到第一個紋理中。有很多種不同的合成方法,但是如果我們假定兩個紋理的像素對齊,並且使用正常的混合模式,我們便可以用公式來計算每一個像素:R = S + D * ( 1 – Sa )
結果的顏色是源色彩(頂端紋理)+目標顏色(低一層的紋理)*(1-源顏色的透明度)。在這個公式中所有的顏色都假定已經預先乘以了它們的透明度。

接着我們進行第二個假定,兩個紋理都完全不透明,比如 alpha=1。如果目標紋理(低一層的紋理)是藍色(RGB=0,0,1),並且源紋理(頂層的紋理)顏色是紅色(RGB=1,0,0),因爲 Sa 爲1,所以結果爲:R = S
結果是源顏色的紅色。這正是我們所期待的(紅色覆蓋了藍色)。如果源顏色層爲50%的透明,比如 alpha=0.5,既然 alpha 組成部分需要預先乘進 RGB 的值中,那麼 S 的 RGB 值爲(0.5, 0, 0),公式看起來便會像這樣:

                       0.5   0               0.5
R = S + D * (1 - Sa) = 0   + 0 * (1 - 0.5) = 0
                       0     1               0.5

我們最終得到RGB值爲(0.5, 0, 0.5),是一個紫色。這正是我們所期望將透明紅色合成到藍色背景上所得到的。

記住我們剛剛只是將紋理中的一個像素合成到另一個紋理的像素上。當兩個紋理覆蓋在一起的時候,GPU需要爲所有像素做這種操作。正如你所知道的一樣,許多程序都有很多層,因此所有的紋理都需要合成到一起。儘管GPU是一塊高度優化的硬件來做這種事情,但這還是會讓它非常忙碌。

爲何圖片縮放會增加GPU工作量

當所有的像素是對齊的時候我們得到相對簡單的計算公式。每當 GPU 需要計算出屏幕上一個像素是什麼顏色的時候,它只需要考慮在這個像素之上的所有 layer 中對應的單個像素,並把這些像素合併到一起。或者,如果最頂層的紋理是不透明的(即圖層樹的最底層),這時候 GPU 就可以簡單的拷貝它的像素到屏幕上。

當一個 layer 上所有的像素和屏幕上的像素完美的對應整齊,那這個 layer 就是像素對齊的。主要有兩個原因可能會造成不對齊。第一個便是滾動,當一個紋理上下滾動的時候,紋理的像素便不會和屏幕的像素排列對齊。另一個原因便是當紋理的起點不在一個像素的邊界上。

在這兩種情況下,GPU 需要再做額外的計算。它需要將源紋理上多個像素混合起來,生成一個用來合成的值。當所有的像素都是對齊的時候,GPU 只剩下很少的工作要做。

Core Animation 工具和模擬器有一個Color Misaligned Images選項,當這些在你的 CALayer 實例中發生的時候,這個功能便可向你展示。

關於iOS設備的一些尺寸限制可以看這裏:iOSRes

離屏渲染

On-Screen Rendering意爲當前屏幕渲染,指的是GPU的渲染操作是在當前用於顯示的屏幕緩衝區中進行。
Off-Screen Rendering意爲離屏渲染,指的是GPU在當前屏幕緩衝區以外新開闢一個緩衝區進行渲染操作。

當圖層屬性的混合體被指定爲在未預合成之前不能直接在屏幕中繪製時,屏幕外渲染就被喚起了。屏幕外渲染並不意味着軟件繪製,但是它意味着圖層必須在被顯示之前在一個屏幕外上下文中被渲染(不論CPU還是GPU)。

離屏渲染可以被 Core Animation 自動觸發,或者被應用程序強制觸發。屏幕外的渲染會合並/渲染圖層樹的一部分到一個新的緩衝區,然後該緩衝區被渲染到屏幕上。

特殊的“離屏渲染”:CPU渲染

如果我們重寫了drawRect方法,並且使用任何Core Graphics的技術進行了繪製操作,就涉及到了CPU渲染。
整個渲染過程由CPU在App內同步地完成,渲染得到的bitmap最後再交由GPU用於顯示。

離屏渲染的體現

相比於當前屏幕渲染,離屏渲染的代價是很高的,主要體現在兩個方面:

  • 1 創建新緩衝區
    要想進行離屏渲染,首先要創建一個新的緩衝區。
  • 2 上下文切換
    離屏渲染的整個過程,需要多次切換上下文環境:先是從當前屏幕(On-Screen)切換到離屏(Off-Screen);等到離屏渲染結束以後,將離屏緩衝區的渲染結果顯示到屏幕上,又需要將上下文環境從離屏切換到當前屏幕。而上下文環境的切換是要付出很大代價的。

觸發離屏渲染

1、drawRect
2、layer.shouldRasterize = true;
3、有mask或者是陰影(layer.masksToBounds, layer.shadow*);
3.1) shouldRasterize(光柵化)
3.2) masks(遮罩)
3.3) shadows(陰影)
3.4) edge antialiasing(抗鋸齒)
3.5) group opacity(不透明)
4、Text(UILabel, CATextLayer, Core Text, etc)...
注:layer.cornerRadius,layer.borderWidth,layer.borderColor並不會Offscreen Render,因爲這些不需要加入Mask。

圓角優化

前面說了那麼多,這裏就給上實際可行方案。圓角的優化目前考慮兩方面:一是,從圖片入手,將圖片切割成指定圓角樣式。二是,使用貝塞爾曲線,利用CALayer層繪製指定圓角樣式的mask遮蓋View。

UIImage切割:

UIGraphicsBeginImageContextWithOptions(self.size, NO, 0);
CGContextRef context = UIGraphicsGetCurrentContext();
CGRect rect = CGRectMake(0, 0, self.size.width, self.size.height);
CGContextScaleCTM(context, 1, -1);
CGContextTranslateCTM(context, 0, -rect.size.height);

CGFloat minSize = MIN(self.size.width, self.size.height);
if (borderWidth < minSize / 2.0) {
    UIBezierPath *path = [UIBezierPath bezierPathWithRoundedRect:CGRectInset(rect, borderWidth, borderWidth) byRoundingCorners:corners cornerRadii:CGSizeMake(radius, borderWidth)];
    CGContextSaveGState(context);
    [path addClip];
    CGContextDrawImage(context, rect, self.CGImage);
    CGContextRestoreGState(context);
}

UIImage *image = UIGraphicsGetImageFromCurrentImageContext();
image = [image dd_imageByCornerRadius:radius borderedColor:borderColor borderWidth:borderWidth corners:corners];
UIGraphicsEndImageContext();

圖片繪製:

UIGraphicsBeginImageContextWithOptions(self.size, NO, 0);
[self drawAtPoint:CGPointZero];
CGRect rect = CGRectMake(0, 0, self.size.width, self.size.height);
CGFloat strokeInset = borderWidth / 2.0;
CGRect strokeRect = CGRectInset(rect, strokeInset, strokeInset);
UIBezierPath *path = [UIBezierPath bezierPathWithRoundedRect:strokeRect byRoundingCorners:corners cornerRadii:CGSizeMake(radius, borderWidth)];
path.lineWidth = borderWidth;
[borderColor setStroke];
[path stroke];
UIImage *result = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章