Using ImageDownloader library with reusable cells

Using ImageDownloader library with reusable cells

This is the second part of original post where I wrote about how I built an image downloader and caching library for images. (webp and gif excluding). However, there is one thing which was briefly mentioned but wasn't elaborated is how to use this library with reusable cells.

As we know, UITableView and UICollectionView constructs make use of reusable cells and we need to be extremely careful while downloading data asynchronously on background thread while cells are being reused. In this blog post, I will explain with examples how we can use this library with any kind of reusable cells.

Let's say we have a UITableViewCell subclass MovieCell, which has a single UIImageView, posterImageView where we are going to attach the downloaded image object.

MovieCell also has a public method called apply which takes an image URL string as an input parameters to download an image with.

final class MovieCell: UITableViewCell {
    
    func apply(with urlString: String) {
        
    }

}

We will call our image downloader utility in the apply method and apply the downloaded image to posterImageView. Referencing our documentation from last post, I can do something like this.

final class MovieCell: UITableViewCell {
    
    func apply(with urlString: String) {
        ImageDownloader.shared.downloadImage(with: movie.fullImageUrlString, completionHandler: { (image, cached) in
           
            self.posterImageView.image = image

        }, placeholderImage: UIImage(named: Constants.placeholderImageName))
    }
}

This seems ok at first. But if you run the app, and either scroll tableView real fast or throttle the network speed, you will see more than one image gets applied on the image view and it changes after some time. It presents very confusing UI and definitely not a user-friendly experience.

So why does this happen?

If you want to know in single line the cause of this bug - It's because of cell reuse pattern. Here is what happens when cells are reused.

  1. Cells are displayed on the table view
  2. A new task is created and executed to download image corresponding to input URL - This task is executed asynchronously and in the background
  3. A network is very slow and while this happens, user quickly scrolls the table view to reach to another cell
  4. A new task is initiated which downloads the image corresponding to image url situated at that index
  5. After some time passes, a first task created in step #2 finishes and assigns that downloaded image to cell. However, it doesn't know that the cell was being reused and that image no longer applies to current cell
  6. After some more time, the second task created in step #4 finishes with correct image and it replaces the image downloaded in step #5

This flow explain why when you look the video, 0:26 shows the stale state of UI where images and title don't match at all,

This is because last 4 images we downloaded here actually belong to first 4 rows. So following image represents the correct state for first 4 rows,

And if you wait for few more seconds, you will see that once second download finishes, it will replace all the images fetched from the first set of tasks. Thus at the end of video around 0:33 you can see that all the images match their titles.

Solution:

So how do we solve this problem? We can solve this problem through an additional property on MovieCell class which stores the previousUrlString value. This value will be replaced every time cell is reused and updated with new value of urlString through apply method.

When the image download operation is completed and control is transferred to completion block, we will compare the current and previous value of image URL string. If they match, cell hasn't been reused and the image returned by the image downloader correctly belongs to the current cell.

If they mismatch, then the cell has been reused and the image returned from this download task is no longer valid and should be ignored. It's important to note that we're not completely ignoring the download of that image. Image if still stored in in-memory cache storage.

final class MovieCell: UITableViewCell {
   
    // A previous URL temporarily stored to keep track of previous URL vs. current URL since cells are being reused
    var previousUrlString: String?
    
    ....
    ....
    
    func apply(with urlString: String) {
        ImageDownloader.shared.downloadImage(with: urlString, completionHandler: { (image, cached) in
           
            if urlString == self.previousUrlString {
                self.posterImageView.image = image
            }

        }, placeholderImage: UIImage(named: Constants.placeholderImageName))
        
        previousUrlString = urlString
    }
}
    

}

Let's see how the UI looks like with the new change,

So this is relatively better than the original implementation. At least, all the titles - Yes man, Spider-Man - Far from Home, and The November Man are getting assigned to correct set of poster images.

However, as you scroll, you will see around 00:30 that cached images are no longer displayed. Why does this happen?

This is because we are getting images in two ways,

  1. From the remote server - This occurs in the asynchronous fashion
  2. From the local cache - This occurs in the synchronous fashion

When the image is fetched from local cache, the completion block is executed asynchronously and thus it's called before we can assign current image url to previousUrlString property on MovieCell.

So how do we solve it?

We can solve this issue by incorporating one more permissible condition. We can say use this image returned by ImageDownloader if,

  1. Current url is same as the previously stored url (Applicable for asynchronous operations where image is not cached)
  2. The cached flag is true (Applicable for synchronous operations where image is directly returned from local cache)
func apply(with urlString: String) {
        ImageDownloader.shared.downloadImage(with: urlString, completionHandler: { (image, cached) in
           
            if caching || (urlString == self.previousUrlString) {
                self.posterImageView.image = image
            }

        }, placeholderImage: UIImage(named: Constants.placeholderImageName))
        
        previousUrlString = urlString
    }

And this is how our movie search app looks like once we're all set with added checked for reusable cells,

The full implementation source code written for this blog post can be accessed directly from this gist.

If you've any other questions, comments or suggestion for improving this library or code mentioned in this post, feel free to reach out directly through the comment box