我怎么能比CGContextStrokePath更快地渲染线?

我正在绘制使用CGContextStrokePath的graphics〜768点。 问题是每一秒我都会得到一个新的数据点,从而重新绘制graphics。 目前,这已经是一个繁忙的应用程序占用50%的CPU。

图形

仪器

graphics绘制是在UIView的drawRect中完成的。 该图是基于时间的,所以新的数据点总是到达右侧。

我正在考虑一些替代方法:

  1. 用GLKit绘图(以不支持旧设备为代价),看起来像很多工作。
  2. 做一些屏幕抓取(renderInContext?),向左移1px,blit,只为最后两个数据点绘制一条线。
  3. 有一个非常广泛的CALayer和泛沿?
  4. 平滑的数据集,但这感觉就像作弊:)

这也有可能是我错过了一些明显的东西,我看到这样糟糕的performance?

CGContextBeginPath(context); CGContextSetLineWidth(context, 2.0); UIColor *color = [UIColor whiteColor]; CGContextSetStrokeColorWithColor(context, [color CGColor]); … CGContextAddLines(context, points, index); CGContextMoveToPoint(context, startPoint.x, startPoint.y); CGContextClosePath(context); CGContextStrokePath(context); 

让我们来实现一个graphics化的视图,它使用一堆很高的瘦图层来减less重绘的次数。 当我们添加样本时,我们会将图层向左滑动,因此在任何时候,我们可能都有一个图层挂在视图的左边缘,一个图层悬在视图的右边缘上:

在视图层

你可以在我的github账户中find一个完整的代码示例。

常量

我们让每个图层都有32点宽:

 #define kLayerWidth 32 

假设我们将沿着X轴以每点一个采样的方式来分隔样本:

 #define kPointsPerSample 1 

所以我们可以推导出每层样本的数量。 我们称之为一个图层的一个层次的价值:

 #define kSamplesPerTile (kLayerWidth / kPointsPerSample) 

当我们绘制一个图层时,我们不能只在图层内部严格绘制样本。 我们必须在每个边缘画一个或两个样本,因为这些样本的线穿过图层的边缘。 我们将这些填充样本称为:

 #define kPaddingSamples 2 

iPhone屏幕的最大尺寸是320点,所以我们可以计算我们需要保留的最大样本数量:

 #define kMaxVisibleSamples ((320 / kPointsPerSample) + 2 * kPaddingSamples) 

