Efficient Drawing - The Performance of a Lifetime - iOS Core Animation: Advanced Techniques (2014)

iOS Core Animation: Advanced Techniques (2014)

Part III. The Performance of a Lifetime

Chapter 13. Efficient Drawing

More computing sins are committed in the name of efficiency (without necessarily achieving it) than for any other single reason—including blind stupidity.

William Allan Wulf

In Chapter 12, “Tuning for Speed,” we looked at how to diagnose Core Animation performance problems using Instruments. There are many potential performance pitfalls when building iOS apps, but in this chapter, we focus on issues relating specifically to drawing performance.

Software Drawing

The term drawing is usually used in the context of Core Animation to mean software drawing (that is, drawing that is not GPU assisted). Software drawing in iOS is done primarily using the Core Graphics framework, and while sometimes necessary, it’s really slow compared to the hardware accelerated rendering and compositing performed by Core Animation and OpenGL.

In addition to being slow, software drawing requires a lot of memory. A CALayer requires relatively little memory by itself; it is only the backing image that takes up any significant space in RAM. Even if you assign an image to the contents property directly, it won’t use any additional memory beyond that required for storing a single (uncompressed) copy of the image; and if the same image is used as the contents for multiple layers, that memory will be shared between them, not duplicated.

But as soon as you implement the CALayerDelegate -drawLayer:inContext: method or the UIView -drawRect: method (the latter of which is just a wrapper around the former), an offscreen drawing context is created for the layer, and that context requires an amount of memory equal to the width × height of the layer (in pixels, not points) × 4 bytes. For a fullscreen layer on a Retina iPad, that’s 2048 × 1536 × 4 bytes, which amounts to a whole 12MB that must not only be stored in RAM, but must be wiped and repopulated every time the layer is redrawn.

Because software drawing is so expensive, you should avoid redrawing your view unless absolutely necessary. The secret to improving drawing performance is generally to try to do as little drawing as possible.

Vector Graphics

A common reason to use Core Graphics drawing is for vector graphics that cannot easily be created using images or layer effects. Vector drawing might involve the following:

Image Arbitrary polygonal shapes (anything other than a rectangle)

Image Diagonal or curved lines

Image Text

Image Gradients

Let’s try an example. Listing 13.1 shows the code for a basic line-drawing app. The app converts user touches into points on a UIBezierPath, which is then drawn into the view. We’ve included all the drawing logic in a UIView subclass called DrawingView in this case (the view controller is empty), but you could implement the touch handling in the view controller instead if you prefer. Figure 13.1 shows a drawing created by using this app.

Listing 13.1 Implementing a Simple Drawing App Using Core Graphics


#import "DrawingView.h"

@interface DrawingView ()

@property (nonatomic, strong) UIBezierPath *path;

@end

@implementation DrawingView

- (void)awakeFromNib
{
//create a mutable path
self.path = [[UIBezierPath alloc] init];
self.path.lineJoinStyle = kCGLineJoinRound;
self.path.lineCapStyle = kCGLineCapRound;
self.path.lineWidth = 5;
}

- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
{
//get the starting point
CGPoint point = [[touches anyObject] locationInView:self];

//move the path drawing cursor to the starting point
[self.path moveToPoint:point];
}

- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event
{
//get the current point
CGPoint point = [[touches anyObject] locationInView:self];

//add a new line segment to our path
[self.path addLineToPoint:point];

//redraw the view
[self setNeedsDisplay];
}

- (void)drawRect:(CGRect)rect
{
//draw path
[[UIColor clearColor] setFill];
[[UIColor redColor] setStroke];
[self.path stroke];
}

@end


Image

Figure 13.1 A simple sketch produced using Core Graphics

The problem with this implementation is that the more we draw, the slower it gets. Because we are redrawing the entire UIBezierPath every time we move our finger, the amount of drawing work that needs to be done increases every frame as the path becomes more complex, causing the frame rate to plummet. We need a better approach.

Core Animation provides specialist classes for drawing these types of shape with hardware assistance (as covered in detail in Chapter 6, “Specialized Layers”). Polygons, lines, and curves can be drawn using CAShapeLayer. Text can be drawn using CATextLayer. Gradients can be drawn using CAGradientLayer. These will all be substantially faster than using Core Graphics, and they avoid the overhead of creating a backing image.

If we modify our drawing app to use CAShapeLayer instead of Core Graphics, the performance is substantially improved (see Listing 13.2). Performance will inevitably still degrade as the complexity of the path increases, but it would now take a very complex drawing to make an appreciable difference to the frame rate.

Listing 13.2 Reimplementing the Drawing App Using CAShapeLayer


#import "DrawingView.h"
#import <QuartzCore/QuartzCore.h>

