When we last checked in on it, our Minesweeper project was suffering from slow rendering. Today, we find a workaround.
Review
We had been rendering our playfield inside a UIScrollView
, drawing 320×416 tiles as necessary. This approach worked, but the time required to render a new tile (between 0.1s and 0.2s) caused intolerable stutter during scrolling. To address this, I was going to look into splitting the rendering and the display of tiles, so as to move the relatively slow render process into a background thread, and perform only a relatively quick “blit” on the foreground/scrolling thread.
Blitting
Asynchronous rendering introduces a host of technical problems, but the most immediate might be the question of how to do a quick “blit” – how to quickly move a pre-calculated image to the screen. As it turns out, one very quick way to do this is to set the contents
property of a UIView's
layer
object. Simply setting this property to a CGImageRef
will cause the UIView
to bypass its normal rendering and display the supplied image. Relevant code might look like this example:
tv.layer.contents = (id) [[self.contents objectAtIndex:9] CGImage];
(Note the extraction of a CGImageRef
from the UIImage
, and the coercion to an id
.)
Shortcut
In fact, this blit is so fast that it got me thinking about revisiting an earlier approach to rendering, in which we simply tiled the UIScrollView
with 32×32 UIViews
, each of which corresponded to a single cell. That approach didn’t work because we were relying upon relatively complicated UIView
rendering – e.g., each UIView
contained a UILabel
. What if we increased the cell size to 64×64 (to put fewer on screen, and make the interface more thumb-friendly) and blitted in pre-calculated images, rather than rendering each view on the fly?
As it turns out, that works just fine. We have to give up zooming, but this gives us a workable interface with which we can move forward right now, as opposed to mucking about with a more complex rendering system.
Code
Aside from changing our tile size to 64×64, we need to make only two alterations to the code we’ve seen before. First, we need to add a pre-calculated array of tile graphics (one for each of the 9 possible symbols), and second, we need to assign one of these graphics to each tile after it’s added to the UIScrollView
.
Here’s the code to get/generate the pre-calculated array, implemented as a getter method:
- (NSArray*)contents
{
if (!contents)
{
contents = [[NSMutableArray alloc] initWithCapacity:[self.symbols count]];
NSMutableArray* myContents = (NSMutableArray*) contents;
for (Glyph* symbol in self.symbols)
{
// Create and get a pointer to context
UIGraphicsBeginImageContext(cellSize);
CGContextRef context = UIGraphicsGetCurrentContext();
// Flop text rightside-up
CGContextSetTextMatrix(context, CGAffineTransformMakeScale(1, -1));
// Render background
CGContextAddRect(context, CGRectMake(0, 0, cellSize.width, cellSize.height));
[[UIColor grayColor] set];
CGContextDrawPath(context, kCGPathFill);
// Render box
for (Glyph* g in self.box)
{
[g renderToContext:context];
}
// Render symbol
[symbol renderToContext:context];
// Create and store image
[myContents addObject:UIGraphicsGetImageFromCurrentImageContext()];
// Release image context, and continue
UIGraphicsEndImageContext();
}
}
return contents;
}
… and here’s the revised scrollViewDidScroll:
method:
- (void)scrollViewDidScroll:(UIScrollView*)scrollView
{
// Tile width and height (in view space)
CGFloat w = tileSize.width;
CGFloat h = tileSize.height;
// Legal region (in view space)
CGRect legal = CGRectMake(0, 0, world.width*scale, world.height*scale);
// Displayed region (in view space)
CGRect viewport = CGRectIntersection([self.content convertRect:self.scrollView.bounds fromView:self.scrollView], legal);
// For existing tiles
for (UIView* tv in [self.content subviews])
{
// If there's no overlap with viewport, toss the tile
if (!CGRectIntersectsRect(viewport, tv.frame))
{
[tv removeFromSuperview];
[extraTiles addObject:tv];
}
}
NSInteger oldMinCol = tileBox.origin.x;
NSInteger oldMinRow = tileBox.origin.y;
NSInteger oldMaxCol = CGRectGetMaxX(tileBox)-1;
NSInteger oldMaxRow = CGRectGetMaxY(tileBox)-1;
// For new tiles
tileBox = CGRectIntegral(CGRectApplyAffineTransform(viewport, CGAffineTransformMakeScale(1/w, 1/h)));
for (int bw = tileBox.size.width, n = bw*tileBox.size.height-1; n >= 0; n--)
{
NSInteger c = tileBox.origin.x + n % bw;
NSInteger r = tileBox.origin.y + n / bw;
// If missing, create
if ((c < oldMinCol) || (c > oldMaxCol) || (r < oldMinRow) || (r > oldMaxRow))
{
UIView* tv = [self addTileForFrame:CGRectIntersection(CGRectMake(c*w, r*h, w, h), legal)];
UIImage* t;
if ([puzzle isMinedAtX:c andY:r])
{
t = [self.contents objectAtIndex:9];
}
else
{
t = [self.contents objectAtIndex:[puzzle neighborsAtX:c andY:r]];
}
tv.layer.contents = (id) [t CGImage];
}
}
}
CATileLayer
For future reference, if we want to pursue the idea with which we started this post (i.e. multithreaded, tiled, vector-graphics rendering) it looks like the CATiledLayer
class will bear looking into. In fact, this project has highlighted the importance (for me) of a better understanding of Core Animation in general; CA seems to underlie most of the 2-D/UIView
stuff on the iPhone, and a better command of it will probably be very useful.
Next
With a workable rendering system addressed, tomorrow we’ll take a look at making our game respond to touches.