当涉及部分操作时,不调用 performBatchUpdates 完成处理程序

performBatchUpdates completion handler is not called when there is section operation involved

到目前为止,根据提供的信息

,以下是几乎适用于 NSFetchedResultsController + UICollectionView 的代码片段

请注意,有 2 个 [BlockOperation],因为 reloadItemsmoveItem 在单个 performBatchUpdates 中表现不佳。根据视频中提出的解决方法,我们必须在单独的 performBatchUpdates.

中调用 reloadItems

我们也不遵循视频中提出的 100% 方法(首先执行 reloadItems 类型的 performBatchUpdates,然后插入/移动/删除类型的 performBatchUpdates)。

这是因为我们注意到即使对于简单的情况它也不能很好地工作。 一些奇怪的行为,包括 reloadItems 会导致重复的单元格 UI 显示在屏幕上 。我们找到的“几乎”工作方法是

NSFetchedResultsController + UICollectionView 集成

private var blockOperations: [BlockOperation] = []

// reloadItems and moveItem do not play well together. We are using the following workaround proposed at
// https://developer.apple.com/videos/play/wwdc2018/225/
private var blockUpdateOperations: [BlockOperation] = []

extension DashboardViewController: NSFetchedResultsControllerDelegate {
    func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChange anObject: Any, at indexPath: IndexPath?, for type: NSFetchedResultsChangeType, newIndexPath: IndexPath?) {
        
        if type == NSFetchedResultsChangeType.insert {
            print(">> insert")
            blockOperations.append(
                BlockOperation(block: { [weak self] in
                    if let self = self {
                        self.collectionView!.insertItems(at: [newIndexPath!])
                    }
                })
            )
        }
        else if type == NSFetchedResultsChangeType.update {
            print(">> update")
            blockUpdateOperations.append(
                BlockOperation(block: { [weak self] in
                    if let self = self, let indexPath = indexPath {
                        self.collectionView.reloadItems(at: [indexPath])
                    }
                })
            )
        }
        else if type == NSFetchedResultsChangeType.move {
            print(">> move")
            blockOperations.append(
                BlockOperation(block: { [weak self] in
                    if let self = self, let newIndexPath = newIndexPath, let indexPath = indexPath {
                        self.collectionView.moveItem(at: indexPath, to: newIndexPath)
                    }
                })
            )
        }
        else if type == NSFetchedResultsChangeType.delete {
            print(">> delete")
            blockOperations.append(
                BlockOperation(block: { [weak self] in
                    if let self = self {
                        self.collectionView!.deleteItems(at: [indexPath!])
                    }
                })
            )
        }
    }
    
    func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChange sectionInfo: NSFetchedResultsSectionInfo, atSectionIndex sectionIndex: Int, for type: NSFetchedResultsChangeType) {
        if type == NSFetchedResultsChangeType.insert {
            print(">> section insert")
            blockOperations.append(
                BlockOperation(block: { [weak self] in
                    if let self = self {
                        self.collectionView!.insertSections(IndexSet(integer: sectionIndex))
                    }
                })
            )
        }
        else if type == NSFetchedResultsChangeType.update {
            print(">> section update")
            blockOperations.append(
                BlockOperation(block: { [weak self] in
                    if let self = self {
                        self.collectionView!.reloadSections(IndexSet(integer: sectionIndex))
                    }
                })
            )
        }
        else if type == NSFetchedResultsChangeType.delete {
            print(">> section delete")
            blockOperations.append(
                BlockOperation(block: { [weak self] in
                    if let self = self {
                        self.collectionView!.deleteSections(IndexSet(integer: sectionIndex))
                    }
                })
            )
        }
    }
    
    func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
        if blockOperations.isEmpty {
            performBatchUpdatesForUpdateOperations()
        } else {
            collectionView.performBatchUpdates({ [weak self] () -> Void  in
                guard let self = self else { return }
                
                for operation: BlockOperation in self.blockOperations {
                    operation.start()
                }
                
                self.blockOperations.removeAll(keepingCapacity: false)
            }, completion: { [weak self] (finished) -> Void in
                print("blockOperations completed")

                guard let self = self else { return }
                
                self.performBatchUpdatesForUpdateOperations()
            })
        }
    }
    
    private func performBatchUpdatesForUpdateOperations() {
        if blockUpdateOperations.isEmpty {
            return
        }
        
        collectionView.performBatchUpdates({ [weak self] () -> Void  in
            guard let self = self else { return }
            
            for operation: BlockOperation in self.blockUpdateOperations {
                operation.start()
            }
            
            self.blockUpdateOperations.removeAll(keepingCapacity: false)
        }, completion: { [weak self] (finished) -> Void in
            print("blockUpdateOperations completed")
            
            guard let self = self else { return }
        })
    }
}

上述方法在不涉及“部分”操作时“几乎”运行良好。

对于上面的动画,你会观察到日志记录

