当异步添加新项目时,UICollectionView 快速滚动在单元格中显示错误的图像

Posted

技术标签:

【中文标题】当异步添加新项目时,UICollectionView 快速滚动在单元格中显示错误的图像【英文标题】:UICollectionView fast scrolling displays wrong images in the cell when new items are appended asynchronously 【发布时间】:2017-05-19 18:50:48 【问题描述】:

我有一个 UICollectionView,它在网格中显示图像,但是当我快速滚动时,它会暂时在单元格中显示错误的图像,直到从 S3 存储下载图像,然后显示正确的图像。

我之前在 SO 上看到过与此问题相关的问题和答案,但没有一个解决方案对我有用。字典 let items = [[String: Any]]() 在 API 调用后填充。我需要细胞从回收的细胞中丢弃图像。现在有一种令人不快的图像“跳舞”效果。

这是我的代码:

var searchResults = [[String: Any]]()

let cellId = "cellId"

override func viewDidLoad() 
    super.viewDidLoad()

    collectionView = UICollectionView(frame: self.view.frame, collectionViewLayout: layout)
    collectionView.delegate = self
    collectionView.dataSource = self
    collectionView.register(MyCollectionViewCell.self, forCellWithReuseIdentifier: cellId)



func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell 

    let cell = collectionView.dequeueReusableCell(withReuseIdentifier: cellId, for: indexPath) as! MyCollectionViewCell

    let item = searchResults[indexPath.item]

    cell.backgroundColor = .white

    cell.itemLabel.text = item["title"] as? String

    let imageUrl = item["img_url"] as! String

    let url = URL(string: imageUrl)

    let request = Request(url: url!)

    cell.itemImageView.image = nil

    Nuke.loadImage(with: request, into: cell.itemImageView)

    return cell



class MyCollectionViewCell: UICollectionViewCell 

var itemImageView: UIImageView = 
    let imageView = UIImageView()
    imageView.contentMode = .scaleAspectFit
    imageView.layer.cornerRadius = 0
    imageView.clipsToBounds = true
    imageView.translatesAutoresizingMaskIntoConstraints = false
    imageView.backgroundColor = .white
    return imageView
()


 override init(frame: CGRect) 
    super.init(frame: frame)

 ---------

 

override func prepareForReuse() 
    super.prepareForReuse()
    itemImageView.image = nil


required init?(coder aDecoder: NSCoder) 
    fatalError("init(coder:) has not been implemented")
    

我将单元格图像加载更改为以下代码:

cell.itemImageView.image = nil

APIManager.sharedInstance.myImageQuery(url: imageUrl)  (image) in

guard let cell = collectionView.cellForItem(at: indexPath) as? MyCollectionViewCell
                else  return 

cell.itemImageView.image = image

cell.activityIndicator.stopAnimating()

这是我的 API 管理器。

struct APIManager 

static let sharedInstance = APIManager()

func myImageQuery(url: String, completionHandler: @escaping (UIImage?) -> ()) 

    if let url = URL(string: url) 

        Manager.shared.loadImage(with: url, token: nil)  // internal to Nuke

            guard let image = $0.value as UIImage? else 
                return completionHandler(nil)
            

            completionHandler(image)
        
    

如果用户滚动超过内容限制,我的收藏视图将加载更多项目。这似乎是单元重用是重用图像的问题的根源。加载新项目时,单元格中的其他字段(例如项目标题)也会交换。

func scrollViewDidScroll(_ scrollView: UIScrollView) 

    if (scrollView.contentOffset.y + view.frame.size.height) > (scrollView.contentSize.height * 0.8) 

        loadItems()
    

这是我的数据加载功能

func loadItems() 

    if loadingMoreItems 
        return
    

    if noMoreData 
        return
    

    if(!Utility.isConnectedToNetwork())

        return
    

    loadingMoreItems = true

    currentPage = currentPage + 1

    LoadingOverlay.shared.showOverlay(view: self.view)

    APIManager.sharedInstance.getItems(itemURL, page: currentPage)  (result, error) -> () in

        if error != nil 

        

        else 

            self.parseData(jsonData: result!)
        

        self.loadingMoreItems = false

        LoadingOverlay.shared.hideOverlayView()
    



func parseData(jsonData: [String: Any]) 

    guard let items = jsonData["items"] as? [[String: Any]] else 
        return
    

    if items.count == 0 

        noMoreData = true

        return
    

    for item in items 

        searchResults.append(item)
    

    for index in 0..<self.searchResults.count 

        let url = URL(string: self.searchResults[index]["img_url"] as! String)

        let request = Request(url: url!)

        self.preHeatedImages.append(request)

    

    self.preheater.startPreheating(with: self.preHeatedImages)

    DispatchQueue.main.async 

//        appendCollectionView(numberOfItems: items.count)

        self.collectionView.reloadData()

        self.collectionView.collectionViewLayout.invalidateLayout()
    

【问题讨论】:

你可以使用SDImage 如果同步设置的标签也被弄乱了,这比图像设置不正确是一个更大的问题。你能分享与loadItems相关的内容吗?当您获得更多项目时,您是重新加载 tableview 还是插入行? 标签看起来不错。我正在重新加载 collectionView。我已经编辑了问题以包括数据加载。预热是为了让 Nuke 预加载图像,但预热数组不是直接访问的,它只是为了缓存。 【参考方案1】:

当集合视图滚动单元格超出范围时,集合视图可能会重用该单元格来显示不同的项目。这就是为什么您从名为 dequeue<b>Reusable</b>Cell(withReuseIdentifier:for:) 的方法中获取单元格的原因。

您需要确保在图像准备好后,图像视图仍应显示该项目的图像。

我建议您更改 Nuke.loadImage(with:into:) 方法以使用闭包而不是图像视图:

