Wednesday, February 25, 2009

Asynchronous Image Caching with the iPhone SDK

Coming from the web world, I'm used to not having to think about all the functionality implicit in the humble HTML <img> tag, including client-side resource caching and asynchronous loading. In fact, the browser will cache any resource sent with the appropriate HTTP response headers, but by far the most common use of this cache behavior on most websites is images of all kinds.

So in building a couple of simple iPhone apps recently that display a list of search results retrieved from a server call, one piece that was missing was an asynchronous image loader that retrieves and displays thumbnail images in UITableViewCells.

[UIImage imageWithData:[NSData dataWithContentsOfURL:url]] gives you default iPhone HTTP caching behavior using NSURLCache, but it turns out that's not good enough: NSURLCache only stores URL contents in memory for the duration of the use of the application. We also need a URL cache that writes results to disk so that frequently-accessed responses only, and it also needs to manage its capacity. After that, we need an image loader that will abstract away the work of downloading images asynchronously, so that when a cell needs an image, it can request the image from the CachedImageLoader, and the CachedImageLoader will call back with the image, whether loaded from cache or remotely. Let's start with the data cache, DiskCache, which simply caches NSData objects in files in the application's file path:

extern const NSUInteger kMaxDiskCacheSize;

@interface DiskCache : NSObject {
@private
        NSString *_cacheDir;
        NSUInteger _cacheSize;
        NSUInteger _diskCapacity;
}

@property (nonatomic) NSUInteger diskCapacity;
@property (nonatomic, readonly) NSString *cacheDir;
@property (nonatomic, readonly) NSUInteger size;

+ (DiskCache *)sharedCache;

- (NSData *)dataInCacheForURLString:(NSString *)urlString;
- (void)cacheData:(NSData *)data
                  request:(NSURLRequest *)request
                 response:(NSURLResponse *)response;
- (void)clearCachedDataForRequest:(NSURLRequest *)request;


@end

The implementation for this one will be pretty straightforward: it's a singleton class that obscures as much activity as possible, including the cache path and file naming scheme (the implementation actually just uses the original URL filename, though this is potentially brittle when resources with the same name are located in different paths). One detail that I find interesting is determining folder size, which is less straightforward than I expected under Cocoa. To determine the size of all files in a given directory, you have to walk the directory and add up their sizes:

- (NSUInteger)size {
    NSString *cacheDir = [self cacheDir];
    if (_cacheSize <= 0 && cacheDir != nil) {
        NSArray *dirContents = [[NSFileManager defaultManager]
                                directoryContentsAtPath:cacheDir];
        NSString *file;
        NSDictionary *attrs;
        NSNumber *fileSize;
        NSUInteger totalSize = 0;

        for (file in dirContents) {
            if ([[file pathExtension] isEqualToString:@"jpg"]) {
                attrs = [[NSFileManager defaultManager]
                         fileAttributesAtPath:[cacheDir
                             stringByAppendingPathComponent:file]
                         traverseLink:NO];

                fileSize = [attrs objectForKey:NSFileSize];
                totalSize += [fileSize integerValue];
            }
        }

        _cacheSize = totalSize;
        DLog(@"cache size is: %d", _cacheSize);
    }
    return _cacheSize;
}

Here, I'm only interested in files with the "jpg" extension, though this is the only hard-coded reference to images in the entire implementation and could easily be changed. Note the Objective-C syntax

for (NSString *file in dirContents)

which is known as fast enumeration and gets compiled down into pointer arithmetic, much faster than indexing using NSArray's objectForKey: method. Perhaps there's a more concise way to perform this task, but I haven't run across it yet.

While we're here, it's also worth taking a look at the mechanism for trimming the old files out of the disk cache when it reaches capacity. This involves first filtering the directory contents, then sorting the filtered contents by date modified; welcome to the wonderful world of NSArray sort functions:

NSInteger dateModifiedSort(id file1, id file2, void *reverse) {
    NSDictionary *attrs1 = [[NSFileManager defaultManager]
                            attributesOfItemAtPath:file1
                            error:nil];
    NSDictionary *attrs2 = [[NSFileManager defaultManager]
                            attributesOfItemAtPath:file2
                            error:nil];

    if ((NSInteger *)reverse == 0) {
        return [[attrs2 objectForKey:NSFileModificationDate]
                compare:[attrs1 objectForKey:NSFileModificationDate]];
    }

    return [[attrs1 objectForKey:NSFileModificationDate]
            compare:[attrs2 objectForKey:NSFileModificationDate]];
}


