必要な再描画の量を減らすために、背の高い細いレイヤーの束を使用するグラフ ビューを実装しましょう。サンプルを追加するときにレイヤーを左にスライドさせるので、常に、ビューの左端からぶら下がっている 1 つのレイヤーと、ビューの右端からぶら下がっている 1 つのレイヤーを持っている可能性があります。

私の github アカウントで、以下のコードの完全な動作例を見つけることができます。
定数
各レイヤーの幅を 32 ポイントにしましょう。
#define kLayerWidth 32
そして、ポイントごとに 1 つのサンプルで X 軸に沿ってサンプルを配置するとします。
#define kPointsPerSample 1
したがって、レイヤーごとのサンプル数を推測できます。1 層分のサンプルをtileと呼びましょう:
#define kSamplesPerTile (kLayerWidth / kPointsPerSample)
レイヤーを描画するとき、厳密にレイヤー内にサンプルを描画することはできません。これらのサンプルへの線がレイヤーのエッジを横切るため、各エッジを越えてサンプルを 1 つまたは 2 つ描画する必要があります。これらをパディング サンプルと呼びます。
#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 で作成するかに関係なく機能させるには、2 つの初期化メソッドが必要です。
- (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];
}
一度に 1 つずつサンプルを破棄したくありません。それでは非効率です。代わりに、ゴミをしばらく蓄積させてから、一度にすべて捨てます。
- (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 座標を計算するには、少し考える必要があります。最新のサンプルをビューの右端に配置し、2 番目に新しいサンプルをkPointsPerSample
その左端に配置するなどです。
- (CGFloat)xForSampleAtIndex:(NSInteger)index myBounds:(CGRect)myBounds {
return myBounds.origin.x + myBounds.size.width - kPointsPerSample * (_totalSampleCount - index);
}
再描画
これで、実際にタイルを描画する方法について説明できます。別の GCD キューで描画を行います。2 つのスレッドから同時に安全にほとんどの Cocoa Touch オブジェクトにアクセスすることはできないため、ここでは注意が必要です。rq_
実行されるすべてのメソッドに接頭辞を使用し_redrawQueue
て、メイン スレッドにいないことを思い出させます。
1 つのタイルを再描画するには、タイル番号、タイルのグラフィック境界、および描画するポイントを取得する必要があります。これらはすべて、メイン スレッドで変更する可能性のあるデータ構造に由来するため、メイン スレッドでのみアクセスする必要があります。したがって、メイン キューにディスパッチします。
- (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
と、通常は同じタイルを 2 回キューに入れようとしていることがわかります。はセット (配列ではない) であるため_tilesToRedraw
、重複は破棄されましたrq_redrawOneTile
が、とにかく 2 回ディスパッチされました。したがって、実際に再描画するタイルがあることを確認する必要があります。
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];
});
}
レイヤーの画像を実際に描画する方法は次のとおりです。これに従うのに十分な Core 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;
}
ただし、タイル、グラフィック境界、および描画するポイントを取得する必要があります。それを行うために、メイン スレッドにディスパッチしました。
// 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;
}
新しいサンプルが到着する速度に一致するように期間を設定する必要があります。