Beating the retina display bloat

TL:DR The iPad retina display is going to cause bloat in app sizes beyond the 20MB 50MB 3G download limit. Instead use vector images to render assets on-the-fly as shown in this GIST and this example Xcode project.

With the imminent announcement of the iPad 3 and the much rumoured Retina display, developers are beginning to scramble to include new high res images in their iPad apps. But, as Matthew Panzarino at The Next Web and Federico Viticci at Mac Stories point out, this introduces a growing problem where many universal apps will suddenly balloon in size and tip over the 20MB 3G download limit.

Consider the default splash screen. For a single orientation it requires 4 images in a universal app:

  1. 320x420 for iPhone non retina
  2. 640x480 for iPhone retina
  3. 768x1024 for iPad
  4. 1536x2048 for iPad retina (assuming)

Using a random photo, the file size for these was around:

  1. 217 KB
  2. 789 KB
  3. 900 KB
  4. 2400 KB

I’m sure there’s some optimization that could be done there but still, that’s a total of around 4.2 MB, just for the splash screen images. Double that for landscape and you’re already at almost half your 20MB and you haven’t included any code yet.

The articles linked above point out two possible solutions.

  1. Get carries to allow an increase in the file size limit. I agree that 20MB is kind of small, even now with many podcasts over the limit, so this makes sense but at the same time it doesn’t really solve the problem. Bloated apps with lots of unused resources will quickly fill up or iDevices and will only increase the traffic on the already strained 3G networks.

  2. Have Apple implement a method to identify which device is downloading the app and only include the appropriate resources. I like this idea but it would require a rethinking of the application packaging process and probably wouldn’t be backwards compatible with existing iOS versions.

The above two solutions have their pros and cons but thinking about the actual problem there’s a third–and in my opinion much better–option that you can do today: don’t include the PNG images at all. Instead, use vector images in-app to generate the necessary assets on-the-fly. It won’t work for photos or large texture images but I’ve already used this technique in several apps and it works extremely well in most cases. It also has several advantages:

  1. The overall application size is considerably reduced. There’s no big retina image to download.
  2. Each device only contains the necessary image assets for itself. No retina screen? no waste of precious storage space for retina images.
  3. Less human time required. You only need to include the original vector image (as a PDF), no need to create four copies of every image for various resolutions and file names.
  4. Super huge bonus: your app won’t need any updates to have retina graphics on a new fancy iPad! It’ll “just work” day one.

The only disadvantages are when the first time the image is displayed. Since it doesn’t exist it will take a moment to load while the the image assets are generated and stored. Depending on the number of different images you have on the screen this could add a bit of loading time, but only the first time. With some creative background threading and appropriate care the impact can be easily minimized. As well, it won’t work for the icon and default image since those can’t be modified by the app so you’re still stuck with the images there.

Generating image assets on-the-fly

So how do we go about doing this? All we need is a vector image saved as a PDF and a bit of rendering code. Then, when you first need the image you just render the pdf to the appropriate PNG resource for the device.

Here’s an example GIST using a PDF to render an image (embedded below) or an example as a complete Xcode project.

The basic idea is you have a PDF in your project resources and you load it in the background like this:

UIImage *myImage = [LoadImageExample LoadImageFromFile:"example" forRect:CGRectMake(0,0,100,100));

UIImageView *imageView = [[UIImageView alloc] initWithFrame:self.view.frame];
[imageView setContentMode:UIViewContentModeCenter];
[self.view addSubview:imageView];

[LoadImage LoadFromPDF:@"apple_logo" size:CGSizeMake(100.0f,100.0f) callback:^(UIImage *image){
	[imageView setImage:image];
}];

If the device has a non-retina display the apple_logo.png image will be rendered with a size of 100x100. If the device has a retina display the apple_logo@2x.png image will be rendered with a size of 200x200.

The example uses blocks and ARC but you could easily re-write it to support older iOS versions.

Now your distributed iOS app only has a small vector file but can render it to an iPhone sized non-retina PNG or a more massive iPad 3 retina PNG.

//
//  LoadImage.h
//  AssetsFromPDF
//
//  Created by Jeffrey Sambells on 2012-03-02.
//

#import <Foundation/Foundation.h>

@interface LoadImage : NSObject

+ (void)LoadFromPDF:(NSString *)fileNameWithoutExtension size:(CGSize)size callback:(void (^)(UIImage *))callback;

@end


//
//  LoadImage.m
//  AssetsFromPDF
//
//  Created by Jeffrey Sambells on 2012-03-02.
//

#import "LoadImage.h"
#import "NSThread+Blocks.h"

@implementation LoadImage

