8

CGContextStrokePath を使用してグラフに ~768 ポイントをプロットしています。問題は、毎秒新しいデータ ポイントを取得し、グラフを再描画することです。これは現在、すでにビジーなアプリで 50% の CPU を使用しています。

グラフ

楽器

グラフの描画は UIView の drawRect で行われます。グラフは時間ベースであるため、新しいデータ ポイントは常に右側に到着します。

私はいくつかの代替アプローチを考えています:

  1. GLKit で描画すると (古いデバイスをサポートしないという代償を払って)、大変な作業のように見えます。
  2. ある種のスクリーン グラブ (renderInContext?) を実行し、左に 1 ピクセルシフトし、ブリットし、最後の 2 つのデータ ポイントに対してのみ線を描画します。
  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

必要な再描画の量を減らすために、背の高い細いレイヤーの束を使用するグラフ ビューを実装しましょう。サンプルを追加するときにレイヤーを左にスライドさせるので、常に、ビューの左端からぶら下がっている 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;
}

新しいサンプルが到着する速度に一致するように期間を設定する必要があります。

于 2012-08-28T23:14:43.533 に答える
3

描画するたびにパス全体をラスタライズする必要はありません。ラスター ビットマップとしてキャッシュできます。ところで、「スクロール」に関するあなたのアイデアは、そのようなタスクの標準的なソリューションです...

于 2012-08-28T15:47:57.343 に答える
0

ビューと同じ高さで幅が2倍のビットマップコンテキストを作成します。ポイントをコンテキストに描画し始め、drawRectでCGImageRefを作成します。アイデアは、最初に画面を埋めるときに、画像が最初から始まるようにすることです。描画する画像の幅と高さは適切ですが、bytesPerRowは2倍になります(詳細はこちら)。最後のポイントに到達するまで、新しいポイントを描画し続けます。これで、xが使い果たされます。

コンテキストでポイントを書き続けますが、ここで、画像を作成するときに、最初のポインターを1ピクセルオフセットします。2x行を完了するまでこれを続けます。これで、コンテキストの最後に到達します。

その際、画像の「右側」を左側に移動し、オフセットカウントをリセットする必要があります。つまり、memcpy(starOfBitMap、startOfBitMap + bytesPerRow / 2、sizeOfBitMap --bytesPerRow / 2)を実行する必要があります。本質的に、あなたは1つの目に見えるフレームをシフトしたままになります。

ここで、新しい線を追加すると、最初のフレームの終わりになり、描画時に1ピクセルずつオフセットを開始します。

于 2012-08-28T16:37:11.217 に答える