iOS AppDev Patterns: Asynchronous Image Loader

In a content-heavy application (a news or a magazine app for example), textual content takes precedence over images in terms of loading/rendering. An acceptable solution is to load/render text and request images from the server/cache in a concurrent fashion. I use the term ‘server’ in a very loose fashion, a more appropriate term is probably ‘content source’, since we can retrieve this information from anything ranging from our own servers to Twitter/RSS feeds.

There are a few considerations when implementing a solution like this:

Load request throttling: You’re likely to have several images spread across pages. It is not prudent to let 50 concurrent requests fire for 50 images. You want to throttle your requests to a reasonable number. A simple example of throttling your request is shown later.

Memory management: You want to gracefully handle the situation in which the loader is able to retrieve the image, but it the frame on which it is supposed to display the image (however it is implemented) has already been deallocated (for whatever reason).

A basic implementation is shown below:
MediaLoadListener.h

@protocol MediaLoadListener
	-(void) mediaLoaded:(UIImage*) image;
@end

WaitingIndication.h

@protocol WaitingIndication <NSObject>
-(void) wait:(UIImageView*) imageView;
-(void) doneWaiting:(UIImageView*) imageView;
@end

MediaLoadListener is the protocol which would be implemented by any class that wishes to be notified of when the image has been loaded. In our case, this will be the ImageContainer class.
The WaitingIndication will be implemented by any class that wants to show some indication in lieu of the image while we are retrieving the image.

ImageContainer.h

@interface ImageContainer {
	UIImageView* imageView;
	NSURL* imageURL;
        UIViewContentMode fillMode;
	id<WaitingIndication> waitIndication;
}

@property (nonatomic, retain) UIImageView* imageView;
@property (nonatomic, retain)  NSURL* imageURL;
@property (nonatomic, assign) UIViewContentMode fillMode;
@property (nonatomic, retain) id<WaitingIndication> waitIndication;

-(ImageContainer*) initWithImageURL:(UIImage*)imgURL;
-(ImageContainer*) withFillMode:(UIViewContentMode) fillMode;
-(ImageContainer*) withWaitingIndicator:(id<WaitingIndication>) indication;
-(void) layoutMediaIn:(UIView*) view;
@end

Most of ImageContainer's structure should be pretty obvious. The one thing I should point out is that ImageContainer could have been a UIViewController-derived class, since it basically manages the lifecycle of an UIImageView; however, I didn’t see it as a full-fledged controller. YMMV.

ImageContainer.m

@implementation ImageContainer
@synthesize imageView;
@synthesize imageURL;
@synthesize fillMode;
@synthesize waitIndication;

-(ImageContainer*) initWithImageURL:(NSURL*)imgURL {
    self = [super init];
    if (self) {
        self.imageURL = imgURL;
        id<MediaLoadListener> blockSelf = self;
        MediaLoadRetryTimer* timer = [[MediaLoadRetryTimer alloc] initWithInterval:10 withMediaURL:self.imageURL withLoadListener:blockSelf];
        self.mediaLoadRetryTimer = timer;
        self.fillMode = UIViewContentModeScaleToFill;
        id<WaitingIndication> indication = [LoadingRingIndication new];
        self.waitIndication = indication;
        [timer release];
        [indication release];

    }
	return self;
}

-(ImageContainer*) withFillMode:(UIViewContentMode) fillMode {
	self.fillMode = fillMode;
	return self;
}

-(ImageContainer*) withWaitingIndicator:(id<WaitingIndication>) indication {
	self.waitIndication = indication;
	return self;
}

-(void) layoutMediaIn:(UIView*) view {
	UIImageView* imgView = [UIImageView new];
	self.imageView = imgView;
	[imgView release];
        [waitIndication wait:imageView];
	imageView.layer.borderWidth = 1;
	[view addSubview:imageView];
	[mediaLoadRetryTimer go];
}

-(void) mediaLoaded:(UIImage *)img {
		[mediaLoadRetryTimer cancel];
		[waitIndication doneWaiting:imageView];
		imageView.image = img;
        imageView.contentMode = self.fillMode;
		imageView.layer.borderWidth = 0;
}

- (void)dealloc {
	self.imageView = nil;
	self.imageURL = nil;
	self.waitIndication = nil;
	[super dealloc];
}
@end

Let’s talk about the workflow of the entire image loading process.
The layoutInView: method is responsible for setting up the view and asking the WaitingIndication object to display itself. It then fires the MediaLoadRetryTimer object, which handles firing off load requests at periodic intervals.
When and if the MediaLoadRetryTimer is successful in retrieving the UIImage object, it notifies the ImageContainer by sending it the mediaLoaded: message.
Upon receiving it, the ImageContainer stops the timer (we don’t want more load requests for this image), gets rid of the WaitingIndication, and finally, sets the image.
We shall look into the guts of MediaLoadRetryTimer now. Here’s the code for it.

MediaLoadRetryTimer.h

@class TimerBatcher;

@interface MediaLoadRetryTimer : NSObject {
    NSTimeInterval timeInterval;
	NSURL* mediaURL;
	id<MediaLoadListener> listener;
	
	dispatch_source_t timer;
	dispatch_block_t timerAction;
	dispatch_queue_t queue;
}

@property (nonatomic, assign) NSTimeInterval timeInterval;
@property (nonatomic, retain) NSURL* mediaURL;
@property (nonatomic, assign) id<MediaLoadListener> listener;

