Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Why does NSTableView crash when processing deleted rows as NSFetchedResultsControllerDelegate?

I am using a fairly standard setup of NSTableView + CoreData + NSFetchedResultsController, with the relevant view controller being NSFetchedResultsControllerDelegate to receive the changes. Here are the relevant bits of code from the view controller:

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

    print("Change type \(type) for indexPath \(String(describing: indexPath)), newIndexPath \(String(describing: newIndexPath)). Changed object: \(anObject). FRC by this moment has \(String(describing: self.frc?.fetchedObjects?.count)) objects, tableView has \(self.tableView.numberOfRows) rows")

    switch type {
    case .insert:
        if let newIndexPath = newIndexPath {
            tableView.insertRows(at: [newIndexPath.item], withAnimation: .effectFade)
        }
    case .delete:
        if let indexPath = indexPath {
            tableView.removeRows(at: [indexPath.item], withAnimation: .effectFade)
        }
    case .update:
        if let indexPath = indexPath {
            let row = indexPath.item
            for column in 0..<tableView.numberOfColumns {
                tableView.reloadData(forRowIndexes: IndexSet(integer: row), columnIndexes: IndexSet(integer: column))
            }
        }
    case .move:
        if let indexPath = indexPath, let newIndexPath = newIndexPath {
            tableView.removeRows(at: [indexPath.item], withAnimation: .effectFade)
            tableView.insertRows(at: [newIndexPath.item], withAnimation: .effectFade)
        }
    @unknown default:
        fatalError("Unknown fetched results controller change result type")
    }
}

func controllerWillChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
    print("tableViewBeginUpdates")
    tableView.beginUpdates()
}

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

I understand that I should be able to batch all the updates this way, even if multiple rows are deleted. However, this causes a crash with multiple deletes in a row.

Here is the log output from a session, with the table initially having four rows, and all of them being deleted:

tableViewBeginUpdates
Change type NSFetchedResultsChangeType for indexPath Optional([0, 2]), newIndexPath nil. Changed object: /… correct object info …/. FRC by this moment has Optional(0) objects, tableView has 4 rows
Change type NSFetchedResultsChangeType for indexPath Optional([0, 1]), newIndexPath nil. Changed object: /… correct object info …/. FRC by this moment has Optional(0) objects, tableView has 3 rows
Change type NSFetchedResultsChangeType for indexPath Optional([0, 0]), newIndexPath nil. Changed object: /… correct object info …/. FRC by this moment has Optional(0) objects, tableView has 2 rows
Change type NSFetchedResultsChangeType for indexPath Optional([0, 3]), newIndexPath nil. Changed object: /… correct object info …/. FRC by this moment has Optional(0) objects, tableView has 1 rows

The last row causes a crash:

2019-05-06 22:01:30.968849+0300 MyApp[3517:598234] *** Terminating app due to uncaught exception 'NSTableViewException', reason: 'NSTableView error inserting/removing/moving row 3 (numberOfRows: 1).'

The first three deletes happen to be reported in the "right" order (rows with bigger indexes [row numbers] being deleted first). The last one arrives “out of order” and the other rows are seemingly already gone from NSTableView by this time.

How are objects deleted from the context in the first place: I am using the recommended best practice of having two Managed Object Contexts work against the same NSPersistentContainer, one for UI work in the main thread, and one for background/network work in background. They watch each other’s changes. This crash is triggered when the sync context receives some changes from the network, saves them, and they propagate to view context, with this method elsewhere in the app:

@objc func syncContextDidSave(note: NSNotification) {
    viewContext.perform {
        self.viewContext.mergeChanges(fromContextDidSave: note as Notification)
    }
}

Have I misunderstood how to work with the fetched results controller delegate? I thought that the beginupdates/endupdates calls make sure that the “table view model” does not change between them? What should I do to eliminate the crash?

like image 802
Jaanus Avatar asked May 03 '19 19:05

Jaanus


2 Answers

Updating from a fetchedResultsController is more difficult than the Apple documentation states. The code that you are sharing will cause this kinds of a bug when there is a move and an insert or a move and delete at the same time. That doesn't seem to be what is going on for your case, but this setup will fix it as well.

indexPath is the index BEFORE the deletes and inserts are applied; newIndexPath is the index AFTER the deletes and inserts are applied.

