1
Mar
2011
 

Subduing CATiledLayer

by Matt Long

Many technologies we use as Cocoa/Cocoa Touch developers stand untouched by the faint of heart because often we simply don’t understand them and employing them can seem a daunting task. One of those technologies is found in Core Animation and is referred to as the CATiledLayer. It seems like a magical sort of technology because so much of its implementation is a bit of a black box and this fact contributes to it being misunderstood. CATiledLayer simply provides a way to draw very large images without incurring a severe memory hit. This is important no matter where you’re deploying, but it especially matters on iOS devices as memory is precious and when the OS tells you to free up memory, you better be able to do so or your app will be brought down. This blog post is intended to demonstrate that CATiledLayer works as advertised and implementing it is not as hard as it may have once seemed.

Download Demo Project

The Trick Is In Listening To The View

Let me cut to the chase here and clue you in on what you need to do. The easiest way to take advantage of the CATiledLayer is to create a UIView based subclass and override the +layerClass class method to return a [CATiledLayer class].

+ layerClass
{
  return [CATiledLayer class];
}

Then, you just need to override drawRect: and draw what it tells you to draw. That’s it! Listen to the UIView. It’s telling you in drawRect: which rectangle it wants to draw. So while your user is scrolling a scroll view that contains your view, for example, drawRect: will be getting called continuously. You just need to calculate how that rectangle corresponds to the image you’re wanting to draw.

- (void)drawRect:(CGRect)rect
{
  // You cant get the current context if you need it.
   CGContextRef context = UIGraphicsGetCurrentContext();

  // Your drawing code here ...
}

I’ll show you a fuller implementation of drawRect: later. Feel free to skip there now or even download the demo project if you want to see the project in action. First I’m going to discuss how we get our tiles and truly take advantage of the memory use reduction we get from the CATiledLayer.

The Downside Is Tiling the Image

So, now that you’ve implemented a UIView derived subclass that overrides drawRect: performance is going to improve drastically, right? Well, unfortunately, not exactly. If you pull the entire image into memory with -imageNamed or even -imageWithContentsOfFile, you’re going to be up against the exact same memory problem you had before using a tiled layer. So what are the solutions? Well, if there were a way to map tiles to bytes on disk, that would be great, but unfortunately that is far more complicated and I’m not even sure it’s possible. In the end, we have to actually tile the image manually and store the images on disk to be loaded on demand.

So, the first question I asked when I realized this is can I do the tiling programmatically and write the files out to disk or do I need to slice them up in a photo editor manually? Just like everything in programming, there are tradeoffs between various solutions. If you have a different part of your workflow where it makes sense to tile the images before sending them to the device, I would choose that solution, however, that doesn’t seem terribly likely. In most cases you’re probably going to need to tile the images on device programmatically. If that’s true, then I suggest that you don’t just tile every image, but rather set a threshold size. If your images reach a certain dimension, only tile those since it will take some time and processing power to do so.

Of course, you’re going to want to do your tiling on a background thread as to not block your user interface, but here is some basic image tiling code that will write your image tiles to disk. You can place this in an NSOperation or use a block and run it on a background queue.

- (void)saveTilesOfSize:(CGSize)size 
               forImage:(UIImage*)image 
            toDirectory:(NSString*)directoryPath 
            usingPrefix:(NSString*)prefix
{
  CGFloat cols = [image size].width / size.width;
  CGFloat rows = [image size].height / size.height;
  
  int fullColumns = floorf(cols);
  int fullRows = floorf(rows);
  
  CGFloat remainderWidth = [image size].width - 
                          (fullColumns * size.width);
  CGFloat remainderHeight = [image size].height - 
                          (fullRows * size.height);


  if (cols > fullColumns) fullColumns++;
  if (rows > fullRows) fullRows++;

  CGImageRef fullImage = [image CGImage];

  for (int y = 0; y < fullRows; ++y) {
    for (int x = 0; x < fullColumns; ++x) {
      CGSize tileSize = size;
      if (x + 1 == fullColumns && remainderWidth > 0) {
        // Last column
        tileSize.width = remainderWidth;
      }
      if (y + 1 == fullRows && remainderHeight > 0) {
        // Last row
        tileSize.height = remainderHeight;
      }
      
      CGImageRef tileImage = CGImageCreateWithImageInRect(fullImage, 
                                        (CGRect){{x*size.width, y*size.height}, 
                                          tileSize});
      NSData *imageData = UIImagePNGRepresentation([UIImage imageWithCGImage:tileImage]);

      CGImageRelease(tileImage);

      NSString *path = [NSString stringWithFormat:@"%@/%@%d_%d.png", 
                        directoryPath, prefix, x, y];
      [imageData writeToFile:path atomically:NO];
    }
  }    
}

