Download and Cache Images in Uitableviewcell

Download and cache images in UITableViewCell

A couple of issues:

  1. One possible source of flickering is that while you're updating the image asynchronously, you really want to clear the image view first, so you don't see images for prior row of reused/dequeued table view cell. Make sure to set the image view's image to nil before initiating the asynchronous image retrieval. Or, perhaps combine that with "placeholder" logic that you'll see in lots of UIImageView sync image retrieval categories.

    For example:

    extension UIImageView {

    func setImage(from url: URL, placeholder: UIImage? = nil) {
    image = placeholder // use placeholder (or if `nil`, remove any old image, before initiating asynchronous retrieval

    ImageCache.shared.image(for: url) { [weak self] result in
    switch result {
    case .success(let image):
    self?.image = image

    case .failure:
    break
    }
    }
    }
    }
  2. The other issue is that if you scroll very quickly, the reused image view may have an old image retrieval request still in progress. You really should, when you call your UIImageView category's async retrieval method, you should cancel and prior request associated with that cell.

    The trick here is that if you're doing this in a UIImageView extension, you can't just create new stored property to keep track of the old request. So you'd often use "associated values" to keep track of prior requests.

UITableViewCell shows the wrong image while images load

UITableView only uses a handful of cells (~ the max number of visible cells on screen) when displaying a collection of items, so you'll have more items than cells. This works because of the table view reusing mechanism, which means that the same UITableViewCell instance will be used for displaying different items. The reason why you are having problems with the images is because you aren't handling the cell reusing properly.

In the cellForRowAt function you call:

cell.postHeroImage.loadImageUsingCacheWithUrlString(postImageURL)

While you scroll the table view, in different invocations of cellForRowAt this function will be called for the same cell, but (most probably) displaying the content of different items (because of the cell reusing).

Let's X be the cell you are reusing, then these are roughly the functions that will be called:

1. X.prepareForReuse()
// inside cellForRowAt
2. X.postHeroImage.loadImageUsingCacheWithUrlString(imageA)

// at this point the cell is configured for displaying the content for imageA
// and later you reuse it for displaying the content of imageB
3. X.prepareForReuse()
// inside cellForRowAt
4. X.postHeroImage.loadImageUsingCacheWithUrlString(imageB)

When the images are cached, then you will always have 1, 2, 3 and 4 in that order, that's why you don't see any issues in that case. However, the code that downloads an image and set it to the image view runs in a separate thread, so that order isn't guaranteed anymore. Instead of only the four steps above, you will have something like:

1. X.prepareForReuse()
// inside cellForRowAt
2. X.postHeroImage.loadImageUsingCacheWithUrlString(imageA)
// after download finishes
2.1 X.imageView.image = downloadedImage

// at this point the cell is configured for displaying the content for imageA
// and later you reuse it for displaying the content of imageB
3. X.prepareForReuse()
// inside cellForRowAt
4. X.postHeroImage.loadImageUsingCacheWithUrlString(imageB)
4.1 X.imageView.image = downloadedImage

In this case, because of concurrency, you could end up with the following cases:

  • 1, 2, 2.1, 3, 4, 4.1: Everything is displayed properly (this will happen if you scroll slowly)
  • 1, 2, 3, 2.1, 4, 4.1: In this case the first image finishes downloading after the call to reuse the cell finishes, so the old image will be displayed (wrongly) for a short period of time while the new one is downloaded, and then replaced.
  • 1, 2, 3, 4, 2.1, 4.1: Similar to the case above.
  • 1, 2, 3, 4, 4.1, 2.1: In this case the old image finishes downloading after the new one (there is no guaranty the downloads finish in the same order they started) so you will end up with the wrong image. This is the worst case.

For fixing this problem, let's turn our attention to the problematic piece of code inside the loadImageUsingCacheWithUrlString function:

let url = URL(string: urlString)
URLSession.shared.dataTask(with: url!, completionHandler: { (data, response, error) in
DispatchQueue.main.async(execute: {
if let downloadedImage = UIImage(data: data!) {
imageCache.setObject(downloadedImage, forKey: urlString as NSString)
// this is the line corresponding to 2.1 and 4.1 above
self.image = downloadedImage
}
})

}).resume()