@interface DrawingView ()

@property (nonatomic, strong) UIBezierPath *path;

@end

@implementation DrawingView

+ (Class)layerClass
{
//this makes our view create a CAShapeLayer
//instead of a CALayer for its backing layer
return [CAShapeLayer class];
}

- (void)awakeFromNib
{
//create a mutable path
self.path = [[UIBezierPath alloc] init];

//configure the layer
CAShapeLayer *shapeLayer = (CAShapeLayer *)self.layer;
shapeLayer.strokeColor = [UIColor redColor].CGColor;
shapeLayer.fillColor = [UIColor clearColor].CGColor;
shapeLayer.lineJoin = kCALineJoinRound;
shapeLayer.lineCap = kCALineCapRound;
shapeLayer.lineWidth = 5;
}

- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
{
//get the starting point
CGPoint point = [[touches anyObject] locationInView:self];

//move the path drawing cursor to the starting point
[self.path moveToPoint:point];
}

- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event
{
//get the current point
CGPoint point = [[touches anyObject] locationInView:self];

//add a new line segment to our path
[self.path addLineToPoint:point];

//update the layer with a copy of the path
((CAShapeLayer *)self.layer).path = self.path.CGPath;

}

@end


Dirty Rectangles

Sometimes it’s not viable to replace Core Graphics drawing with a CAShapeLayer or one of the other vector graphics layers. Consider our drawing app: The current implementation uses hard, straight lines, ideally suited to vector drawing. But suppose we enhance the app so that it resembles a chalkboard, with a chalk-like texture applied to our lines. A simple way to simulate chalk is to use a small “brush stroke” image and paste it onto the screen wherever the user’s finger touches, but that approach isn’t achievable using a CAShapeLayer.

We could create an individual layer for each brush stroke, but this would perform very badly. The practical upper limit for the number of layers onscreen simultaneously is at most a few hundred, and we would quickly exceed that. This is the sort of situation where we have little recourse but to use Core Graphics drawing (unless we want to do something far more complex using OpenGL).

Our initial chalkboard implementation is shown in Listing 13.3. We’ve modified the DrawingView from our earlier example to use an array of stroke positions instead of a UIBezierPath. Figure 13.2 shows the result.

Listing 13.3 A Simple Chalkboard-Style Drawing App


#import "DrawingView.h"
#import <QuartzCore/QuartzCore.h>

#define BRUSH_SIZE 32

@interface DrawingView ()

@property (nonatomic, strong) NSMutableArray *strokes;

@end

@implementation DrawingView

- (void)awakeFromNib
{
//create array
self.strokes = [NSMutableArray array];
}

- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
{
//get the starting point
CGPoint point = [[touches anyObject] locationInView:self];

//add brush stroke
[self addBrushStrokeAtPoint:point];
}

- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event
{
//get the touch point
CGPoint point = [[touches anyObject] locationInView:self];

//add brush stroke
[self addBrushStrokeAtPoint:point];
}

- (void)addBrushStrokeAtPoint:(CGPoint)point
{
//add brush stroke to array
[self.strokes addObject:[NSValue valueWithCGPoint:point]];

//needs redraw
[self setNeedsDisplay];
}

- (void)drawRect:(CGRect)rect
{
//redraw strokes
for (NSValue *value in self.strokes)
{
//get point
CGPoint point = [value CGPointValue];

//get brush rect
CGRect brushRect = CGRectMake(point.x - BRUSH_SIZE/2,
point.y - BRUSH_SIZE/2,
BRUSH_SIZE, BRUSH_SIZE);

//draw brush stroke
[[UIImage imageNamed:@"Chalk.png"] drawInRect:brushRect];

}
}

@end


Image

Figure 13.2 A sketch produced using the chalkboard drawing app

This performs reasonably well on the simulator, but is not great on a real device. The problem is that we are redrawing every previous brush stroke each time our finger moves, even though the vast majority of the scene hasn’t changed. The more we draw, the slower it gets, so the gap between each new stroke increases over time as the frame rate drops (see Figure 13.3). How can we improve this performance?

Image

Figure 13.3 The frame rate and line quality degrades as we draw.

To cut down on unnecessary drawing, both Mac OS and iOS divide the screen into regions that need redrawing and ones that don’t. The part that needs to be redrawn is known as the “dirty” region. Because it’s not practical to redraw nonrectangular regions due to the complexity of clipping and blending the edges, the entire containing rectangle around the part of the dirty region that overlaps a given view will be redrawn—this is the dirty rectangle.

When a view is modified, it may need to be redrawn. But often, only part of the view has changed, so redrawing the entire backing image would be wasteful. Because Core Animation doesn’t usually know anything about your custom drawing code, it cannot calculate the dirty rectangle by itself. You can provide this information, however.

