Loading remote images for UITableViewCell

Discussion in 'iOS Programming' started by Luke Redpath, Aug 30, 2008.

  1. macrumors 6502a

    Joined:
    Nov 9, 2007
    #1
    I'm struggling to come up with an efficient and performant way of loading remote images (over a local wifi network) to be used as images in a UITableViewCell.

    What I'm basically trying to achieve is something similar to what the Apple iTunes Remote app does - when you browse a list of albums, it retrieves the artwork from the machine running iTunes and displays it on the left. The Remote app obviously does this asynchronously as the artwork doesn't appear immediately the first time you view a row.

    Here's what I have so far: a custom UITableViewCell subclass that represents an Album; it has two properties, album name and album artwork URL, and two views, a UILabel and a UIImageView. When the album name is set, the UILabel is updated. This works just fine. When the artwork URL is set, the idea is that an asynchronous HTTP connection downloads the artwork an dupdates the UIImageView when its finished.

    Here is my code for a custom AsyncArtworkFetcher class which, given a URL, downloads the artwork asynchronously and invokes a method on a delegate object when finished:

    Code:
    @implementation AsyncArtworkFetcher
    
    @synthesize delegate;
    @synthesize url;
    @synthesize userData;
    
    - (void)fetch;
    {
      NSURLRequest *request = [NSURLRequest requestWithURL:url cachePolicy:NSURLRequestUseProtocolCachePolicy timeoutInterval:5.0];
      NSURLConnection *connection = [[NSURLConnection alloc] initWithRequest:request delegate:self];
      if(connection) {
        receivedData = [[NSMutableData data] retain];
      }
    }
    
    #pragma mark NSURLConnection Delegate Methods
    
    - (void)connection:(NSURLConnection *)connection didReceiveResponse:(NSURLResponse *)response
    {
      [receivedData setLength:0];
    }
    
    - (void)connection:(NSURLConnection *)connection didReceiveData:(NSData *)data
    {
      [receivedData appendData:data];
    }
    
    - (void)connectionDidFinishLoading:(NSURLConnection *)connection
    {
      UIImage *downloadedImage = [UIImage imageWithData:receivedData];
      SEL delegateSelector = @selector(artworkFetcher:didFinish:);
      if([delegate respondsToSelector:delegateSelector]) {
        [delegate performSelector:delegateSelector withObject:self withObject:downloadedImage];
      }
      [receivedData release];
      [connection release];
    }
    
    @end
    
    And here's the relevant part of my custom table view cell class:

    Code:
    - (void)setArtworkURL:(NSURL *)newURL;
    {
      if(newURL != artworkURL) {
        [artworkURL release];
        artworkURL = [newURL retain];
        AsyncArtworkFetcher *artworkFetcher = [[AsyncArtworkFetcher alloc] init];
        artworkFetcher.url = artworkURL;
        artworkFetcher.delegate = self;
        [artworkFetcher fetch];
      }
    }
    
    #pragma mark AsyncArtworkFetcher Delegate Methods
    
    - (void)artworkFetcher:(AsyncArtworkFetcher *)fetcher didFinish:(UIImage *)artworkImage;
    {
      albumArtView.image = artworkImage;
      [self setNeedsDisplay];
    }
    
    The main issue with this approach is down to the way in which UITableViewCell objects are reused. As you scroll through the list, when a cell is reused it will display the artwork for another album until its updated (its only briefly but it just looks wrong). One approach would be to have a unique cell for each album but this defeats the whole point of reusable cells.

    Apart from this one issue, this works well enough in the simulator, but running on my actual iPhone it just locks the phone up, probably because of the number of asynchronous requests it's firing off at once. I guess I need to cache the downloaded image in some way (although I thought NSURLConnection dealt with caching automatically - perhaps I'm not configuring it correctly).

    Any suggestions on a better way of going about this? Maybe I'm missing a really obvious technique - it must be possible, the Apple Remote app shows it can be done without any serious performance penalties.
     
  2. macrumors newbie

    Joined:
    Sep 1, 2008
    #2
    Similar situation

    I am having a similar issue also. I am currently just fetching the image when the table view cell comes into view and then caching the image in a dictionary by URL key... Please let me know if you have figured out a better way to do this. THANKS!
     
  3. macrumors regular

    Joined:
    Apr 4, 2007
    #3
    Just use [UIImage initWithData:[NSData dataFromContentsOfURL:[NSURL URLWithString:mad:"URLHERE"]]];
     
  4. thread starter macrumors 6502a

    Joined:
    Nov 9, 2007
    #4
    Garret - not sure if you even read my post. I know how to load a remote image. Doing that in a synchronous (blocking) way is certainly not going to help with my performance issues.
     
  5. Moderator emeritus

    robbieduncan

    Joined:
    Jul 24, 2002
    Location:
    London
    #5
    I'd create a queue system. Instead of creating an instance of AsyncArtworkFetcher I'd make this a singleton object (this example shows the Apple suggested method of creating a singleton). I'd then change the way this works to enable the queuing of an image to be downloaded along with a target and action to call when the image downloads, probably along with some sort of tag to let the called object know which image was downloaded.

    Some the interface to AsyncArtworkFetcher would look something like

    Code:
    @interface AsyncArtworkFetcher 
    {
    }
    
    - (AsyncArtworkFetcher *) sharedAsyncArtworkFetcher;
    - (void) queueImage:(NSURL *) imagePath withIdentifier:(id) identifier andCallbackTarget:(id) target andCallbackMethod:(SEL) method;
    
    When queue image is called push a dictionary (or a specific data storage class if you want to write one) with the 4 parameters into the queue (an NSMutableArray). If there is not a download in progress start one.

    The download method should download as per you existing code using the request at the start of the queue. When complete perform method on target with the parameter identifier to inform the original caller the download is complete (and I suppose pass them the image). If the queue is not empty move onto the next item in the queue.

    This should limit the amount of NSURLConnections in flight to one and hopefully prevent lockups. If this is too slow try increasing the number of connections you have in flight at any time...

    Edit to add: and I agree blocking is not a good idea. It would most likely freeze the UI totally which is not going to be acceptable to the users. You might need to enhance the above idea to enable the cancelling of requests if it's possible for the view to disappear before a download completes.

    Further edit to add: adding caching to this singleton approach is easy: simply use a NSMutableDictionary. When you download an image push it into the dictionary using the URL as the key. When get a request for an image just check the cache first. Of course you will need to ensure that when the OS lets know you are running out of memory you flush the cache...
     
  6. thread starter macrumors 6502a

    Joined:
    Nov 9, 2007
    #6
    This sounds like it might work although I'm wondering if I could (should?) create an NSOperation and use NSOperationQueue rather than rolling my own.

    Funnily enough, I stumbled upon this approach yesterday as a good way of pushing my long running data requests into the background.

    I had originally faced the prospect of switching my existing JSONRPC class over to use the aysynchronous API but the biggest issue with that approach was having to bubble up my delegate messages through the various layers of my app (the JSONRPC is at the lowest level and has a couple of domain-specific layers on top of it before it gets to the controller).

    NSOperation has worked a treat with that so maybe it will work in this case too. Will update on how it goes later.
     
  7. Moderator emeritus

    robbieduncan

    Joined:
    Jul 24, 2002
    Location:
    London
    #7
    Yeah, that's probably a good idea. I've never used them as until the iPhone came along I couldn't target a platform where they were guaranteed to exist due to old versions of Mac OSX not having them.
     
  8. macrumors 68030

    PhoneyDeveloper

    Joined:
    Sep 2, 2008
    #8
    First, you need a proper MVC design. You need a separate data model object that holds the album art and name and you need an array of these objects. This model object will own the art fetcher object.

    Your cell is only for display. It just displays text and images and knows nothing about urls and remote anything. When the url is finished loading the model object will notify the view, indirectly, that the view should reload the image from the model. You can do this notification either via the UIViewController, or by an NSNotification, or maybe by KVO. Simplest, probably is have the model object call the UIVIewController and then the view controller will know which cell needs to be notified and then it calls the cell.

    The model objects will be initialized either with an image that is nil or with a placeholder image (a question mark or something).

    You may need to throttle the number of connections to say 5 or 6. You could use NSOperation for that. I'm pretty sure it can be configured to only run a maximum number of Operations.
     
  9. thread starter macrumors 6502a

    Joined:
    Nov 9, 2007
    #9
    Thanks for the heads up on queuing - the NSOperationQueue was a step in the right direction and I eventually got it working perfectly.

    I'd quite like to write a blog post about this as I think it's a useful technique, not sure if I can with the NDA though. :(
     
  10. Moderator emeritus

    robbieduncan

    Joined:
    Jul 24, 2002
    Location:
    London
    #10
    Well NSOperationQueue, NSOperation, NSURLRequest and NSURLConnection are all available in Leopard and can be talked about freely. As long as you don't include any iPhone specific information I don't see that there would be a problem...
     
  11. thread starter macrumors 6502a

    Joined:
    Nov 9, 2007
    #11
    Unfortunately the post I have in mind is quite iPhone-specific, as it relates to the way UITableViewCell works. Oh well, I shall have to have a think about it. It doesn't seem to have stopped other people posting iPhone dev tutorials.
     
  12. Moderator emeritus

    robbieduncan

    Joined:
    Jul 24, 2002
    Location:
    London
    #12
    Ah. I assumed you could post something more generic about using the operation structure to manage a number of connection objects. I'd personally not make blog posts that are clearly breaking the NDA.
     
  13. macrumors newbie

    Joined:
    Sep 9, 2008
    #13
    Hi Luke

    Do you plan to blog about this technic, i would be really interested in reading about it!

    Thanx
     
  14. thread starter macrumors 6502a

    Joined:
    Nov 9, 2007
    #14
    Haven't decided yet to be honest...I'm thinking about sending an email to the guys who run the O'Reilly iphone blog and see if they've had any trouble.
     
  15. macrumors newbie

    Joined:
    Sep 9, 2008
    #15
    I see :(

    And would accept to send the specific code by private message?

    I would understand if you are not ok with it :)
     
  16. macrumors newbie

    Joined:
    Sep 1, 2008
    #16
    interested as welll

    I would be interested in learning how you did this solution as well. Could you private message me?
     
  17. macrumors newbie

    Joined:
    Oct 1, 2008
    #17
    How about NSTableViewCell reuse?

    I have ready the above answers a couple of times, two things remain unclear to me:
    - how do you get around the fact that TableViewCell objects are reused for every row in a table? I would assume that creating a new instance for every row is not a good idea, but how to make sure the right row is updated once the image becomes available?
    - can someone post an example of using the NSOperation code?

    Thanks alot in advance!
     
  18. macrumors member

    Joined:
    Sep 8, 2008
    #18
    I made an image loader class, and I ran into a problem where using URLs for the keys of my NSMutableDictionary was causing multiple entries for the same key. I think it was because the URLs were too long. If you're experiencing this problem, then you need to hash the URLs and you'll get a nice integer that's suitable as a dictionary key:

    Code:
    + (NSString*) urlToHashString:(NSURL*)aURL
    {
    	return [NSString stringWithFormat:@"%U",[[aURL absoluteString] hash]];
    }
    
    - (NSData*) dataForURL:(NSURL*)aURL
    {
    	return [imageCache valueForKey:[ImageLoader urlToHashString:aURL]];
    }
    
    - (void) addImageDataToCache:(NSData*)aDatum forURL:(NSURL*)aURL
    {
    	[imageCache setValue:aDatum forKey:[ImageLoader urlToHashString:aURL]];
    }
     
  19. macrumors member

    Joined:
    Sep 8, 2008
    #19
    You can find some code for the NSOperation here
    http://forums.macrumors.com/showthread.php?t=571887&highlight=nsoperation

    That will give you an example of doing an NSOperation


    Here's the flow I have. I have an ImageLoader class that's based on NSObject. It's set up like a "regular" class except that I have a couple of static variables defined in the .m file:

    Code:
    static NSOperationQueue    *imageLoadingQueue;
    static NSMutableDictionary *imageCache;
    
    This allows you to access the same NSOperationQueue and imageCache for all instances of ImageLoader.

    I also have a custom NSOperation created like the one in the link I gave (except only using the "main" method like I state at the bottom of that thread that I linked).

    So the ImageLoader class creates a new NSOperation which will download the data and send that data back to the ImageLoader class through a callback function.

    The ImageLoader callback function then makes an Image out of that NSData and sends the image back to the UITableCell that called it which then sets the image to the correct UIImageView


    So here's some example snippets of code:

    In the UITableCell class, it calls:

    Code:
    [imageLoader loadImageFromURL:feedData.avatarURL withCallbackTarget:self withCallbackSelector:@selector(setupImage:)];
    
    The loadImageFromURL:withCallbackTarget:withCallbackSelector: method in ImageLoader will create a new DataLoaderOperation and put it in the queue:
    Code:
    DataLoaderOperation *op = [DataLoaderOperation queueDataLoadWithURL:anImageURL withIdentifier:identifierCounter withCallbackTarget:self withCallbackSelector:@selector(sendImageBack:)];
    [imageLoadingQueue addOperation:op];
    Then the main method in DataLoaderOperation class looks like:

    Code:
    @implementation DataLoaderOperation
    
    @synthesize callbackTarget;
    @synthesize callbackSelector;
    @synthesize identifier;
    @synthesize imageURL;
    
    + (id) queueDataLoadWithURL:(NSURL*)anImageURL withIdentifier:(NSInteger)anIdentifier withCallbackTarget:(id)aTarget withCallbackSelector:(SEL)aSelector
    {
    	NSLog(@"Made an NSOperation withId:(%d)", anIdentifier);
    	DataLoaderOperation *thisDataLoaderOperation = [[[DataLoaderOperation alloc] init] autorelease];
    	
    	thisDataLoaderOperation.callbackTarget = aTarget;
    	thisDataLoaderOperation.callbackSelector = aSelector;
    	thisDataLoaderOperation.identifier = anIdentifier;
    	thisDataLoaderOperation.imageURL = anImageURL;
    	
    	return thisDataLoaderOperation;
    }
    
    - (void) main
    {
    	isRunning = YES;
    	hasStarted = YES;
    	
    	if ([callbackTarget respondsToSelector:callbackSelector]) {
    		NSData *data = [NSData dataWithContentsOfURL:imageURL];
    		[callbackTarget performSelector:callbackSelector withObject:data];
    	}
    	
    	isRunning = NO;
    }
    
    @end
    
    So it calls the sendImageBack: function in the ImageLoader class:
    Code:
    - (void) sendImageBack:(NSData*)data
    {
    	if (!data) {
    		return;
    	}
    	
    	[data retain];
    	[self addImageDataToCache:data forURL:imageURL];
    	
    	UIImage *thisImage = [UIImage imageWithData:data];
    	
    	if ([callbackTarget respondsToSelector:callbackSelector]) {
    		[callbackTarget performSelector:callbackSelector withObject:thisImage];
    	}
    }
    
    finally, in the UITableCell class I have:

    Code:
    - (void) setupImage:(UIImage*)anImage
    {
    	avatarImageView.image = anImage;
    }
    


    Sorry if that doesn't fill in all the holes but it should help you out with what you may want for your flow.
     
  20. macrumors newbie

    Joined:
    Oct 16, 2008
    #20
    hellou youPhone,
    i have a little problem with loading remote images and i google it and stumbled over your post about this.

    you said that you made a custom NSOperation in the ImageLoader class which will download the data and send that data back to the ImageLoader class through a callback function.

    The custom operation in your code above is DataLoaderOperation? because i can't get my head round this...

    thank you :)
     
  21. macrumors member

    Joined:
    Sep 8, 2008
    #21
    There are two classes. The ImageLoader class (which is a subclass of NSObject), and there is a DataLoaderOperation class (which is a subclass of NSOperation).

    So I already posted part of the code, but here's the complete code for the DataLoaderOperation class:

    DataLoaderOperation.h
    Code:
    #import <UIKit/UIKit.h>
    
    
    @interface DataLoaderOperation : NSOperation
    {
    	NSInteger  identifier;
    	id         callbackTarget;
    	SEL        callbackSelector;
    	NSURL     *imageURL;
    }
    
    @property (assign)            id         callbackTarget;
    @property (assign)            SEL        callbackSelector;
    @property (assign)            NSInteger  identifier;
    @property (nonatomic, retain) NSURL     *imageURL;
    
    + (id) queueDataLoadWithURL:(NSURL*)imageURL withIdentifier:(NSInteger)identifier withCallbackTarget:(id)target withCallbackSelector:(SEL)selector;
    
    @end

    DataLoaderOperation.m
    Code:
    #import "DataLoaderOperation.h"
    
    @implementation DataLoaderOperation
    
    @synthesize callbackTarget;
    @synthesize callbackSelector;
    @synthesize identifier;
    @synthesize imageURL;
    
    + (id) queueDataLoadWithURL:(NSURL*)anImageURL withIdentifier:(NSInteger)anIdentifier withCallbackTarget:(id)aTarget withCallbackSelector:(SEL)aSelector
    {
    	NSLog(@"Made an NSOperation withId:(%d)", anIdentifier);
    	DataLoaderOperation *thisDataLoaderOperation = [[[DataLoaderOperation alloc] init] autorelease];
    	
    	thisDataLoaderOperation.callbackTarget = aTarget;
    	thisDataLoaderOperation.callbackSelector = aSelector;
    	thisDataLoaderOperation.identifier = anIdentifier;
    	thisDataLoaderOperation.imageURL = anImageURL;
    	
    	return thisDataLoaderOperation;
    }
    
    - (void) main
    {
    	if ([callbackTarget respondsToSelector:callbackSelector]) {
    		NSData *data = [NSData dataWithContentsOfURL:imageURL];
    		[callbackTarget performSelector:callbackSelector withObject:data];
    	}
    }
    
    @end
     
  22. macrumors newbie

    Joined:
    Oct 16, 2008
    #22
    Thanks for the quick reply!

    i made the modifications in my classes but i have some questions..first my code:

    Code:
    @interface ImageLoader: NSObject
    {
    	id         callbackTarget;
    	SEL        callbackSelector;
    	NSURL     *imageURL;
    }
    
    @property (assign)            id         callbackTarget;
    @property (assign)            SEL        callbackSelector;
    @property (nonatomic, retain) NSURL     *imageURL;
    
    + (void)loadImageFromURL:(NSURL *)imageURL withCallbackTarget:(id)target withCallbackSelector:(SEL) selector;
    - (void)sendImageBack:(NSData *) data;
    - (void) addImageDataToCache:(NSData *)aData forURL:(NSURL *) aImageURL;
    
    Code:
    @implementation ImageLoader
    @synthesize callbackTarget;
    @synthesize callbackSelector;
    @synthesize imageURL;
    
    static NSOperationQueue *imageLoadingQueue;
    static NSMutableDictionary *imageCache;
    
    + (void)loadImageFromURL:(NSURL *)anImageURL withCallbackTarget:(id)target withCallbackSelector:(SEL) selector
    {
    	//error identifierCounter undeclared first use
    	DataLoaderOperation *op = [DataLoaderOperation queueDataLoadWithURL:anImageURL withIdentifier:identifierCounter withCallbackTarget:self withCallbackSelector:@selector(sendImageBack:)];
    	[imageLoadingQueue addOperation:op];
    }
    
    - (void) sendImageBack:(NSData*)data
    {
    	if (!data) {
    		return;
    	}
    	
    	[data retain];
    	[self addImageDataToCache:data forURL:imageURL];
    	
    	UIImage *thisImage = [UIImage imageWithData:data];
    	
    	if ([callbackTarget respondsToSelector:callbackSelector]) {
    		[callbackTarget performSelector:callbackSelector withObject:thisImage];
    	}
    }
    
    - (void) addImageDataToCache:(NSData *)aData forURL:(NSURL *) aImageURL
    {
    		[imageCache setObject:aData forKey:aImageURL];
    }
    
    DataLoaderOperation is what you've send yesterday.
    In ImageLoader's function loadImageFromURL I don;t know where you get identifierCounter from.

    My custom TableCell looks like this:

    Code:
    @interface FeedTableViewCell : UITableViewCell {
    	
    	UILabel *titleLabel;
    	UILabel *dateLabel;
    	NSMutableString *URL;
    	NSMutableString *imgSrc;
    	UIImageView *img;
    	
    }
    
    - (void) setData:(NSDictionary *) aFeed;
    
    And in the TableViewCell.m i first initialize the UIImageView like this with a no-image.png

    Code:
    UIImage *noPicImageSmall = [[UIImage imageNamed:@"noFeedImg.png"] retain];
    		self.img = [[UIImageView alloc] initWithImage:noPicImageSmall];
    		[myContentView addSubview: self.img];
    
    
    The setupImage function from the TableViewCell i should call it in the TableViewController's function (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath?

    Thank you for your help
     
  23. macrumors newbie

    Joined:
    Oct 16, 2008
    #23
    Me again,
    i can;t understand why in my ImageLoader class after adding the image to the OperationQUeue the other functions don;t run...
    The indetifier in the DataLoaderOperation i made it equal to Identifier:[self urlToHashString: anImageURL] but still nothing...and i don't understand why it doesn't call the other functions...i doing something wrong and i don;t know what.... :(

    Code:
    @implementation ImageLoader
    @synthesize callbackTarget;
    @synthesize callbackSelector;
    @synthesize imageURL;
    
    static NSOperationQueue *imageLoadingQueue;
    static NSMutableDictionary *imageCache;
    
    + (void)loadImageFromURL:(NSURL *)anImageURL withCallbackTarget:(id)target withCallbackSelector:(SEL) selector
    {
    	//error identifierCounter undevlared first use
    	NSLog(@"ImageLoader.m anImageURL = %@", anImageURL);
    	DataLoaderOperation *op = [DataLoaderOperation queueDataLoadWithURL:anImageURL withIdentifier:[self urlToHashString: anImageURL] withCallbackTarget:self withCallbackSelector:@selector(sendImageBack:)];
    	[imageLoadingQueue addOperation:op];
    }
    
    + (NSString*) urlToHashString:(NSURL*)aURL
    {
    	return [NSString stringWithFormat:@"%U",[[aURL absoluteString] hash]];
    }
    
    - (NSData*) dataForURL:(NSURL*)aURL
    {
    	NSLog(@"ImageLoader.m dataForURL %@", aURL);
    	return [imageCache valueForKey:[ImageLoader urlToHashString:aURL]];
    }
    
    - (void) addImageDataToCache:(NSData*)aDatum forURL:(NSURL*)aURL
    {
    	NSLog(@"ImageLoader.m am adaugat in cache imaginea pentru url= %@", aURL);
    	[imageCache setValue:aDatum forKey:[ImageLoader urlToHashString:aURL]];
    }
    
    - (void) sendImageBack:(NSData*)data
    {
    	if (!data) {
    		return;
    	}
    	
    	[data retain];
    	[self addImageDataToCache:data forURL:imageURL];
    	
    	UIImage *thisImage = [UIImage imageWithData:data];
    	if ([callbackTarget respondsToSelector:callbackSelector]) {
    		[callbackTarget performSelector:callbackSelector withObject:thisImage];
    	}
    }
    
     
  24. macrumors member

    Joined:
    Sep 8, 2008
    #24
    In ImageLoader, add a new static variable

    Code:
    static NSInteger  identifierCounter;
    It looks like you have set up self.img to be your UIImageView. So you should be able to call something like this:
    [imageLoader loadImageWithURL:url withCallbackTarget:self.img withCallbackSelector:mad:selector(setImage:)];

    Next, I've always had to make an instance variable in any objects that require an ImageLoader because if your ImageLoader is autoreleased (or released), then it will try to perform the callback method on a released object.

    Lastly, look at one of my previous posts where I talked about a "urlToHashString:" method. You're going to want to use a hash value instead of the actual url for the key when doing this call:
    Code:
    [imageCache setObject:aData forKey:aImageURL];
     
  25. macrumors member

    Joined:
    Sep 8, 2008
    #25
    Change
    Code:
    + (void)loadImageFromURL:(NSURL *)anImageURL withCallbackTarget:(id)target withCallbackSelector:(SEL) selector
    
    to an instance method:
    Code:
    - (void)loadImageFromURL:(NSURL *)anImageURL withCallbackTarget:(id)target withCallbackSelector:(SEL) selector
    In your class that uses the image loader, make an instance variable:
    Code:
    ImageLoader *imageLoader;
    Then:
    Code:
    imageLoader = [[ImageLoader alloc] init];
    [imageLoader loadImageFromURL:yourURL withCallbackTarget:yourObject withCallbackSelector:@selector(yourMethod:)];
     

Share This Page