Thursday, September 20, 2012

Seamless downloads with MCCURLConnection

I already told you about this class of mine which is a queued NSURLConnection with block delegates.  I use it as a ASIHTTPRequest replacement. Now it's been updated (here) and there are new features I'm going to show you.


Hands on Lab

Say you want to fetch images from the internet in your iOS application. Let's see how we can manage to do that in very few lines with the MCCURLConnection.

First get the class files from github either by cloning the repository or by getting the zip file.

Then, add the 2 files to your project using the xcode "add files" menu.

Now, let's pretend we are in the middle of a method in your controller code that needs to load an image.  The first thing to do is to add the #import directive to your view controller class implementation file. Then we will fetch the image, something like this:


#import "MCCURLConnection.h"

- (void)viewWillAppear:(BOOL)animated {
 
  // Fetch the image
  NSURLRequest *request = [NSURLRequest requestWithURL:[NSURL URLWithString:@"http://www.myimages.com/my/image.jpg"]];
  [[MCCURLConnection connectionWithRequest:request onFinished:^(MCCURLConnection *connection) {
    /* Do stuff with the completed connection object */
  }];
  
}

Now, what can we do in the callback block, with this connection object? Few things.
  • Check the error code if any
  [[MCCURLConnection connectionWithRequest:request onFinished:^(MCCURLConnection *connection) {
    if (connection.error) {
      NSLog(@"An error occured: %@", connection.error);
    }
  }];

An error occurs when the network is not available or when the server didn't respond in a timely fashion (timeout)
  • Check the HTTP status code if no error occurred
  [[MCCURLConnection connectionWithRequest:request onFinished:^(MCCURLConnection *connection) {
    
    NSLog(@"Status Code: %d", connection.httpStatusCode);
    
  }];
  • Check the response header sent by the server
  [[MCCURLConnection connectionWithRequest:request onFinished:^(MCCURLConnection *connection) {
    
    NSLog(@"Response headers: %@", [((NSHTTPURLResponse*)connection.response) allHeaderFields]);
    
  }];
  • Check the received data
  [[MCCURLConnection connectionWithRequest:request onFinished:^(MCCURLConnection *connection) {
    
    NSLog(@"Received data length: %d Bytes", connection.data.length);
    
  }];

That's quite a few interesting things you can access from within the onFinished callback block.

The image Data is automatically appended to the data property of the connection when a chunk is received. In this case, in the end, if the connection did not error, the image data is included in the connection.data. Let's get it, shall we?

  [[MCCURLConnection connectionWithRequest:request onFinished:^(MCCURLConnection *connection) {
    
    if (connection.error) {
      NSLog(@"Error: %@", connection.error);
      return;
    }
    
    if ((connection.httpStatusCode < 200) || (connection.httpStatusCode >= 400)) {
      NSLog(@"Oups: Status code: %d", connection.httpStatusCode);
      return;
    }
    
    // Let's build an image with the received data
    UIImage *image = [UIImage imageWithData:connection.data];

    // Resize it? ...

    dispatch_async(dispatch_get_main_queue(), ^{
      // update the UI with the image
    });
  }];

One comment though. We assume the image to be fetched is a small sized image. If we were to fetch a big NASA picture of 300 MB, we would proceed differently. We would create a connection object, and then create a writeable stream in its onResponse callback, then in its onData callback we would append the downloaded chunks. This way the memory footprint of the download would be minimum.

Use the cache... Luke!

Ok so we can already download an image with the code above but let's go a bit further. What if we need to do some modifications to the image, or resize it? We would need to save it somewhere so that we don't need to resize it each time, so...
  • We could save it to a file...
  • We could cache it !
Yes, let's use the builtin http cache! Yay!

The cache mechanism is handled by a super simple class in the Foundation framework which is named NSURLCache. You may ask yourself why I insist on caching the file. Well it's obviously to demonstrate an other cool feature of MCCURLConnection. 

There is a callback method in NSURLConnectionDataDelegate protocol or so that allows one to say what needs to be cached when a request response needs to be cached. If the server is kind enough to not disable caching in it's returned headers then the NSURLConnection will trigger the delegate. And of course, now, MCCURLConnection has a block for this delegate method.
How do we proceed ?

Well, first we need to rewrite the code a bit so that we can set the onWillCacheResponse callback.

  MCCURLConnection *connection = [[MCCURLConnection connectionWithRequest:request onFinished:^(MCCURLConnection *connection) {
    
    if (connection.error) {
      NSLog(@"Error: %@", connection.error);
      return;
    }
    
    if ((connection.httpStatusCode < 200) || (connection.httpStatusCode >= 400)) {
      NSLog(@"Oups: Status code: %d", connection.httpStatusCode);
      return;
    }
    
    // Let's build an image with the received data
    UIImage *image = [UIImage imageWithData:connection.data];

    // Resize it? ...

    dispatch_async(dispatch_get_main_queue(), ^{
      // update the UI with the image
    });
  }];

Now, let's add the onWillCacheResponse callback. The basic idea in onWillCacheResponse is that it needs to return a NSCachedURLResponse that will be used to cache the request response. By default it returns the NSCachedURLResponse passed as parameter.

  extern UIImage *fitImageInSize(UIImage *, CGSize);  

  connection.onWillCacheResponse = ^NSCachedURLResponse *(NSCachedURLResponse *cachedResponse) {
    UIImage *image = [UIImage imageWithData:cachedResponse.data];
    
    CGFloat scale = [[UIScreen mainScreen]scale];
    [connection.data setData:UIImageJPEGRepresentation(
      fitImageInSize(image, (CGSize){80.0 * scale, 60.0 * scale}), 0.8f)];
    
    NSCachedURLResponse *resizedResponse = [[NSCachedURLResponse alloc]initWithResponse:cachedResponse.response 
      data:connection.data];
    
    return [resizedResponse autorelease];
  };
  

In this version, we resize and compress the image a bit (0.8), then we get back the jpg data of the image which we replace in the connection.data.

By doing so,  right after this block is run, the NSURLCache knows how to cache the request response with the jpg data AND the onFinished callback can now access the resized jpg data directly from the connection.data. So basically we did the two things in one shot. Wonderful!

If we just run the app as it is, it will now be able to download the image, cache the resized jpg version in the shared NSURLCache and update the UI with the resized jpg image. If we rerun the app it will depending on the caching policy of the request, reload the image from the cache.
To force the cache policy we set the request as follow:

NSURLRequest *request = [NSURLRequest requestWithURL:imageURL
  cachePolicy:NSURLRequestReturnCacheDataElseLoad
  timeoutInterval:60.0];

Full code

Here is the full code of the lab.

#import "MCCURLConnection.h"

extern UIImage *fitImageInSize(UIImage *, CGSize);

- (void)viewWillAppear:(BOOL)animated {
  CGFloat scale = [[UIScreen mainScreen]scale]; 

  // Fetch the image
  NSURLRequest *request = [NSURLRequest requestWithURL:imageURL
    cachePolicy:NSURLRequestReturnCacheDataElseLoad
    timeoutInterval:60.0];

  MCCURLConnection *connection = [[MCCURLConnection connectionWithRequest:request 
    onFinished:^(MCCURLConnection *connection) {
    if (connection.error || (connection.httpStatusCode < 200) || (connection.httpStatusCode >= 400)) {
      NSLog(@"Error: %@ (status code: %d)", connection.error, connection.httpStatusCode);
      return;
    }
    
    UIImage *image = [UIImage imageWithData:connection.data scale:scale];
    dispatch_async(dispatch_get_main_queue(), ^{
      // update the UI with the image
    });
  }];

  connection.onWillCacheResponse = ^NSCachedURLResponse *(NSCachedURLResponse *response) {
    [connection.data setData:UIImageJPEGRepresentation(
      fitImageInSize([UIImage imageWithData:response.data], (CGSize){80.0 * scale, 60.0 * scale}), 0.8f)];
    
    return [[[NSCachedURLResponse alloc]initWithResponse:response.response 
      data:connection.data]autorelease];    
  };
}

Final word

As shown in this example, it doesn't take much to download a file from the internet using MCCURLConnection. In fact without the caching it's a matter of 3 lines of code. It is not much either to handle the caching of a resized image. With the caching we made, even with an offline network connection, the very same code will fetch the image from the cache, no need to change anything, it's magic :)

You may find the MCCURLConnection class on my github account here.

Cheers.

No comments:

Post a Comment