Having a smoothly scrolling table view with images that load from the internet is a surprisingly complex task.
The Problem
If you’re building an app for any kind of web service, you’ve certainly come across the situation where you want to load images for a tableview from the internet, while the user is scrolling. It’s a really hard problem to solve, and the solution you use will be dependent mostly on style. There are a couple of techniques in the newer versions of iOS that make this somewhat simpler, but the solution gets complex as you add affordances for a properly-scrolling tableview. (For the purposes of this blog post, a performant, properly-scrolling tableview involves something that at the least doesn’t block the users input, and at best scrolls at 60 frames per second.)
The wrongest you can get it
The worst thing you can do is synchronous loading.
cell.imageView.image = [UIImage imageWithData:[NSData dataWithContentsOfURL:[NSURL URLWithString:imageURL]]];
-dataWithContentsOfURL:
doesn’t return until the URL connection is over, which can take up to 2 minutes in the worst case (when the host isn’t available and the connection times out). Until that method returns, the UI is completely blocked. The user can’t do anything, the app won’t respond to any touches, including any for scrolling your tableview. Unacceptable.
A little better
A better solution is to subclass UIImageView
, and set up a non-blocking NSURLConnection
within the image view that takes the url, gets data for it, and creates the image view. This can have some unexpected results (giving the same image two different URLS, since the cells are getting reused), results an an extra view being attached to your cell, and generally gets messy.
All aboard
Grand Central Dispatch, added in iOS 4 and Leopard, gives us some handy tools for pushing work to other queues so they don’t block the main one from processing input events. We can get the data for the image, and hop back to the main queue to actually set the image (since almost all UIKit classes must be accessed from the main queue).
cell.imageView.image = [UIImage imageNamed:@"icn_default"];
dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0ul);
dispatch_async(queue, ^{
NSData *data = [NSData dataWithContentsOfURL:imageURL];
UIImage *image = [UIImage imageWithData:data];
dispatch_async(dispatch_get_main_queue(), ^{
cell.imageView.image = image;
});
});
This is good. Non-blocking, no extra classes needed, supports placeholder images, and is neat.
A small improvement that we need to make is to change anything accessing cell
to a call to [tableView cellForRowAtIndexPath:indexPath]
, since cell
might have been reused by the time that the URL connection is complete, causing it to update the wrong row. cellForRowAtIndexPath:
will return nil
if the intended cell is off the screen.
Let’s do some more
This will try to download the image every the cell shown, even if it’s already been downloaded. It has no caching. Using something simple like ` FTWCache`, we can store these images locally and pull them when we need them.
NSString *key = [imageURL.absoluteString MD5Hash];
NSData *data = [FTWCache objectForKey:key];
if (data) {
UIImage *image = [UIImage imageWithData:data];
cell.imageView.image = image;
} else {
cell.imageView.image = [UIImage imageNamed:@"icn_default"];
dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0ul);
dispatch_async(queue, ^{
NSData *data = [NSData dataWithContentsOfURL:imageURL];
[FTWCache setObject:data forKey:key];
UIImage *image = [UIImage imageWithData:data];
dispatch_async(dispatch_get_main_queue(), ^{
[tableView cellForRowAtIndexPath:indexPath].imageView.image = image;
});
});
}
Better still. Now we have caching, along with the other Fs and Bs we got earlier.
Package it up
This is a hot mess of code to keep in each cellForRowAtIndexPath:
method, though, and we should try to package it up into a UIImageView
category.
#import
static char URL_KEY;
@implementation UIImageView(Network)
@dynamic imageURL;
- (void) loadImageFromURL:(NSURL*)url placeholderImage:(UIImage*)placeholder cachingKey:(NSString*)key {
self.imageURL = url;
self.image = placeholder;
dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0ul);
dispatch_async(queue, ^{
NSData *data = [NSData dataWithContentsOfURL:url];
UIImage *imageFromData = [UIImage imageWithData:data];
[FTWCache setObject:UIImagePNGRepresentation(imageFromData) forKey:key];
UIImage *imageToSet = imageFromData;
if (imageToSet) {
if ([self.imageURL.absoluteString isEqualToString:url.absoluteString]) {
dispatch_async(dispatch_get_main_queue(), ^{
self.image = imageFromData;
});
}
}
self.imageURL = nil;
});
}
- (void) setImageURL:(NSURL *)newImageURL {
objc_setAssociatedObject(self, &URL_KEY, newImageURL, OBJC_ASSOCIATION_COPY);
}
- (NSURL*) imageURL {
return objc_getAssociatedObject(self, &URL_KEY);
}
@end
Since we can’t refer to our tableView to get its cell, and we can’t add synthesized properties to an Objective-C category, we can use Objective-C’s associative properties to store a reference to an object that is associated to another object. In this case, we store the image’s URL, and check to make sure that it hasn’t changed since the network connection has started. If it has changed, that means the cell (and therefore imageView
) has been reused and we shouldn’t update the image.
Extra credit
What else can we do? We can do image resizing. If you control the source of the images, you can resize on the server. This is ideal, since resizing on the device is pretty slow. Image resizing is pretty hard to do properly on the device, especially with the way UIImage
works. If you do want to do image resizing on the device, take a look at UIImage+Resize
, a category made in 2009, and doing that in the background queue will assure that it doesn’t lock up your UI.
There’s more still. Even with the UIImageView
category, the download is happening regardless and can’t be cancelled, since you put it on the queue with no reference to it. To be able to cancel it, you need to keep track of an NSOperationQueue
and the operations therein. It gets significantly more complicated, and the implementation for that is beyond the scope of this blog post. If you are going to be reusing the images, like on a Twitter client for example, it might be smart to let it download anyway.
You should also be profiling the scrolling performance of your exact code to understand what the fastest method for your code is. If you choose to go the NSOperation route, note that there is some cost to allocating and releasing NSOperation
objects, and that can lose you frames every second doing that.
Update
I’ve uploaded a gist with a simple category for this technique: https://gist.github.com/khanlou/4998479