For updates you don't care where it was BEFORE the inserts and delete - only after - so use newIndexPath not indexPath. This will fix bugs that can happen when you an update and insert (or update and delete) at the same time and the cell doesn't update as you expect.

For move the delegate is saying where it moved from BEFORE the inserts and where it should be inserted AFTER the inserts and deletes. This can be challenging when you have a move and insert (or move and delete). You can fix this by saving all the changes from controller:didChangeObject:atIndexPath:forChangeType:newIndexPath: into three different arrays, insert, delete and update. When you get a move add an entry for it in both the insert array and in the delete array. In controllerDidChangeContent: sort the delete array descending and the insert array ascending. Then apply the changes - first delete, then insert, then update. This will fix crashes that can happens when you have a move and insert (or move and delete) at the same time.

I can't explain why you have an out of order delete. In my testing I have always seen deletes are served descending and inserts are served ascending. Nevertheless this setup will fix your issues as well, as there is a step to sort the deletes.

If you have sections then also save the sections changes in arrays, and then apply the changes in order: deletes (descending), sectionDelete (descending), sectionInserts (ascending), inserts(ascending), updates (any order). Sections can't move or be updated.

Summary:

  1. Have 5 arrays : sectionInserts, sectionDeletes, rowDeletes, rowInserts, and rowUpdates

  2. in controllerWillChangeContent clear all the arrays

  3. in controller:didChangeObject: add the indexPaths into the arrays (move is a delete and an insert. Update uses newIndexPath)

  4. in controller:didChangeSection add the section into the sectionInserts or rowDeletes array

  5. in controllerDidChangeContent: process them as follows:

    • sort rowDeletes descending
    • sort sectionDelete descending
    • sort sectionInserts ascending
    • sort rowInserts ascending
  6. then in one performBatchUpdates block apply the changes to the collectionView: rowDeletes, sectionDelete, sectionInserts, rowInserts and rowUpdates in that order.

like image 109
Jon Rose Avatar answered Nov 03 '22 15:11

Jon Rose


Hopefully the official documentation on batch delete operations on the UITableView might help you a little here.

In the sample below, the delete operations will always run first, postponing the remove operations, but the idea is that you commit them all at the same time in between begin and end, so that the UITableView can do the heavy lifting for you.

- (IBAction)insertAndDeleteRows:(id)sender {
    // original rows: Arizona, California, Delaware, New Jersey, Washington

    [states removeObjectAtIndex:4]; // Washington
    [states removeObjectAtIndex:2]; // Delaware
    [states insertObject:@"Alaska" atIndex:0];
    [states insertObject:@"Georgia" atIndex:3];
    [states insertObject:@"Virginia" atIndex:5];

    NSArray *deleteIndexPaths = [NSArray arrayWithObjects:
                                [NSIndexPath indexPathForRow:2 inSection:0],
                                [NSIndexPath indexPathForRow:4 inSection:0],
                                nil];
    NSArray *insertIndexPaths = [NSArray arrayWithObjects:
                                [NSIndexPath indexPathForRow:0 inSection:0],
                                [NSIndexPath indexPathForRow:3 inSection:0],
                                [NSIndexPath indexPathForRow:5 inSection:0],
                                nil];
    UITableView *tv = (UITableView *)self.view;

    [tv beginUpdates];
    [tv insertRowsAtIndexPaths:insertIndexPaths withRowAnimation:UITableViewRowAnimationRight];
    [tv deleteRowsAtIndexPaths:deleteIndexPaths withRowAnimation:UITableViewRowAnimationFade];
    [tv endUpdates];

    // ending rows: Alaska, Arizona, California, Georgia, New Jersey, Virginia
}

This example removes two strings from an array (and their corresponding rows) and inserts three strings into the array (along with their corresponding rows). The next section, Ordering of Operations and Index Paths, explains particular aspects of the row (or section) insertion and deletion behavior.

The key here is that all deletion indexes are passed in at the same time in-between the begin and end update calls. If you store the indexes first, then pass them in then you hit the situation I mentioned in my comment where the indexes start to throw out of bounds exceptions.

The apple documentation can be found here, and the above sample under the heading: 'An Example of Batched Insertion and Deletion Operations'

Hope this helps point you in the right direction.

like image 2
JoeTomks Avatar answered Nov 03 '22 13:11

JoeTomks