iOS 核心動畫的高效繪圖
高效繪圖
不必要的效率考慮往往是效能問題的萬惡之源。——William Allan Wulf
如果你依然在程式設計的世界裡迷茫,不知道自己的未來規劃,小編給大家推薦一個IOS高階交流群:458839238 裡面可以與大神一起交流並走出迷茫。小白可進群免費領取學習資料,看看前輩們是如何在程式設計的世界裡傲然前行!
群內提供資料結構與演算法、底層進階、swift、逆向、整合面試題等免費資料
附上一份收集的各大廠面試題(附答案) ! 群檔案直接獲取
各大廠面試題
在第12章『速度的曲率』我們學習如何用Instruments來診斷Core Animation效能問題。在構建一個iOS app的時候會遇到很多潛在的效能陷阱,但是在本章我們將著眼於有關 繪製 的效能問題。
軟體繪圖
術語 繪圖 通常在Core Animation的上下文中指代軟體繪圖(意即:不由GPU協助的繪圖)。在iOS中,軟體繪圖通常是由Core Graphics框架完成來完成。但是,在一些必要的情況下,相比Core Animation和OpenGL,Core Graphics要慢了不少。
軟體繪圖不僅效率低,還會消耗可觀的記憶體。 CALayer
只需要一些與自己相關的記憶體:只有它的寄宿圖會消耗一定的記憶體空間。即使直接賦給 contents
屬性一張圖片,也不需要增加額外的照片儲存大小。如果相同的一張圖片被多個圖層作為 contents
屬性,那麼他們將會共用同一塊記憶體,而不是複製記憶體塊。
但是一旦你實現了 CALayerDelegate
協議中的 -drawLayer:inContext:
方法或者 UIView
中的 -drawRect:
方法(其實就是前者的包裝方法),圖層就建立了一個繪製上下文,這個上下文需要的大小的記憶體可從這個算式得出:圖層寬 圖層高 4位元組,寬高的單位均為畫素。對於一個在Retina iPad上的全屏圖層來說,這個記憶體量就是 2048 1526 4位元組,相當於12MB記憶體,圖層每次重繪的時候都需要重新抹掉記憶體然後重新分配。
軟體繪圖的代價昂貴,除非絕對必要,你應該避免重繪你的檢視。提高繪製效能的祕訣就在於儘量避免去繪製。
向量圖形
我們用Core Graphics來繪圖的一個通常原因就是隻是用圖片或是圖層效果不能輕易地繪製出向量圖形。向量繪圖包含一下這些:
- 任意多邊形(不僅僅是一個矩形)
- 斜線或曲線
- 文字
- 漸變
舉個例子,清單13.1 展示了一個基本的畫線應用。這個應用將使用者的觸控手勢轉換成一個 UIBezierPath
上的點,然後繪製成檢視。我們在一個 UIView
子類 DrawingView
中實現了所有的繪製邏輯,這個情況下我們沒有用上view controller。但是如果你喜歡你可以在view controller中實現觸控事件處理。圖13.1是程式碼執行結果。
清單13.1 用Core Graphics實現一個簡單的繪圖應用
#import "DrawingView.h" @interface DrawingView () @property (nonatomic, strong) UIBezierPath *path; @end @implementation DrawingView - (void)awakeFromNib { //create a mutable path self.path = [[UIBezierPath alloc] init]; self.path.lineJoinStyle = kCGLineJoinRound; self.path.lineCapStyle = kCGLineCapRound; self.path.lineWidth = 5; } - (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event { //get the starting point CGPoint point = [[touches anyObject] locationInView:self]; //move the path drawing cursor to the starting point [self.path moveToPoint:point]; } - (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event { //get the current point CGPoint point = [[touches anyObject] locationInView:self]; //add a new line segment to our path [self.path addLineToPoint:point]; //redraw the view [self setNeedsDisplay]; } - (void)drawRect:(CGRect)rect { //draw path [[UIColor clearColor] setFill]; [[UIColor redColor] setStroke]; [self.path stroke]; } @end

圖13.1
圖13.1 用Core Graphics做一個簡單的『素描』這樣實現的問題在於,我們畫得越多,程式就會越慢。因為每次移動手指的時候都會重繪整個貝塞爾路徑( UIBezierPath
),隨著路徑越來越複雜,每次重繪的工作就會增加,直接導致了幀數的下降。看來我們需要一個更好的方法了。Core Animation為這些圖形型別的繪製提供了專門的類,並給他們提供硬體支援(第六章『專有圖層』有詳細提到)。 CAShapeLayer
可以繪製多邊形,直線和曲線。 CATextLayer
可以繪製文字。 CAGradientLayer
用來繪製漸變。這些總體上都比Core Graphics更快,同時他們也避免了創造一個寄宿圖。如果稍微將之前的程式碼變動一下,用 CAShapeLayer
替代Core Graphics,效能就會得到提高(見清單13.2).雖然隨著路徑複雜性的增加,繪製效能依然會下降,但是隻有當非常非常浮躁的繪製時才會感到明顯的幀率差異。清單13.2 用 CAShapeLayer
重新實現繪圖應用
#import "DrawingView.h" #import <QuartzCore/QuartzCore.h> @interface DrawingView () @property (nonatomic, strong) UIBezierPath *path; @end @implementation DrawingView + (Class)layerClass { //this makes our view create a CAShapeLayer //instead of a CALayer for its backing layer return [CAShapeLayer class]; } - (void)awakeFromNib { //create a mutable path self.path = [[UIBezierPath alloc] init]; //configure the layer CAShapeLayer *shapeLayer = (CAShapeLayer *)self.layer; shapeLayer.strokeColor = [UIColor redColor].CGColor; shapeLayer.fillColor = [UIColor clearColor].CGColor; shapeLayer.lineJoin = kCALineJoinRound; shapeLayer.lineCap = kCALineCapRound; shapeLayer.lineWidth = 5; } - (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event { //get the starting point CGPoint point = [[touches anyObject] locationInView:self]; //move the path drawing cursor to the starting point [self.path moveToPoint:point]; } - (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event { //get the current point CGPoint point = [[touches anyObject] locationInView:self]; //add a new line segment to our path [self.path addLineToPoint:point]; //update the layer with a copy of the path ((CAShapeLayer *)self.layer).path = self.path.CGPath; } @end
髒矩形
有時候用 CAShapeLayer
或者其他向量圖形圖層替代Core Graphics並不是那麼切實可行。比如我們的繪圖應用:我們用線條完美地完成了向量繪製。但是設想一下如果我們能進一步提高應用的效能,讓它就像一個黑板一樣工作,然後用『粉筆』來繪製線條。模擬粉筆最簡單的方法就是用一個『線刷』圖片然後將它貼上到使用者手指碰觸的地方,但是這個方法用 CAShapeLayer
沒辦法實現。我們可以給每個『線刷』建立一個獨立的圖層,但是實現起來有很大的問題。螢幕上允許同時出現圖層上線數量大約是幾百,那樣我們很快就會超出的。這種情況下我們沒什麼辦法,就用Core Graphics吧(除非你想用OpenGL做一些更復雜的事情)。我們的『黑板』應用的最初實現見清單13.3,我們更改了之前版本的 DrawingView
,用一個畫刷位置的陣列代替 UIBezierPath
。圖13.2是執行結果清單13.3 簡單的類似黑板的應用
#import "DrawingView.h" #import <QuartzCore/QuartzCore.h> #define BRUSH_SIZE 32 @interface DrawingView () @property (nonatomic, strong) NSMutableArray *strokes; @end @implementation DrawingView - (void)awakeFromNib { //create array self.strokes = [NSMutableArray array]; } - (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event { //get the starting point CGPoint point = [[touches anyObject] locationInView:self]; //add brush stroke [self addBrushStrokeAtPoint:point]; } - (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event { //get the touch point CGPoint point = [[touches anyObject] locationInView:self]; //add brush stroke [self addBrushStrokeAtPoint:point]; } - (void)addBrushStrokeAtPoint:(CGPoint)point { //add brush stroke to array [self.strokes addObject:[NSValue valueWithCGPoint:point]]; //needs redraw [self setNeedsDisplay]; } - (void)drawRect:(CGRect)rect { //redraw strokes for (NSValue *value in self.strokes) { //get point CGPoint point = [value CGPointValue]; //get brush rect CGRect brushRect = CGRectMake(point.x - BRUSH_SIZE/2, point.y - BRUSH_SIZE/2, BRUSH_SIZE, BRUSH_SIZE); //draw brush stroke [[UIImage imageNamed:@"Chalk.png"] drawInRect:brushRect]; } } @end

圖13.2
圖13.2 用程式繪製一個簡單的『素描』這個實現在模擬器上表現還不錯,但是在真實裝置上就沒那麼好了。問題在於每次手指移動的時候我們就會重繪之前的線刷,即使場景的大部分並沒有改變。我們繪製地越多,就會越慢。隨著時間的增加每次重繪需要更多的時間,幀數也會下降(見圖13.3),如何提高效能呢?