Let me walk you through this a little bit. We pass in the size of the tiles we want to break our image into. We pass in the image itself, the directory we want to save it to, and finally a prefix that we will use as a unique identifier for the file names for the image in question. We need this so that we can retrieve them again later when we’re ready display them.

The first thing we do is calculate the number of tiles we’re going to have in columns and rows by dividing the total width by the tile width and the total height by the tile height. The result is a floating point number. We then get the number of columns and rows that are full sized tiles by using floorf(). We then calculate the width of the last column and the height of last row. Next we iterate through the entire grid of what will soon be tiled images, columns per row. Then we extract the image data at the rect in question and write its contents to disk using an NSData. The filename format we’re using takes into account the x and y positions in our grid of tiles such that an image would have its tiles named:

<directory>/<prefix>x_y.png

Where directory and prefix are the strings passed into the function. The .png extension here is really just for clarity. It is completely unnecessary.

Implementing Draw Rect

The first implementation of CATiledLayer expected that you would create a delegate for your layer and then override

- (void)drawLayer:(CALayer*)theLayer 
            inContext:(CGContextRef)theContext

This was error prone for several reasons. The trouble was you couldn’t use both UIKit and Core Graphics calls to draw in the layer which people would often do. They would soon start asking why their app was crashing and would discover that the problem could be solved by only drawing using only Core Graphics.

Now, in iOS4, things have gotten much easier. You can simply override drawRect: in your UIView subclass and everything works correctly. To draw my layer correctly, I adapted the drawing code you find in the PhotoScroller sample code from the WWDC 2010 sessions (Link opens iTunes). It looks like this:

- (void)drawRect:(CGRect)rect {
 	CGContextRef context = UIGraphicsGetCurrentContext();

  CGSize tileSize = (CGSize){256, 256};

  int firstCol = floorf(CGRectGetMinX(rect) / tileSize.width);
  int lastCol = floorf((CGRectGetMaxX(rect)-1) / tileSize.width);
  int firstRow = floorf(CGRectGetMinY(rect) / tileSize.height);
  int lastRow = floorf((CGRectGetMaxY(rect)-1) / tileSize.height);
  
  for (int row = firstRow; row <= lastRow; row++) {
    for (int col = firstCol; col <= lastCol; col++) {
      UIImage *tile = [self tileAtCol:col row:row];

      CGRect tileRect = CGRectMake(tileSize.width * col, 
                         tileSize.height * row,
                         tileSize.width, tileSize.height);
      
      tileRect = CGRectIntersection(self.bounds, tileRect);
      
      [tile drawInRect:tileRect];

      // Draw a white line around the tile border so 
      // we can see it
      [[UIColor whiteColor] set];
      CGContextSetLineWidth(context, 6.0);
      CGContextStrokeRect(context, tileRect);
    }
  }
}

Our view port in the app is set by the frame of the scroll view. This view is a lot larger than a single tile, which we have set to the default size of 256 x 256. That means we need to find all of the tiles that need to be drawn for displaying in the view port. So, we calculate the first column, last column, first row, and last row. This tells us where within the image we need to start and stop drawing. It also tells us which tiles we need to load. Once we've got all of these rows and columns calculated, we can then iterate through them and grab the tile for the current row and column using the same filename format we used to save the files in the first place. The code to load the image tiles looks like this:

- (UIImage*)tileAtCol:(int)col row:(int)row
{
  NSString *path = [NSString stringWithFormat:@"%@/%@%d_%d.png", tileDirectory, tileTag, col, row];
  return [UIImage imageWithContentsOfFile:path];  
}

