Caching +imageForURL: Results
About a month ago I wrote about the recurring but ever-solvable problem of loading images in the background without losing performance while say, scrolling a table view. While I would be pleased to ship that code today, there is an improvement that betters performance as soon as an image is requested more than once: Caching.
Consider an app where most of its content is fetched from the internet for display. Text content is fairly lightweight, often returned as large chunks of JSON. But images usually have to be fetched one at a time. This can be time consuming, and can potentially stall your app. That problem has been solved (see related post) many times. Is it enough, however, simply not to stall your app? It is okay to load those images off the internet every time your app is run? Maybe, but we can do better!
It was always my intent to add caching to this solution. I had previously solved this general problem, and had included caching. This time around, however, the source URLs I am dealing with are quite complicated; not nearly as “well groomed” as I had to work with in the past:
[code lang=”bash”]
http://…/images/di/66/32/4d/784d705930725469336f6e55586d5435313541-200×200-0-0.jpg?rqid=v1.f2b2ec0ad70c0c285136&rqt=SRS&a=1&c=1&l=7000610&r=1&pr=1&lks=123429&fks=123429
[/code]
Luckily, the SDK contains a lot of great tools to make dealing with this mess a breeze. So extracting a usable file path, sans URI or parameters, is quite easy, yielding this:
[code lang=”bash”]
/images/di/66/32/4d/784d705930725469336f6e55586d5435313541-200×200-0-0.jpg
[/code]
This is integral to the caching plan, which is to write out the fetched data to a file with the same name and path as it exists on the server. Future requests for the same URL will check before invoking a network fetch if the file exists. And if the file does exist, the required UIImage is reconstituted from disk rather than from the internet.
You may be concerned that all these files are going to consume valuable storage space inside the app’s sandboxed file system. Or worse, that the files are going to get backed up to the user’s iCloud backup. And those would be valid concerns. But it turns out these are easy to address.
The files are carefully stored not in the ~/Documents directory, but rather in the ~/Library/Caches directory. The latter has two interesting characteristics: (1) Its contents are not backed up to iCloud (and there is no need to tag files there in as not requiring backup, as iOS 5.0.1 enabled when it was released after iOS5 made backup of ~/Documents to iCloud theĀ default), and (2) if the OS decides it needs to free up memory, it will delete files from any app’s ~/Library/Caches directory. Apple’s guideline on all this is that anything you can easily re-create (you, and not the user) should be put in ~/Library/Caches. (You can read all about Apple’s App Backup Best Practices for yourself, and I recommend you do.)
Without further ado, the code. This is a new category method for UIImage. It looks a lot like the version posted previously, except it now includes local file handling to cache fetched data for later re-use. It really could replace the original version. But on the off chance you’d want to take advantage of this without caching, I created a new method. You’ll notice in the dispatch_once block that the code checks that _operationQueue is not already created. That’s because the original method (which also has the same check now) might have been called before this method, if you were to use both in a single app. There are yet more opportunities to refactor, I think. But that’ll be for another day. Enjoy!
[code lang=”objc”]
+(NSBlockOperation *)cachedImageFromURL:(NSString *)urlString
withCompletionBlock:(void(^)(UIImage *image))completionBlock
{
static CGFloat scale = 0;
static dispatch_once_t onceToken;
static NSString *cachesDirectory = nil;
dispatch_once(&onceToken, ^{
scale = [UIScreen mainScreen].scale;
if (!_operationQueue) {
_operationQueue = [[NSOperationQueue alloc] init];
_operationQueue.maxConcurrentOperationCount = 4;
}
NSArray *paths = NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES);
cachesDirectory = [paths objectAtIndex:0];
});
NSURL *imageURL = [NSURL URLWithString:urlString];
NSString *filePath = [[cachesDirectory stringByAppendingFormat:@"/%@", imageURL.path] stringByStandardizingPath];
NSBlockOperation *operation = [NSBlockOperation blockOperationWithBlock:^{
UIImage *image = nil;
NSData *imageData = nil;
BOOL needsSave = NO;
if ([[NSFileManager defaultManager] fileExistsAtPath:filePath]) {
imageData = [NSData dataWithContentsOfFile:filePath];
} else {
imageData = [NSData dataWithContentsOfURL:imageURL];
needsSave = YES;
}
if (imageData) {
image = [UIImage imageWithData:imageData scale:scale];
if (needsSave) {
NSMutableArray *pathCompnents = [NSMutableArray arrayWithArray:[filePath pathComponents]];
[pathCompnents removeLastObject];
NSString *fileDirectoryPath = [pathCompnents componentsJoinedByString:@"/"];
// If any of the following file operations fail, there is no one to tell, really, so we fail silently.
// Best practices in other scenarios would demand more robust error handling, of course.
if ([[NSFileManager defaultManager] createDirectoryAtPath:fileDirectoryPath
withIntermediateDirectories:YES attributes:nil error:nil]) {
[imageData writeToFile:filePath options:NSDataWritingAtomic error:nil];
}
}
}
dispatch_async(dispatch_get_main_queue(), ^{
completionBlock(image);
});
}];
[_operationQueue addOperation:operation];
return operation;
}
[/code]