如何比CGContext Stroke Path更快地渲染线条?

时间:2012-08-28 15:43:49

标签: ios performance cgcontext

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

graph

instruments

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

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

  1. 使用GLKit绘制(以不支持旧设备为代价),似乎做了大量工作。
  2. 进行某种屏幕抓取(renderInContext?),向左移动1 px,blit,只为最后两个数据点画一条线。
  3. 有一个非常宽的CALayer并沿着它平移?
  4. 平滑数据集,但这感觉就像作弊:)
  5. 我也可能错过了一些明显的东西,我看到的表现如此糟糕?

        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);
    

3 个答案:

答案 0 :(得分:15)

让我们实现一个图形视图,它使用一堆高大的瘦层来减少所需的重绘量。我们在添加样本时将图层向左滑动,因此在任何时候,我们可能会在视图的左边缘悬挂一个图层,而在视图的右边缘悬挂一个图层:

layers over view

您可以在my github account上找到以下代码的完整工作示例。

常量

让每一层32点宽:

#define kLayerWidth 32

让我们说我们要按照每个点一个样本沿X轴划分样本:

#define kPointsPerSample 1

因此我们可以推断出每层的样本数量。让我们将 tile 的一层样本称为:

#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);
}

实例变量

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

@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;

由于我们不想存储任意数量的样本,因此当_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队列上进行绘制。我们需要跟踪需要在该队列上绘制哪些区块。为避免多次绘制待处理的图块,我们使用一个集合(消除重复)而不是数组:

    // 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;
}

初始化/销毁

无论是在代码中还是在nib中创建此视图,我们都需要两种初始化方法:

- (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);
    }
}

添加样本

要添加新样本,我们会选择一个随机数并将其附加到_samples。我们还增加_totalSampleCount。如果_samples变大,我们会丢弃最旧的样本。

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

然后,我们检查是否已经开始新的磁贴。如果是这样,我们会找到绘制最旧图块的图层,并重复使用它来绘制新创建的图块。

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

现在我们重新计算所有图层的布局,这将在左侧稍微一点,以便新图像在图表中可见。

    [self layoutTileLayers];

最后,我们将切片添加到重绘队列。

    [self queueTilesForRedrawIfAffectedByLastSample];
}

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

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

要为新图块重用图层,我们需要找到最旧图块的图层:

- (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会将其滑动动画。我们不希望这样,因为它将是一个大的空橙色矩形滑过我们的图形。我们想立即移动它:

    // 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];
    });
}

布局

系统会在layoutSubviews首次出现时发送GraphView,并且只要其大小发生变化(例如设备轮换调整大小)。当我们真正要出现在屏幕上时,我们只收到layoutSubviews消息,并设置了我们的最终界限。因此,layoutSubviews是设置切片图层的好地方。

首先,我们需要根据需要创建或删除图层,以便为我们的尺寸提供正确的图层。然后我们需要通过适当地设置它们的框架来布置图层。最后,对于每个图层,我们需要将其图块排队以进行重绘。

- (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];
    }
}

调整图块字典意味着为每个可见图块设置图层并删除不可见图块的图层。我们每次都会从头开始重新设置字典,但我们会尝试重复使用我们已创建的图层。需要图层的图块是最新的图块和前面的图块,因此我们有足够的图层来覆盖视图。

- (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];
    }
}

第一次通过,任何时候视图变得足够宽,我们需要创建新的图层。在我们创建视图时,我们会告诉它避免动画其内容或位置。否则它将默认为它们设置动画。

- (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,宽度和高度部分很容易:

- (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对象,因此我们需要在此处小心。我们会在rq_上运行的所有方法上使用_redrawQueue前缀来提醒自己我们不在主线程上。

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

- (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,您会发现它通常会尝试将同一个磁贴排队两次。由于_tilesToRedraw是一个集合(不是数组),因此丢弃了副本,但无论如何都会调度rq_redrawOneTile两次。所以我们需要检查一下我们实际上有一个要重绘的图块:

    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];
    });
}

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

- (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;
}

但是我们仍然需要获得图块,图形边界和要绘制的点。我们派遣回主线程去做:

// 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;

图形边界只是图块的边界,就像我们之前为计算图层边框而计算的那样:

    *boundsOut = [self frameForTile:tile];

我需要在拼贴的第一个样本之前从填充样本开始绘制图形。但是,在有足够的样本填充视图之前,我的瓷砖编号实际上可能是负数!所以我需要确保不要尝试以负索引访问样本:

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

我们还需要确保在计算停止绘制图形的样本时,不要尝试超出样本的末尾:

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

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

    NSInteger discardedSampleCount = _totalSampleCount - _samples.count;

现在我们可以计算要绘制的实际点数:

    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;
    }
}

让它更性感

如果你做了所有这些,它会正常工作。但实际上,当新样本进入时,可以通过动画重新定位图层来使其看起来更漂亮。这非常容易。我们只需修改newTileLayer,以便为position属性添加动画:

- (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;
}

我们创建这样的动画:

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

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

答案 1 :(得分:3)

每次绘制时都不必栅格化整个路径 - 您可以将其缓存为栅格位图。顺便说一句,你对“滚动”的想法是这种任务的标准解决方案......

答案 2 :(得分:0)

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

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

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

现在当您添加新线条时,它会在第一帧的末尾添加,并在绘制时开始偏移一个像素。