iOS核心動畫高級技巧--(十三)高效繪圖

第12章『速度的曲率』我們學習如何用Instruments來診斷Core Animation性能問題。在構建一個iOS app的時候會遇到很多潛在的性能陷阱,但是在本章我們將 著眼于有關繪制的性能問題。

軟件繪圖

術語繪圖通常在Core Animation的上下文中指代軟件繪圖(意即:不由GPU協助 的繪圖)。在iOS中,軟件繪圖通常是由Core Graphics框架完成來完成。但是,在一些必要的情況下,相比Core AnimationOpenGLCore Graphics要慢了不少。
軟件繪圖不僅效率低,還會消耗可觀的內存。CALayer只需要一些與自己相關 的內存:只有它的寄宿圖會消耗一定的內存空間。即使直接賦給 contents屬性一 張圖片,也不需要增加額外的照片存儲大小。如果相同的一張圖片被多個圖層作為 contents 屬性,那么他們將會共用同一塊內存,而不是復制內存塊。
但是一旦你實現了CALayerDelegate 協議中的 -drawLayer:inContext:方 法或者 UIView 中的- drawRect: 方法(其實就是前者的包裝方法),圖層就創 建了一個繪制上下文,這個上下文需要的大小的內存可從這個算式得出:圖層寬*圖 層高*4字節,寬高的單位均為像素。對于一個在Retina iPad上的全屏圖層來說,這 個內存量就是 2048*1526*4字節,相當于12MB內存,圖層每次重繪的時候都需要 重新抹掉內存然后重新分配。
軟件繪圖的代價昂貴,除非絕對必要,你應該避免重繪你的視圖。提高繪制性能的秘訣就在于盡量避免去繪制。

矢量圖形

我們用Core Graphics來繪圖的一個通常原因就是只是用圖片或是圖層效果不能 輕易地繪制出矢量圖形。矢量繪圖包含一下這些:

  • 任意多邊形(不僅僅是一個矩形)
  • 斜線或曲線
  • 文本
  • 漸變

舉個例子,一個基本的畫線應用。這個應用將用戶的觸摸手勢轉換成一個UIBezierPath上的點,然后繪制成視圖。我們在一個 UIView子類DrawingView中實現了所有的繪制邏輯,這個情況下我們沒有用上view controller。但是如果你喜歡你可以在view controller中實現觸摸事件處理。

#import "DrawingView.h"

@interface DrawingView()
@property (nonatomic, strong) UIBezierPath *path;
@end


@implementation DrawingView

- (void)awakeFromNib{
    [super awakeFromNib];
    self.path = [[UIBezierPath alloc]init];
    self.path.lineJoinStyle = kCGLineJoinRound;
    self.path.lineCapStyle = kCGLineCapRound;
    self.path.lineWidth = 5.0f;
}

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
    CGPoint point = [[touches anyObject] locationInView:self];
    [self.path moveToPoint:point];
}

- (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
    CGPoint point  = [[touches anyObject] locationInView:self];
    [self.path addLineToPoint:point];
    [self setNeedsDisplay];
    
}

- (void)drawRect:(CGRect)rect{
    [[UIColor clearColor] setFill];
    [[UIColor redColor] setStroke];
    [self.path stroke];
}
@end
用Core Graphics實現一個簡單的繪圖應用.gif

這樣實現的問題在于,我們畫得越多,程序就會越慢。因為每次移動手指的時候 都會重繪整個貝塞爾路徑(UIBezierPath),隨著路徑越來越復雜,每次重繪 的工作就會增加,直接導致了幀數的下降。看來我們需要一個更好的方法了。

Core Animation為這些圖形類型的繪制提供了專門的類,并給他們提供硬件支持 (第六章『專有圖層』有詳細提到)CAShapeLayer可以繪制多邊形,直線和 曲線。 CATextLayer 可以繪制文本。CAGradientLayer 用來繪制漸變。這些總體上都比Core Graphics更快,同時他們也避免了創造一個寄宿圖。

如果稍微將之前的代碼變動一下,用 CAShapeLayer替代Core Graphics,性能 就會得到提高.雖然隨著路徑復雜性的增加,繪制性能依然會下降, 但是只有當非常非常浮躁的繪制時才會感到明顯的幀率差異。

#import "DrawingView.h"

@interface DrawingView()
@property (nonatomic, strong) UIBezierPath *path;
@end


@implementation DrawingView

+ (Class)layerClass{
    
    return [CAShapeLayer class];
}

