UITableViewCell 在图像加载时显示错误的图像

UITableViewCell shows the wrong image while images load

我有 UITableView,其中列出了社交媒体 post,其中包含图片。 一旦加载了所有 post 详细信息并缓存了图像,它看起来很棒,但在加载它时 经常 显示带有错误 post 的错误图像。

几个月来我一直在努力并回到这个问题上。我不认为这是一个加载问题,它几乎看起来像 iOS 将图像转储到任何旧单元格,直到找到正确的单元格,但老实说我没有想法。

这是我的图像扩展,它也负责缓存:

    let imageCache = NSCache<NSString, AnyObject>()

    extension UIImageView {

    func loadImageUsingCacheWithUrlString(_ urlString: String) {

        self.image = UIImage(named: "loading")

        if let cachedImage = imageCache.object(forKey: urlString as NSString) as? UIImage {
            self.image = cachedImage
            return
        }

        //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)

                    self.image = downloadedImage
                }
            })

        }).resume()
    }

}

这是我的 UITableView:

的简化版
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {

    let postImageIndex = postArray [indexPath.row]
    let postImageURL = postImageIndex.postImageURL

    let cell = tableView.dequeueReusableCell(withIdentifier: "FeedItem", for: indexPath) as! FeedItem
    cell.delegate = self

    cell.postHeroImage.loadImageUsingCacheWithUrlString(postImageURL)
    cell.postTitle.text = postArray [indexPath.row].postTitle
    cell.postDescription.text = postArray [indexPath.row].postBody

    return cell
}

FeedItem Class 包含 prepareForReuse() 并且如下所示:

override func prepareForReuse() {
    super.prepareForReuse()
    self.delegate = nil
    self.postHeroImage.image = UIImage(named: "loading")
}

编辑:这是我从 Firebase 检索数据的方法:

func retrievePosts () {

    let postDB = Database.database().reference().child("MyPosts")

    postDB.observe(.childAdded) { (snapshot) in

        let snapshotValue =  snapshot.value as! Dictionary <String,AnyObject>

        let postID = snapshotValue ["ID"]!
        let postTitle = snapshotValue ["Title"]!
        let postBody = snapshotValue ["Description"]!
        let postImageURL = snapshotValue ["TitleImage"]!

        let post = Post()

        post.postTitle = postTitle as! String
        post.postBody =  postBody as! String
        post.postImageURL = postImageURL as! String

        self.configureTableView()
    }
}

你在你的cellForRowAt函数中使用了一个可重复使用的cells,虽然cells一直在加载和卸载信息,但我们都知道在下载图片时,下载速度不快,你需要下载您的图像在除 cellForRowAt 之外的任何函数中。例如

如果你有一个 url 数组

let arrayImages = ["url1", "url2", "url3"]
let downloadImages = [UIImage]()
var dispatchGroup = DispatchGroup()

UIImage 扩展

import Foundation
import UIKit

extension UIImage {

    func downloaded(from url: URL, completion: ((UIImage,String) -> Void)?) {
        URLSession.shared.dataTask(with: url) { data, response, error in
            guard
                let httpURLResponse = response as? HTTPURLResponse, httpURLResponse.statusCode == 200,
                let mimeType = response?.mimeType, mimeType.hasPrefix("image"),
                let data = data, error == nil,
                let image = UIImage(data: data)
                else { return }
            DispatchQueue.global().async() {
                completion?(image,url.absoluteString)
            }
            }.resume()
    }

    func downloaded(from link: String, completion: ((UIImage,String) -> Void)?) {
        guard let url = URL(string: link) else { return }
        downloaded(from: url, completion: completion)
    }
}

您的视图代码

override func viewWillAppear()
{
    super.viewWillAppear(true)
    for url in arrayImages 
    {
       dispatchGroup.enter()
       let imageDownloaded = UIImage()
       imageDownloaded.downloaded(from: url) { (image, urlImage2) in
                DispatchQueue.main.async {
                    self.downloadImages.append(image)
                    self.tableView.reloadData()
                    self.dispatchGroup.leave()
                }
            }     
    }
    dispatchGroup.notify(queue: .main) {
         tableView.reloadData()
    }
}

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {    
    let cell = tableView.dequeueReusableCell(withIdentifier: "FeedItem", for: indexPath) as! FeedItem
    cell.delegate = self
    if downloadImages.count > 0 {
        cell.postHeroImage.image = downloadImages[indexPath.row]
    }
    cell.postTitle.text = postArray [indexPath.row].postTitle
    cell.postDescription.text = postArray [indexPath.row].postBody

    return cell
}