Notice we're using -imageWithContentsOfFile rather than -imageNamed, since -imageNamed actually caches the image in memory--which we don't want. If we used that, we would be right back at our memory usage issue after scrolling around for a few minutes.

Conclusion

Using the CATiledLayer makes a lot of sense when memory is of the essence, which it is much of the time when doing iOS development. Examples from Apple and other places do a good job showing how you can use a tiled layer for use with PDFs, but if you want to tile an image, things are a little more complicated. I hope this post has served to help you better understand this powerful Core Animation layer. Until next time.

Download Demo Project

Comments

Jerry Beers says:

If you used imageNamed, wouldn’t it cache the tiles until it got a memory warning and then flush the cache? Without having tried it, it seems like that might make scrolling smoother and still behave correctly under memory pressure.

Matt Long says:

@Jerry Beers,

Thanks for the feedback. So, I should probably test this again with the newer iOS, but the conventional wisdom seems to be that -imageNamed is a bad idea since it doesn’t seem to actually flush the cache as you suppose: http://www.alexcurylo.com/blog/2009/01/13/imagenamed-is-evil/

wuf810 says:

Hi Matt,

Couple of questions:

1) Is -imageNamed still problematic under 4.x I was under the impression that it had been “fixed”.

2) Also you say “Now, in iOS4, things have gotten much easier. You can simply override drawRect” – could this not be done before iOS 4? What part is only available to iOS 4

Thanks, Michael.

Matt Long says:

Hi Michael,

1) See my comments above. I think I probably need to re-test this issue, but I believe it is still a problem.

2) Take a look at this technical q&a from Apple: http://developer.apple.com/library/ios/#qa/qa2009/qa1637.html.

You couldn’t override drawRect prior to iOS 4.0 and have it work correctly.

-Matt

wuf810 says:

Thanks Matt. Didn’t realise you couldn’t override drawRect before OS 4.

I might check with someone like Michael jurewitz – positive he told me all was Ok with imageNamed now – (but that might just have been Apple’s official line :-)

Anyway thanks again. Great article.

Regards, Michael

Twitter – @wuf810

an0 says:

It seems this line has problem:
tileRect = CGRectIntersection(self.bounds, tileRect);

If the tileRect cross any edge of self.bounds, the intersection is a smaller rect than tile size, then the tile will scale down when drawing with drawInRect:.

See a case like this https://skitch.com/an00na/rus7n/tile-across-bounds.

cocteau says:

Thanks for the post. I tried it out and as you say, it crashes on the device. I was looking for something to tile a repeated image so I kept looking and found a good starting point with sample code on developer.apple.com, which also applies to this problem of large images:

http://developer.apple.com/library/ios/samplecode/ScrollViewSuite/Introduction/Intro.html#//apple_ref/doc/uid/DTS40008904

I didn’t use these but they look interesting too:

http://developer.apple.com/library/ios/samplecode/Scrolling/Introduction/Intro.html#//apple_ref/doc/uid/DTS40008023

http://developer.apple.com/library/ios/samplecode/PhotoScroller/Introduction/Intro.html#//apple_ref/doc/uid/DTS40010080

pierre says:

Great post, thanks.

Here is a small script for the ones who want to generate the tiles offline :

http://www.mikelin.ca/blog/2010/06/iphone-splitting-image-into-tiles-for-faster-loading-with-imagemagick/

EricK says:

Great post, but I’m confused by the purpose of the nested for loops. If you put logging code just before the first for statement and another inside the inner loop you’ll see that the inner code is only run once. The rect that is delivered to the drawRect: method is the exact position and size for one tile. Why do you need the loops? I have removed the loops in the PhotoScroller app and it performs identically. That’s the only sample code I’ve found that uses the loops, all the others just determine which tile to draw and then draw it. I’d really like to understand CATiledLayer and I’m getting closer. But, this doesn’t make any sense yet.

lifemoveson says:

Great work and thanks for sharing the idea of tiling images. But I have a quick question How will you add the savefiletosize() function in NSInvocationOperation or NSOperationQueue as suggested to improve the speed pf tiling the images.