- (void)awakeFromNib{
    [super awakeFromNib];
    self.path = [[UIBezierPath alloc]init];
  
    CAShapeLayer *shapeLayer = (CAShapeLayer *)self.layer;
    shapeLayer.strokeColor = [UIColor redColor].CGColor;
    shapeLayer.fillColor = [UIColor clearColor].CGColor;
    shapeLayer.lineJoin = kCALineJoinRound;
    shapeLayer.lineCap = kCALineCapRound;
    shapeLayer.lineWidth = 5.0f;
}
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
    CGPoint point = [[touches anyObject] locationInView:self];
    [self.path moveToPoint:point];
}
- (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
    CGPoint point  = [[touches anyObject] locationInView:self];
    [self.path addLineToPoint:point];
    ((CAShapeLayer *)self.layer).path = self.path.CGPath;
    
}
@end
臟矩形

有時候用 CAShapeLayer 或者其他矢量圖形圖層替代Core Graphics并不是那么 切實可行。比如我們的繪圖應用:我們用線條完美地完成了矢量繪制。但是設想一 下如果我們能進一步提高應用的性能,讓它就像一個黑板一樣工作,然后用『粉 筆』來繪制線條。模擬粉筆最簡單的方法就是用一個『線刷』圖片然后將它粘貼到 用戶手指碰觸的地方,但是這個方法用 CAShapeLayer沒辦法實現。

我們可以給每個『線刷』創建一個獨立的圖層,但是實現起來有很大的問題。屏 幕上允許同時出現圖層上線數量大約是幾百,那樣我們很快就會超出的。這種情況 下我們沒什么辦法,就用Core Graphics吧(除非你想用OpenGL做一些更復雜的事 情)。

我們的『黑板』應用的最初實現如上面代碼,我們更改了之前版本的DrawingView ,用一個畫刷位置的數組代替 UIBezierPath

#import "DrawingView.h"

#define BRUSH_SIZE 32
@interface DrawingView()


@property (nonatomic, strong) NSMutableArray *strokes;
@end



@implementation DrawingView

- (void)awakeFromNib{
    [super awakeFromNib];
    self.strokes = [NSMutableArray array];
    
}
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
    CGPoint point = [[touches anyObject] locationInView: self];
    [self addBrushStrokeAtPoint:point];
}

- (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
    CGPoint point = [[touches anyObject] locationInView: self];
    [self addBrushStrokeAtPoint:point];
}

- (void)addBrushStrokeAtPoint:(CGPoint)point{
    [self.strokes addObject:[NSValue valueWithCGPoint:point]];
    [self setNeedsDisplay];
}

- (void)drawRect:(CGRect)rect {
    for (NSValue *value in self.strokes) {
        CGPoint point = [value CGPointValue];
        CGRect brushRect = CGRectMake(point.x / BRUSH_SIZE, point.y / BRUSH_SIZE, BRUSH_SIZE - 10, BUFSIZ - 10);
        [[UIImage imageNamed:@"ball"] drawInRect:brushRect];
    }
}
@end

這個實現在模擬器上表現還不錯,但是在真實設備上就沒那么好了。問題在于每 次手指移動的時候我們就會重繪之前的線刷,即使場景的大部分并沒有改變。我們 繪制地越多,就會越慢。隨著時間的增加每次重繪需要更多的時間,幀數也會下降 ,如何提高性能呢?

為了減少不必要的繪制,Mac OSiOS設備將會把屏幕區分為需要重繪的區域和 不需要重繪的區域。那些需要重繪的部分被稱作『臟區域』。在實際應用中,鑒于 非矩形區域邊界裁剪和混合的復雜性,通常會區分出包含指定視圖的矩形位置,而 這個位置就是『臟矩形』。

當一個視圖被改動過了,TA可能需要重繪。但是很多情況下,只是這個視圖的一 部分被改變了,所以重繪整個寄宿圖就太浪費了。但是Core Animation通常并不了 解你的自定義繪圖代碼,它也不能自己計算出臟區域的位置。然而,你的確可以提供這些信息。

當你檢測到指定視圖或圖層的指定部分需要被重繪,你直接調用 - setNeedsDisplayInRect:來標記它,然后將影響到的矩形作為參數傳入。這樣就 會在一次視圖刷新時調用視圖的-drawRect: (或圖層代理的- drawLayer:inContext:方法)。

傳入-drawLayer:inContext:CGContext參數會自動被裁切以適應對應的 矩形。為了確定矩形的尺寸大小,你可以用CGContextGetClipBoundlingBox() 方法來從上下文獲得大小。調用 - drawRect()會更簡單,因為CGRect會作為參數直接傳入。

