iOS 16.4 - UICollectionView and NSFetchedResultsController

Since iOS 16.4, a new exception is raised when "the number of sections and/or items returned by the data source before and/or after performing the batch updates are inconsistent with the updates".

This post (iOS 16.4.1 - UICollectionViewController crashes) goes into some more detail, with a response from an Apple engineer, but doesn't explain exactly how this mechanism should/could work with NSFetchedResultsController.

When using NSFetchedResultsController, its contents could contain unfetched/faulted core data objects in the tens of thousands. As the NSArray returned by NSFetchedResultsController is a reference type, the developer has no control over the point in time it is updated. Further, there doesn't seem to be a way to copy NSFetchedResultsSectionInfo without triggering a deep copy.

Therefore, if a developer wishes to play nicely with the UICollectionView batch updates mechanism they are forced to make a deep copy of the results which means faulting a bunch of potentially unused objects and defeating a primary benefit of NSFetchedResultsController.

Is there a more performance optimised way of getting UICollectionView batch updates and NSFetchedResultsController to play nicely?

A Feedback report would be useful for diagnosing this, preferably with a reproducing project though since iOS 16.4 a sysdiagnose may be sufficient.

As that linked thread indicates, -[NSFetchedResultsControllerDelegate controller:didChangeContentWithSnapshot:] will yield an NSDiffableDataSourceSnapshot that can be used directly with UIKit's diffable data sources API without causing any faulting behaviour. This approach is not quite as performant as using the per-change methods in some situations (very large views with very few changes) but it has the benefit of being self-healing when something goes wrong.

In any case, it would be useful to see this problem in action—assuming the NSFetchedResultsControllerDelegateUICollectionView adapter is faithful it could indicate a bug in UIKit.

Thanks, @numist, for your response.

Ideally, I'd use the NSDiffableDataSourceSnapshot API but it has a couple of limitations that mean it won't work for my use case:

  1. It's a NSDiffableDataSourceSnapshot and not a NSDiffableDataSourceSectionSnapshot so it can't be composed very well.
  2. It tightly couples Core Data to the view which isn't ideal for those of us who like to modularise our code. Ideally NSDiffableDataSource(Section)Snapshot would by lazily mappable which would perhaps go some ways to facilitating de-coupling Core Data from the View.

For the adapter example, if I get time I'll put one together but it's pretty easy to see for your self:

Within a NSFetchedResultsControllerDelegate, add/remove some items from a section and then check the section counts in controllerWillChangeContent(_:) (storing the sections within the delegate for later) then check the stored section counts again in controllerDidChangeContent(_:).

For NSFetchedResultsSectionInfo to be usable as a collection view data source, you would expect the section counts of the sections stored in controllerWillChangeContent(_:) to stay constant. However, by controllerDidChangeContent(_:) the section counts have changed. In other words, there's some 'spooky action at a distance' occurring which means NSFetchedResultsSectionInfo can't effectively be stored for use as part of a UICollectionViewDataSource and performBatchUpdates specifically.

Reproducible code:

final class SomeController: NSObject, NSFetchedResultsControllerDelegate {
  
  private var willChangeSections: [NSFetchedResultsSectionInfo]?
  
  func controllerWillChangeContent(
    _ controller: NSFetchedResultsController<NSFetchRequestResult>
  ) {
    print(#function)
    if let sections = controller.sections {
      print("sections will change: \(ObjectIdentifier(sections as NSArray))")
      for (i, section) in sections.enumerated() {
        guard let objects = section.objects else { return }
        print("\t\(ObjectIdentifier(objects as NSArray)) - section \(i) count: \(section.numberOfObjects)")
      }
    }
    self.willChangeSections = controller.sections
  }
  
  func controllerDidChangeContent(
    _ controller: NSFetchedResultsController<NSFetchRequestResult>
  ) {
    print(#function)
    if let oldSections = willChangeSections {
      print("sections did change (old): \(ObjectIdentifier(oldSections as NSArray))")
      for (i, section) in oldSections.enumerated() {
        guard let objects = section.objects else { return }
        print("\t\(ObjectIdentifier(objects as NSArray)) - section \(i) count: \(section.numberOfObjects)")
      }
    }
    if let newSections = controller.sections {
      print("sections did change (new): \(ObjectIdentifier(newSections as NSArray))")
      for (i, section) in newSections.enumerated() {
        guard let objects = section.objects else { return }
        print("\t\(ObjectIdentifier(objects as NSArray)) - section \(i) count: \(section.numberOfObjects)")
      }
    }
  }
}

// EXPECTED:
//  controllerWillChangeContent(_:)
//  sections will change: ObjectIdentifier(0x0000600001019020)
//    ObjectIdentifier(0x0000600001347c00) - section 0 count: 10
//
//  controllerDidChangeContent(_:)
//  sections did change (old): ObjectIdentifier(0x00006000010187a0)
//    ObjectIdentifier(0x0000600001347aa0) - section 0 count: 10 // <--- count the same as in willChange ✅
//  sections did change (new): ObjectIdentifier(0x0000600001018660)
//    ObjectIdentifier(0x0000600001347b20) - section 0 count: 11

// ACTUAL:
//  controllerWillChangeContent(_:)
//  sections will change: ObjectIdentifier(0x0000600001019020)
//    ObjectIdentifier(0x0000600001347c00) - section 0 count: 10
//
//  controllerDidChangeContent(_:)
//  sections did change (old): ObjectIdentifier(0x00006000010187a0)
//    ObjectIdentifier(0x0000600001347aa0) - section 0 count: 11 // <--- count has updated since willChange ❌
//  sections did change (new): ObjectIdentifier(0x0000600001018660)
//    ObjectIdentifier(0x0000600001347b20) - section 0 count: 11

@FlatMap did you find a solution?

iOS 16.4 - UICollectionView and NSFetchedResultsController
 
 
Q