struct Nuke 

    static func loadImage(with request: URLRequest, body: @escaping (UIImage) -> ()) 
        // ...
    


这样,在collectionView(_:cellForItemAt:),你可以像这样加载图片:

Nuke.loadImage(with: request)  [weak collectionView] (image) in
    guard let cell = collectionView?.cellForItem(at: indexPath) as? MyCollectionViewCell
    else  return 
    cell.itemImageView.image = image

如果集合视图不再显示任何单元格中的项目,则图像将被丢弃。如果集合视图在任何单元格中显示项目(即使在不同的单元格中),您将把图像存储在正确的图像视图中。

【讨论】:

我可以使用 Nuke 作为结构的代码吗?我尝试使用 Nuke Manager,但收到错误 Ambiguous reference to member loadimage 我对@9​​87654327@ 或Nuke.loadImage(with:into:) 一无所知。我认为这是您编写的内容,因为您在问题的代码中使用了它。我不知道加载图像实际上做了什么。 同意。而且,为了未来的读者,如果有可能插入单元格,你甚至不能依赖indexPath,而是必须回到模型并确认正确的索引路径是什么,看看是否单元格是可见的(如上面的 rob 所示),并使用它。 我没有使用除了字典以外的模型。我不知道如何完成你的建议。当用户滚动时,项目会以 30 个项目批次附加。这似乎是这里真正的问题。 守卫声明对我不起作用。我的结构类似于此答案中建议的结构,以使用 Nuke Manager 将图像加载到转义闭包中。【参考方案2】:

试试这个:

每当重用单元格时,都会调用其prepareForReuse 方法。您可以在此处重置您的手机。在您的情况下,您可以在此处的图像视图中设置默认图像,直到下载原始图像。

 override func prepareForReuse()
    
        super.prepareForReuse()
        self.imageView.image = UIImage(named: "DefaultImage"
    

【讨论】:

我试图在该函数中将图像设置为零,但它不起作用。我不想使用默认图像。这个建议也不起作用。 试试这个:self.imageView.image = nil【参考方案3】:
@discardableResult
public func loadImage(with url: URL,
                  options: ImageLoadingOptions = ImageLoadingOptions.shared,
                  into view: ImageDisplayingView,
                  progress: ImageTask.ProgressHandler? = nil,
                  completion: ImageTask.Completion? = nil) -> ImageTask? 
return loadImage(with: ImageRequest(url: url), options: options, into: view, progress: progress, completion: completion)

除非图像在缓存中,否则所有 Nuke API 在请求时都会返回一个 ImageTask。如果有,请保留对此 ImageTask 的引用。在 prepareForReuse 函数中。在其中调用 ImageTask.cancel() 并将 imageTask 设置为 nil。

【讨论】:

【参考方案4】:

来自 Nuke 项目页面:

Nuke.loadImage(with:into:) 方法取消与目标关联的先前未完成的请求。 Nuke 持有对目标的弱引用,当目标被释放时,关联的请求会自动取消。

据我所知,您正在正确使用他们的示例代码:

func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell 
    ...
    cell.imageView.image = nil
    Nuke.loadImage(with: url, into: cell.imageView)
    ...

您将 UIImageViews 图像显式设置为 nil,因此在 Nuke 将新图像加载到目标之前,它实际上应该无法显示图像。

Nuke 也会取消之前的请求。我自己在使用 Nuke,没有这些问题。您确定您的“更改代码”不是这里的错误部分吗?对我来说,一切似乎都是正确的,我没有看到为什么它不应该工作的问题。

一个可能的问题可能是重新加载整个集合视图。我们应始终尝试提供最佳用户体验,因此如果您更改集合视图的数据源,请尝试使用 performBatchUpdates 为插入/更新/移动/删除设置动画。

一个很好的库可以自动处理这个问题,例如 Dwift。

【讨论】:

我尝试使用笔尖,但使用与您使用的代码相同的代码也无法正常工作,仍然存在单元格交换。只有当我将记录附加到模型字典时才会发生这种情况。 对于这个问题,我冷冷地向你推荐 Dwift 库。它允许您获取项目,将它们附加到现有项目,然后将它们设置为数据源的项目。 Dwift 会自动进行差异比较,然后更新您的 collectionview 动画。除此之外,可能重新加载整个 collectionview 是问题所在。尝试正确实现 performBatchUpdates。如果你得到一个错误,你做错了什么;) @xxlesaxx,写 performBatchUpdates 作为分数的答案。它并不能完全解决单元交换问题,但它比 dataReload() 效果好很多 虽然 performBatchUpdates 工作得更好,但它仍然会在快速向上滚动时稍微交换单元格。在堆栈视图中并排放置 2 个 ietsm 的效果也会产生奇怪的视觉效果。如果找到解决方案,我更愿意使用 dataReload()。 嗯,在不知道所有副作用的情况下,其他代码可能很难判断问题出在哪里。在这种情况下,我总是喜欢用一个视图控制器开始一个新项目,它只做一件不起作用的事情(在你的情况下显示图像网格)。根据我的经验,带有 UIImage 和其他视图的 UICollectionViewCell 内的 UIStackView 与 Nuke 配合使用效果很好。当然,必须正确重置图像(我喜欢使用 prepareForReuse 而不是 cellForItemAt)。

以上是关于当异步添加新项目时,UICollectionView 快速滚动在单元格中显示错误的图像的主要内容,如果未能解决你的问题,请参考以下文章

当gestureRecognizerShouldBegin 触发时numberOfTouches 为零

异步fifo with 读控制

异步滚动和添加搜索栏

如何在 iOS 中异步向 UITableViewCell 添加视图?

GWT-Designer 在添加远程服务时不会构建异步接口

tornado异步原理——高并发