- (void)trimDiskCacheFilesToMaxSize:(NSUInteger)targetBytes {
    targetBytes = MIN([self diskCapacity], MAX(0, targetBytes));
    if ([self size] > targetBytes) {
        NSArray *dirContents = [[NSFileManager defaultManager]
                                directoryContentsAtPath:[self cacheDir]];

        NSMutableArray *filteredArray = [NSMutableArray 
                                arrayWithCapacity:[dirContents count]];
        for (NSString *file in dirContents) {
            if ([[file pathExtension] isEqualToString:@"jpg"]) {
                [filteredArray addObject:[[self cacheDir]
                                stringByAppendingPathComponent:file]];
            }
        }

        NSInteger reverse = 1;
        NSMutableArray *sortedDirContents = [NSMutableArray arrayWithArray:
                                     [filteredArray
                                sortedArrayUsingFunction:dateModifiedSort
                                                                             context:&reverse]];
        while (_cacheSize > targetBytes && [sortedDirContents count] > 0) {
            id file = [sortedDirContents lastObject];
            NSDictionary *attrs = [[NSFileManager defaultManager]
                                   attributesOfItemAtPath:file
                                   error:nil];
            _cacheSize -= [[attrs objectForKey:NSFileSize] integerValue];
            [[NSFileManager defaultManager] removeItemAtPath:file
                                                       error:nil];
            [sortedDirContents removeLastObject];
        }
    }
}

Next, we take a look at the CachedImageLoader, which uses a protocol ImageConsumer to for its clients; this keeps us from being locked into any particular image renderer (such as UITableViewCells or what have you); in fact, it's used in a few different places in the GoTime apps:

@protocol ImageConsumer <NSObject>
- (NSURLRequest *)request;
- (void)renderImage:(UIImage *)image;
@end


@interface CachedImageLoader : NSObject {
@private
        NSOperationQueue *_imageDownloadQueue;
}


+ (CachedImageLoader *)sharedImageLoader;


- (void)addClientToDownloadQueue:(id<ImageConsumer>)client;
- (UIImage *)cachedImageForClient:(id<ImageConsumer>)client;

- (void)suspendImageDownloads;
- (void)resumeImageDownloads;
- (void)cancelImageDownloads;


@end

Again, this is a singleton class, which is basically a wrapper for NSOperationQueue, which is a really handy high-level threading implementation. (There are reports that NSOperationQueue is broken on OS X Leopard, but the single-core chip architecture on the iPhone avoids the cause of this bug, so it can be safely used; indeed, I haven't had any problems with it.) NSOperationQueue allows you to add NSOperations, which get run FIFO on a number of threads specified in maxConcurrentOperationCount. I've found that a single image download thread is sufficient, even on edge networks: the network speed is always the bottleneck, not lack of concurrency. This allows the implementation of CachedImageLoader to be single threaded (counting the operation thread) and use synchronous downloading. A image consumer needing an image implements the ImageConsumer protocol, then calls addClientToDownloadQueue: passing itself as the argument:

- (void)addClientToDownloadQueue:(id<ImageConsumer>)client {
    UIImage *cachedImage;
    if (cachedImage = [self cachedImageForClient:client]) {
        [client renderImage:cachedImage];
    } else {
        NSOperation *imageDownloadOp = [[[NSInvocationOperation alloc] 
                        initWithTarget:self
                              selector:@selector(loadImageForClient:)
                                object:client] autorelease];
        [_imageDownloadQueue addOperation:imageDownloadOp];
    }
}

- (void)loadImageForClient:(id<ImageConsumer>)client {
    NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init];

    if (![self loadImageRemotelyForClient:client]) {
        [self addClientToDownloadQueue:client];
    }

    [pool release];
}

In cachedImageForClient, CachedImageLoader uses both the in-memory NSURLCache as well as our DiskCache class above, in turn. Failing to find the image in the cache, the loader creates an NSInvocationOperation which, in turn, gets called on our download thread; we create an autorelease pool and download the image synchronously, caching the image data when we have it (in both DiskCache and NSURLCache.)

So there you have it, most of an implementation of an asynchronous, caching image downloader for iPhone.

UPDATE 11/26/2009: The complete source code for the cache is available here.

22 comments:

simone said...

hi,
can you give me a complete example so i can understood how it works?
thanks, bye
s.

Malcolm Hall said...

Apple supplied a sample called URLCache which might have saved you some work. Although I do like your operation queue for downloading one at a time, that might be useful. Here is another implementation of loading images into table cells when they are viewed, like the app store app does:
http://www.markj.net/iphone-asynchronous-table-image/

Unknown said...

I'd also really like a sample app or a release of the class files.
I have an asynchronous image loader, but the file caching is giving me trouble.

Thanks!

Unknown said...

Hi


Could you mail me at lohigorry@gmail.com an example with all the code in order to understand well your code ?

Thanks a lot

Unknown said...

Interesting blog, but this post is rather pointless without actual code.

Jason said...

David, are you willing to share the complete source for this pattern?

Antonio Virzì said...

Hi David,
thanks for the post.
It seems one of the cleaner examples I've seen so far on the topic.

Could it be possible for you to share the code?

It would be very helpful for a lot of people.

Cheers,
Antonio

David Golightly said...

I've updated the post with a link to the source files.

Unknown said...

first, thanks for this code and article, it helped me both in solving an actual problem as well as gave me much insight into stuff... :)

anyway, I did notice (of course, I might be wrong), that the code does not create the cache directory, so if it doesn't exist, no caching...