如果您有任何疑问,请告诉我。希望对您有所帮助

另一种解决此问题的方法是使用 tableView(_:willDisplay:forRowAt:) 从缓存中加载下载的图像,并使用 tableView(_:didEndDisplaying:forRowAt:) 从单元格中删除图像

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {    
    let cell = tableView.dequeueReusableCell(withIdentifier: "FeedItem", for: indexPath) as! FeedItem
    cell.delegate = self
    cell.postTitle.text = postArray [indexPath.row].postTitle
    cell.postDescription.text = postArray [indexPath.row].postBody

    return cell
}

func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
    let feedCell = cell as! FeedItem 
    if downloadImages.count > 0 {
        cell.postHeroImage.image = downloadImages[indexPath.row]
    }
}

func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
    let feedCell = cell as! FeedItem 
    cell.postHeroImage.image = nil
}

如果您使用的是 CocoaPods,我强烈建议您使用 Kingfisher 来处理您项目的图像下载

您需要根据图像 URL 本身下载并缓存图像,并在使用 url 加载 table 时使用它。

假设您有一个图像数组 URL 使用此数组加载行数,下载图像应映射到 indexPath 并缓存。稍后您可以根据带数组的 indexPath 使用它。

您遇到的问题是下载图像中的行与映射数据不同步。作为 TableViewCell 双端队列并重新使用单元格。

UITableView 在显示项目集合时仅使用少量单元格(~屏幕上可见单元格的最大数量),因此您拥有的项目多于单元格。这是因为 table 视图重用机制,这意味着相同的 UITableViewCell 实例将用于显示不同的项目。您遇到图像问题的原因是因为您没有正确处理细胞重用。

在您调用的 cellForRowAt 函数中:

cell.postHeroImage.loadImageUsingCacheWithUrlString(postImageURL)

当您滚动 table 视图时,在 cellForRowAt 的不同调用中,将为同一个单元格调用此函数,但(很可能)显示不同项目的内容(因为细胞再利用)。

假设 X 是您要重复使用的单元格,那么这些大致是将要调用的函数:

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)

缓存图像后,您将始终按顺序排列 1、2、3 和 4,这就是为什么在这种情况下您看不到任何问题的原因。但是,下载图像并将其设置到图像视图的代码在单独的线程中运行,因此不再保证顺序。除了上面的四个步骤,你还会有类似的东西:

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

在这种情况下,由于并发性,您可能会遇到以下情况:

  • 1, 2, 2.1, 3, 4, 4.1: 一切正常显示(如果滚动缓慢,就会出现这种情况)
  • 1, 2, 3, 2.1, 4, 4.1:在这种情况下,第一个图像在调用重用单元格完成后完成下载,因此旧图像将(错误地)显示一小段时间而下载新的,然后替换。
  • 1, 2, 3, 4, 2.1, 4.1:同上例
  • 1, 2, 3, 4, 4.1, 2.1:在这种情况下,旧图像在新图像之后完成下载(不保证下载按照开始的相同顺序完成)所以你最终会得到错误的图像。这是最坏的情况。

为了解决这个问题,让我们把注意力转向 loadImageUsingCacheWithUrlString 函数中有问题的代码段:

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()

如您所见,即使您不再显示与该图像关联的内容,您也在设置 self.image = downloadedImage,因此您需要某种方法来检查情况是否仍然如此。由于您在 UIImageView 的扩展中定义了 loadImageUsingCacheWithUrlString,因此您没有太多上下文可以知道是否应该显示该图像。相反,我建议将该函数移动到 UIImage 的扩展,这将 return 该图像在完成处理程序中,然后从您的单元格内部调用该函数。它看起来像:

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

那是因为 tableView 重复使用了它的单元格。因此,一个细胞可能负责具有不同 urls.

的多个图像

所以有一个简单的解决方案:

您不应传递对可重用单元格的引用,而应传递 IndexPath。它是值类型,不会重用。

然后当您从异步任务中获取图像后,您可以使用 .cellForRow(at: indexPath) 函数向 TableView 询问实际单元格。

所以,去掉这一行:

cell.postHeroImage.loadImageUsingCacheWithUrlString(postImageURL)

并将其替换为一个函数,该函数采用实际的 indexPath 并且可能是对 tableView.

的引用

观看 this WWDC 2018 session 了解更多信息。大约 UICollectionView 但与 UITableView.

相同

您也可以像 一样从单元格本身获取 indexPathtableView,但请确保在 之前 调用异步函数。