Objective-c – Populating NSImage with data from an asynchronous NSURLConnection

asynchronouscocoansurlconnectionobjective-cxcode

I have hit the proverbial wall trying to figure out how to populate an NSImage with data returned from an asynchronous NSURLConnection in my desktop app (NOT an iPhone application!!).

Here is the situation.

I have a table that is using custom cells. In each custom cell is an NSImage which is being pulled from a web server. In order to populate the image I can do a synchronous request easily:

myThumbnail = [[NSImage alloc] initWithContentsOfFile:myFilePath];

The problem with this is that the table blocks until the images are populated (obviously because it's a synchronous request). On a big table this makes scrolling unbearable, but even just populating the images on the first run can be tedious if they are of any significant size.

So I create an asynchronous request class that will retrieve the data in its own thread as per Apple's documentation. No problem there. I can see the data being pulled and populated (via my log files).

The problem I have is once I have the data, I need a callback into my calling class (the custom table view).

I was under the impression that I could do something like this, but it doesn't work because (I'm assuming) that what my calling class really needs is a delegate:

NSImage * myIMage;
myImage = [myConnectionClass getMyImageMethod];

In my connection class delegate I can see I get the data, I just don't see how to pass it back to the calling class. My connectionDidFinishLoading method is straight from the Apple docs:

- (void)connectionDidFinishLoading:(NSURLConnection *)connection
{
    // do something with the data
    // receivedData is declared as a method instance elsewhere
    NSLog(@"Succeeded! Received %d bytes of data",[receivedData length]);

    // release the connection, and the data object
    [connection release];
    [receivedData release];
}

I am hoping this is a simple problem to solve, but I fear I am at the limit of my knowledge on this one and despite some serious Google searches and trying many different recommended approaches I am struggling to come up with a solution.

Eventually I will have a sophisticated caching mechanism for my app in which the table view checks the local machine for the images before going out and getting them form the server and maybe has a progress indicator until the images are retrieved. Right now even local image population can be sluggish if the image's are large enough using a synchronous process.

Any and all help would be very much appreciated.

Solution Update

In case anyone else needs a similar solution thanks to Ben's help here is what I came up with (generically modified for posting of course). Bear in mind that I have also implemented a custom caching of images and have made my image loading class generic enough to be used by various places in my app for calling images.

In my calling method, which in my case was a custom cell within a table…

ImageLoaderClass * myLoader = [[[ImageLoaderClass alloc] init] autorelease];

    [myLoader fetchImageWithURL:@"/my/thumbnail/path/with/filename.png"
                     forMethod:@"myUniqueRef"
                        withId:1234
                   saveToCache:YES
                     cachePath:@"/path/to/my/custom/cache"];

This creates an instance of myLoader class and passes it 4 parameters. The URL of the image I want to get, a unique reference that I use to determine which class made the call when setting up the notification observers, the ID of the image, whether I want to save the image to cache or not and the path to the cache.

My ImageLoaderClass defines the method called above where I set what is passed from the calling cell:

-(void)fetchImageWithURL:(NSString *)imageURL 
           forMethod:(NSString *)methodPassed 
              withId:(int)imageIdPassed 
         saveToCache:(BOOL)shouldISaveThis
           cachePath:(NSString *)cachePathToUse
{

    NSURLRequest *theRequest=[NSURLRequest requestWithURL:[NSURL URLWithString:imageURL]
                                          cachePolicy:NSURLRequestUseProtocolCachePolicy
                                      timeoutInterval:60.0];

    // Create the connection with the request and start loading the data

    NSURLConnection *theConnection=[[NSURLConnection alloc] initWithRequest:theRequest delegate:self];

    if (theConnection) {

        // Create the NSMutableData that will hold
        // the received data 
        // receivedData is declared as a method instance elsewhere

        receivedData = [[NSMutableData data] retain];

        // Now set the variables from the calling class

        [self setCallingMethod:methodPassed];
        [self setImageId:imageIdPassed];
        [self setSaveImage:shouldISaveThis];
        [self setImageCachePath:cachePathToUse];

    } else {
        // Do something to tell the user the image could not be downloaded
    }
}

In the connectionDidFinishLoading method I saved the file to cache if needed and made a notification call to any listening observers:

- (void)connectionDidFinishLoading:(NSURLConnection *)connection
{
    NSLog(@"Succeeded! Received %d bytes of data",[receivedData length]);

    // Create an image representation to use if not saving to cache
    // And create a dictionary to send with the notification    

    NSImage * mImage = [[NSImage alloc ] initWithData:receivedData];
    NSMutableDictionary * mDict = [[NSMutableDictionary alloc] init];

    // Add the ID into the dictionary so we can reference it if needed

    [mDict setObject:[NSNumber numberWithInteger:imageId] forKey:@"imageId"];

    if (saveImage)
    {
        // We just need to add the image to the dictionary and return it
        // because we aren't saving it to the custom cache

        // Put the mutable data into NSData so we can write it out

        NSData * dataToSave = [[NSData alloc] initWithData:receivedData];

        if (![dataToSave writeToFile:imageCachePath atomically:NO])
    NSLog(@"An error occured writing out the file");
    }
    else
    {
        // Save the image to the custom cache

        [mDict setObject:mImage forKey:@"image"];
    }

    // Now send the notification with the dictionary

    NSNotificationCenter *nc = [NSNotificationCenter defaultCenter];

    [nc postNotificationName:callingMethod object:self userInfo:mDict];

    // And do some memory management cleanup

    [mImage release];
    [mDict release];
    [connection release];
    [receivedData release];
}

Finally in the table controller set up an observer to listen for the notification and send it off to the method to handle re-displaying the custom cell:

-(id)init
{
    [super init];

    NSNotificationCenter *nc = [NSNotificationCenter defaultCenter];

    [nc addObserver:self selector:@selector(updateCellData:) name:@"myUniqueRef" object:nil];

    return self;
}

Problem solved!

Best Solution

My solution is to use Grand Central Dispatch (GCD) for this purpose, you could save the image to disc too in the line after you got it from the server.

- (NSView *)tableView:(NSTableView *)_tableView viewForTableColumn:(NSTableColumn *)tableColumn row:(NSInteger)row
{
    SomeItem *item = [self.items objectAtIndex:row];
    NSTableCellView *cell = [_tableView makeViewWithIdentifier:tableColumn.identifier owner:self];
    if (item.artworkUrl)
    {
        cell.imageView.image = nil;
        dispatch_async(dispatch_queue_create("getAsynchronIconsGDQueue", NULL), 
        ^{
            NSURL *url = [NSURL URLWithString:item.artworkUrl];
            NSImage *image = [[NSImage alloc] initWithContentsOfURL:url];
            cell.imageView.image = image;        
        });
    }
    else
    {
        cell.imageView.image = nil;    
    }
    return cell;
}

(I am using Automatic Reference Counting (ARC) therefore there are no retain and release.)

Related Question