+ (void)LoadFromPDF:(NSString *)fileNameWithoutExtension size:(CGSize)size callback:(void (^)(UIImage *))callback  {
	
	[NSThread performBlockInBackground:^{
	
		// Determine if the device is retina.
		BOOL isRetina = [UIScreen instancesRespondToSelector:@selector(scale)] && [[UIScreen mainScreen] scale] == 2.0;
		
		
		// Create a file manager so we can check if the image exists and store the image.
		NSArray *paths = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES);
		NSString *documentsDirectory = [paths objectAtIndex:0];
		NSFileManager *fileManger = [NSFileManager defaultManager];
		
		// Define the formats for the image names (low and high res).
		NSString *path = @"rendered-%@.png";
		NSString *pathHigh = @"rendered-%@@2x.png";
		
		// Get the file name.
		NSString *file = [documentsDirectory stringByAppendingPathComponent:[NSString stringWithFormat:( isRetina ? pathHigh : path ) , fileNameWithoutExtension]];
		
		UIImage *image;
		
		if ( ![fileManger fileExistsAtPath:file] ) {
			
			// Image doesn't exist so load the PDF and create it.
			
			// Get a reference to the PDF.
			NSString *pdfName = [NSString stringWithFormat:@"%@.pdf", fileNameWithoutExtension];
			CFURLRef pdfURL = CFBundleCopyResourceURL(CFBundleGetMainBundle(), (__bridge CFStringRef)pdfName, NULL, NULL);
			CGPDFDocumentRef pdfDoc = CGPDFDocumentCreateWithURL((CFURLRef)pdfURL);
			CFRelease(pdfURL);
			
			if (isRetina) {
				UIGraphicsBeginImageContextWithOptions(size, false, 0);	
			} else {
				UIGraphicsBeginImageContext( size );
			}
			
			// Load the first page. You could have multiple pages if you wanted.
			CGPDFPageRef pdfPage = CGPDFDocumentGetPage(pdfDoc, 1);
			
			CGContextRef context = UIGraphicsGetCurrentContext();
			
			// PDF page drawing expects a lower-left coordinate system, 
			// flip the coordinate system before we start drawing.
			CGRect bounds = CGContextGetClipBoundingBox(context);
			CGContextTranslateCTM(context, 0, bounds.size.height);
			CGContextScaleCTM(context, 1.0, -1.0);
			
			// Save the graphics state.
			CGContextSaveGState(context);
			
			// CGPDFPageGetDrawingTransform provides an easy way to get the transform 
			// for a PDF page. It will scale down to fit, including any
			// base rotations necessary to display the PDF page correctly. 
			CGRect transformRect = CGRectMake(0, 0, size.width, size.height);
			CGAffineTransform pdfTransform = CGPDFPageGetDrawingTransform(pdfPage, kCGPDFCropBox, transformRect, 0, true);
			
			// And apply the transform.
			CGContextConcatCTM(context, pdfTransform);
			
			// Draw the page.
			CGContextDrawPDFPage(context, pdfPage);
			
			// Restore the graphics state.
			CGContextRestoreGState(context);
			
			// Generate the image.
			image = UIGraphicsGetImageFromCurrentImageContext();
			
			// Store the PNG for next time.
			[UIImagePNGRepresentation(image) writeToFile:file atomically:YES];
			
			UIGraphicsEndImageContext();
			CGPDFDocumentRelease(pdfDoc);	
			
		} else {
			
			// Load the image from the file system.
			image = [UIImage imageWithContentsOfFile:file];
			
		}
	
		[[NSThread mainThread] performBlock:^{
			callback(image);
		}];
		
	}];}

@end


//
//  NSThread+Blocks.h
//

#import <Foundation/Foundation.h>

@interface NSThread (BlocksAdditions)
- (void)performBlock:(void (^)())block;
- (void)performBlock:(void (^)())block waitUntilDone:(BOOL)wait;
+ (void)performBlockInBackground:(void (^)())block;
@end

//
//  NSThread+Blocks.m
//

#import "NSThread+Blocks.h"

@implementation NSThread (BlocksAdditions)

- (void)performBlock:(void (^)())block
{
	if ([[NSThread currentThread] isEqual:self])
		block();
	else
		[self performBlock:block waitUntilDone:NO];
}

- (void)performBlock:(void (^)())block waitUntilDone:(BOOL)wait
{
	[NSThread performSelector:@selector(ng_runBlock:)
					 onThread:self
				   withObject:[block copy]
				waitUntilDone:wait];
}

+ (void)ng_runBlockPool:(void (^)())block
{
	@autoreleasepool {
		block();
	}
}

+ (void)ng_runBlock:(void (^)())block
{
	block();
}

+ (void)performBlockInBackground:(void (^)())block
{
	[NSThread performSelectorInBackground:@selector(ng_runBlockPool:)
							   withObject:[block copy]];
}

@end