(如果你想在iPad上运行,你应该改变320。

我们需要能够计算哪个瓦片包含给定的样本。 正如你所看到的,即使样本数是负数,我们也会这样做,因为这会使后面的计算变得更简单:

 static inline NSInteger tileForSampleIndex(NSInteger sampleIndex) { // I need this to round toward -∞ even if sampleIndex is negative. return (NSInteger)floorf((float)sampleIndex / kSamplesPerTile); } 

实例variables

现在,要实现GraphView ,我们需要一些实例variables。 我们需要存储我们用来绘制graphics的图层。 我们希望能够根据绘制的图块来查找每个图层:

 @implementation GraphView { // Each key in _tileLayers is an NSNumber whose value is a tile number. // The corresponding value is the CALayer that displays the tile's samples. // There will be tiles that don't have a corresponding layer. NSMutableDictionary *_tileLayers; 

在真实的项目中,您希望将样本存储在模型对象中,并为该视图提供对模型的引用。 但是对于这个例子,我们只是将样本存储在视图中:

  // Samples are stored in _samples as instances of NSNumber. NSMutableArray *_samples; 

由于我们不想存储任意数量的样本,当样本变大时,我们会丢弃旧的样本。 但是如果我们大多假装我们从不丢弃样本,这将简化实施。 为此,我们跟踪有史以来收到的样本总数。

  // I discard old samples from _samples when I have more than // kMaxTiles' worth of samples. This is the total number of samples // ever collected, including discarded samples. NSInteger _totalSampleCount; 

我们应该避免阻塞主线程,所以我们将在单独的GCD队列上进行绘图。 我们需要跟踪哪些图块需要在该队列上绘制。 为了避免不止一次绘制待处理的图块,我们使用一个set(消除重复)而不是一个数组:

  // Each member of _tilesToRedraw is an NSNumber whose value // is a tile number to be redrawn. NSMutableSet *_tilesToRedraw; 

这是GCD队列,我们​​将在其中进行绘图。

  // Methods prefixed with rq_ run on redrawQueue. // All other methods run on the main queue. dispatch_queue_t _redrawQueue; } 

初始化/销毁

为了使这个视图能够在代码或笔尖中创build,我们需要两个初始化方法:

 - (id)initWithFrame:(CGRect)frame { if ((self = [super initWithFrame:frame])) { [self commonInit]; } return self; } - (void)awakeFromNib { [self commonInit]; } 

这两个方法都调用commonInit来进行真正的初始化:

 - (void)commonInit { _tileLayers = [[NSMutableDictionary alloc] init]; _samples = [[NSMutableArray alloc] init]; _tilesToRedraw = [[NSMutableSet alloc] init]; _redrawQueue = dispatch_queue_create("MyView tile redraw", 0); } 

ARC不会为我们清理GCD队列:

 - (void)dealloc { if (_redrawQueue != NULL) { dispatch_release(_redrawQueue); } } 

添加一个样本

要添加一个新的样本,我们select一个随机数并将其附加到_samples 。 我们也增加_totalSampleCount 。 如果_samples变大,我们丢弃最老的样本。

 - (void)addRandomSample { [_samples addObject:[NSNumber numberWithFloat:120.f * ((double)arc4random() / UINT32_MAX)]]; ++_totalSampleCount; [self discardSamplesIfNeeded]; 

然后,我们检查我们是否开始了一个新的瓷砖。 如果是这样,我们find正在绘制最古老的瓷砖的图层,并重新使用它来绘制新创build的瓷砖。

  if (_totalSampleCount % kSamplesPerTile == 1) { [self reuseOldestTileLayerForNewestTile]; } 

现在我们重新计算所有图层的布局,这些图层会左移一点,以便在graphics中显示新的样本。

  [self layoutTileLayers]; 

最后,我们将瓷砖添加到重绘队列。

  [self queueTilesForRedrawIfAffectedByLastSample]; } 

我们不想一次丢弃一个样本。 这将是无效的。 相反,我们让垃圾堆积一段时间,然后立即把它扔掉:

 - (void)discardSamplesIfNeeded { if (_samples.count >= 2 * kMaxVisibleSamples) { [_samples removeObjectsInRange:NSMakeRange(0, _samples.count - kMaxVisibleSamples)]; } } 

要重新使用新图块的图层,我们需要find最旧的图块的图层:

 - (void)reuseOldestTileLayerForNewestTile { // The oldest tile's layer should no longer be visible, so I can reuse it as the new tile's layer. NSInteger newestTile = tileForSampleIndex(_totalSampleCount - 1); NSInteger reusableTile = newestTile - _tileLayers.count; NSNumber *reusableTileObject = [NSNumber numberWithInteger:reusableTile]; CALayer *layer = [_tileLayers objectForKey:reusableTileObject]; 

现在我们可以将它从旧密钥下的_tileLayers字典中删除,并将其存储在新密钥下:

  [_tileLayers removeObjectForKey:reusableTileObject]; [_tileLayers setObject:layer forKey:[NSNumber numberWithInteger:newestTile]]; 

默认情况下,当我们将重用的图层移动到新的位置时,Core Animation会将其滑动。 我们不想这样做,因为它将是一个巨大的空的橙色矩形滑过我们的graphics。 我们想要立即移动它:

  // The reused layer needs to move instantly to its new position, // lest it be seen animating on top of the other layers. [CATransaction begin]; { [CATransaction setDisableActions:YES]; layer.frame = [self frameForTile:newestTile]; } [CATransaction commit]; } 

当我们添加一个样本时,我们总是要重新绘制包含样本的图块。 如果新样本在前一个图块的填充范围内,我们还需要重新绘制之前的图块。

 - (void)queueTilesForRedrawIfAffectedByLastSample { [self queueTileForRedraw:tileForSampleIndex(_totalSampleCount - 1)]; // This redraws the second-newest tile if the new sample is in its padding range. [self queueTileForRedraw:tileForSampleIndex(_totalSampleCount - 1 - kPaddingSamples)]; } 

排队一个图块进行重绘只是将其添加到重绘集并调度一个块来在_redrawQueue上重绘它。

 - (void)queueTileForRedraw:(NSInteger)tile { [_tilesToRedraw addObject:[NSNumber numberWithInteger:tile]]; dispatch_async(_redrawQueue, ^{ [self rq_redrawOneTile]; }); } 

布局

系统将在第一次出现GraphView时发送layoutSubviews ,并随时调整其大小(例如,如果设备旋转resize)。 当我们真的要出现在屏幕上时,我们只得到layoutSubviews消息,并设置了最后的边界。 所以layoutSubviews是设置图块图层的好地方。

首先,我们需要根据需要创build或移除图层,因此我们拥有适合自己尺寸的图层。 然后我们需要通过适当地设置它们的框架来布置图层。 最后,对于每个图层,我们需要对它的图块进行排队重绘。

 - (void)layoutSubviews { [self adjustTileDictionary]; [CATransaction begin]; { // layoutSubviews only gets called on a resize, when I will be // shuffling layers all over the place. I don't want to animate // the layers to their new positions. [CATransaction setDisableActions:YES]; [self layoutTileLayers]; } [CATransaction commit]; for (NSNumber *key in _tileLayers) { [self queueTileForRedraw:key.integerValue]; } } 

调整瓷砖字典意味着为每个可见瓷砖设置一个图层,并为不可见的瓷砖移除图层。 我们将每次从头开始重新设置字典,但是我们将尝试重新使用已经创build的图层。 需要层的瓦片是最新的瓦片,并且在瓦片之前,所以我们有足够的层来覆盖视图。

 - (void)adjustTileDictionary { NSInteger newestTile = tileForSampleIndex(_totalSampleCount - 1); // Add 1 to account for layers hanging off the left and right edges. NSInteger tileLayersNeeded = 1 + ceilf(self.bounds.size.width / kLayerWidth); NSInteger oldestTile = newestTile - tileLayersNeeded + 1; NSMutableArray *spareLayers = [[_tileLayers allValues] mutableCopy]; [_tileLayers removeAllObjects]; for (NSInteger tile = oldestTile; tile <= newestTile; ++tile) { CALayer *layer = [spareLayers lastObject]; if (layer) { [spareLayers removeLastObject]; } else { layer = [self newTileLayer]; } [_tileLayers setObject:layer forKey:[NSNumber numberWithInteger:tile]]; } for (CALayer *layer in spareLayers) { [layer removeFromSuperlayer]; } } 

第一次,当视图足够宽时,我们需要创build新的图层。 当我们创build视图时,我们会告诉它避免animation化内容或位置。 否则,它会默认animation。

 - (CALayer *)newTileLayer { CALayer *layer = [CALayer layer]; layer.backgroundColor = [UIColor greenColor].CGColor; layer.actions = [NSDictionary dictionaryWithObjectsAndKeys: [NSNull null], @"contents", [NSNull null], @"position", nil]; [self.layer addSublayer:layer]; return layer; } 

其实铺设瓷砖层只是设置每一层的框架:

 - (void)layoutTileLayers { [_tileLayers enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) { CALayer *layer = obj; layer.frame = [self frameForTile:[key integerValue]]; }]; } 

当然诀窍是计算每一层的框架。 y,width和height部分很容易:

 - (CGRect)frameForTile:(NSInteger)tile { CGRect myBounds = self.bounds; CGFloat x = [self xForTile:tile myBounds:myBounds]; return CGRectMake(x, myBounds.origin.y, kLayerWidth, myBounds.size.height); } 

为了计算图块的x坐标,我们计算图块中第一个样本的x坐标:

 - (CGFloat)xForTile:(NSInteger)tile myBounds:(CGRect)myBounds { return [self xForSampleAtIndex:tile * kSamplesPerTile myBounds:myBounds]; } 

计算样本的x坐标需要一点思考。 我们希望最新的样本位于视图的右边,第二个最新的是kPointsPerSample指向左边的位置,依此类推:

 - (CGFloat)xForSampleAtIndex:(NSInteger)index myBounds:(CGRect)myBounds { return myBounds.origin.x + myBounds.size.width - kPointsPerSample * (_totalSampleCount - index); } 

重绘

现在我们可以谈谈如何实际绘制图块。 我们将在单独的GCD队列上进行绘图。 我们无法同时从两个线程安全地访问大多数Cocoa Touch对象,所以我们在这里需要小心。 我们将在所有在_redrawQueue上运行的方法上使用rq_的前缀来提醒自己我们不在主线程中。

要重绘一个图块,我们需要获取图块编号,图块的graphics边界以及要绘制的点。 所有这些东西都来自我们可能在主线程中修改的数据结构,所以我们只需要在主线程上访问它们。 所以我们发回主队列:

 - (void)rq_redrawOneTile { __block NSInteger tile; __block CGRect bounds; CGPoint pointStorage[kSamplesPerTile + kPaddingSamples * 2]; CGPoint *points = pointStorage; // A block cannot reference a local variable of array type, so I need a pointer. __block NSUInteger pointCount; dispatch_sync(dispatch_get_main_queue(), ^{ tile = [self dequeueTileToRedrawReturningBounds:&bounds points:points pointCount:&pointCount]; }); 

恰巧我们可能没有任何瓷砖重绘。 如果你回头看看queueTilesForRedrawIfAffectedByLastSample ,你会发现它通常会尝试对同一个tile进行两次排队。 由于_tilesToRedraw是一个集合(不是数组),所以重复被丢弃,但rq_redrawOneTile被调度两次。 所以我们需要检查一下我们是否有一个tile要重绘:

  if (tile == NSNotFound) return; 

现在我们需要真正画出瓷砖的样品:

  UIImage *image = [self rq_imageWithBounds:bounds points:points pointCount:pointCount]; 

最后,我们需要更新图块的图层来显示新的图像。 我们只能触摸主线程上的一个图层:

  dispatch_async(dispatch_get_main_queue(), ^{ [self setImage:image forTile:tile]; }); } 

以下是我们如何为图层绘制图像。 我会假设你知道足够的核心graphics来遵循这个:

 - (UIImage *)rq_imageWithBounds:(CGRect)bounds points:(CGPoint *)points pointCount:(NSUInteger)pointCount { UIGraphicsBeginImageContextWithOptions(bounds.size, YES, 0); { CGContextRef gc = UIGraphicsGetCurrentContext(); CGContextTranslateCTM(gc, -bounds.origin.x, -bounds.origin.y); [[UIColor orangeColor] setFill]; CGContextFillRect(gc, bounds); [[UIColor whiteColor] setStroke]; CGContextSetLineWidth(gc, 1.0); CGContextSetLineJoin(gc, kCGLineCapRound); CGContextBeginPath(gc); CGContextAddLines(gc, points, pointCount); CGContextStrokePath(gc); } UIImage *image = UIGraphicsGetImageFromCurrentImageContext(); UIGraphicsEndImageContext(); return image; } 

但是我们仍然需要获取图块,graphics边界和绘制点。 我们发回到主线程来做到这一点:

 // I return NSNotFound if I couldn't dequeue a tile. // The `pointsOut` array must have room for at least // kSamplesPerTile + 2*kPaddingSamples elements. - (NSInteger)dequeueTileToRedrawReturningBounds:(CGRect *)boundsOut points:(CGPoint *)pointsOut pointCount:(NSUInteger *)pointCountOut { NSInteger tile = [self dequeueTileToRedraw]; if (tile == NSNotFound) return NSNotFound; 

graphics边界就是图块的边界,就像我们之前计算的设置图层的边框一样:

  *boundsOut = [self frameForTile:tile]; 

我需要在瓦片的第一个样本之前从填充样本开始绘图。 但是,在拥有足够的样本来填充视图之前,我的图块编号实际上可能是负值! 所以我需要确保不要尝试访问负指数的样本:

  NSInteger sampleIndex = MAX(0, tile * kSamplesPerTile - kPaddingSamples); 

当我们计算停止绘图的样本时,我们也需要确保我们不会试图超过样本的末端:

  NSInteger endSampleIndex = MIN(_totalSampleCount, tile * kSamplesPerTile + kSamplesPerTile + kPaddingSamples); 

而当我实际访问样本值时,我需要考虑我丢弃的样本:

  NSInteger discardedSampleCount = _totalSampleCount - _samples.count; 

现在我们可以计算出graphics的实际点:

  CGFloat x = [self xForSampleAtIndex:sampleIndex myBounds:self.bounds]; NSUInteger count = 0; for ( ; sampleIndex < endSampleIndex; ++sampleIndex, ++count, x += kPointsPerSample) { pointsOut[count] = CGPointMake(x, [[_samples objectAtIndex:sampleIndex - discardedSampleCount] floatValue]); } 

我可以返回点数和瓦片的数量:

  *pointCountOut = count; return tile; } 

以下是我们如何从重绘队列中拉出一个图块。 请记住,队列可能是空的:

 - (NSInteger)dequeueTileToRedraw { NSNumber *number = [_tilesToRedraw anyObject]; if (number) { [_tilesToRedraw removeObject:number]; return number.integerValue; } else { return NSNotFound; } } 

最后,下面是我们如何将拼贴图层的内容设置为新图像。 请记住,我们派遣回主队列来做到这一点:

 - (void)setImage:(UIImage *)image forTile:(NSInteger)tile { CALayer *layer = [_tileLayers objectForKey:[NSNumber numberWithInteger:tile]]; if (layer) { layer.contents = (__bridge id)image.CGImage; } } 

使它更性感

如果你这样做,它会正常工作。 但是当新的样品进来时,通过animation重新定位图层,您可以使其更加美观。这非常简单。 我们只是修改newTileLayer以便为position属性添加一个animation:

 - (CALayer *)newTileLayer { CALayer *layer = [CALayer layer]; layer.backgroundColor = [UIColor greenColor].CGColor; layer.actions = [NSDictionary dictionaryWithObjectsAndKeys: [NSNull null], @"contents", [self newTileLayerPositionAnimation], @"position", nil]; [self.layer addSublayer:layer]; return layer; } 

我们创build这样的animation:

 - (CAAnimation *)newTileLayerPositionAnimation { CABasicAnimation *animation = [CABasicAnimation animationWithKeyPath:@"position"]; animation.duration = 0.1; animation.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionLinear]; return animation; } 

您将需要设置持续时间以匹配新样品到达的速度。

您不必在每次绘制时都对整个path进行栅格化 – 您可以将其caching为栅格位图。 顺便说一句,你的想法与“滚动”是这样的任务的标准解决scheme…

创build一个位图上下文与视图相同的高度,但宽度的两倍。 开始绘制你的点到上下文中,然后在drawRect中创build一个CGImageRef。 这个想法是,当你最初填充屏幕时,图像将从头开始。 您将绘制的图像将具有适当的宽度和高度,但bytesPerRow将是2倍(更多)。 当你到达最后一点时,你继续画出新的点 – 现在x已经耗尽了。

继续在上下文中写点,但现在,当您创build图像时,将初始指针偏移一个像素。 继续这样做直到你完成了2行 – 你现在处于你的上下文的最后。

在那个时候,您需要将图像的“右侧”移动到左侧,并重置您的偏移计数。 也就是说,你需要memcpy(starOfBitMap,startOfBitMap + bytesPerRow / 2,sizeOfBitMap – bytesPerRow / 2)。 本质上,你左移了一个可见的框架。

现在当你添加新的线条,在第一帧结束时,你开始画一个像素。