为什么 NSTableView 在将已删除的行作为 NSFetchedResultsControllerDelegate 处理时崩溃?

Why does NSTableView crash when processing deleted rows as NSFetchedResultsControllerDelegate?

我正在使用 NSTableView + CoreData + NSFetchedResultsController 的相当标准的设置,相关的视图控制器是 NSFetchedResultsControllerDelegate 以接收更改。以下是视图控制器中的相关代码位:

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

我知道我应该能够通过这种方式批量更新所有内容,即使删除了多行也是如此。但是,这会导致连续多次删除导致崩溃。

这是一个会话的日志输出,table 最初有四行,所有这些都被删除了:

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

最后一行导致崩溃:

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).'

前三个删除恰好按 "right" 顺序报告(具有较大索引 [行号] 的行首先被删除)。最后一行“乱序”到达,此时其他行似乎已经从 NSTableView 中消失了。

首先如何从上下文中删除对象:我正在使用推荐的最佳实践,让两个托管对象上下文针对同一个 NSPersistentContainer,一个用于 UI 在主线程中工作,background/network 中的一个在后台工作。他们观察彼此的变化。当同步上下文从网络接收到一些更改、保存它们并将它们传播到视图上下文时,会触发此崩溃,在应用程序的其他地方使用此方法:

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

我是否误解了如何使用获取的结果控制器委托?我认为 beginupdates/endupdates 调用确保“table 视图模型”不会在它们之间发生变化?我应该怎么做才能消除崩溃?

希望官方关于UITableView批量删除操作的文档能对你有所帮助。

在下面的示例中,删除操作总是 运行 首先,推迟删除操作,但想法是您在开始和结束之间同时提交它们,以便 UITableView可以为您完成繁重的工作。

- (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.

这里的关键是所有删除索引都是同时传入的 in-between begin 和 end 更新调用。如果您先存储索引,然后传递它们,那么您会遇到我在评论中提到的索引开始抛出越界异常的情况。

苹果 documentation can be found here,以及上面标题下的示例:'An Example of Batched Insertion and Deletion Operations'

希望这可以帮助您指明正确的方向。

从 fetchedResultsController 进行更新比 Apple 文档中描述的更困难。当同时进行移动和插入或移动和删除时,您共享的代码将导致此类错误。这似乎不是你的情况,但这个设置也会解决它。

indexPath 是应用删除和插入之前的索引; newIndexPath 是应用删除和插入后的索引。

对于更新,您不关心它在插入和删除之前的位置 - 仅在插入和删除之后 - 所以使用 newIndexPath 而不是 indexPath。这将修复当您同时更新和插入(或更新和删除)并且单元格未按预期更新时可能发生的错误。

对于 move,代表说明了它在插入之前移动到的位置以及在插入和删除之后应该插入的位置。当您移动并插入(或移动并删除)时,这可能具有挑战性。您可以通过将 controller:didChangeObject:atIndexPath:forChangeType:newIndexPath: 中的所有更改保存到三个不同的数组、插入、删除和更新来解决此问题。当您获得 move 时,在插入数组和删除数组中为其添加一个条目。在 controllerDidChangeContent: 中,删除数组降序排列,插入数组升序排列。然后应用更改 - 先删除,然后插入,然后更新。这将修复当您同时进行移动和插入(或移动和删除)时可能发生的崩溃。

我无法解释您为什么会乱序删除。在我的测试中,我一直看到删除是按降序提供的,而插入是按升序提供的。尽管如此,此设置也可以解决您的问题,因为有一个步骤可以对删除进行排序。

如果您有部分,则还要将部分更改保存在数组中,然后按顺序应用更改:删除(降序)、sectionDelete(降序)、sectionInserts(升序)、inserts(升序)、updates(任意顺序) ).部分无法移动或更新。

总结:

  1. 有 5 个数组:sectionInserts、sectionDeletes、rowDeletes、rowInserts 和 rowUpdates

  2. 在controllerWillChangeContent中清空所有数组

  3. in controller:didChangeObject:将indexPaths添加到数组中(移动是删除和插入。更新使用newIndexPath)

  4. in controller:didChangeSection 将节添加到 sectionInserts 或 rowDeletes 数组

  5. 在controllerDidChangeContent中:处理如下:

    • 降序排列 rowDeletes
    • 排序部分降序删除
    • 排序部分插入升序
    • 按升序排列 rowInserts
  6. 然后在一个 performBatchUpdates 块中将更改应用到 collectionView:rowDeletes、sectionDelete、sectionInserts、rowInserts 和 rowUpdates 的顺序。