iOS開發:繪製像素到屏幕

UI的底層原理,值得一看。

像素是如何繪製到屏幕上面的?把數據輸出到屏幕的方法有很多,通過調用很多不同的framework和不同的函數。這裏我們講一下這個過程背後的東西。希望能夠幫助大家瞭解什麼時候該使用什麼API,特別是當遇到性能問題需要調試的時候。當然,我們這裏主要講iOS,但是事實上,很多東西也是可以應用到OSX上面的。

Graphics Stack

繪製屏幕的過程中又很多都是不被人瞭解的。但是一旦像素被繪製到屏幕上面,那麼像素就是有3種顏色組成:紅綠藍。這3個顏色單元通過特定的強弱組合形成一個特定的顏色。對於iPhone5 IPS_LCD 的分辨率是1,136×640 = 727,040個像素,也就是有2,181,120個顏色單元。對於一個15寸高清屏幕的MacBook Pro來說,這個數字差不多是1500萬。Graphics Stack 就是確保每一個單元的強弱都正確。當滑動整個屏幕的時候,上百萬的顏色單元需要在每秒60次的更新。

The Software Components

下面是一個簡單的例子,整個軟件看起來是這個樣子:

image

顯示器上面的就是GPU,圖像處理單元。GPU是一個高度併發計算的硬件單元,特別是處理圖形圖像的並行計算。這就是爲什麼可以這麼快的更新像素並輸出到屏幕的原因。並行計算的設計讓GPU可以高效的混合圖像紋理。我們會在後面詳細解釋混合圖像紋理這個過程。現在需要知道的就是GPU是被高度優化設計的,因此非常適合計算圖像這種類型的工作。他比CPU計算的更快,更節約能耗。因爲CPU是爲了更一般的計算設計的硬件。CPU雖然可以做很多事情,但是在圖像這方面還是遠遠慢於GPU。

GPU驅動是一些直接操作GPU的代碼,由於各個GPU是不同的,驅動在他們之上創建一個層,這個層通常是OpenGL/OpenGL ES。

OpenGL(Open Graphics Library)是用來做2D和3G圖形圖像渲染的API。由於GPU是一個非常定製化的硬件,OpenGL和GPU緊密合作充分發揮GPU的能力來實現圖形圖像渲染硬件加速。對大多數情況,OpenGL太底層了。但是當1992年第一個版本發佈後(20多年前),它就成爲主流的操作GPU的方式,並且前進了一大步。因爲程序員再也不用爲了每一個GPU編寫不同的應用程序。

在OpenGL上面,分開了幾個。iOS設備幾乎所有的東西變成了Core Animation,但是在OSX,繞過Core Animation而使用Core Graphic 並不是不常見。有一些特別的應用程序,特別是遊戲,可能直接使用OpenGL/OpenGL ES. 然後事情變得讓人疑惑起來,因爲有些渲染Core Animation 使用 Core Graphic。類似AVFoundation, Core Image 這樣的框架,或是其他的一些混合的方式。

這裏提醒一件事情, GPU是一個強有力的圖形圖像硬件,在顯示像素方面起着核心作用。它也連接着CPU。從硬件方面講就是有一些總線把他們連接了起來。也有一些框架比如 OpenGL, Core Animation。Core Graphic控制GPU和CPU之間的數據傳輸。爲了讓像素能夠顯示到屏幕上面,有一些工作是需要CPU的。然後數據會被傳給GPU,然後數據再被處理,最後顯示到屏幕上面。

每一個過程中都有自己的挑戰,在這個過程中也存在很多權衡。

硬件層

image

這是一個很簡單的圖表用來描述一個挑戰。GPU有紋理(位圖)合成爲一幀(比如1秒60幀)每一個紋理佔用VRAM(顯卡)因此GPU一次處理的紋理有大小限制。GPU處理合成方面非常高效,但是有一些合成任務比其他要複雜,所以GPU對處理能力有一個不能超過16.7ms的限制(1秒60幀)。

另一個挑戰是把數據傳給GPU。爲了讓GPU能夠訪問數據,我們需要把數據從內存複製到顯存。這個過程叫做上傳到GPU。這個可能看上去不重要,但是對於一個大的紋理來說,會非常耗時。

最後CPU運行程序。你可能告訴CPU從資源文件夾中加載一個PNG圖片,並解壓。這些過程都發生在CPU。當需要顯示這些解壓的圖片時,就需要上傳數據到GPU。一些事情看似非常簡單,比如顯示一段文字,對CPU來說是一個非常複雜的任務。需要調用Core Text 和 Core Graphic框架去根據文字生成一個位圖。完成後,以紋理的方式上傳到GPU,然後準備顯示。當你滑動或是移動一段屏幕上面的文字時,同樣的紋理會被重用,CPU會簡單的告訴GPU只是需要一個新的位置,所以GPU可以重新利用現有的紋理。CPU不需要重新繪製文字,位圖也不需要重新上傳到GPU。

上面的有一點複雜,在有一個整體概念之後,我們會開始解釋裏面的技術細節。

