Our recent iPad app – London Unfurled – draws enormous images (90,000 pixels wide) very fast, with zooming and panning. We built it using a custom OpenGL rendering loop – we did NOT use CATiledLayer.
When I gave a couple of talks on this recently, several people wondered if “CATiledLayer would do it all for you?”. Ah … no. Try the app. You’ll quickly see that it’s much faster than CATL, even at CATL’s fastest.
It seems that CATL is a little slower, and uses more memory, than many of us expected. But it’s still very useful – e.g. we’re using it in a new game project right now. So … what’s going on? When should you use CATL, and when should you avoid it? What works, and what doesn’t?
Why all the confusion?
Three problems:
- Until iOS4, CATL routinely crashed people’s apps, with little or no explanation. It was a documented feature in Apple’s rendering code (Apple has now changed this to be more programmer-friendly – no crashes), quite easy to workaround once you knew it. But … it created a lot of FUD and frustration. A lot of experienced iOS programmers have learnt to live without CATL, and haven’t used it in a shipping app
- There’s very little documentation for CATL. A total of 5 sentences for the class, and 5 sentences covering configuration. Even the most Frequently Asked Question about CATL is missing from the docs (“how do I get rid of the fade/flash effect?”).
- CATL appears to fill an obvious hole in Apple’s libraries: Apple hasn’t (yet) provided classes to handle lazy rendering and “automatic redraw” when zooming. CATL doesn’t necessarily fill those holes – but with the lack of docs, lots of programmers jump to some (reasonable) conclusions here
What do iOS programmers try to use CATiledLayer for?
So, let’s have a look at some common use cases, and some common misconceptions:
- Drawing images that are “too big” to place in a single UIImageView (e.g. anything over 2096×2096 pixels)
- Reducing the memory footprint of “huge” images, so that an inifinitely large image can be rendered
- Rendering “huge” images very quickly, using automatic caching
- Adding “infinite zooming” to your application (e.g. as used in the Google Maps app)
- Making CGPath objects zoom correctly when you zoom in a UIScrollView (without CATL, they go blurry when you zoom)
What is CATiledLayer actually good for?
CATiledLayer:
- GOOD INTEGRATION with UIScrollView, automatically “Just Works” (almost; see below)
- When you zoom in a UIScrollView, CATL will AUTOMATICALLY RE-RENDER ITS CONTENTS AT HIGH RESOLUTION
- When tiles are cached, panning and zooming is FAST and SMOOTH
- When some tiles are NOT cached, panning is JUDDERY, but zooming is SMOOTH
- Uses MORE MEMORY than manually managing memory; on iOS, if you’re rendering truly huge images, and you want them to run fast … then you need more fine-grained control over the exact amount of RAM you’re using from second to second
- Uses a SIMPLISTIC, OFTEN POOR caching algorithm: e.g. for any content that is not “UIImage / CGImageRef”, it will REDUCE rendering speed
- Even for images, if an image is more than one tile in size, CATL will ADD A FLICKERING EFFECT TO THE RENDERING (like the tiles in Google Maps “flicking” into existence). This CANNOT BE REMOVED.
- Additionally, by default, it will ANIMATE THE FLICKERING to make it less offensive – but more obvious. This CAN BE REMOVED.
So, in summary, when to use CATL?
- If the “raw” content of your layer CHANGES RARELY OR NEVER
- If your content can be rendered at MULTIPLE RESOLUTIONS (e.g. text, e.g. CAShapeLayer, e.g. CGPath)
If you need to change content, there’s workarounds that work nicely (see below) – but it’s only workable if the changes are small and/or relatively infrequent (i.e. no more than a couple / second).
Making use of CATiledLayer…
…CATL does NOT automatically re-draw!
Every time you make ANY change to the contents of a CATL, you must manually call:
[(CATiledLayer*) YOUR_OBJECT setNeedsDisplay];
…effectively, you are really calling the private internal method:
[(CATiledLayer*) YOUR_OBJECT invalidateCache]; // just guessing. This?
[(CATiledLayer*) YOUR_OBJECT reGenerateTiles]; // ...or maybe this?
…but that’s not entirely obvious.
…with UIScrollView
For “perfect” integration, the CATL would use the UISV’s zoom settings to automatically determine it’s own zoom settings.
Fortunately, this is relatively easy to implement manually. There’s some excellent walk-through info on this at Things That Were Not Immediately Obvious To Me (NB: if you also read the “Part 1″ of that page, don’t panic – it’s doing a LOT more work than you need to. Probably ignore it for now).
…with CAShapeLayer
If you place a CAShapeL inside a UIScrollView, and zoom in … it all goes blurry.
.
However, if you place CATiledL inside a UIScrollView, and write your CAShapeL’s into the CATiledL, and zoom in … you get high-resolution. Yay!
But … performance drops through the floor. Because CATiledL isn’t doing any bitmap caching.
Fortunately, you can flip the “shouldRasterize” property of CAShapeL to “TRUE” just before drawing it to the CATiledL, and it wil output something that’s already a bitmap, so you get the best of both worlds.
NB: shouldRasterize can have unexpected consequences when using CA Animations – however, it’s a huge performance boost for some things, like drop shadows. So, if your rendering looks wrong when you use this trick, Google for the extensive tutorials and support questions on shouldRasterize…
Example code:
-(void)drawRect:(CGRect)rect
{
CGContextRef context = UIGraphicsGetCurrentContext();
for( CAShapeLayer* s in self.myShapes )
{
// NB: this "if" assumes that you manually created your CAShapeLayer
// the correct "frame" such that it fits the embedded CGPath.
// By default, Apple doesn't do this for you - your CAShapeLayer will
// have a frame of {{0,0} {0,0}}.
// You can omit the "if" altogether - it will reduce performance a little,
// but the tile is going to be cached anyway, so it's not fatal
if( CGRectIntersectsRect(rect, s.frame )
{
CGContextSaveGState(context);
/**
this effectively causes the CATiledLayer to cache a bitmap instead of complete CGPath's
so ... rendering performance is noticeably faster, even with just 5-6 paths on screen!
*/
s.shouldRasterize = TRUE;
/**
CGContextTranslateCTM(context, s.frame.origin.x, s.frame.origin.y );
[s renderInContext:context];
CGContextRestoreGState(context);
}
}
}
…with small changes to contents
If you redraw a CALayer, Apple waits till you’ve drawn the whole thing, then moves it to the screen. It appears all in one go, maybe with a tiny delay.
If you change even a TINY part of a CATL, even if you tell the CATL that “only this small piece has changed”, then CATL updates the screen lots of times, once for each tile that is generated. This is slower than doing it all in once, and creates an artificial “flicker” on the screen.
(NB: to do “only a small change”, use [setNeedsDisplayInRect:] – I’ve tested, and this appears to only de-cache the tiles in that rect. It is noticeably faster than [setNeedsDisplay], when the rect is small)
There is no way around this – CATL is just a little weak in its core algorithm. I believe Apple is introducing an artificial delay so that it can execute on a background thread without “using too much CPU time”.
So … instead, if you want the screen to update instantly with no flicker … you have to create an “overlay” layer above the CATL, and write your changes into that layer.
e.g. I recently made a game which showed all the countries of the world, and let you click a country to select it. When you select it, I wanted to change the fill-colour of the country. Implementation:
- UIScrollView (requires ONE AND ONLY ONE subview, or it doesn’t work correctly)
- UIView (with embedded CALayer) “container”
- UIView (with embedded CATiledLayer) “all countries of the world”
- UIView (with embedded CALayer) “OVERLAY for temporary changes to the CATL
- … if nothing selected, this layer is empty
- … if a country is selected, I clone the country’s pixels, change the colours, and add them to this layer
This works very fast – when you select a country, there’s no flicker, and it appears to happen instantaneously.
Without this, the update is slow, and you can see the tiles getting rendered one by one.
What does CATL actually do?
Through trial and error, we can see that CATL works something like this…
Sublayers
CATL layers *cannot have* CALayer sublayers.
Try it. Everything goes horribly wrong. Your sublayers might render, if you’re lucky (I saw this happen approx 1 time in 100 – I think it was a bug).
You can force them to render, by adding them to your tiles. Don’t do this: it *destroys* the performance of CATL.
If you want sublayers … you need to create a separate “container” CALayer, add it to the CATL’s *superlayer* … and then add your “sublayers” to that new “container”.
However, generally, it’s better to make a container UIView, and add to the CALayer’s UIView’s superview. Why? Because then you can still add sublayers (UIView.layer is always there), but you can also add things like UIButton too…
Tiles
CATL – surprise, surprise! – works by keeping a list of Tile objects internally, and each time the rendering system asks it to render itself, it blits one or more tiles onto the screen to cover the dirty rectangle.
Unfortunately, Apple *does not* allow us access to the NSObject (or, possibly, the struct) that they use to represent individual “tiles”. They give us an approximation – when they are creating a new Tile, they send us a CGRect that is the exact frame of the tile to generate (i.e. an offset/origin, and a width/height).
All Tiles are the same size *in pixels*, but they end up different sizes when you start zooming (see below).
There is also some magic about the meaning of “size” when a view starts to zoom, more on that later.
You can change the tile-size (it’s a config option for CATL), but that resets the CATL, and deletes all existing tiles (it seems).
The process for “generating new tiles” used to be complex, but now it’s very very simple:
/*! Override "drawRect:".
With CATL, the "rect" object has a special definition: it is the exact *frame* of the Tile that you are
generating - whatever you render will be cached as "TILE-N" (educated guess: no-one knows how Apple implements this internally
).
NOTE1: Apple hasn't documented this in the CATL class; you have to watch the WWDC videos, or use trial and error to discover it.
NOTE2: because "rect" is a *frame*, it captures both the tile size AND the tile offset. e.g. rect might be: {{512,256}, {256,256}}
*/
-(void)drawRect:(CGRect)rect
{
CGContextRef context = UIGraphicsGetCurrentContext();
for( CALayer* l in self.myInternalLayers )
{
if( CGRectIntersectsRect( rect, l.frame ) )
{
CGContextSaveGState(context);
CGContextTranslateCTM(context, l.frame.origin.x, l.frame.origin.y );
[l renderInContext:context];
CGContextRestoreGState(context);
}
else
;//DEBUG: NSLog(@"ignored non-intersecting tile");
}
}
Now: here there IS a surprise. By default, CATL stores the tiles in a very low performance way – it stores them as rendering commands. I think most people assume it stores them as blit’able bitmaps – that would make sense: it’s fast, it’s efficient.
IF you’re rendering pure image data in the CATL, then de facto a Tile is “only slightly more memory” than a blit. If you’re rendering anything else, you should write your own code to convert your render commands to a bitmap, and render the bitmap to CATL. If you do this, you can often get literally 5x increase in render speed.
So, for instance, if you draw CGPath objects to a CATL, they will be stored *multiple times over* in memory, increasing your memory usage, and reducing your rendering speed. Yes: used naively, CATL can make rendering slower, and take more memory.
Tile Caching
When CATL renders to screen, it only generates Tiles that it doesn’t already have inside its cache. When it hits some arbitrary limit, it deletes some existing tiles. Apple provides zero info on what the limit is, or which tiles get deleted first.
The cache is private, opaque, of “undocumented” size, with “undocumented” behaviour. This – if nothing else – makes CATL useless in many real-world cases. Opaque caches are evil.
Also, c.f. notes above on Tiles: this is a RENDER COMMAND cache, not a BITMAP cache. If you’re going to do render commands (i.e. 95% of rendering in a modern iOS app!), make sure you generate bitmaps on the fly, and render those to the CATL instead.
LOD (Levels of Detail)
CATL innately supports zooming: it has a special feature where you can insert it inside a UIScrollView, and it will intelligently redraw itself *at higher resolution* whenever the UIScrollView zooms in.
(NB: you can also achieve this behaviour manually, without a UIScrollView, by manually updating the low-level CALayer properties that tell a layer the size/area/zoom it should render with)
When you zoom (the complex process where you change the CALayer size, frame, contentScale, transform, etc), the CATL simply creates a whole NEW set of Tiles, and adds them to its cache. The old ones are NOT deleted (unless it runs out of space).
If you then zoom back out again, CATL will render very quickly, because it has the data cached. Probably.
i.e. the CATL cache looks something like this:
- TILE1 — ZOOM = 1:1 — SIZE = 256×256 — POSITION = 0,0
- TILE2 — ZOOM = 1:1 — SIZE = 256×256 — POSITION = 0,1
- TILE3 — ZOOM = 1:1 — SIZE = 256×256 — POSITION = 1,0
- TILE4 — ZOOM = 1:2 — SIZE = 256×256 — POSITION = 0,0
- TILE5 — ZOOM = 1:2 — SIZE = 256×256 — POSITION = 0,1
- TILE6 — ZOOM = 1:2 — SIZE = 256×256 — POSITION = 1,0
Rendering
NB: what follows comes from trial and error and educated guesses. I’d be very happy for someone from Apple to correct this with what’s *actually* happening – but I think this is close enough for us to understand how to use CATL.
The process that CATL uses goes something like this:
- Look at the layer’s contentsScale (I think that’s the property it uses? Or … maybe it reads the affineTransform property instead?), and the layer’s “normal” bounds (widthxheight)
- Use the “scale” info to decide “how many on-screen pixels” a standard tile would cover
- e.g. if you’ve zoomed-in by a factor of 1.5, and you’re using the default Tile size of 256×256, then the “on-screen” size of a tile is now 384×384
- WHILE the “on-screen pixel size” is greater than 2 x the default size, switch LOD level (i.e. use tiles that have smaller and smaller “layer.bounds” size)
- Look at the CGRect that the windowing system has told the CATL to “drawRect:” in
- if you’ve zoomed in, this will be much smaller area than the layer’s normal bounds
- if you’ve zoomed out, this will be much bigger area than the layer’s normal bounds
- Use that CGRect to calculate a list of tile-offsets that are needed to cover the VISIBLE area
- e.g. for a VISIBLE frame of “{{100,0}, {500,256}}”, and 256×256 tiles, you’d need tiles at: {0,0}, {256,0}, {512,0}.
- For each tile that is in the cache, render it immediately *BY REPLAYING RENDER COMMANDS*
- c.f. above, this can be SLOW. So slow that you see tiles appearing one-by-one on screen
- Display to screen
- …
- While that’s appearing on scren, in a background thread, do:
- For each tile that is NOT in the cache, call the user-written “drawRect:” method to generate the tiles.
- Each time a tile completes, schedule an update to the mainthread that will OVERWRITE the screen contents with the new tile
- i.e. some time after the CATL was rendered on screen (typically “half a second, up to several seconds”), while your main app is now running some other piece of code, small pieces of the CATL start magically appearing
- With the default CATL implementation, each new tile is animated in, using a “flash/fade” animation that takes 0.25 seconds per tile
- For most apps, most of the time, this is painfully slow. Most people subclass CATL, and override the “+(CSTimeInterval) fadeDuration” method to return “0.0″ instead.
- c.f. above: even with a fadeDuration of 0.0, you can often STILL “see” the tiles appear on screen, because CATL uses an inefficient tile rendering / tile caching algorithm