简体   繁体   中英

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

So far, here's are the code snippets that almost work for NSFetchedResultsController + UICollectionView , based on the information provided

Please note that, there are 2 [BlockOperation] , as reloadItems and moveItem doesn't play well within single performBatchUpdates . Based on the workaround proposed in the video, we have to call reloadItems in a separate performBatchUpdates .

We also do not follow 100% methods (Perform reloadItems typed performBatchUpdates first, followed by insert/ move/ delete typed performBatchUpdates) proposed in the video.

This is because we notice that it doesn't work well even for simple case. Some strange behaviour including reloadItems will cause duplicated cell UI to be shown on screen . The "almost" work method we found are

  • Perform performBatchUpdates for insert, move and delete
  • At completion handler of performBatchUpdates, perform another performBatchUpdates for reloadItems

NSFetchedResultsController + UICollectionView integration

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

The above way, works "almost" well when no "section" operations involved.

在此处输入图片说明

For the above animation, you will observe logging

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

However, when a section is being added/ removed, the completion handler of performBatchUpdates is not being called!

在此处输入图片说明

For the above animation, you will observe logging

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

This means the completion handler block is not executed! Does anyone know why it is so, and how I can workaround with this issue?

I expect "blockOperations completed" should be printed out. The expected log should be

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

Thanks.

I tested this on Xcode 12 and Xcode 13.0 beta.

On Xcode 12 I can reproduce the bug that you describe:
When changing an object so that a whole section gets removed, the completion handler is not getting called. When performing another subsequent change, I get two completion handler calls.

On Xcode 13 however, the issue is not reproducible in my tests. I get proper callbacks when a section gets cleared and is removed.
Nevertheless I still get an odd message in the console saying

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

My conclusion at this point is, that this is a bug within the system, that has been fixed in iOS 15.

[Update]

Regardless I have updated your code to achieve the proper behavior on both os versions.

The key concepts are:

  • first execute single value updates
  • second execute section updates
  • in case of a move also execute a reload in the completion block, otherwise possible simultaneous updates will not be rendered

It may be possible to refine the last step if you store moved indexPaths and only reload these rows.

This is the code that I added to reproduce the issue.
I you want to test, please do the following steps:

  1. create a new Xcode project
  2. delete ViewController, SceneDelegate, Storyboard
  3. remove Storyboard and Scene references from info.plist
  4. replace the content of AppDelegate with the code below (just minimal boilerplate view/data setup plus delegate methods)

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 { $0() }
            // execute section operations afterwards
            self.sectionOperations.forEach { $0() }
            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()
            }
        })
    }
}

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM