核心動畫(13)高效繪圖
- 高效繪圖
-
- 軟體繪圖
- 矢量圖形
- 髒矩形
- 異步繪制
-
- CATiledLayer
- drawsAsynchronously
高效繪圖
不必要的效率考慮往往是性能問題的萬惡之源。
——William Allan Wulf
在第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
[外鍊圖檔轉存失敗,源站可能有防盜鍊機制,建議将圖檔儲存下來直接上傳(img-iHNUV8bw-1571318463019)(evernotecid://0294FA8E-C4A1-4E5C-AB3D-D7C21715A372/appyinxiangcom/25888792/ENResource/p539)]
圖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
[外鍊圖檔轉存失敗,源站可能有防盜鍊機制,建議将圖檔儲存下來直接上傳(img-6vn7vR9d-1571318463020)(evernotecid://0294FA8E-C4A1-4E5C-AB3D-D7C21715A372/appyinxiangcom/25888792/ENResource/p540)]
圖13.2 用程式繪制一個簡單的『素描』
這個實作在模拟器上表現還不錯,但是在真實裝置上就沒那麼好了。問題在于每次手指移動的時候我們就會重繪之前的線刷,即使場景的大部分并沒有改變。我們繪制地越多,就會越慢。随着時間的增加每次重繪需要更多的時間,幀數也會下降(見圖13.3),如何提高性能呢?
[外鍊圖檔轉存失敗,源站可能有防盜鍊機制,建議将圖檔儲存下來直接上傳(img-vJHG4TkP-1571318463021)(evernotecid://0294FA8E-C4A1-4E5C-AB3D-D7C21715A372/appyinxiangcom/25888792/ENResource/p541)]
圖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];
}
}
}
[外鍊圖檔轉存失敗,源站可能有防盜鍊機制,建議将圖檔儲存下來直接上傳(img-95Fr9bcj-1571318463021)(evernotecid://0294FA8E-C4A1-4E5C-AB3D-D7C21715A372/appyinxiangcom/25888792/ENResource/p542)]
圖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』,我們将讨論圖檔的載入性能。