Subduing CATiledLayer

| Blog | March 1, 2011

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 thosetechnologiesisfoundin 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.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
- (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]);
      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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
- (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:

1
2
3
4
5
- (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

Notes on the Demo Project

When running on the device the image I am tiling is just too large to be tiled completely before the app uses up too much memory and is killed. You will probably want to run in the simulator to see the tiling finish. As noted in the code comments, you un-comment the tiling code and do a first run with the app allowing it to finish tiling. Then, stop the app and re-comment that tiling code and it will just load and display the tiles it created without having to go through that tiling again. I suppose I could have made the tiling code get called in response to a button tap or something like that, but I’ll leave that as an exercise for the reader. Contact me through the comments below if you have any problems or questions.

Leave a Reply