@property (nonatomic, assign) dispatch_source_t timer;
@property (copy) dispatch_block_t timerAction;

-(MediaLoadRetryTimer*) initWithInterval:(NSTimeInterval) interval withMediaURL:(NSURL*) mediaURL withLoadListener:(id<MediaLoadListener>) listener;
-(void) cancel;
-(void) go;
-(void) safeCancelTimer;
-(void) run;
@end

MediaLoadRetryTimer.m

@implementation MediaLoadRetryTimer
@synthesize timer;
@synthesize timerAction;
@synthesize timeInterval;
@synthesize mediaURL;
@synthesize listener;

-(MediaLoadRetryTimer*) initWithInterval:(NSTimeInterval) interval withMediaURL:(NSURL*) mediaURL withLoadListener:(id<MediaLoadListener>) listener {
    self = [super init];
    
    if (self) {
        self.timeInterval = interval;
        self.mediaURL = mediaURL;
        self.listener = listener;
    }
	
	return self;
}

-(void) cancel {
    [TimerBatcher complete:self];
	self.listener = nil;
	if (self.timerAction) {
		[self safeCancelTimer];
        dispatch_release(timer);
        self.timerAction = nil;
    }
}

-(void) safeCancelTimer {
	if (!timer) return;
	dispatch_source_cancel(timer);
}

-(void) go {
	[TimerBatcher registerAs:self];
	[TimerBatcher run:self];
}

-(void) run {
	queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_LOW, 0);
	self.timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, queue);
	if (!timer) return;
	dispatch_source_set_timer(timer, dispatch_walltime(NULL, 0), timeInterval * NSEC_PER_SEC, 5 * NSEC_PER_SEC);
	id<MediaLoadListener> loadListener = self.listener;
	NSURL* mediaURL = self.mediaURL;
	NSURL* u = [NSURL URLWithString:[NSString stringWithString:[mediaURL absoluteString]]];
	dispatch_source_t timer = self.timer;
	self.timerAction = ^{
		@try {
			if (loadListener == nil) return;
			
			NSData* data = [NSData dataWithContentsOfURL:u options:0 error:nil];
			UIImage* img = [UIImage imageWithData:data];
			if (img == nil) return;
			dispatch_source_cancel(timer);
			dispatch_async(dispatch_get_main_queue(), ^{[loadListener mediaLoaded:img]; });
		}
		@catch (NSException *exception) {
		}
	};
	
	dispatch_source_set_event_handler(timer, timerAction);
	dispatch_resume(timer);
}

-(void) dealloc {
	self.mediaURL = nil;
	[self cancel];
	[super dealloc];
}
@end

The meat of this code is in the run: method. To perform concurrent operations, we create a timer source (dispatch_source_t) on a concurrent global queue with low priority. The interval at which the timer will tick is injected into the MediaLoadRetryTimer object.
At every tick, the action to be taken is defined in a block (timerAction). Inside this block, we attempt to retrieve the image. If we cannot (img == nil) we exit the block. Otherwise, we notify the listener that the image is available.

All of this is well and fine, but this does not prevent as many requests spawning as there are images. We need some way of throttling these requests. How you want to throttle it will depend upon your particular situation. If you look at the declaration of MediaLoadRetryTimer, you’ll notice a second method called go:.
This is the method that ImageContainer calls, rather than run:; that internally calls TimerBatcher, which is how I throttle requests in this example. The source for TimerBatcher is below.

TimerBatcher.m

@interface TimerBatcher : NSObject {
}

+(void) registerAs:(MediaLoadRetryTimer*) timer;
+(void) run:(MediaLoadRetryTimer*) timer;
+(void) complete:(MediaLoadRetryTimer*) timer;

@end

TimerBatcher.m

static NSMutableArray* inactiveTimers;
static NSMutableArray* activeTimers;
static NSUInteger limit;

@implementation TimerBatcher
+(void) initialize {
	inactiveTimers = [[NSMutableArray array] retain];
	activeTimers = [[NSMutableArray array] retain];
	limit = MAXIMUM_CONCURRENT_MEDIA_LOAD_THREADS;
}

+(void) registerAs:(MediaLoadRetryTimer*) timer {
	[inactiveTimers addObject:timer];
}

+(void) run:(MediaLoadRetryTimer*) timer {
	NSUInteger index = [inactiveTimers indexOfObject:timer];
	if (index == NSNotFound) return;
	if ([activeTimers count] >= limit) return;
	[activeTimers addObject:timer];
	[inactiveTimers removeObjectAtIndex:index];
	[timer run];
}

+(void) complete:(MediaLoadRetryTimer*) timer {
	NSUInteger index = [activeTimers indexOfObject:timer];
	if (index == NSNotFound) return;
	[activeTimers removeObjectAtIndex:index];
	if ([activeTimers count] >= limit) return;
	if ([inactiveTimers isEmpty]) return;
	[TimerBatcher run:[inactiveTimers objectAtIndex:0]];
}
@end

The logic of TimerBatcher is pretty simple in this case. It has a limit defined by MAXIMUM_CONCURRENT_MEDIA_LOAD_THREADS. It allows timers to be activated as long as the number of active timers is below or equal to that limit. Beyond that, it refuses further activation requests, but permits activation requests to be queued. Whenever an active timer finishes, it picks up the next timer in the queue awaiting activation.
You might have more complex requirements to allow which timers get activated and which don’t. The above should give you some ideas about how to go about building one.