圖像合成

圖像合成的字面意思就是把不同的位圖放到一起創建成最後的圖像然後顯示到屏幕上面。在很多方面來看,這個過程都是顯而易見的,所以很容易忽視其中的複雜性和運算量。

讓我們忽視一些特殊情況,假設屏幕上面都是紋理。紋理就是一個RGBA值的矩形區域。每一個像素包括紅,綠,藍,透明度。在Core Animation世界裏面,基本上相當於CALayer。

在這個簡單的假設中,每一個層是一個紋理,所有的紋理通過棧的方式排列起來。屏幕上的每一個像素,CPU都需要明白應該如何混合這些紋理,從而得到相對應的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. 那麼 S 的RGB值需要乘以alpha會變成 (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需要把2個紋理之間覆蓋的部分中的像素都合成起來。大家都知道,大多數的app都有多層,因此很多紋理需要被合成起來。這個對GPU的開銷很大,即便GPU已經是被高度硬件優化的設備。

不透明 VS 透明

當源紋理是完全不透明,最終的顏色和源紋理一樣。這就可以節省GPU的很多工作,因爲GPU可以簡單的複製源紋理而不用合成所有像素值。但是GPU沒有辦法區別紋理中的像素是不透明的還是透明。只有程序員才能知道CALayer裏面的到底是什麼。這也就是CAlayer有opaque屬性的原因。如果opaque = YES, 那麼GPU將不會做任何合成計算,而是直接直接簡單的複製顏色,不管下面還有什麼東西。GPU可以減少大量的工作。這就是Instruments(Xcode 的性能測試工具)中 color blended layers 選項做的事情。(這個選項也在模擬器菜單裏面)。它可以讓你瞭解哪一個層(紋理)被標記成透明,也就是說,GPU需要做合成工作。合成不透明層要比透明的層工作量少很多,因爲沒有那麼多的數學運算在裏面。

如果你知道哪一個層是不透明的,那麼一定確保opaque = YES。如果你載入一個沒有alpha通道的image,而且在UIImageView顯示,那麼UIImageView會自動幫你設置opaque = YES。但是需要注意一個沒有alpha通道的圖片和每個地方的alpha都是100%的圖片區別很大。後面的情況,Core Animation 需要假定所有像素的alpha都不是100%。在Finder中,你可以使用Get Info並且檢查More Info部分。它將告訴你這張圖片是否擁有alpha通道。

像素對齊和不對齊

到目前爲止,我們考慮的層都是完美的像素對齊的。當所有的像素都對齊時,我們有一個相對簡單的公式。當GPU判斷屏幕上面的一個像素應該是什麼時,只需要看一下覆蓋在屏幕上面的所有層中的單個像素,然後把這些像素合成起來,或者如果最上面的紋理是不透明的,GPU只需要簡單的複製最上面的像素就好了。

當一個層上面的所有像素和屏幕上面的像素完美對應,我們就說這個層是像素對齊的。主要有2個原因導致可能不對齊。第一個是放大縮小;當放大或是縮小是,紋理的像素和屏幕像素不對齊。另一個原因是當紋理的起點不在一個像素邊界上。

這2種情況,GPU不得不做額外的計算。這個需要從源紋理中混合很多像素來創建一個像素用來合成。當所有像素對齊時,GPU就可以少做很多工作。

注意,Core Animation Instrument和模擬器都有color misaligned images 選項,當CALayer中存在像素不對齊的時候,把問題顯示出來。

遮罩(mask)

一個層可以有一個和它相關聯的遮罩。遮罩是一個有alpha值的位圖,而且在合成像素之前需要被應用到層的contents屬性上。當你這頂一個層爲圓角時,一就在設置一個遮罩在這個層上面。然而,我們也可以指定一個任意的遮罩。比如我們有一個形狀像字母A的遮罩。只有CALayer的contents中的和字母A重合的一部分被會被繪製到屏幕。

離屏渲染(Offscreen rendering)

離屏渲染可以被Core Animation 自動觸發或是應用程序手動觸發。離屏渲染繪製layer tree中的一部分到一個新的緩存裏面(這個緩存不是屏幕,是另一個地方),然後再把這個緩存渲染到屏幕上面。

你可能希望強制離屏渲染,特別是計算很複雜的時候。這是一種緩存合成好的紋理或是層的方式。如果你的呈現樹(render tree)是複雜的。那麼就希望強制離屏渲染到緩存這些層,然後再使用緩存合成到屏幕。

如果你的APP有很多層,而且希望增加動畫。GPU一般來說不得不重新合成所有的層在1秒60幀的速度下。當使用離屏渲染時,GPU需要合成這些層到一個新的位圖紋理緩存裏面,然後再用這個紋理繪製到屏幕上面。當這些層一起移動時,GPU可以重複利用這個位圖緩存,這樣就可以提高效率。當然,如果這些層沒有修改的化,纔能有效。如果這些層被修改了,GPU就不得不重新創建這個位圖緩存。你可以觸發這個行爲,通過設置shouldRasterize = YES

這是一個權衡,如果只是繪製一次,那麼這樣做反而會更慢。創建一個額外的緩存對GPU來說是一個額外的工作,特別是如果這個位圖永遠沒有被複用。這個實在是太浪費了。然而,如果這個位圖緩存可以被重用,GPU也可能把緩存刪掉了。所以你需要計算GPU的利用率和幀的速率來判斷這個位圖是否有用

離屏渲染也可以在一些其他場景發生。如果你直接或是間接的給一個層增加了遮罩。Core Animation 會爲了實現遮罩強制做離屏渲染。這個增加了GPU的負擔,因爲一般上來,這些都是直接在屏幕上面渲染的。

Instrument的Core Animation 有一個叫做Color Offscreen-Rendered Yellow的選項。它會將已經被渲染到屏幕外緩衝區的區域標註爲黃色(這個選項在模擬器中也可以用)。同時確保勾選Color Hits Green and Misses Red選項。綠色代表無論何時一個屏幕外緩衝區被複用,而紅色代表當緩衝區被重新創建。

一般來說,你需要避免離屏渲染。因爲這個開銷很大。在屏幕上面直接合成層要比先創建一個離屏緩存然後在緩存上面繪製,最後再繪製緩存到屏幕上面快很多。這裏面有2個上下文環境的切換(切換到屏幕外緩存環境,和屏幕環境)。

所以當你打開Color Offscreen-Rendered Yellow後看到黃色,這便是一個警告,但這不一定是不好的。如果Core Animation能夠複用屏幕外渲染的結果,這便能夠提升性能,當繪製到緩存上面的層沒有被修改的時候,就可以被複用了。

注意,緩存位圖的尺寸大小是有限制的。Apple 提示大約是2倍屏幕的大小。

如果你使用的層引發了離屏渲染,那麼你最好避免這種方式。增加遮罩,設置圓角,設置陰影都造成離屏渲染。

對於遮罩來說,圓角只是一個特殊的遮罩。clipsToBounds 和 masksToBounds 2個屬性而已。你可以簡單的創建一個已經設置好遮罩的層創建內容。比如,使用已經設置了遮罩的圖片。當然,這個也是一種權衡。如果你希望在層的contents屬性這隻一個矩形的遮罩,那你更應該使用contentsRect而不是使用遮罩。

如果你最後這是shouldRasterize = YES,記住還要設置rasterizationScale = contentsScale

更多的關於合成

通常,維基百科上面有許多關於圖像合成的背景知識。我們這裏簡單的拓展一下像素中的紅、綠、藍以及alpha是如何呈現在內存中的。

OSX

如果你在OSX上面工作,你會發現大部分的這些調試選項在一個獨立的叫做“Quartz Debug”的程序裏面。而並不在 Instruments 中。Quartz Debug是Graphics Tools中的一部分,這可以在蘋果的developer portal中下載到。

Core Animation & OpenGL ES

就像名字所建議的那樣,Core Animation 讓我們可以創建屏幕動畫。我們將跳過大部分的動畫,關注於繪製部分。重要的是,Core Animation允許你坐高效的渲染。這就是爲什麼你可以通過Core Animation 實現每秒60幀的動畫。

Core Animation 的核心就是基於OpenGL ES的抽象。簡單說,它讓你使用OpenGL ES的強大能力而不需要知道OpenGL ES的複雜性。當我討論像素合成的時候,我們提到的層(layer)和 紋理(texture)是等價的。他們準確來說不是一個東西,但是缺非常類似。

Core Animation的層可以有多個子層。所以最後形成了一個layer tree。Core Animation做的最複雜的事情就是判斷出那些層需要被繪製或重新繪製,那些層需要OpenGL ES 去合成到屏幕上面。

例如,當你這是一個layer的contents屬性是一個CGImageRef時,Core Animation創建一個OpenGL 紋理,然後確保這個圖片中的位圖上傳到指定的紋理中。或者,你重寫了-drawInContext方法,Core Animation 會分配一個紋理,確保你的Core Graphics的調用將會被作用到這個紋理中。層的 性質和CALayer的子類會影響OpenGL渲染方式的效率。很多底層的OpenGL ES行爲被簡單的封裝到容易理解的CALayer的概念中去。

Core Animation通過Core Graphics和OpenGL ES,精心策劃基於CPU的位圖繪製。因爲Core Animation在渲染過程中處於非常重要的地位,所以如何使用Core Animation,將會對性能產生極大影響。

CPU限制 vs GPU限制(CPU bound vs. GPU bound)

當在屏幕上面顯示的時候,有很多組件都參與其中。這裏面有2個主要的硬件分別是CPU和GPU。P和U的意思就是處理單元。當東西被顯示到屏幕上面是,CPU和GPU都需要處理計算。他們也都受到限制。

爲了能夠達到每秒60幀的效果,你需要確保CPU和GPU都不能過載。也就是說,即使你當前能達到60fps,你還是要儘可能多的繪製工作交給GPU做。CPU需要做其他的應用程序代碼,而不是渲染。通常,GPU的渲染性能要比CPU高效很多,同時對系統的負載和消耗也更低一些。

因爲繪製的性能是基於GPU和CPU的。你需要去分辨哪一個是你繪製的瓶頸。如果你用盡的GPU的資源,GPU是性能的瓶頸,也就是繪製是GPU的瓶頸,反之就是CPU的瓶頸。

如果你是GPU的瓶頸,你需要爲GPU減負(比如把一些工作交給CPU),反之亦然。

如果是GPU瓶頸,可以使用OpenGL ES Driver instrument,然後點擊 i 按鈕。配置一下,同時注意查看Device Utilization % 是否被選中。然後運行app。你會看到GPU的負荷。如果這個數字接近100%,那麼你交給GPU的工作太多了。

CPU瓶頸是更加通常的問題。可以通過Time Profiler instrument,找到問題所在。

Core Graphics / Quartz 2D

通過Core Graphics這個框架名字,Quartz 2D更被人所知。

Quartz 2D 有很多小功能,我們不會在這裏提及。我們不會講有關PDF創建,繪製,解析或打印。只需要瞭解答應PDF和創建PDF和在屏幕上面繪製位圖原理幾乎一致,因爲他們都是基於Quartz 2D。

讓我們簡單瞭解一下Quartz 2D的概念。更多細節可以參考 Apple 的 官方文檔

Quartz 2D是一個處理2D繪製的非常強大的工具。有基於路徑的繪製,反鋸齒渲染,透明圖層,分辨率,並且設備獨立等很多特性。因爲是更爲底層的基於C的API,所以看上去會有一點讓人恐懼。

主要概念是非常簡單的。UIKit和AppKit都封裝了Quartz 2D的一些簡單API,一旦你熟練了,一些簡單C的API也是很容易理解的。最後你可以做一個引擎,它的功能和Photoshop一樣。Apple提到的一個 APP,就是一個很好的Quartz 2D例子。

當你的程序進行位圖繪製時,不管使用哪種方式,都是基於Quartz 2D的。也就是說,CPU通過Quartz 2D繪製。儘管Quartz可以做其他事情,但是我們這裏還是集中於位圖繪製,比如在緩存(一塊內存)繪製位圖會包括RGBA數據。

比方說,我們要畫一個八角形,我們通過UIKit能做到這一點

UIBezierPath *path = [UIBezierPath bezierPath];
[path moveToPoint:CGPointMake(16.72, 7.22)];
[path addLineToPoint:CGPointMake(3.29, 20.83)];
[path addLineToPoint:CGPointMake(0.4, 18.05)];
[path addLineToPoint:CGPointMake(18.8, -0.47)];
[path addLineToPoint:CGPointMake(37.21, 18.05)];
[path addLineToPoint:CGPointMake(34.31, 20.83)];
[path addLineToPoint:CGPointMake(20.88, 7.22)];
[path addLineToPoint:CGPointMake(20.88, 42.18)];
[path addLineToPoint:CGPointMake(16.72, 42.18)];
[path addLineToPoint:CGPointMake(16.72, 7.22)];
[path closePath];
path.lineWidth = 1;
[[UIColor redColor] setStroke];
[path stroke];

Core Graphics 的代碼差不多:

CGContextBeginPath(ctx);
CGContextMoveToPoint(ctx, 16.72, 7.22);
CGContextAddLineToPoint(ctx, 3.29, 20.83);
CGContextAddLineToPoint(ctx, 0.4, 18.05);
CGContextAddLineToPoint(ctx, 18.8, -0.47);
CGContextAddLineToPoint(ctx, 37.21, 18.05);
CGContextAddLineToPoint(ctx, 34.31, 20.83);
CGContextAddLineToPoint(ctx, 20.88, 7.22);
CGContextAddLineToPoint(ctx, 20.88, 42.18);
CGContextAddLineToPoint(ctx, 16.72, 42.18);
CGContextAddLineToPoint(ctx, 16.72, 7.22);
CGContextClosePath(ctx);
CGContextSetLineWidth(ctx, 1);
CGContextSetStrokeColorWithColor(ctx, [UIColor redColor].CGColor);
CGContextStrokePath(ctx);

問題就是,繪製到哪裏呢? 這就是CGContext做的事情。我們傳遞的ctx這個參數。這個context定義了我們繪製的地方。如果我們實現了CALayer的-drawInContext:方法。我們傳遞了一個參數context。在context上面繪製,最後會在layer的一個緩存裏面。我們也可以創建我們自己的context,比如 CGBitmapContextCreate()。這個函數返回一個context,然後我們可以傳遞這個context,然後在剛剛創建的這個context上面繪製。

這裏我們發現,UIKit的代碼並沒有傳遞context。這是因爲UIKit或AppKit的context是隱形的。UIKit和UIKit維護着一個context棧。這些UIKit的方法始終在最上面的context繪製。你可以使用UIGraphicsPushContext()和 UIGraphicsPopContext()來push和pop對應的context。

UIKit有一個簡單的方式,通過 UIGraphicsBeginImageContextWithOptions() 和 UIGraphicsEndImageContext()來創建一個位圖context,和 CGBitmapContextCreate()一樣。混合UIKit和 Core Graphics調用很簡單。

UIGraphicsBeginImageContextWithOptions(CGSizeMake(45, 45), YES, 2);
CGContextRef ctx = UIGraphicsGetCurrentContext();
CGContextBeginPath(ctx);
CGContextMoveToPoint(ctx, 16.72, 7.22);
CGContextAddLineToPoint(ctx, 3.29, 20.83);
...
CGContextStrokePath(ctx);
UIGraphicsEndImageContext();

或其他方式

CGContextRef ctx = CGBitmapContextCreate(NULL, 90, 90, 8, 90 * 4, space, bitmapInfo);
CGContextScaleCTM(ctx, 0.5, 0.5);
UIGraphicsPushContext(ctx);
UIBezierPath *path = [UIBezierPath bezierPath];
[path moveToPoint:CGPointMake(16.72, 7.22)];
[path addLineToPoint:CGPointMake(3.29, 20.83)];
...
[path stroke];
UIGraphicsPopContext(ctx);
CGContextRelease(ctx);

通過 Core Graphics可以做很多有趣的事情。蘋果的文檔有很多例子,我們這裏就不太細說他們了。但是Core Graphics有一個非常接近Adobe Illustrator和Adobe Photoshop如何工作的繪圖模型,並且大多數工具的理念翻譯成Core Graphics了。畢竟這就是NextStep一開始做的。

CGLayer

一件非常值得提起的事,便是CGLayer。它經常被忽視,並且它的名字有時會造成困惑。他不是Photoshop中的圖層的意思,也不是Core Animation中的層的意思。

把CGLayer想象成一個子context。它共用父context的所有特性。你可以獨立於父context,在它自己的緩存中繪製。並且因爲它跟context緊密的聯繫在一起,CGLayer可以被高效的繪製到context中。

什麼時候這將變得有用呢?如果你用Core Graphics來繪製一些相當複雜的,並且部分內容需要被重新繪製的,你只需將那部分內容繪製到CGLayer一次,然後便可繪製這個CGLayer到父context中。這是一個非常優雅的性能竅門。這和我們前面提到的離屏繪製概念有點類似。你需要做出權衡,是否需要爲CGLayer的緩存申請額外的內存,確定這是否對你有所幫助。

像素(Pixels)

屏幕上面的像素是通過3個顏色組成的:紅,綠,藍。因此位圖數據有時候也被成爲RGB數據。你可能想知道這個數據在內存中是什麼樣子。但是實際上,有非常非常多的方式。

後面我們會提到壓縮,這個和下面講得完全不一樣。現在我們看一下RGB位圖數據。RGB位圖數據的每一個值有3個組成部分,紅,綠,藍。更多的時候,我們有4個組成部分,紅,綠,藍,alpha。這裏我們講4個組成部分的情況。

默認的像素佈局

iOS和OS X上面的最通常的文件格式是32 bits-per-pixel (bpp),8 bits-per-component (bpc),alpha會被預先計算進去。在內存裏面像這個樣子

  A   R   G   B   A   R   G   B   A   R   G   B  
| pixel 0       | pixel 1       | pixel 2   
  0   1   2   3   4   5   6   7   8   9   10  11 ...

這個格式經常被叫做ARGB。每一個像素使用4個字節,每一個顏色組件1個字節。每一個像素有一個alpha值在R,G,B前面。最後RGB分別預先乘以alpha。如果我們有一個橘黃的顏色。那麼看上去就是 240,99,24. ARGB就是 255,240,99,24 如果我們有一個同樣的顏色,但是alpha是0.33,那麼最後的就是 ARGB就是 84,80,33,8

另一個常見的格式是32bpp,8bpc,alpha被跳過了:

  x   R   G   B   x   R   G   B   x   R   G   B  
| pixel 0       | pixel 1       | pixel 2   
  0   1   2   3   4   5   6   7   8   9   10  11 ...

這個也被稱爲xRGB。像素並沒有alpha(也就是100%不透明),但是內存結構是相同的。你可能奇怪爲什麼這個格式很流行。因爲如果我們把這個沒用的字節從像素中去掉,我們可以節省25%的空間。實際上,這個格式更適合現代的CPU和圖像算法。因爲每一個獨立的像素和32字節對齊。現代CPU不喜歡讀取不對其的數據。算法會處理大量的位移,特別是這個格式和ARGB混合在一起的時候。

當處理xRGB時,Core Graphic也需要支持把alpha放到最後的格式,比如RGBA,RGBx(RGB已經預先乘以alpha的格式)

深奧的佈局

大多數時候,當我們處理位圖數據時,我們就在使用Core Graphic 或是 Quartz 2D。有一個列表包括了所有的支持的文件格式。讓我們先看一下剩餘的RGB格式。

有16bpp,5bpc,不包括alpha。這個格式比之前節省50%空間(2個字節一個像素)。但是如果解壓成RGB數據在內存裏面或是磁盤上面就有用了。但是,因爲只有5個字節一個像素,圖像特別是一些平滑的漸變,可能就混合到一起了。(圖像質量下降)。

還有一個是64bpp,16bpc,最終爲128bpp,32bpc,浮點數組件(有或沒有alpha值)。它們分別使用8字節和16字節,並且允許更高的精度。當然,這會造成更多的內存和更復雜的計算。

最後,Core Graphics 也支持一些其他格式,比如CMYK,還有一些只有alpha的格式,比如之前提到的遮罩。

平面數據 (Planar Data color plane)

大多數的框架(包括 Core Graphics)使用的像素格式是混合起來的。這就是所謂的 planar components, or component planes。每一個顏色組件都在內存中的一個區域。比如,對於RGB數據。我們有3個獨立的內存空間,分別保存紅色,綠色,和藍色的數值。

在某些情況下,一些視頻框架會使用 Planar Data。

YCbCr

YCbCr 是一個常見的視頻格式。同樣有3個部分組成(Y,Cb,Cr)。但是它更傾向於人眼識別的顏色。人眼是很難精確識別出來Cb和Cr的色彩度。但是卻能很容易識別出來Y的亮度。在相同的質量下,Cb和Cr要比Y壓縮的更多。

JPEG有時候把RGB格式轉換爲YCbCr格式。JPEG單獨壓縮每一個color plane。當壓縮YCbCr格式時,Cb和Cr比Y壓縮得更好。

圖片格式

iOS和OSX上面的大多數圖片都是JPEG和PNG格式。下面我們再瞭解一下。

JPEG

每個人都知道JPEG,他來自相機。他代表了圖片是圖和存儲在電腦裏,即時是你的媽媽也聽過JPEG。

大家都認爲JPEG就是一個像素格式。就像我們之前提到的RGB格式一樣,但是實際上並不是這樣。

真正的JPEG數據變成像素是一個非常複雜的過程。一個星期都沒有辦法講清楚,或是更久。對於一個color plane, JPEG使用一種離散餘弦變換的算法。講空間信息轉換爲頻率(convert spatial information into the frequency domain)。然後通過哈夫曼編碼的變種來壓縮。一開始會把RGB轉換成YCbCr,解壓縮的時候,再反過來。

這就是爲什麼從一個JPEG文件創建一個UIImage然後會知道屏幕上面會有一點點延遲的原因。因爲CPU正在忙於解壓圖片。如果每個TableViewCell都需要解壓圖片的話,那麼你的滾動效果就不會平滑。

那麼,爲什麼使用JPEG文件呢?因爲JPEG可以把圖片壓縮的非常非常好。一個沒有壓縮過的IPhone5拍照的圖片差不多24MB。使用默認的壓縮設置,這個只有2-3MB。JPEG壓縮效果非常好,因爲幾乎沒有損失。他把那些人眼不能識別的部分去掉了。這樣做可以遠遠的超過gzip這樣的壓縮算法。但是,這個僅僅在圖片上面有效。因爲,JPEG依賴於丟掉那些人眼無法識別的數據。如果你從一個基本是文本的網頁截取一張圖片,JPEG就不會那麼高效,壓縮效率會變得低下。你甚至都可以看出圖片已經變形了。

PNG

PNG讀作“ping”,和JPEG相反,他是無損壓縮的。當你保存圖片成PNG時,然後再打開。所有的像素數據和之前的完全一樣。因爲有這個限制,所有PNG壓縮圖片的效果沒有JPEG那麼好。但是對於app中的設計來說,比如按鈕,icon,PNG就非常適合。而且PNG的解碼工作要比JPEG簡單很多。

在真實的世界裏面,事情沒有這麼簡單。有很多不同的PNG格式。維基百科上面有很多細節。但是簡單說,PNG支持壓縮有alpha或是沒有alpha通道的RGB像素,這也就是爲什麼他適合app上面的原因。

格式挑選

當在app中使用顏色是,你需要使用者2種格式中得一個,PNG和JPEG。他們的解碼和壓縮算法都是被高度硬件優化的。有些情況甚至支持並行計算。同時Apple也在不斷地提高解碼的能力在未來的操作系統版本中。如果使用其他格式,這可能會對你的程序性能產生影響,而且可能會產生漏洞,因爲圖像解碼的算法是黑客們最喜歡攻擊的目標。

已經講了好多有關PNG的優化了,你可以在互聯網上面自己查找。這裏需要注意一點,Xcode的壓縮算法和大部分的壓縮引擎不一樣。

當Xcode壓縮png時,技術上來說,已經不是一個有效的PNG文件了。但是iOS系統可以讀取這個文件,然後比通常的PNG圖片處理速度更快。Xcode這樣做,是爲了更好地利用解碼算法,而這些解碼算法不能在一般的PNG文件上面適用。就像上面提到的,有非常多的方法去表示RGB數據。而且如果這個格式不是iOS圖形圖像系統需要的,那麼就需要增加額外的計算。這樣就不會有性能上的提高了。

再搶到一次,如果你可以,你需要設置 resizable images。你的文件會變得更小,因此,這樣就會有更小的文件需要從文件系統裏面讀取,然後在解碼。

UIKit and Pixels

UIKit中得每一個view都有自己的CALayer,一般都有一個緩存,也就是位圖,有一點類似圖片。這個緩存最後會被繪製到屏幕上面。

With -drawRect:

如果你的自定義view的類實現了-drawRest:,那麼就是這樣子工作的:

當你調用-setNeedsDisplay時,UIKit會調用這個view的層的 -setNeedsDisplay方法。這個設置一個標記,表明這個層已經髒了(dirty,被修改了)。實際上,並沒有做任何事情,所以,調用多次-setNeedsDisplay 沒有任何問題。

當渲染系統準備好後,會調用層的-display方法。這時,層會設置緩存。然後設置緩存的Core Graphics的上下文環境(CGContextRef)。後面的繪製會通過這個CGContextRef繪製到緩存中。

當你調用UIKit中的函數,比如UIRectFill() 或者 -[UIBezierPath fill]時,會通過這個CGContextRef調用你的drawRect方法。他們是通過把上面的CGContextRef push 到 圖形圖像堆棧中,也就是設置成當前的上下文環境。UIGraphicsGetCurrent()會返回剛纔push的那個context。由於UIKit繪製方法使用UIGraphicsGetCurrent(),所以這些繪製會被繪製到緩存中。如果你希望直接使用 Core Graphics 方法,那麼你需要調用UIGraphicsGetCurrent()方法,然後自己手動傳遞context參數到Core Graphics的繪製函數中去。

那麼,一個個層的緩存都會被繪製到屏幕上面,知道下一次設置-setNeedsDisplay,然後再重新更新緩存,再重複上面的過程。

不使用 drawRect

當你使用UIImageView的時候,有一點點的不同。這個view依然包含一個CALayer,但是這個層並不會分配一個緩存空間。而是使用CGImageRef作爲CALayer的contents屬性,渲染系統會把這個圖片繪製到幀的緩存,比如屏幕。

這個情況下,就沒有繼續繪製的過程了。我們就是簡單的通過傳遞位圖這種方式把圖片傳遞給UIImageView,然後傳遞給Core Animation,然後傳遞給渲染系統。

使用drawRect 還是不使用drawRect

聽上去不怎麼樣,但是,最快速的方法,就是不使用。

大多數情況,你可以通過自定義view或是組合其他層來實現。可以看一下Chris的文章,有關自定義控件。這個方法是推薦的,因爲UIKit非常高效。

當你需要自定義繪製的時候 WWDC2012 session 506 Optimizing 2D Graphics and Animation Performance是一個非常好的例子 。

另一個地方需要自定義繪製的是iOS的股票軟件。這個股票圖是通過Core Graphics實現的。注意,這個只是你需要自定義繪製,並不是一定要實現drawRect函數,有時候通過UIGraphicsBeginImageContextWithOptions()或是 CGBitmapContextCreate()創建一個額外的位圖,然後再上面繪製圖片,然後傳遞給CALayer的contents會更容易。下面有一個測試例子

單色

這是一個簡單的例子

// Don't do this
- (void)drawRect:(CGRect)rect
{
    [[UIColor redColor] setFill];
    UIRectFill([self bounds]);
}

我們知道爲什麼這樣做很爛,我們讓Core Animation創建了一個額外的緩存,然後我們讓Core Graphics 在緩存上面填充了一個顏色。然後上傳給了GPU。

我們可以不實現-drawRect:函數來省去這些步驟。只是簡單的設置view的backgroundColor就好了。如果這個view有CAGradientLayer,那麼同樣的方法也可以設置成漸變的顏色。

可變大小的圖片(resizable image)

你可以簡單的通過可變大小的圖片減少圖形系統的工作壓力。如果你原圖上面的按鈕大小是300*50。那麼就有 600 * 100 = 60k 像素 * 4 = 240KB的內存數據需要傳遞給GPU。傳遞給顯存。如果我們使用resizable image。我們可以使用一個 52 * 12 大小的圖片,這樣可以節省10kb的內存。這樣會更快。

Core Animation 通過 contentsCenter 來resize圖片,但是,更簡單的是通過 -[UIImage resizableImageWithCapInsets:resizingMode:]。

而且,在第一次繪製的時候,我們並不需要從文件系統讀取60K像素的PNG文件,然後解碼。越小的圖片解碼越快。這樣,我們的app就可以啓動的更快。

併發繪製

上一個我們講到了併發。UIKit的線程模型非常簡單,你只能在主線程使用UIKit。所以,這裏面還能有併發的概念?

如果你不得不實現-drawRect:,並且你必須繪製大量的東西,而這個會花費不少時間。而且你希望動畫變得更平滑,除了在主線程中,你還希望在其他線程中做一些工作。併發的繪圖是複雜的,但是除了幾個警告,併發的繪圖還是比較容易實現的。

你不能在CAlayer的緩存裏面做任何事情出了主線程,否則不好的事情會發生。但是你可以在一個獨立的位圖上面繪製。

所有的Core Graphics的繪製方法需要一個context參數,指定這個繪製到那裏去。UIKit有一個概念是繪製到當前的context上。而這個當前的context是線程獨立的。

爲了實現異步繪製,我們做下面的事情。我們在其他隊列(queue,GCD中的概念)中創建一個圖片,然後我們切換到主隊列中把結果傳遞給UIImageView。這個技術被 WWDC 2012 session 211中提到

- (UIImage *)renderInImageOfSize:(CGSize)size;
{
    UIGraphicsBeginImageContextWithOptions(size, NO, 0);

    // do drawing here

    UIImage *result = UIGraphicsGetImageFromCurrentImageContext();
    UIGraphicsEndImageContext();
    return result;
}

這個函數通過UIGraphicsBeginImageContextWithOptions 創建一個新的CGContextRef。這個函數也修改了當前的UIKit的context。然後可以銅鼓UIKit的方法繪製,然後通過UIGraphicsGetImageFromCurrentImageContext()根據位圖數據生成一個UIImage。然後關閉掉創建的這個context。

保證線程安全是非常重要的,比如你訪問UIKit的屬性,必須線程安全。如果你在其他隊列調用這個方法,而這個方法在你的view類裏面,這個事情就可能古怪了。更簡單的方法是創建一個獨立的渲染類,然後當觸發繪製這個圖片的時候才設置這些必須得屬性。

但是UIKit的繪製函數是可以在其他隊列中調用的,只需要保證這些操作在 UIGraphicsBeginImageContextWithOptions() 和 UIGraphicsEndImageContext ()之前就好。

你可以通過下面的方法觸發繪製

UIImageView *view; // assume we have this
NSOperationQueue *renderQueue; // assume we have this
CGSize size = view.bounds.size;
[renderQueue addOperationWithBlock:^(){
    UIImage *image = [renderer renderInImageOfSize:size];
    [[NSOperationQueue mainQueue] addOperationWithBlock:^(){
        view.image = image;
    }];
}];

注意view.image = image; 必須在主隊列調用。這是非常重要的細節。你不能在其他隊列中調用。

通常來說,異步繪製會帶來很多複雜度。你需要實現取消繪製的過程。你還需要限制異步操作的最大數目。

所以,最簡單的就是通過NSOperation的子類來實現renderInImageOfSize方法。

最後,有一點非常重要的就是異步設置UITableViewCell 的content有時候很詭異。因爲當異步繪製結束的時候,這個Cell很可能已經被重用到其他地方了。

CALayer的奇怪和最後

現在你是到了CALayer某種程度上很像GPU中的紋理。層有自己的緩存,緩存就是一個會被繪製到屏幕上的位圖。 大多數情況,當你使用CALayer時,你會設置contents屬性給一個圖片。這個意思就是告訴 Core Animation,使用這個圖片的位圖數據作爲紋理。 如果這個圖片是PNG或JPEG,Core Animation 會解碼,然後上傳到GPU。

當然,還有其他種類的層,如果你使用CALayer,不設置contents,而是這事background color, Core Animation不會上傳任何數據給GPU,當然這些工作還是要被GPU運算的,只是不需要具體的像素數據,同理,漸變也是一個道理,不需要把像素上傳給GPU。

圖層和自定義繪製

如果CALayer或是子類實現了 -drawInContext 或是-drawLayer:inContext delegate。Core Animation會爲這個layer創建一個緩存,用來保存這些函數中繪製的結果。這些代碼是在CPU上面運行的,結果會被傳遞給GPU。

形狀和文本層(Shape and Text Layers)

形狀和文本層會有一點不同。首先,Core Animation 會爲每一個層生成一個位圖文件用來保存這些數據。然後Core Animation 會繪製到layer的緩存上面。如果你實現了-drawInContext方法,結果和上面提到的一樣。最後性能會受到很大影響。

當你修改形狀層或是文本層導致需要更新layer的緩存時,Core Animation會重新渲染緩存,比如。當實現shape layer的大小動畫時,Core Animation會在動畫的每一幀中重新繪製形狀。

異步繪製

CALayer 有一個屬性是 drawsAsynchronously。這個似乎看上去很不錯,可以解決所有問題。實際上雖然可能會提高效率,但是可能會讓事情更慢。

當你設置 drawsAsynchronously = YES 後,-drawRect: 和 -drawInContext: 函數依然實在主線程調用的。但是所有的Core Graphics函數(包括UIKit的繪製API,最後其實還是Core Graphics的調用)不會做任何事情,而是所有的繪製命令會被在後臺線程處理。

這種方式就是先記錄繪製命令,然後在後臺線程執行。爲了實現這個過程,更多的事情不得不做,更多的內存開銷。最後只是把一些工作從主線程移動出來。這個過程是需要權衡,測試的。

這個可能是代價最昂貴的的提高繪製性能的方法,也不會節省很多資源。

轉載地址:http://segmentfault.com/a/1190000000390012
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章