When you detect that a specific part of your view/layer needs to be redrawn, you mark it as dirty by calling -setNeedsDisplayInRect: passing the affected rectangle as a parameter. This will cause the view’s -drawRect: method (or the layer delegate’s -drawLayer:inContext: method) to be called automatically prior to the next display update.

The CGContext that is passed to -drawLayer:inContext: will automatically be clipped to match the dirty rectangle. To find out the dimensions of that rectangle, you can use the CGContextGetClipBoundingBox() function to get this from the context itself. This is simpler when using -drawRect: because the CGRect is passed directly as a parameter.

You should try to limit your drawing to only the parts of the image that overlap this rectangle. Anything you draw outside of the dirty CGRect will be clipped automatically, but the CPU time spent on evaluating and discarding those redundant drawing commands will be wasted.

By clipping your own drawing rather than relying on Core Graphics to do it for you, you may be able to avoid unnecessary processing. That said, if your clipping logic is complex, you might be better off letting Core Graphics handle it; only clip your own drawing if you can do so efficiently.

Listing 13.4 shows an updated version of our -addBrushStrokeAtPoint: method that only redraws the rectangle around the current brush stroke. In addition to only refreshing the part of the view around the last brush stroke, we also use CGRectIntersectsRect() to avoid redrawing any old brush strokes that don’t overlap the updated region. This significantly improves the drawing performance (see Figure 13.4).

Listing 13.4 Using setNeedsDisplayInRect: to Reduce Unnecessary Drawing


- (void)addBrushStrokeAtPoint:(CGPoint)point
{
//add brush stroke to array
[self.strokes addObject:[NSValue valueWithCGPoint:point]];

//set dirty rect
[self setNeedsDisplayInRect:[self brushRectForPoint:point]];
}

- (CGRect)brushRectForPoint:(CGPoint)point
{
return CGRectMake(point.x - BRUSH_SIZE/2,
point.y - BRUSH_SIZE/2,
BRUSH_SIZE, BRUSH_SIZE);
}

- (void)drawRect:(CGRect)rect
{
//redraw strokes
for (NSValue *value in self.strokes)
{
//get point
CGPoint point = [value CGPointValue];

//get brush rect
CGRect brushRect = [self brushRectForPoint:point];

//only draw brush stroke if it intersects dirty rect
if (CGRectIntersectsRect(rect, brushRect))
{
//draw brush stroke
[[UIImage imageNamed:@"Chalk.png"] drawInRect:brushRect];
}
}
}


Image

Figure 13.4 Better frame rate and smoother line thanks to smarter drawing

Asynchronous Drawing

The single-threaded nature of UIKit means that backing images usually have to be updated on the main thread, which means that the drawing interrupts user interaction and can make the entire app feel unresponsive. We can’t help that drawing is slow, but it would be good if we could avoid making the user wait for it to complete.

There are a few ways around this: In some cases, you can speculatively draw view contents in advance on a separate thread and then set the resultant image directly as the layer contents. This can be awkward to set up, however, and is only applicable in certain cases. Core Animation provides a couple of alternatives: CATiledLayer and the drawsAsynchronously property.

CATiledLayer

We explored CATiledLayer briefly in Chapter 6. In addition to subdividing a layer into individual tiles that are updated independently (a kind of automation of the dirty rectangles concept), CATiledLayer also has the interesting feature of calling the -drawLayer:inContext:method for each tile concurrently on multiple threads. This avoids blocking the user interface and also enables it to take advantage of multiple processor cores for faster tile drawing. A CATiledLayer with just a single tile is a cheap way to implement an asynchronously updating image view.

drawsAsynchronously

In iOS 6, Apple added this intriguing new property to CALayer. The drawsAsynchronously property modifies the CGContext that is passed to the -drawLayer:inContext: method, allowing it to defer execution of drawing commands so that they don’t block user interaction.

This isn’t the same kind of asynchronous drawing as CATiledLayer uses. The -drawLayer:inContext: method itself is only ever called on the main thread, but the CGContext doesn’t wait for each drawing command to complete before proceeding. Instead, it will queue up the commands and perform the actual drawing on a background thread after the method has returned.

According to Apple, this feature works best for views that are redrawn frequently (such as our drawing app, or something like a UITableViewCell) and will not provide any benefit for layer contents that are only drawn once, or infrequently.

Summary

In this chapter, we discussed the performance challenges around software drawing using Core Graphics and explored some ways that we can either improve drawing performance or cut down the amount of drawing we need to do.

In Chapter 14, “Image IO,” we look at image loading performance.