>> move
blockOperations completed
>> move
blockOperations completed
>> move
blockOperations completed

但是,当添加/删除一个部分时,performBatchUpdates 的完成处理程序没有被调用!

对于上面的动画,你会观察到日志记录

>> section delete
>> move
>> section insert
>> move

这意味着完成处理块没有被执行!有谁知道为什么会这样,我该如何解决这个问题?

我希望打印出“blockOperations completed”。预期的日志应该是

>> section delete
>> move
blockOperations completed
>> section insert
>> move
blockOperations completed

谢谢。

我在 Xcode 12 和 Xcode 13.0 beta 上测试了这个。

在 Xcode 12 我可以重现您描述的错误:
更改对象以删除整个部分时,不会调用完成处理程序。在执行另一个后续更改时,我收到 两次 完成处理程序调用。

在 Xcode 13 然而,这个问题在我的测试中不能重现。当一个部分被清除和删除时,我会得到适当的回调。
尽管如此,我仍然在控制台中收到一条奇怪的消息说

[Snapshotting] Snapshotting a view (xxx, WhosebugDemo.Cell) that has not been rendered at least once requires afterScreenUpdates:YES.

我现在的结论是,这是系统中的一个错误,已在 iOS 15.

中修复

[更新]

尽管我已经更新了您的代码以在两个 os 版本上实现正确的行为。

关键概念是:

  • 首先执行单值更新
  • 第二次执行部分更新
  • 在移动的情况下也会在完成块中执行重新加载, 否则 possible 同步更新将不会呈现

如果您存储移动的 indexPaths 并且只重新加载这些行,则可能 os 可以优化最后一步。

这是我为重现问题而添加的代码。
如果您想测试,请执行以下步骤:

  1. 创建一个新的 Xcode 项目
  2. 删除ViewController、SceneDelegate、Storyboard
  3. 从 info.plist
  4. 中删除故事板和场景引用
  5. 用下面的代码替换 AppDelegate 的内容(只是最小的样板 view/data 设置和委托方法)

import UIKit
import CoreData

@main
class AppDelegate: UIResponder, UIApplicationDelegate {
    
    var window: UIWindow?
    
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        self.window = UIWindow(frame: UIScreen.main.bounds)
        
        let layout = UICollectionViewFlowLayout()
        layout.headerReferenceSize = CGSize(width: 30,height: 30)
        layout.sectionInset = UIEdgeInsets(top: 10, left: 10, bottom: 10, right: 10)
        self.window?.rootViewController = UINavigationController.init(rootViewController: DashboardViewController(collectionViewLayout: layout) )
        self.window?.makeKeyAndVisible()
        return true
    }
}

class DashboardViewController: UICollectionViewController {
    
    let persistentContainer = PersistentContainer()
    
    lazy var resultsController: NSFetchedResultsController<Entity>? = {
        
        let fetchRequest = NSFetchRequest<Entity>(entityName: "Entity")
        fetchRequest.sortDescriptors = [NSSortDescriptor(key: "section", ascending: true), NSSortDescriptor(key: "name", ascending: false)]
        let resultsController = NSFetchedResultsController(fetchRequest: fetchRequest,
                                                           managedObjectContext: self.persistentContainer.viewContext,
                                                           sectionNameKeyPath: "section",
                                                           cacheName: nil)
        resultsController.delegate = self
        try! resultsController.performFetch()
        return resultsController
    }()
    
    private var itemOperations = [() -> Void]()
    private var sectionOperations = [() -> Void]()
    private var reloadRequired = false
    
    override func viewDidLoad() {
        super.viewDidLoad()
        self.navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .add, target: self, action: #selector(add))
        self.collectionView.register(Cell.self, forCellWithReuseIdentifier: "Cell")
        self.collectionView.register(Header.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: "Header")
    }
    
    var itemIndex: Int = 0
    var section: Double = 0
    @objc func add() {
        let entity = Entity(context: self.persistentContainer.viewContext)
        entity.name = Int64(self.itemIndex)
        itemIndex += 1
        entity.section = Int64(floor(self.section))
        section += 0.5
        try! self.persistentContainer.viewContext.save()
    }
    
    override func numberOfSections(in collectionView: UICollectionView) -> Int { return resultsController!.sections?.count ?? 0 }
    
    override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { return self.resultsController!.sections![section].numberOfObjects }
    
    override func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
        let header = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: "Header", for: indexPath) as! Header
        let sectionInfo = self.resultsController!.sections?[indexPath.section]
        header.label.text = sectionInfo?.name
        return header
    }
    
    override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let item = self.resultsController?.object(at: indexPath)
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "Cell", for: indexPath) as! Cell
        cell.label.text = String(describing: item?.name ?? -1)
        return cell
    }
    
    override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        let item = self.resultsController?.object(at: indexPath)
        item?.section = max(0, (item?.section ?? 0) - 1)
        item?.name = 10 + (item?.name ?? 0)
    }
}