As you can see, you are setting self.image = downloadedImage even when you aren't displayed the content associated to that image anymore, so what you need is some way to check if that's still the case. Since you define loadImageUsingCacheWithUrlString in an extension for UIImageView, then you don't have much context there to know whether you should display the image or not. Instead of that, I propose to move that function to an extension of UIImage that will return that image in a completion handler, and then call that function from inside your cell. It would look like:

extension UIImage {
static func loadImageUsingCacheWithUrlString(_ urlString: String, completion: @escaping (UIImage) -> Void) {
if let cachedImage = imageCache.object(forKey: urlString as NSString) as? UIImage {
completion(cachedImage)
}

//No cache, so create new one and set image
let url = URL(string: urlString)
URLSession.shared.dataTask(with: url!, completionHandler: { (data, response, error) in
if let error = error {
print(error)
return
}

DispatchQueue.main.async(execute: {
if let downloadedImage = UIImage(data: data!) {
imageCache.setObject(downloadedImage, forKey: urlString as NSString)
completion(downloadedImage)
}
})

}).resume()
}
}

class FeedItem: UITableViewCell {
// some other definitions here...
var postImageURL: String? {
didSet {
if let url = postImageURL {
self.image = UIImage(named: "loading")

UIImage.loadImageUsingCacheWithUrlString(url) { image in
// set the image only when we are still displaying the content for the image we finished downloading
if url == postImageURL {
self.imageView.image = image
}
}
}
else {
self.imageView.image = nil
}
}
}
}

// inside cellForRowAt
cell.postImageURL = postImageURL

Cache Images in tableviewcontroller

Don't try to reinvent the wheel. You should use an external library to do that, there are some great examples. Take a look at SDWebImage, it does exactly what you want.

Using it, you can forget queues, manually caching... Just import the header:

#import <SDWebImage/UIImageView+WebCache.h>

and, in your tableView:cellForRowAtIndexPath: method, you can just set the image into the UIImageView with the setImageWithURL: (or any of its variants: with placeholders, etc.) method provided by the library:

NSString* imageURL = [[tableData objectAtIndex:indexPath.row] valueForKey:@"picture"];
[cell.imageView setImageWithURL:[NSURL URLWithString:imageURL]];

That's all. The library takes care of everything for you. If you want to somehow manage the cache behind this and configure it, you can, using the SDWebImageManager class (more info in the GitHub page).

Handling download of image using SDWebImage while reusing UITableViewCell

Effective iOS 10, the manual prefetching code of my original answer is no longer needed. Just set a prefetchDataSource. For example, in Swift 3:

override func viewDidLoad() {
super.viewDidLoad()

tableView.prefetchDataSource = self
}

And then have a prefetchRowsAtIndexPaths which uses SDWebImagePrefetcher to fetch the rows

extension ViewController: UITableViewDataSourcePrefetching {
public func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
let urls = indexPaths.map { baseURL.appendingPathComponent(images[$0.row]) }
SDWebImagePrefetcher.shared().prefetchURLs(urls)
}
}

And you can have the standard cellForRowAt:

override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
let url = baseURL.appendingPathComponent(images[indexPath.row])
cell.imageView?.sd_setImage(with: url, placeholderImage: placeholder)
return cell
}

Personally, I prefer AlamofireImage. So the UITableViewDataSourcePrefetching is slightly different

extension ViewController: UITableViewDataSourcePrefetching {
public func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
let requests = indexPaths.map { URLRequest(url: baseURL.appendingPathComponent(images[$0.row])) }
AlamofireImage.ImageDownloader.default.download(requests)
}
}

And obviously, the cellForRowAt would use af_setImage:

override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
let url = baseURL.appendingPathComponent(images[indexPath.row])
cell.imageView?.af_setImage(withURL: url, placeholderImage: placeholder)
return cell
}

My original answer below, shows, for Objective-C, how you might do it in iOS versions before 10 (where we had to do our own prefetch calculations).


This behavior, of canceling the download of cells that are no longer visible is precisely what keeps the asynchronous image retrieval so responsive when you scroll quickly, even with a slow Internet connection. For example, if you quickly scroll down to the 100th cell of the tableview, you really don't want to have that image retrieval to get backlogged behind the image retrieval for the preceding 99 rows (which are no longer visible). I'd suggest leaving the UIImageView category alone, but instead use SDWebImagePrefetcher if you want to prefetch images for cells that you're likely to scroll to.

