UICollectionView reloadData 两次只能工作一次

UICollectionView reloadData only works one time in two

我有一个 UIViewController,它显示一个 UIDocumentPicker 来选择 PDF 文件,并包含一个 UICollectionView 来显示它们(每个单元格都包含一个 PDFView 来显示它们).

代码如下:

import MobileCoreServices; import PDFKit; import UIKit

class ViewController: UIViewController {
    var urls: [URL] = []
    @IBOutlet weak var collectionView: UICollectionView!

    @IBAction func pickFile() {
        DispatchQueue.main.async {
            let documentPicker = UIDocumentPickerViewController(documentTypes: [kUTTypePDF as String], in: .import)
            documentPicker.delegate = self
            documentPicker.modalPresentationStyle = .formSheet
            self.present(documentPicker, animated: true, completion: nil)
        }
    }
    
    override func viewDidLoad() {
        collectionView.register(UINib(nibName: PDFCollectionViewCell.identifier, bundle: .main),
                                forCellWithReuseIdentifier: PDFCollectionViewCell.identifier)
    }
    
    init() { super.init(nibName: "ViewController", bundle: .main) }
    required init?(coder: NSCoder) { fatalError() }
}

extension ViewController: UICollectionViewDataSource, UICollectionViewDelegate, UICollectionViewDelegateFlowLayout {
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: PDFCollectionViewCell.identifier, for: indexPath) as! PDFCollectionViewCell
        cell.pdfView.document = PDFDocument(url: urls[indexPath.row])
        return cell
    }
    
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return urls.count
    }

    func collectionView(_ collectionView: UICollectionView,
                        layout collectionViewLayout: UICollectionViewLayout,
                        sizeForItemAt indexPath: IndexPath) -> CGSize {
        CGSize(width: 150, height: 150)
    }
}

extension ViewController: UIDocumentPickerDelegate {
    // MARK: PDF Picker Delegate
    func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
        controller.dismiss(animated: true, completion: nil)
        
    }
    
    func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
        controller.dismiss(animated: true, completion: {
            DispatchQueue.main.async {
                self.urls.append(contentsOf: urls)
                self.collectionView.reloadData()
            }
        })
    }
}

class PDFCollectionViewCell: UICollectionViewCell {
    static let identifier = "PDFCollectionViewCell"
    
    @IBOutlet weak var pdfView: PDFView! { didSet { setPdfViewUI() } }
    
    func setPdfViewUI() {
        pdfView.displayMode = .singlePage
        pdfView.autoScales = true
        pdfView.displayDirection = .vertical
        pdfView.isUserInteractionEnabled = false
    }
}

现在,由于某种原因,collectionView.reloadData() 实际上只能工作两次。它第一次工作,然后第二次没有任何反应,然后第三次使用三个预期元素再次更新集合视图...

我意识到,即使我正在调用 reloadData(),发生这种情况时也不会调用数据源和委托方法 (numberOfItems/cellForItem)。

知道发生了什么吗?

感谢您的帮助!

编辑:我可以确保 viewDidLoad/appear 方法中没有任何其他代码,pickFile 函数实际上被调用得很好并且 url 被正确获取,urls 数组在调用 reloadData().

之前按应有的方式更新

另外,我在 UITableViewUICollectionView 上都试过了,我在每种情况下都遇到了这个问题。我使用的是 PDFView 或文档选择器,感觉好像出了点问题。

这是由您的单元格中的 PDFView 引起的,具体原因我不确定。 PDFView 可能对于在集合视图单元格中使用来说过于重量级,或者在重新加载或重新添加文档时可能会出现一些问题。我重现了你的问题,但没有设法解决它,但你可能需要牢记以下几点:

  • 您正在为文档选择器使用已弃用的初始化程序
  • 您得到的“收件箱”url 不能保证持久存在,实际上您可以看到随着 select 更多文件的出现和消失,所以您可能想要考虑将 selected 文件移动到更永久的位置

解决这些问题对奇怪的重新加载问题没有影响。我所做的是 import QuickLookThumbnailing,将图像视图而不是 PDF 视图添加到单元格,并添加此代码:

class PDFCollectionViewCell: UICollectionViewCell {
    static let identifier = "PDFCollectionViewCell"
    
    @IBOutlet var imageView: UIImageView!
    private var request: QLThumbnailGenerator.Request?
        
    func load(_ url: URL) {
        let req = QLThumbnailGenerator.Request.init(fileAt: url, size: bounds.size, scale: UIScreen.main.scale, representationTypes: .all)
        
        QLThumbnailGenerator.shared.generateRepresentations(for: req) {
            [weak self]
            rep, type, error in
            DispatchQueue.main.async {
                self?.imageView.image = rep?.uiImage
            }
        }
        request = req
    }
    
    override func prepareForReuse() {
        if let request = request {
            QLThumbnailGenerator.shared.cancel(request)
            self.request = nil
        }
        imageView.image = nil
    }
}

这会异步呈现您传递给它的任何 URL 缩略图,同时提高质量。

这是一个非常非常奇怪的错误,当您在 UICollectionViewCell 中使用 PDFView 时会发生这种错误。我在以下环境中确认了这一点 -

  1. Xcode12.5
  2. iPhone SE 2020 (iOS 14.6)

UICollectionView.reloadData() 调用 无法可靠地工作 PDFView 作为子视图添加到 UICollectionViewCell.contentView.

中时

我们还能尝试什么?

令人惊讶的是 UICollectionView.insertItems(at:)UICollectionView.reloadData() 不适用于这种情况的情况下有效。在这个答案的末尾提供了工作代码示例,供任何其他试图reproduce/confirm这个问题的人使用。