圖13.3
圖13.3 幀率和線條質量會隨時間下降。
為了減少不必要的繪製,Mac OS和iOS裝置將會把螢幕區分為需要重繪的區域和不需要重繪的區域。那些需要重繪的部分被稱作『髒區域』。在實際應用中,鑑於非矩形區域邊界裁剪和混合的複雜性,通常會區分出包含指定檢視的矩形位置,而這個位置就是『髒矩形』。當一個檢視被改動過了,TA可能需要重繪。但是很多情況下,只是這個檢視的一部分被改變了,所以重繪整個寄宿圖就太浪費了。但是Core Animation通常並不瞭解你的自定義繪圖程式碼,它也不能自己計算出髒區域的位置。然而,你的確可以提供這些資訊。當你檢測到指定檢視或圖層的指定部分需要被重繪,你直接呼叫 -setNeedsDisplayInRect:
來標記它,然後將影響到的矩形作為引數傳入。這樣就會在一次檢視重新整理時呼叫檢視的 -drawRect:
(或圖層代理的 -drawLayer:inContext:
方法)。傳入 -drawLayer:inContext:
的 CGContext
引數會自動被裁切以適應對應的矩形。為了確定矩形的尺寸大小,你可以用 CGContextGetClipBoundingBox()
方法來從上下文獲得大小。呼叫 -drawRect()
會更簡單,因為 CGRect
會作為引數直接傳入。你應該將你的繪製工作限制在這個矩形中。任何在此區域之外的繪製都將被自動無視,但是這樣CPU花在計算和拋棄上的時間就浪費了,實在是太不值得了。相比依賴於Core Graphics為你重繪,裁剪出自己的繪製區域可能會讓你避免不必要的操作。那就是說,如果你的裁剪邏輯相當複雜,那還是讓Core Graphics來代勞吧,記住:當你能高效完成的時候才這樣做。清單13.4 展示了一個 -addBrushStrokeAtPoint:
方法的升級版,它只重繪當前線刷的附近區域。另外也會重新整理之前線刷的附近區域,我們也可以用 CGRectIntersectsRect()
來避免重繪任何舊的線刷以不至於覆蓋已更新過的區域。這樣做會顯著地提高繪製效率(見圖13.4)清單13.4 用 -setNeedsDisplayInRect:
來減少不必要的繪製
- (void)addBrushStrokeAtPoint:(CGPoint)point { //add brush stroke to array [self.strokes addObject:[NSValue valueWithCGPoint:point]]; //set dirty rect [self setNeedsDisplayInRect:[self brushRectForPoint:point]]; } - (CGRect)brushRectForPoint:(CGPoint)point { return CGRectMake(point.x - BRUSH_SIZE/2, point.y - BRUSH_SIZE/2, BRUSH_SIZE, BRUSH_SIZE); } - (void)drawRect:(CGRect)rect { //redraw strokes for (NSValue *value in self.strokes) { //get point CGPoint point = [value CGPointValue]; //get brush rect CGRect brushRect = [self brushRectForPoint:point]; //only draw brush stroke if it intersects dirty rect if (CGRectIntersectsRect(rect, brushRect)) { //draw brush stroke [[UIImage imageNamed:@"Chalk.png"] drawInRect:brushRect]; } } }

圖13.4
圖13.4 更好的幀率和順滑線條
非同步繪製
UIKit的單執行緒天性意味著寄宿圖通暢要在主執行緒上更新,這意味著繪製會打斷使用者互動,甚至讓整個app看起來處於無響應狀態。我們對此無能為力,但是如果能避免使用者等待繪製完成就好多了。針對這個問題,有一些方法可以用到:一些情況下,我們可以推測性地提前在另外一個執行緒上繪製內容,然後將由此繪出的圖片直接設定為圖層的內容。這實現起來可能不是很方便,但是在特定情況下是可行的。Core Animation提供了一些選擇: CATiledLayer
和 drawsAsynchronously
屬性。
CATiledLayer
我們在第六章簡單探索了一下 CATiledLayer
。除了將圖層再次分割成獨立更新的小塊(類似於髒矩形自動更新的概念), CATiledLayer
還有一個有趣的特性:在多個執行緒中為每個小塊同時呼叫 -drawLayer:inContext:
方法。這就避免了阻塞使用者互動而且能夠利用多核心新片來更快地繪製。只有一個小塊的 CATiledLayer
是實現非同步更新圖片檢視的簡單方法。
drawsAsynchronously
iOS 6中,蘋果為 CALayer
引入了這個令人好奇的屬性, drawsAsynchronously
屬性對傳入 -drawLayer:inContext:
的CGContext進行改動,允許CGContext延緩繪製命令的執行以至於不阻塞使用者互動。它與 CATiledLayer
使用的非同步繪製並不相同。它自己的 -drawLayer:inContext:
方法只會在主執行緒呼叫,但是CGContext並不等待每個繪製命令的結束。相反地,它會將命令加入佇列,當方法返回時,在後臺執行緒逐個執行真正的繪製。根據蘋果的說法。這個特性在需要頻繁重繪的檢視上效果最好(比如我們的繪圖應用,或者諸如 UITableViewCell
之類的),對那些只繪製一次或很少重繪的圖層內容來說沒什麼太大的幫助。
總結
本章我們主要圍繞用Core Graphics軟體繪製討論了一些效能挑戰,然後探索了一些改進方法:比如提高繪製效能或者減少需要繪製的數量。第14章,『影象IO』,我們將討論圖片的載入效能。