For example, where I call reloadData, I also prefetch the images for the ten cells immediately preceding and following the currently visible cells:

[self.tableView reloadData];
dispatch_async(dispatch_get_main_queue(), ^{
[self prefetchImagesForTableView:self.tableView];
});

Likewise, anytime I stop scrolling, I do the same:

- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView {
[self prefetchImagesForTableView:self.tableView];
}

- (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate {
if (!decelerate)
[self prefetchImagesForTableView:self.tableView];
}

In terms of how I do that prefetch of the ten preceding and following cells, I do it like so:

#pragma mark - Prefetch cells

static NSInteger const kPrefetchRowCount = 10;

/** Prefetch a certain number of images for rows prior to and subsequent to the currently visible cells
*
* @param tableView The tableview for which we're going to prefetch images.
*/

- (void)prefetchImagesForTableView:(UITableView *)tableView {
NSArray *indexPaths = [self.tableView indexPathsForVisibleRows];
if ([indexPaths count] == 0) return;

NSIndexPath *minimumIndexPath = indexPaths[0];
NSIndexPath *maximumIndexPath = [indexPaths lastObject];

// they should be sorted already, but if not, update min and max accordingly

for (NSIndexPath *indexPath in indexPaths) {
if ([minimumIndexPath compare:indexPath] == NSOrderedDescending)
minimumIndexPath = indexPath;
if ([maximumIndexPath compare:indexPath] == NSOrderedAscending)
maximumIndexPath = indexPath;
}

// build array of imageURLs for cells to prefetch

NSMutableArray<NSIndexPath *> *prefetchIndexPaths = [NSMutableArray array];

NSArray<NSIndexPath *> *precedingRows = [self tableView:tableView indexPathsForPrecedingRows:kPrefetchRowCount fromIndexPath:minimumIndexPath];
[prefetchIndexPaths addObjectsFromArray:precedingRows];

NSArray<NSIndexPath *> *followingRows = [self tableView:tableView indexPathsForFollowingRows:kPrefetchRowCount fromIndexPath:maximumIndexPath];
[prefetchIndexPaths addObjectsFromArray:followingRows];

// build array of imageURLs for cells to prefetch (how you get the image URLs will vary based upon your implementation)

NSMutableArray<NSURL *> *urls = [NSMutableArray array];
for (NSIndexPath *indexPath in prefetchIndexPaths) {
NSURL *url = self.objects[indexPath.row].imageURL;
if (url) {
[urls addObject:url];
}
}

// now prefetch

if ([urls count] > 0) {
[[SDWebImagePrefetcher sharedImagePrefetcher] prefetchURLs:urls];
}
}

/** Retrieve NSIndexPath for a certain number of rows preceding particular NSIndexPath in the table view.
*
* @param tableView The tableview for which we're going to retrieve indexPaths.
* @param count The number of rows to retrieve
* @param indexPath The indexPath where we're going to start (presumably the first visible indexPath)
*
* @return An array of indexPaths.
*/

- (NSArray<NSIndexPath *> *)tableView:(UITableView *)tableView indexPathsForPrecedingRows:(NSInteger)count fromIndexPath:(NSIndexPath *)indexPath {
NSMutableArray *indexPaths = [NSMutableArray array];
NSInteger row = indexPath.row;
NSInteger section = indexPath.section;

for (NSInteger i = 0; i < count; i++) {
if (row == 0) {
if (section == 0) {
return indexPaths;
} else {
section--;
row = [tableView numberOfRowsInSection:section] - 1;
}
} else {
row--;
}
[indexPaths addObject:[NSIndexPath indexPathForRow:row inSection:section]];
}

return indexPaths;
}

/** Retrieve NSIndexPath for a certain number of following particular NSIndexPath in the table view.
*
* @param tableView The tableview for which we're going to retrieve indexPaths.
* @param count The number of rows to retrieve
* @param indexPath The indexPath where we're going to start (presumably the last visible indexPath)
*
* @return An array of indexPaths.
*/

- (NSArray<NSIndexPath *> *)tableView:(UITableView *)tableView indexPathsForFollowingRows:(NSInteger)count fromIndexPath:(NSIndexPath *)indexPath {
NSMutableArray *indexPaths = [NSMutableArray array];
NSInteger row = indexPath.row;
NSInteger section = indexPath.section;
NSInteger rowCountForSection = [tableView numberOfRowsInSection:section];

for (NSInteger i = 0; i < count; i++) {
row++;
if (row == rowCountForSection) {
row = 0;
section++;
if (section == [tableView numberOfSections]) {
return indexPaths;
}
rowCountForSection = [tableView numberOfRowsInSection:section];
}
[indexPaths addObject:[NSIndexPath indexPathForRow:row inSection:section]];
}

return indexPaths;
}

Downloading images in UITableViewCell: Data vs URLSession?

You should never use NSData's contentsOfURL method to retrieve non-local data, for two reasons:

  • The documentation explicitly says that doing so is unsupported.
  • The name resolution and retrieval happens synchronously, blocking the current thread.

In your case, doing it on a concurrent queue, the last one isn't quite as bad as it otherwise would be, but if you're fetching enough resources, I suspect it would result in a fair amount of overhead.

One reason why your NSURLSession approach appears slower is that I think you're probably fetching all the resources simultaneously (hitting the server really hard) with your other approach. Changing the concurrency limit in NSURLSession might improve its performance a bit, but only do that if you know the server can handle it.

To answer the other questions:

  • You can tie the image to a particular cell in any number of ways, but the most common approach is to have a singleton that manages all the requests, and in that singleton, either:

    • keep a dictionary that maps a data task to a unique identifier. Set that as the cell's accessibility identifier, and use the built-in lookup methods to find the cell with that accessibility identifier. If it no longer exists, throw away the response.
    • keep a dictionary that maps a data task to a block. When the request completes, run the block. Let the block keep a strong reference to the cell.

    In either case, store the URL in a property on the cell, and make sure it matches the request's original URL before setting the image, so that if the cell got reused, you won't overwrite its existing image with the wrong image from a stale request.

  • NSURLCache is your friend. Create an on-disk cache of a reasonable size, and it should take care of avoiding unnecessary network requests (assuming your server supports caching properly).

Asynchronous downloading of images for UITableView with GCD

The problem here is that your image-fetching blocks are holding references to the tableview cells. When the download completes, it sets the imageView.image property, even if you have recycled the cell to display a different row.

You'll need your download completion block to test whether the image is still relevant to the cell before setting the image.

It's also worth noting that you're not storing the images anywhere other than in the cell, so you'll be downloading them again each time you scroll a row onscreen. You probably want to cache them somewhere and look for locally cached images before starting a download.

Edit: here's a simple way to test, using the cell's tag property:

- (UITableViewCell *)tableView:(UITableView *)tableView 
cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
static NSString *CellIdentifier = @"Cell";
UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:CellIdentifier forIndexPath:indexPath];

cell.tag = indexPath.row;
NSDictionary *parsedData = self.loader.parsedData[indexPath.row];
if (parsedData)
{
cell.imageView.image = nil;
dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0ul);
dispatch_async(queue, ^(void) {

NSData *imageData = [NSData dataWithContentsOfURL:[NSURL URLWithString:parsedData[@"imageLR"]];

UIImage* image = [[UIImage alloc] initWithData:imageData];
if (image) {
dispatch_async(dispatch_get_main_queue(), ^{
if (cell.tag == indexPath.row) {
cell.imageView.image = image;
[cell setNeedsLayout];
}
});
}
});

cell.textLabel.text = parsedData[@"id"];
}
return cell;
}

How to cache images and label texts downloaded from the internet for every uitableviewcell?

Checkout https://github.com/rs/SDWebImage/ for a super simple way of doing this...

From the README:

[cell.imageView setImageWithURL:[NSURL URLWithString:@"http://www.domain.com/path/to/image.jpg"]
placeholderImage:[UIImage imageNamed:@"placeholder.png"]];

This is all you need for cached images. You can look at the source for how to extend this to support caching text objects too. Alternatively you could use NSUserDefaults to store Key-Value pairs based on URL and text data.



Related Topics



Leave a reply



Submit