让我们实现一个图形视图,它使用一堆又高又瘦的层来减少所需的重绘量。我们将在添加样本时将图层向左滑动,因此在任何时候,我们都可能有一层悬挂在视图的左边缘,另一层悬挂在视图的右边缘:
您可以在我的 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];
});
}
布局
系统将在它第一次出现时发送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、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;
}
您需要设置持续时间以匹配新样本到达的速度。