5

I have a UITableView which displays data from an NSFetchedResultsController. There are varying quantities of data which show up in the FRC, ranging from ~20 records for one fetch to ~14k records for the biggest one. The issue I'm encountering is if I perform a fetch while the tableView is scrolling on the large fetch, I get an exception. By the time cellForRowAtIndexPath gets called, the FRC has already updated and there's no data for it there, resulting in an exception.

I found this post, which sounds like what I've encountered, though I'm unable to resolve it via that methodology.

  func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(withIdentifier: cellReuseID, for: indexPath) as! CustomTableViewCell
    // Exception occurs here
    let myObject = fetchedResultsController.object(at: indexPath)
    cell.myObject = myObject

    return cell
  }

Here is the stack trace:

0   CoreFoundation                      0x0000000110b241e6 __exceptionPreprocess + 294
1   libobjc.A.dylib                     0x000000010b820031 objc_exception_throw + 48
2   CoreData                            0x000000010c2458fd -[NSFetchedResultsController objectAtIndexPath:] + 685
3   MyApp                         0x000000010aaf36c5 _T011MyApp0B14ViewControllerC05tableC0So07UITableC4CellCSo0fC0C_10Foundation9IndexPathV12cellForRowAttF + 437
4   MyApp                         0x000000010aaf39ec _T011MyApp0B14ViewControllerC05tableC0So07UITableC4CellCSo0fC0C_10Foundation9IndexPathV12cellForRowAttFTo + 92
5   UIKit                               0x000000010cf45567 -[UITableView _createPreparedCellForGlobalRow:withIndexPath:willDisplay:] + 783
6   UIKit                               0x000000010cf45ae4 -[UITableView _createPreparedCellForGlobalRow:willDisplay:] + 74
7   UIKit                               0x000000010cf0ceaa -[UITableView _updateVisibleCellsNow:isRecursive:] + 3168
8   UIKit                               0x000000010cf2d7e0 -[UITableView layoutSubviews] + 176
9   UIKit                               0x000000010ceb77a8 -[UIView(CALayerDelegate) layoutSublayersOfLayer:] + 1515
10  QuartzCore                          0x000000010cc21456 -[CALayer layoutSublayers] + 177
11  QuartzCore                          0x000000010cc25667 _ZN2CA5Layer16layout_if_neededEPNS_11TransactionE + 395
12  QuartzCore                          0x000000010cbac0fb _ZN2CA7Context18commit_transactionEPNS_11TransactionE + 343
13  QuartzCore                          0x000000010cbd979c _ZN2CA11Transaction6commitEv + 568
14  UIKit                               0x000000010cde22ef _UIApplicationFlushRunLoopCATransactionIfTooLate + 167
15  UIKit                               0x000000010d747662 __handleEventQueueInternal + 6875
16  CoreFoundation                      0x0000000110ac6bb1 __CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION__ + 17
17  CoreFoundation                      0x0000000110aab4af __CFRunLoopDoSources0 + 271
18  CoreFoundation                      0x0000000110aaaa6f __CFRunLoopRun + 1263
19  CoreFoundation                      0x0000000110aaa30b CFRunLoopRunSpecific + 635
20  GraphicsServices                    0x0000000115c2fa73 GSEventRunModal + 62
21  UIKit                               0x000000010cde8057 UIApplicationMain + 159
22  MyApp                         0x000000010aacd427 main + 55
23  libdyld.dylib                       0x0000000111c5b955 start + 1

I welcome recommendations in resolving this issue. I would like to avoid doing something hacky like disabling new fetches until the tableview is done decelerating. Thank you for reading.

Update

In response to a comment, here is my implementation of the UIFetchedResultsControllerDelegate. It is the code Apple has in documentation:

extension ViewController: NSFetchedResultsControllerDelegate {
  func controllerWillChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
    tableView.beginUpdates()
  }

  func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>,
                  didChange sectionInfo: NSFetchedResultsSectionInfo,
                  atSectionIndex sectionIndex: Int,
                  for type: NSFetchedResultsChangeType) {
    switch type {
    case .insert:
      tableView.insertSections(IndexSet(integer: sectionIndex), with: .fade)
    case .delete:
      tableView.deleteSections(IndexSet(integer: sectionIndex), with: .fade)
    case .move:
      break
    case .update:
      break
    }
  }

  func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>,
                  didChange anObject: Any,
                  at indexPath: IndexPath?,
                  for type: NSFetchedResultsChangeType,
                  newIndexPath: IndexPath?) {
    switch type {
    case .insert:
      tableView.insertRows(at: [newIndexPath!], with: .fade)
    case .delete:
      tableView.deleteRows(at: [indexPath!], with: .fade)
    case .update:
      tableView.reloadRows(at: [indexPath!], with: .fade)
    case .move:
      tableView.moveRow(at: indexPath!, to: newIndexPath!)
    }
  }

  func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
    tableView.endUpdates()
  }
}

New Attempt