I'm not (yet) experienced enough to claim I can do this elegantly (or "the right" way), so I hope it will be added, so I can learn even more ;)

Unknown said...

ok, maybe I wrote too soon... :)
I changed the "- (NSString *)cacheDir" method, hope it helps someone ;)

- (NSString *)cacheDir {
if (_cacheDir == nil)
{
NSArray *paths = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES);
_cacheDir = [[NSString alloc] initWithString:[[paths objectAtIndex:0] stringByAppendingPathComponent:@"URLCache"]];
}

/* check for existence of cache directory */
if ([[NSFileManager defaultManager] fileExistsAtPath:_cacheDir]) {
return _cacheDir;
}

/* create a new cache directory */
if (![[NSFileManager defaultManager] createDirectoryAtPath:_cacheDir
withIntermediateDirectories:NO
attributes:nil
error:nil]) {
NSLog(@"Error creating cache directory");
}
return _cacheDir;
}

Unknown said...

Nice post!!!

I'm trying to use your classes, so far I got the images downloaded and displayed.

But I'm getting an error with DiskCache:

"ERROR: Could not create file at path: ..."

Am I missing something?

Thnkx a lot

Unknown said...

Hi;

First, these comments are very nice, as is the code. As others have noted there is an issue with the initial creation of the cache folder. What's not been noted is that the code on the web page does not match the code in the tar file. In particular, the tar code never calls cachedImageForClient:, so even if/when an image is cached it's never retrieved from cache on subsequent calls.

So closely examine addClientToDownloadQueue: and add the if clause so that the disc cache is checked.

David Golightly said...

Thanks for the comments, guys. Crake, your problem is probably fixed by creating the cache directory using the example code given by goranche. sol0, thanks for the feedback. I've updated the code with some of your suggestions.

Unknown said...

There's also a spurious autorelease that will crash the code if the NSURLRequest timesout:

//[error autorelease];

To test, when invoking addClientToDownloadQueue create the NSURLRequest via requestWithURL:cachePolicy:timeoutInterval and use a small timeout value like 0.01.

The code now seems, well, really solid ... I'll hand you back my modified version of cache.tar files later tonight, hopefully. I took the liberty of changing the protocol name to CachedImageConsumer, but then again that was *only* for my benefit only. Maybe it should be CachedImageLoaderConsumer, I don't know.

To summarize, the code seems to work well with these mods:

1) create cache folder if non-existant
2) check local cache folder first
3) prevent double free

Thanks again, this is a wonderful example of how to cache net images locally in an iPhone app that persist over app restarts, and without blocking the GUI.

More testing is required to check for edge cases like case overflow, etc,but so far I'm very pleased.

Unknown said...

Hi David;

You can pick up my mods plus another bug fix plus example at http://www.lehigh.edu/~sol0/cache2.tar.gz - thanks again for the code,

Steve

Unknown said...

There's one more item that needs addressing : there can only be one client request active at a time. It's up to the client to wait until the previous request is complete before invoking addClientToDownloadQueue again. For a subclass of UIViewController something like this:


- (void)fetchRemoteImage:(NSString *)imageName {

if ( self.ur != nil ) {
return; // can handle one and only one request at a time
}

UIActivityIndicatorView *av = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleGray];
av.frame = CGRectMake( 10, 10, 20, 20 );
[self.view addSubview:av];
[av startAnimating];
[self.view setNeedsDisplay];

self.ur = [NSURLRequest requestWithURL:[NSURL URLWithString:[NSString stringWithFormat:@"http://.../%@", imageName]]
cachePolicy:NSURLRequestUseProtocolCachePolicy
timeoutInterval:kCachedImageLoaderRequestTimeout
];
[[CachedImageLoader sharedImageLoader] addClientToDownloadQueue:self];

}

- (void)renderImage:(UIImage *)image {

if ( [[self.view subviews] count] >=1 ) {
[ [ [self.view subviews] objectAtIndex:0] removeFromSuperview]; // remove UIActivityIndicator
}
((UIImageView *)self.view).image = image;
[self.view setNeedsDisplay];
self.ur = nil;

}

James said...

If I scroll fast it crashes?

2010-02-20 11:19:25.732 MusicPix[2855:5d0b] *** -[NSURLRequest _CFURLRequest]: message sent to deallocated instance 0x45a21e0

Unknown said...

Thanks so much for this! I've extened it a little bit, to avoid filename conflicts by md5suming the url.


see http://pastebin.ca/1815226

Unknown said...

Hi David...

What is it the license of this class? Could I use this in my commercial app?

Thanks

dub said...

Hi,

I downloaded the example from solo purely because there was the fixes and an example. However the example implementation is for a tableview cell.

Could any body offer an implentation for using just a UIImageView?

Olivier Poitrey said...

Here is another implementation of this technic: http://github.com/rs/SDWebImage, with the advantage to provide an UIImageView category for seamless integration in your code.

dub said...

@pufpuf

Thanks very much for sharing your implementation. It's really helped me out a lot in terms of my project and learning more.