为什么会发生这种情况?

老实说不知道。 UICollectionView.reloadData() 应该保证 UI 与您的数据源同步。让我们看看 reloadData() 的堆栈跟踪(在这种情况下有效)和 insertItems(at:).

ReloadData_StackTrace

InsertItemsAtIndexPaths_StackTrace

结论

  1. reloadData() 依靠 layoutSubviews() 来执行 UI 刷新。这是从 UIView 继承而来的,比如 - UIView.layoutSubviews() > UIScrollView > UICollectionView。这是一个众所周知的 UI 事件,可以很容易地被 UIView 的任何子类拦截。 PDFView: UIView 也可以做到。 为什么会出现不一致的情况?只有会拆机检查PDFKit.framework的人才知道。这显然是 PDFView.layoutSubviews() 实现中的一个错误,它干扰了其父视图的 layoutSubviews() 实现。

  2. insertItems(at:) 在指定的 indexPath(s) 处添加单元格的新实例并且显然不依赖于 layoutSubviews() 因此在这种情况下可以可靠地工作.

示例代码

import MobileCoreServices
import PDFKit
import UIKit

class ViewController: UIViewController {
    
    // MARK: - Instance Variables
    private lazy var flowLayout: UICollectionViewFlowLayout = {
        let sectionInset = UIEdgeInsets(top: 20, left: 20, bottom: 20, right: 20)
        
        let layout = UICollectionViewFlowLayout()
        layout.scrollDirection = .vertical
        layout.sectionInset = sectionInset
        layout.itemSize = CGSize(width: 150, height: 150)
        layout.minimumInteritemSpacing = 20
        layout.minimumLineSpacing = 20
        
        return layout
    }()
    
    private lazy var pdfsCollectionView: UICollectionView = {
        let cv = UICollectionView(frame: self.view.bounds, collectionViewLayout: flowLayout)
        cv.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        cv.backgroundColor = .red
        
        cv.dataSource = self
        cv.delegate = self
        return cv
    }()
    
    private lazy var pickFileButton: UIButton = {
        let button = UIButton(frame: CGRect(x: 300, y: 610, width: 60, height: 40)) // hard-coded for iPhone SE
        button.setTitle("Pick", for: .normal)
        button.setTitleColor(.white, for: .normal)
        button.backgroundColor = .purple
        
        button.addTarget(self, action: #selector(pickFile), for: .touchUpInside)
        return button
    }()
    
    private var urls: [URL] = []
    
    
    // MARK: - View Life Cycle
    override func viewDidLoad() {
        super.viewDidLoad()
        
        self.view.addSubview(pdfsCollectionView)
        pdfsCollectionView.register(
            PDFCollectionViewCell.self,
            forCellWithReuseIdentifier: PDFCollectionViewCell.cellIdentifier
        )
        
        self.view.addSubview(pickFileButton)
    }
    
    
    // MARK: - Helpers
    @objc private func pickFile() {
        let documentPicker = UIDocumentPickerViewController(documentTypes: [kUTTypePDF as String], in: .import)
        documentPicker.delegate = self
        documentPicker.modalPresentationStyle = .formSheet
        self.present(documentPicker, animated: true, completion: nil)
    }
    
}

extension ViewController: UICollectionViewDataSource, UICollectionViewDelegate {
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: PDFCollectionViewCell.cellIdentifier, for: indexPath) as! PDFCollectionViewCell
        cell.pdfView.document = PDFDocument(url: urls[indexPath.row])
        cell.contentView.backgroundColor = .yellow
        return cell
    }
    
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return urls.count
    }
}

extension ViewController: UIDocumentPickerDelegate {
    func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
        controller.dismiss(animated: true, completion: nil)
    }
    
    func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
        
        // CAUTION:
        // These urls are in the temporary directory - `.../tmp/<CFBundleIdentifier>-Inbox/<File_Name>.pdf`
        // You should move/copy these files to your app's document directory
        
        controller.dismiss(animated: true, completion: {
            DispatchQueue.main.async {
                let count = self.urls.count
                var indexPaths: [IndexPath] = []
                for i in 0..<urls.count {
                    indexPaths.append(IndexPath(item: count+i, section: 0))
                }
                
                self.urls.append(contentsOf: urls)
                
                // Does not work reliably
                /*
                self.pdfsCollectionView.reloadData()
                */
                
                // Works reliably
                self.pdfsCollectionView.insertItems(at: indexPaths)
            }
        })
    }
}


class PDFCollectionViewCell: UICollectionViewCell {
    static let cellIdentifier = "PDFCollectionViewCell"
    
    lazy var pdfView: PDFView = {
        let view = PDFView(frame: self.contentView.bounds)
        view.displayMode = .singlePage
        view.autoScales = true
        view.displayDirection = .vertical
        view.isUserInteractionEnabled = false
        
        self.contentView.addSubview(view)
        view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        view.backgroundColor = .yellow
        
        return view
    }()
}

collectionView 以某种方式缓存了毫秒 reloadingData 所以它不会在您调用 reloadData() 两次时重新加载两次,同样基于 apple developer documentation:

You should not call this method in the middle of animation blocks where items are being inserted or deleted. Insertions and deletions automatically cause the collection’s data to be updated appropriately.

我有一些解决方法可以实现此目的:

使 collectionView 布局无效:

据我所知里面有remove cache的方法collectionViewLayout prepare

collectionView!.reloadData()
collectionView!.collectionViewLayout.invalidateLayout()
collectionView!.layoutSubviews()