你應該將你的繪制工作限制在這個矩形中。任何在此區域之外的繪制都將被自動 無視,但是這樣CPU花在計算和拋棄上的時間就浪費了,實在是太不值得了。

相比依賴于Core Graphics為你重繪,裁剪出自己的繪制區域可能會讓你避免不 必要的操作。那就是說,如果你的裁剪邏輯相當復雜,那還是讓Core Graphics來代勞吧,記住:當你能高效完成的時候才這樣做。
下面代碼:展示了一個-addBrushStrokeAtPoint:方法的升級版,它只重繪當前線刷的附近區域。另外也會刷新之前線刷的附近區域,我們也可以用 CGRectIntersectsRect() 來避免重繪任何舊的線刷以不至于覆蓋已更新過的區域。這樣做會顯著地提高繪制效率

#import "DrawingView.h"

#define BRUSH_SIZE 32
@interface DrawingView()


@property (nonatomic, strong) NSMutableArray *strokes;
@end
@implementation DrawingView

- (void)awakeFromNib{
    [super awakeFromNib];
    self.strokes = [NSMutableArray array];
    
}
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
    CGPoint point = [[touches anyObject] locationInView: self];
    [self addBrushStrokeAtPoint:point];
}

- (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
    CGPoint point = [[touches anyObject] locationInView: self];
    [self addBrushStrokeAtPoint:point];
}

- (void)addBrushStrokeAtPoint:(CGPoint)point{
    [self.strokes addObject:[NSValue valueWithCGPoint:point]];
    [self setNeedsDisplayInRect:[self burshRectForPoint:point]];
}

- (CGRect)burshRectForPoint:(CGPoint)point{
    return CGRectMake(point.x / BRUSH_SIZE / 2, point.y / BRUSH_SIZE / 2, BUFSIZ, BUFSIZ);
    
}

- (void)drawRect:(CGRect)rect {
    for (NSValue *value in self.strokes) {
        
        CGPoint point = [value CGPointValue];
        CGRect brushRect = [self burshRectForPoint:point];
        if (CGRectIntersectsRect(rect, brushRect)) {
            [[UIImage imageNamed:@"ball"] drawInRect:brushRect];
        }
    }
}
@end
異步繪制

UIKit的單線程天性意味著寄宿圖通暢要在主線程上更新,這意味著繪制會打斷用 戶交互,甚至讓整個app看起來處于無響應狀態。我們對此無能為力,但是如果能 避免用戶等待繪制完成就好多了。

針對這個問題,有一些方法可以用到:一些情況下,我們可以推測性地提前在另外一個線程上繪制內容,然后將由此繪出的圖片直接設置為圖層的內容。這實現起 來可能不是很方便,但是在特定情況下是可行的。Core Animation提供了一些選 擇: CATiledLayerdrawsAsynchronously屬性。

CATiledLayer

我們在第六章簡單探索了一下CATiledLayer. 除了將圖層再次分割成獨立更新的小塊(類似于臟矩形自動更新的概念),CATiledlayer還有一個有趣的特性:在多個線程中為每個小塊同時調用- drawLayer: inContext:方法. 這就避免了阻塞用戶交互而且能夠利用多核心芯片來更快地繪制。只有一個小塊 的 CATiledLayer是實現異步更新圖片視圖的簡單方法。

drawsAsynchronously

iOS 6中,蘋果為CALayer引入了這個令人好奇的屬性,drawsAsynchronously 屬性對傳入-drawLayer:inContext:CGContext進行改動,允許CGContext延緩繪制命令的執行以至于不阻塞用戶交互。

它與CATiledLayer使用的異步繪制并不相同。它自己的- drawLayer: inContext:方法只會在主線程調用,但是CGContext并不等待每個繪制命令的結束。相反地,它會將命令加入隊列,當方法返回時,在后臺線程逐個執行真正的繪制。

根據蘋果的說法。這個特性在需要頻繁重繪的視圖上效果最好(比如我們的繪圖 應用,或者諸如 UITableViewCell 之類的),對那些只繪制一次或很少重繪的圖 層內容來說沒什么太大的幫助。

總結

本章我們主要圍繞用Core Graphics軟件繪制討論了一些性能挑戰,然后探索了一 些改進方法:比如提高繪制性能或者減少需要繪制的數量。

iOS核心動畫高級技巧--目錄

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容