Search code examples
iosswiftuicollectionviewuicollectionviewcell

Custom UICollectionViewCell not loading from cache


I have a custom UICollectionViewCell defined as follows:

class MomentsCell: UICollectionViewCell {
@IBOutlet weak var imageView: UIImageView!}

And my delegate method looks as such:

    override func collectionView(_ collectionView: UICollectionView,
                             cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
    let cell = collectionView.dequeueReusableCell(withReuseIdentifier: reuseIdentifier,
                                                  for: indexPath) as! MomentsCell
    let imageURL = imageURLS[indexPath.row]
    self.updateImageForCell(cell: cell,
                            inCollectionView: collectionView,
                            withImageURL: imageURL,
                            atIndexPath: indexPath)
    return cell

The method "updateImageForCell" looks as follows:

    func updateImageForCell(cell: MomentsCell,
                        inCollectionView collectionView: UICollectionView,
                        withImageURL: String,
                        atIndexPath indexPath: IndexPath) {
    /*if let url = URL(string: withImageURL) {
        cell.imageView.setImageWith(url, placeholderImage: UIImage(named: "placeholder"))
    }*/
     cell.imageView.image = UIImage(named: "placeholder")
     ImageManager.shared.downloadImageFromURL(withImageURL) {
        (success, image) -> Void in
        if success && image != nil {
            // checks that the view did not move before setting the image to the cell!
            if collectionView.cellForItem(at: indexPath) == cell {
                cell.imageView.image = image
            }
        }
     }
}

The ImageManager is a singleton that contains a cache of a set number of images. If the image URL is in the cache, it returns the cached image. If not, it initiates a URLSession to download the image from Firebase.

The images do show up when the view is first loaded, which indicates to me that everything is working more or less correctly at this point. However, when I scroll, random images are loaded, some end up not being loaded, and eventually all of the cells become blank and do not load no matter what, even though everything is saved in the cache.

It's a doozy, but here is my ImageManager class:

class ImageManager: NSObject {
static var shared: ImageManager { return _singletonInstance }
var imageCache = [String : UIImage]()

// checks the local variable for url string to see if the UIImage was already downloaded
func cachedImageForURL(_ url: String) -> UIImage? {
    return imageCache[url]
}

// saves a downloaded UIImage with corresponding URL String
func cacheImage(_ image: UIImage, forURL url: String) {
    // First check to see how many images are already saved in the cache
    // If there are more images than the max, we have to clear old images
    if imageCache.count > kMaxCacheImageSize {
        imageCache.remove(at: imageCache.startIndex)
    }
    // Adds the new image to the END of the local image Cache array
    imageCache[url] = image
}

func downloadImageFromURL(_ urlString: String,
                          completion: ((_ success: Bool,_ image: UIImage?) -> Void)?) {

    // First, checks for cachedImage
    if let cachedImage = cachedImageForURL(urlString) {
         completion?(true, cachedImage)
    } else {
        guard let url = URL(string: urlString) else {
            completion?(false,nil)
            return
        }
        print("downloadImageFromURL")

        let task = URLSession.shared.downloadTask(with: url,
            completionHandler: { (url, response, error) in

                print("downloadImageFromURL complete")


                if error != nil {
                    print("Error \(error!.localizedDescription)")
                } else {
                    if let url = URL(string: urlString),
                        let data = try? Data(contentsOf: url) {
                        if let image = UIImage(data: data) {
                            self.cacheImage(image, forURL: url.absoluteString)
                            DispatchQueue.main.async(execute: { completion?(true, image) })
                        }
                    }
                }
        })
        task.resume()
    }
}

func prefetchItem(url urlString: String) {
    guard let url = URL(string: urlString) else {
        return
    }

    let task = URLSession.shared.downloadTask(with: url,
            completionHandler: { (url, response, error) in
                if error != nil {
                    print("Error \(error!.localizedDescription)")
                } else {
                    if let url = URL(string: urlString),
                    let data = try? Data(contentsOf: url) {
                    if let image = UIImage(data: data) {
                    self.cacheImage(image, forURL: url.absoluteString)
                }
            }
        }
        })
        task.resume()
    }

}

Any help would be appreciated. If I missed any important information, please let me know.


Solution

  • There are multiple issues at play here, but I believe only #1 is causing your problem of images not showing at all.

    1. The line when you check that the cell is the same before setting the image: collectionView.cellForItem(at: indexPath) == cell. cellForItem is actually returning nil as it is offscreen. It works when it downloads the image since there is time to bring it on screen, but when pulling the image out of the cache your completionHandler is called immediately, so the cell has not been returned back to the collectionView yet!

      There are multiple solutions to this, but perhaps the simplest is adding a wasCached flag returned to your completionHandler (see code at the end of this answer).

    2. You are actually downloading each image twice: First, using URLSession.shared.downloadTask, then again inside the completionHandler when you fetch the data from the download task's url. The URL you are passing to try? Data(contentsOf: url) is the image URL from the server, not the file URL returned inside the completionHandler.

    3. From Apple's documentation of downloadTask(with:completionHandler:) the URL passed into the completionHandler block you are reading from:

      The location of a temporary file where the server’s response is stored. You must move this file or open it for reading before your completion handler returns. Otherwise, the file is deleted, and the data is lost.

    If you do not need disk caching, then to fix #2 and #3, switch to using dataTask(with:completionHandler:) function instead which provides you with the image data already in memory and you can construct the image from that.

    func downloadImageFromURL(
        _ urlString: String,
        completion: ((_ success: Bool,_ image: UIImage?, _ wasCached: Bool) -> Void)?) {
        
        // First, checks for cachedImage
        if let cachedImage = cachedImageForURL(urlString) {
            print("Found cached image for URL \(urlString)")
            completion?(true, cachedImage, true)
        } else {
            guard let url = URL(string: urlString) else {
                completion?(false,nil, false)
                return
            }
            print("downloadImageFromURL \(urlString)")
            
            let task = URLSession.shared.dataTask(with: url) { data, response, error in
                print("downloadImageFromURL complete \(urlString)")
                if let error = error {
                    print("Error \(urlString) \(error.localizedDescription)")
                } else if let data = data, let image = UIImage(data: data) {
                    self.cacheImage(image, forURL: url.absoluteString)
                    DispatchQueue.main.async { completion?(true, image, false) }
                }
            }
            task.resume()
        }
    }
    

    And its usage:

    ImageManager.shared.downloadImageFromURL(withImageURL) { success, image, wasCached in
        if success && image != nil {
            // checks that the view did not move before setting the image to the cell!
            if wasCached || collectionView.cellForItem(at: indexPath) == cell {
                cell.imageView.image = image
            }
        }
    }