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.