@objc(Entity)
public class Entity: NSManagedObject {
    @NSManaged public var name: Int64
    @NSManaged public var section: Int64
}

class Cell: UICollectionViewCell {
    let label = UILabel()
    override init(frame: CGRect) {
        super.init(frame: frame)
        self.backgroundColor = .lightGray
        self.label.textAlignment = .center
        self.label.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        self.label.frame = self.contentView.bounds
        self.label.translatesAutoresizingMaskIntoConstraints = true
        self.contentView.addSubview(self.label)
    }
    required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
}
class Header: UICollectionReusableView {
    let label = UILabel()
    override init(frame: CGRect) {
        super.init(frame: frame)
        self.backgroundColor = .gray
        self.label.textAlignment = .center
        self.label.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        self.label.frame = self.bounds
        self.label.translatesAutoresizingMaskIntoConstraints = true
        self.addSubview(self.label)
    }
    required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
}

class PersistentContainer: NSPersistentContainer {
    convenience init() {
        // create object model
        let nameProperty = NSAttributeDescription()
        nameProperty.name = "name"
        nameProperty.attributeType = .integer64AttributeType
        let sectionProperty = NSAttributeDescription()
        sectionProperty.name = "section"
        sectionProperty.attributeType = .integer64AttributeType
        let entity = NSEntityDescription()
        entity.name = "Entity"
        entity.managedObjectClassName = "Entity"
        entity.properties = [nameProperty, sectionProperty]
        let model = NSManagedObjectModel()
        model.entities.append(entity)
        
        // create container
        self.init(name: "Foo", managedObjectModel: model)
        let description = NSPersistentStoreDescription()
        description.type = NSInMemoryStoreType
        self.persistentStoreDescriptions = [description]
        self.loadPersistentStores(completionHandler: { (storeDescription, error) in
            if let error = error as NSError? {
                fatalError("Unresolved error \(error), \(error.userInfo)")
            }
        })
    }
}

extension DashboardViewController: NSFetchedResultsControllerDelegate {
    func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChange anObject: Any, at indexPath: IndexPath?, for type: NSFetchedResultsChangeType, newIndexPath: IndexPath?) {
        
        reloadRequired = false
        if type == NSFetchedResultsChangeType.insert {
            print(">> insert")
            itemOperations.append { [weak self] in
                if let self = self {
                    self.collectionView!.insertItems(at: [newIndexPath!])
                }
            }
        }
        else if type == NSFetchedResultsChangeType.update {
            print(">> update")
            itemOperations.append { [weak self] in
                if let self = self, let indexPath = indexPath {
                    self.collectionView.reloadItems(at: [indexPath])
                }
            }
        }
        else if type == NSFetchedResultsChangeType.move {
            print(">> move")
            self.reloadRequired = true
            itemOperations.append { [weak self] in
                if let self = self, let newIndexPath = newIndexPath, let indexPath = indexPath {
                    self.collectionView.moveItem(at: indexPath, to: newIndexPath)
                }
            }
        }
        else if type == NSFetchedResultsChangeType.delete {
            print(">> delete")
            itemOperations.append { [weak self] in
                if let self = self {
                    self.collectionView!.deleteItems(at: [indexPath!])
                }
            }
        }
    }
    
    func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChange sectionInfo: NSFetchedResultsSectionInfo, atSectionIndex sectionIndex: Int, for type: NSFetchedResultsChangeType) {
        if type == NSFetchedResultsChangeType.insert {
            print(">> section insert")
            sectionOperations.append { [weak self] in
                if let self = self {
                    self.collectionView!.insertSections(IndexSet(integer: sectionIndex))
                }
            }
        }
        else if type == NSFetchedResultsChangeType.update {
            print(">> section update")
            sectionOperations.append { [weak self] in
                if let self = self {
                    self.collectionView!.reloadSections(IndexSet(integer: sectionIndex))
                }
            }
        }
        else if type == NSFetchedResultsChangeType.delete {
            print(">> section delete")
            sectionOperations.append { [weak self] in
                if let self = self {
                    self.collectionView!.deleteSections(IndexSet(integer: sectionIndex))
                }
            }
        }
    }
    
    func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
        
        collectionView.performBatchUpdates({ [weak self] () -> Void  in
            guard let self = self else { return }
            
            // execute single item operations first
            self.itemOperations.forEach { [=10=]() }
            // execute section operations afterwards
            self.sectionOperations.forEach { [=10=]() }
            self.itemOperations.removeAll(keepingCapacity: false)
            self.sectionOperations.removeAll(keepingCapacity: false)
        }, completion: { [weak self] (finished) -> Void in
            print("blockOperations completed")

            guard let self = self else { return }

            // in case of a move do a reload in case the item has also changed
            // it will not update otherwise
            if self.reloadRequired {
                self.collectionView.reloadData()
            }
        })
    }
}