I’m plotting ~768 points for a graph using CGContextStrokePath. The problem is that every second I get a new data point, and thus redraw the graph. This is currently taking 50% CPU in what’s already a busy App.


Graph drawing is done in drawRect in a UIView. The graph is time based, so new data points always arrive on the right hand side.
I’m thinking a few alternative approaches:
- Draw with GLKit (at cost of not supporting older devices) and seems like a lot of work.
- Do some kind of screen grab (renderInContext?), shift left by 1 px, blit, and only draw a line for the last two data points.
- Have a very wide CALayer and pan along it?
- Smooth the data set, but this feels like cheating 🙂
It’s also possible I’m missing something obvious here that I’m seeing such poor performance?
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);
Let’s implement a graphing view that uses a bunch of tall, skinny layers to reduce the amount of redrawing needed. We’ll slide the layers to the left as we add samples, so at any time, we probably have one layer hanging off the left edge of the view and one hanging off the right edge of the view:
You can find a complete working example of the code below on my github account.
Constants
Let’s make each layer 32 points wide:
And let’s say we’re going to space the samples along the X axis at one sample per point:
So we can deduce the number of samples per layer. Let’s call one layer’s worth of samples a tile:
When we’re drawing a layer, we can’t just draw the samples strictly inside the layer. We have to draw a sample or two past each edge, because the lines to those samples cross the edge of the layer. We’ll call these the padding samples:
The maximum dimension of an iPhone screen is 320 points, so we can compute the maximum number of samples we need to retain:
(You should change the 320 if you want to run on an iPad.)
We’ll need to be able to compute which tile contains a given sample. And as you’ll see, we’ll want to do this even if the sample number is negative, because it will make later computations easier:
Instance Variables
Now, to implement
GraphView, we’ll need some instance variables. We’ll need to store the layers that we’re using to draw the graph. And we want to be able to look up each layer according to which tile it’s graphing:In a real project, you’d want to store the samples in a model object and give the view a reference to the model. But for this example, we’ll just store the samples in the view:
Since we don’t want to store an arbitrarily large number of samples, we’ll discard old samples when
_samplesgets big. But it will simplify the implementation if we can mostly pretend that we never discard samples. To do that, we keep track of the total number of samples ever received.We should avoid blocking the main thread, so we’ll do our drawing on a separate GCD queue. We need to keep track of which tiles need to be drawn on that queue. To avoid drawing a pending tile more than once, we use a set (which eliminates duplicates) instead of an array:
And here’s the GCD queue on which we’ll do the drawing.
Initialization / Destruction
To make this view work whether you create it in code or in a nib, we need two initialization methods:
Both methods call
commonInitto do the real initialization:ARC won’t clean up the GCD queue for us:
Adding a sample
To add a new sample, we pick a random number and append it to
_samples. We also increment_totalSampleCount. We discard the oldest samples if_sampleshas gotten big.Then, we check if we’ve started a new tile. If so, we find the layer that was drawing the oldest tile, and reuse it to draw the newly-created tile.
Now we recompute the layout of all the layers, which will to the left a bit so the new sample will be visible in the graph.
Finally, we add tiles to the redraw queue.
We don’t want to discard samples one at a time. That would be inefficient. Instead, we let the garbage build up for a while, then throw it away all at once:
To reuse a layer for the new tile, we need to find the layer of the oldest tile:
Now we can remove it from the
_tileLayersdictionary under the old key and store it under the new key:By default, when we move the reused layer to its new position, Core Animation will animate it sliding over. We don’t want that, because it will be a big empty orange rectangle sliding across our graph. We want to move it instantly:
When we add a sample, we’ll always want to redraw the tile containing the sample. We also need to redraw the prior tile, if the new sample is within the padding range of the prior tile.
Queuing a tile for redraw is just a matter of adding it to the redraw set and dispatching a block to redraw it on
_redrawQueue.Layout
The system will send
layoutSubviewsto theGraphViewwhen it first appears, and any time its size changes (such as if a device rotation resizes it). And we only get thelayoutSubviewsmessage when we’re really about to appear on the screen, with our final bounds set. SolayoutSubviewsis a good place to set up the tile layers.First, we need to create or remove layers as necessary so we have the right layers for our size. Then we need to lay out the layers by setting their frames appropriately. Finally, for each layer, we need to queue its tile for redraw.
Adjusting the tile dictionary means setting up a layer for each visible tile and removing layers for non-visible tiles. We’ll just reset the dictionary from scratch each time, but we’ll try to reuse the layer’s we’ve already created. The tiles that need layers are the newest tile, and preceding tiles so we have enough layers to cover the view.
The first time through, and any time the view gets sufficiently wider, we need to create new layers. While we’re creating the view, we’ll tell it to avoid animating its contents or position. Otherwise it will animate them by default.
Actually laying out the tile layers is just a matter of setting each layer’s frame:
Of course the trick is computing the frame for each layer. And the y, width, and height parts are easy enough:
To compute the x coordinate of the tile’s frame, we compute the x coordinate of the first sample in the tile:
Computing the x coordinate for a sample requires a little thought. We want the newest sample to be at the right edge of the view, and the second-newest to be
kPointsPerSamplepoints to the left of that, and so on:Redrawing
Now we can talk about how to actually draw tiles. We’re going to do the drawing on a separate GCD queue. We can’t safely access most Cocoa Touch objects from two threads simultaneously, so we need to be careful here. We’ll use a prefix of
rq_on all the methods that run on_redrawQueueto remind ourselves that we’re not on the main thread.To redraw one tile, we need to get the tile number, the graphical bounds of the tile, and the points to draw. All of those things come from data structures that we might be modifying on the main thread, so we need to access them only on the main thread. So we dispatch back to the main queue:
It so happens that we might not have any tiles to redraw. If you look back at
queueTilesForRedrawIfAffectedByLastSample, you’ll see that it usually tries to queue the same tile twice. Since_tilesToRedrawis a set (not an array), the duplicate was discarded, butrq_redrawOneTilewas dispatched twice anyway. So we need to check that we actually have a tile to redraw:Now we need to actually draw the tile’s samples:
Finally we need to update the tile’s layer to show the new image. We can only touch a layer on the main thread:
Here’s how we actually draw the image for the layer. I will assume you know enough Core Graphics to follow this:
But we still have to get the tile, the graphics bounds, and the points to draw. We dispatched back to the main thread to do it:
The graphics bounds are just the bounds of the tile, just like we computed earlier to set the frame of the layer:
I need to start graphing from the padding samples before the first sample of the tile. But, prior to having enough samples to fill the view, my tile number may actually be negative! So I need to be sure not to try to access a sample at a negative index:
We also need to make sure we don’t try to run past the end of the samples when we compute the sample at which we stop graphing:
And when I actually access the sample values, I need to account for the samples I’ve discarded:
Now we can compute the actual points to graph:
And I can return the number of points and the tile:
Here’s how we actually pull a tile off the redraw queue. Remember that the queue might be empty:
And finally, here’s how we actually set the tile layer’s contents to the new image. Remember that we dispatched back to the main queue to do this:
Making it sexier
If you do all of that, it will work fine. But you can actually make it slightly nicer-looking by animating the repositioning of the layers when a new sample comes in. This is very easy. We just modify
newTileLayerso that it adds an animation for thepositionproperty:and we create the animation like this:
You will want to set the duration to match the speed at which new samples arrive.