Streaming thumbnails smoothly using HTTP in your iPhone app
Network enabled apps such as news aggregators, blogging clients often load lots of small files, such as thumbnails, trough HTTP connection. Usually we expect that such application is: (1) responsive (does not block scrolling while loading), (2) it does load thumbnails almost realtime.
I suppose the first thing everybody tries is calling [UIImage
imageWithData:[NSData dataWithContentsOfURL:someURL]]
. There is nothing wrong
with it, except it make your UI stutter and load images painfully slow. After
trying that one may think of using background threads, NSOperationQueue
, or
something like that, but still any modern web browser loads those images much
faster when they are embedded into regular web page than your application.
Wonder why?
Man behind the bar at HTTP server
HTTP 1.1 introduces feature called persistent connections – later backported
also to HTTP 1.0 in the form of an extra header Connection: keep-alive
sent
by the client. This is the clue of the solution here.
You may think of HTTP server working thread serving you as bartender taking your order and bring back drinks. First your connection is waiting in the queue to the bar, once bartender dispatches all orders of clients in the front of you, he asks for yours. Then (during the connection) you have the bartender (almost) exclusively serving you. Once you disconnect, the bartender focuses his attention on the next client, so if you decide to make another request short while afterwards you need to go back in the queue and wait again for your turn.
Back in HTTP 1.0 times, server was taking only one order, sending back response and disconnecting. But since HTTP 1.1 you may send many orders to the server one after the other (or even parallel). You can also stay connected while you are non sending requests, just in case you want something extra. All modern web browsers follow simple rule of trying to execute all the requests using existing persistent connection while not making more than 2-4 connections to single server.
Thats why putting your thumbnails download in background threads or NSOperationQueue does not do any better, but just pollutes remote HTTP server with many connections, that anyway will not be served altogether. Moreover the overall time you gonna spend waiting for response for all requests will be significantly longer than time use with single persistent connection sending requests one-by-one.
CFNetwork HTTP connection pooling
It is better then to keep your application single threaded, but instead use
good event driven download mechanism based on persistent connections. The first
candidate for that is NSURLConnection
which unfortunately has some problems
with keeping persistent connections to HTTP 1.0 servers via keep-alive header,
even it uses internally CFNetwork. This is the reason I will focus on more
low level solution directly based on CFNetwork’s CFReadStream
object and
CFReadStreamCreateForHTTPRequest
function.
CFNetwork documentation states that this framework tries to pool and reuse
existing HTTP connections for newly created CFReadStream
if there is still
other CFReadStream
connected to the same server. This implies two facts: (1)
CFNetwork
does queue all read requests to the same server where possible
trough single connection (2) if only you keep the CFReadStream
reference to
the server (i.e. using global variable), so every time you open new
CFReadStream
, it will reuse connection kept inside previously used
CFReadStream
.
Below I attach ready to use implementation of Fetch class (under MIT-like
license) that does all of that for you. All you need to do is to [Fetch
fetchURL:someURL delegate:self tag:someTagForYourComfort]
. The connection
itself does retain Fetch instance and releases it once the request if over, so
you do not need to retain it yourself. The delegate (also retained in case it
is deallocated before request is over) should implement 3 methods:
- (void)fetch:(Fetch *)fetch didFailWithError:(NSError *)error;
- (void)fetchDidFinishLoading:(Fetch *)connection;
- (void)fetch:(Fetch *)fetch didReceiveStatusCode:(NSInteger)statusCode contentLength:(NSInteger)contentLength;
HINT #1: Once you receive didReceiveStatusCode
, you should assign
fetch.data property (retained) some NSMutableData
instance otherwise it will
not store downloaded data anywhere.
HINT #2: If you wish to cancel Fetch request you may use -[Fetch cancel]
.
Since iPhone/iPod Touch is pretty quick to do image decoding in main thread
once you get the data, creating image with [UIImage imageWithData:fetch.data]
in fetchDidFinishLoading
and binding the image to UITableView
cell works
for me fine.
Implementation part that deserves the attention is initialization part:
- (id)initWithURL:(NSURL *)_url
delegate:(id<FetchDelegate>)_delegate
tag:(NSInteger)_tag
{
// Copy properties
self.delegate = _delegate;
tag = _tag;
CFHTTPMessageRef request = CFHTTPMessageCreateRequest(
kCFAllocatorDefault,
CFSTR("GET"),
(CFURLRef)_url,
kCFHTTPVersion1_1);
CFHTTPMessageSetHeaderFieldValue(request, CFSTR("Keep-Alive"), CFSTR("30"));
stream = CFReadStreamCreateForHTTPRequest(kCFAllocatorDefault, request);
CFRelease(request);
CFStreamClientContext context = {
0, (void *)self,
CFClientRetain,
CFClientRelease,
CFClientDescribeCopy
};
CFReadStreamSetClient(stream,
kCFStreamEventHasBytesAvailable |
kCFStreamEventErrorOccurred |
kCFStreamEventEndEncountered,
FetchReadCallBack,
&context);
CFReadStreamScheduleWithRunLoop(stream,
CFRunLoopGetCurrent(),
kCFRunLoopCommonModes);
// In meantime our persistent stream may be closed, check that.
// If we won't do it, our new stream will raise an error on startup
// FIXME: This is a bug in CFNetwork!
if (persistentStream != NULL) {
CFStreamStatus status = CFReadStreamGetStatus(persistentStream);
if (status == kCFStreamStatusNotOpen ||
status == kCFStreamStatusClosed ||
status == kCFStreamStatusError) {
CFReadStreamClose(persistentStream);
CFRelease(persistentStream);
persistentStream = NULL;
}
}
CFReadStreamSetProperty(stream, kCFStreamPropertyHTTPAttemptPersistentConnection, kCFBooleanTrue);
CFReadStreamOpen(stream);
if (persistentStream != NULL) {
CFReadStreamClose(persistentStream);
CFRelease(persistentStream);
persistentStream = NULL;
}
streamCount++;
return self;
}
Few notes about initialization part of Fetch instance:
It forces using 30 seconds keep-alive trough
CFHTTPMessageSetHeaderFieldValue
It retains itself using
CFReadStreamSetClient
It uses main thread (current) runloop with
CFReadStreamScheduleWithRunLoop
It maintains persistentStream global reference to last used stream, and replaces it by its own connection (so persistentStream always points to the last one)
Finally it forces persistent connections, even they may be default (or not?) using
CFReadStreamSetProperty(stream, kCFStreamPropertyHTTPAttemptPersistentConnection, kCFBooleanTrue);
So this is all folks. Waiting for your comments. I hope you will find this post useful.