8

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

图形

仪器

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

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

  1. 使用 GLKit 绘图(以不支持旧设备为代价)似乎需要做很多工作。
  2. 做某种屏幕抓取(renderInContext?),向左移动 1 px,blit,只为最后两个数据点画一条线。
  3. 有一个非常宽的 CALayer 并沿着它平移?
  4. 平滑数据集,但这感觉像是作弊:)

也有可能我在这里遗漏了一些明显的东西,我看到的表现如此糟糕?

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

3 回答 3

15

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

层视图

您可以在我的 github 帐户上找到以下代码的完整工作示例。

常数

让我们将每一层设为 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];
    });
}

布局

系统将在它第一次出现时发送layoutSubviewsGraphView,并且在它的大小发生变化的任何时候(例如,如果设备旋转调整它的大小)。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、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 对象,所以在这里我们需要小心。我们将rq_在所有运行的方法上使用前缀 of_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;
}

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

于 2012-08-28T23:14:43.533 回答
3

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

于 2012-08-28T15:47:57.343 回答
0

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

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

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

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

于 2012-08-28T16:37:11.217 回答