In response to a comment, I added my NSFetchedResultsControllerDelegate implementation, which is what Apple's got listed in documentation. I noticed there was force unwrapping of optionals, so I threw some guard statements in and I turned off animation for good measure. I get the same crash in the same place doing the same thing.

Here's the updated delegate method with a guard statements added and animation turned off:

  func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>,
                  didChange anObject: Any,
                  at indexPath: IndexPath?,
                  for type: NSFetchedResultsChangeType,
                  newIndexPath: IndexPath?) {

    guard let indexPath = indexPath else { return }

    switch type {
    case .insert:
      guard let newIndexPath = newIndexPath else { return }
      tableView.insertRows(at: [newIndexPath], with: .none)
    case .delete:
      tableView.deleteRows(at: [indexPath], with: .none)
    case .update:
      tableView.reloadRows(at: [indexPath], with: .none)
    case .move:
      guard let newIndexPath = newIndexPath else { return }
      tableView.moveRow(at: indexPath, to: newIndexPath)
    }
  }

Update 2

In response to a comment, this is the code I'm using to update the predicate. I have an observer of startDate & endDate that calls updatePredicate(). startDate and endDate are updated when a segmentedControl is pressed.:

  // This is used to set values for searching via segmentedControl
  @objc dynamic var startDate: Date?
  @objc dynamic var endDate: Date?

  func updatePredicate() {
    // you need a start date or stop executing
    guard let startDate = startDate else { return }

    var predicateArray: [NSPredicate] = []

    if let endDate = endDate {
      let startEndPredicate = NSPredicate(format: "time >= %@ AND time <= %@", argumentArray: [startDate, endDate])
      predicateArray.append(startEndPredicate)
    } else {
      // use the startDate's end of day if endDate is nil
      let startPredicate = NSPredicate(format: "time >= %@ AND time <= %@", argumentArray: [startDate, startDate.endOfDay!])
      predicateArray.append(startPredicate)
    }

    let sliderPredicate = NSPredicate(format: "magnitude >= %f", argumentArray: [magSlider.value])
    predicateArray.append(sliderPredicate)

    currentPredicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicateArray)

    fetchResults()
  }

Here is fetchResults():

  func fetchResults() {
    do {
      fetchedResultsController.fetchRequest.fetchBatchSize = 35
      fetchedResultsController.fetchRequest.fetchLimit = 1_000
      try fetchedResultsController.performFetch()
    } catch let error {
      print("\(error) \(error.localizedDescription)")
    }
    DispatchQueue.main.async {
      self.tableView.reloadData()
      self.tableView.setContentOffset(.zero, animated: true)
      self.updateLabelsForResults()
    }
  }

Update 3

In response to another comment, below is the FRC declaration:

  private lazy var fetchedResultsController: NSFetchedResultsController<EarthquakeEntity> = {
    let managedObjectContext = appDelegate.persistentContainer.viewContext
    let fetchRequest: NSFetchRequest<EarthquakeEntity> = EarthquakeEntity.fetchRequest()
    let dateSortDescriptor = NSSortDescriptor(key: #keyPath(EarthquakeEntity.time), ascending: false)
    fetchRequest.sortDescriptors = [dateSortDescriptor]
    let frc = NSFetchedResultsController(fetchRequest: fetchRequest, managedObjectContext: managedObjectContext, sectionNameKeyPath: frcKeyPath, cacheName: nil)
    frc.delegate = self

    return frc
  }()
Adrian
  • 16,233
  • 18
  • 112
  • 180

2 Answers2

2

I ended up playing with fetchLimit and that seems to have resolved my issue.

When I perform a fetch, I use it as follows:

fetchedResultsController.fetchRequest.fetchLimit = 2_500

2,500 is about as big as I could get it without crashing the app.

Adrian
  • 16,233
  • 18
  • 112
  • 180
0

When FRC is being used in conjunction with fetchBatchSize, the data set on which FRC operates is a proxy array. If data for the batch is not yet fetched by the time of access to the element of the batch, it is fetched at the time of access.

Thread safety of the proxy array is the same as for managed objects that you operate on - it has to be accessed from the private queue of underlying MOC. You can check whether the thread safety is the issue following this article: https://oleb.net/blog/2014/06/core-data-concurrency-debugging/. Ideally, the access has to be in this way:

var myObject;
fetchedResultsController.managedObjectContext.performBlockAndWait({
    myObject = fetchedResultsController.object(at: indexPath) 
})
cell.myObject = myObject

Also, at the time of crash please check if these statements are true:

1) fetchedResultsController.sections.first.numberOfObjects <= 
                                         fetchedResultsController.fetchRequest.fetchLimit

2) fetchedResultsController.sections.first.numberOfObjects == 
                                         tableView.numberOfRows(inSection: 0)

If 1) is not true - fetchLimit is not respected by the FRC and only fetchBatchSize should be used.

If 2) is not true - that means that tableView does not get updated appropriately when the FRC dataset is changed. More further debugging will be required to determine the cause.

Eugene Dudnyk
  • 5,553
  • 1
  • 23
  • 48