7

I have used third party library SDWebImage to download image for my UITableView cells, UIImageView is created within cell and fired request while configuring cell like this.

[imageView setImageWithURL:[NSURL URLWithString:imageUrl] placeholderImage:[UIImage imageNamed:@"default.jpg"] completed:^(UIImage *image, NSError *error, SDImageCacheType cacheType) {
    }];

Its working fine, however when I scroll fast, most of the images are not fully downloaded (I can see that in charles) because of that images are not cached. How do I cache the already sent request even though my cell got reused, so that same request won't go multiple times.

Please ignore any typo :)

fztest1
  • 144
  • 2
  • 8

3 Answers3

11

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;
}
Rob
  • 415,655
  • 72
  • 787
  • 1,044
  • `dispatch_async(dispatch_get_main_queue(), ^...{` did you mean to do that on a background thread? – Alexandre G May 09 '15 at 05:39
  • @AlexandreG - No, I just wanted to defer it until after the table reloading events, hence dispatching back to the main queue. – Rob May 09 '15 at 12:21
  • @Rob Thanks for the answer. I am still confused though.. Is `reloadData` called from a non-ui thread? (I thought that's not ok?). Or if it is on ui thread, would calling `[self prefetchImagesForTableView: ]` be somehow ran at the same time/before `reloadData` and `dispatch_async` to the main queue is a way to make it run after? – Alexandre G May 11 '15 at 00:24
  • No, `reloadData` is happening on the main queue. This is the old "dispatch asynchronously from main queue back to the main queue" trick: It's used when you want to add something on the main queue, but you want to give the main thread a chance to do something else first (e.g. reloading the table view). Bottom line, I want `cellForRowAtIndexPath` to initiate image fetches for visible rows before I start prefetching images for cells that aren't yet visible yet. It's sort of analogous to a `dispatch_after` or `NSTimer`. It's cryptic little trick: Sorry about that. – Rob May 11 '15 at 00:58
  • @Rob Oh no, don't be. I'm sure I wont be the only one happy to learn of another way to avoid magic numbered `dispatch_after`s. Thanks for taking the time – Alexandre G May 12 '15 at 01:33
  • @Rob my implementation is like : I have collection view and all cell has image views. Each cell when visible , will show image with sd_image and loading indicator in it. If image is there no loading indicator. Now with this I also want to implement this prefetching functionality. Is this feasible to do both as both will send requests multiple time for possibly same cells. can you suggest me some approach? – Pooja Shah Jun 03 '15 at 14:57
  • Where are you getting the urls from? I just converted this code to Swift and frankly have no clue what's going on. I've converted it to swift and called it after my reload data functions. But again, where does `prefetchURLs` gets the URLs from?? – GgnDpSingh Dec 27 '15 at 18:03
  • @GgnDpSingh - Sorry about that; I've updated this with an example of how you take the array of `NSIndexPath` and build your array of `NSURL` from that. Unfortunately, the details here depend a lot upon the implementation of your model. But bottom line, just build an array of `NSURL` from your array of `NSIndexPath`. – Rob Dec 27 '15 at 20:11
  • By the way, the implementation of `SDWebImagePrefetcher` is hopelessly broken right now. See issue [#1366](https://github.com/rs/SDWebImage/issues/1366). – Rob Dec 27 '15 at 20:18
  • I've been trying to work around the broken `SDWebImagePrefetcher` and have made things work a little. But now `SDWebImageRefreshCached` doesn't work. There's always something :) – GgnDpSingh Dec 27 '15 at 20:25
  • 1
    Can somebody translate the answer in Swift Please? – John Jan 19 '17 at 21:36
  • @John - Use [KingFisher](https://github.com/onevcat/Kingfisher) or if you're already using Alamofire, use [AlamofireImage](https://github.com/Alamofire/AlamofireImage). – Rob Jan 20 '17 at 01:26
  • @Rob Using AlamofireImage the images load automatically? SDWebImage doesn't load the images that appears after scrollling. – JCarlosR Mar 08 '17 at 02:41
  • @JCarlos - Yep, you can use AlamofireImage. I actually find it to be more robust than SDWebImage. I've revised my answer, adding some Swift examples, one with AlamofireImage and another with SDWebImage and iOS 10's [`prefetchDataSource`](https://developer.apple.com/reference/uikit/uitableview/1771763-prefetchdatasource). – Rob Mar 08 '17 at 09:54
  • @Rob So, with `AlamofireImage` or `SDWebImage`, we don't need `cancelPrefetchingForRowsAt` method I guess – Bhavin Bhadani May 17 '19 at 07:51
  • You can, of course, implement `cancelPrefetchingForRowsAt`. It’s just a little more complicated. For example, in AlamofireImage, you have to keep track of the `RequestReceipt` so you can later cancel it. – Rob May 17 '19 at 18:59
  • @Rob Is there any possible effect to not use `cancelPrefetchingForRowsAt` method? – Bhavin Bhadani May 18 '19 at 03:40
  • Sure, you might continue to do requests that the OS suggests you might no longer need to do (at this point). This is especially of concern if your requests are slow. (By the way, if you have further questions, I’d suggest you just post your own question rather than continuing long exchanges in comments.) – Rob May 18 '19 at 17:54
2

The line that causes the download to get cancelled is in UIImageView+WebCache.m

The first line in - (void)setImageWithURL:(NSURL *)url placeholderImage:(UIImage *)placeholder options:(SDWebImageOptions)options progress:(SDWebImageDownloaderProgressBlock)progressBlock completed:(SDWebImageCompletedBlock)completedBlock

calls [self cancelCurrentImageLoad];. If you get rid of this line, the download operations should continue on going.

The problem at this point will be that there will be a race condition if a single UIImageView is waiting for multiple downloads to complete. The last download to complete may not necessarily be the last one started, so you may end up with the wrong image in your cell.

There are a few ways to go about fixing this. The easiest might be just to add another associated object in UIImageView+WebCache, simply the last loaded URL. Then, you can check this URL in the completion block and only set the image if it matches.

Dima
  • 23,484
  • 6
  • 56
  • 83
  • Yeah.. You are right. If we are able to keep track of last fired request, we may handle this case I guess. – fztest1 May 22 '14 at 22:53
  • I just edited my post with an idea of how to handle this at the end. – Dima May 22 '14 at 22:59
  • oh Sounds great idea, Can you give some more details about the prefetcher, Do I need to handle on my own..or SDWebimgae supports that? – fztest1 May 22 '14 at 23:23
  • Great solution, but I still don't understand how could be started multiple downloads for a single UIImageView if - (void)setImageWithURL:(NSURL *)url placeholderImage:(UIImage *)placeholder options:(SDWebImageOptions)options progress:(SDWebImageDownloaderProgressBlock)progressBlock completed:(SDWebImageCompletedBlock)completedBlock was called only once for one cell with it UIImageView. Could you explain please? – Maria May 26 '14 at 09:42
  • @Maria While reusing cell possibility are there to use the same ImageView for multiple request. – fztest1 May 28 '14 at 03:07
0

I met the same problem,I found UIImageView+WebCache cancel last download when a new download come.

I not sure whether this is the intention of the author. So I write a new category of UIImageView base on SDWebImage.

To view more: ImageDownloadGroup

Easy to use:

[cell.imageView mq_setImageWithURL:[NSURL URLWithString:@"http://www.domain.com/path/to/image.jpg"]
                   groupIdentifier:@"customGroupID"
                         completed:^(UIImage *image, NSError *error, SDImageCacheType cacheType, NSURL *imageURL) {

                         }];
maquannene
  • 2,257
  • 1
  • 12
  • 10