为啥将删除的行作为 NSFetchedResultsControllerDelegate 处理时 NSTableView 会崩溃?

Posted

技术标签:

【中文标题】为啥将删除的行作为 NSFetchedResultsControllerDelegate 处理时 NSTableView 会崩溃?【英文标题】:Why does NSTableView crash when processing deleted rows as NSFetchedResultsControllerDelegate?为什么将删除的行作为 NSFetchedResultsControllerDelegate 处理时 NSTableView 会崩溃? 【发布时间】:2019-05-03 19:45:14 【问题描述】:

我使用的是相当标准的 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")

我知道我应该能够以这种方式批量处理所有更新,即使删除了多行。但是,这会导致连续多次删除导致崩溃。

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

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

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

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

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

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

【问题讨论】:

beginupdates/endupdates 对动画进行分组,而不是 removeRowss。如何从上下文中删除对象? anObject 是否匹配 indexPath 对象删除是由另一个 NSManagedObjectContext 针对同一个 NSPersistentCoordinator 触发的。我在我的问题中添加了一些额外的信息。对象似乎是正确的。报告的对象与删除前的indexPath 匹配。 批量处理但还是按顺序执行,当第一个被删除时,会改变第二个请求的索引,最终会出现索引越界问题。 @Digitalsa1nt 谢谢,这是有道理的。我是否应该通过处理批量更改来跟踪这些更改的索引,并为每个传入的更改调整索引?听起来可行,但我不敢相信我是世界上第一个不得不处理这个问题的人。我希望得到一个现成的(希望经过验证/测试的)食谱作为答案:D @Digitalsa1nt 这些删除不是由用户触发的,而是由来自网络的某些东西(特别是 CloudKit)触发的。用户只是看到信息消失了(可能是因为这些项目是由决定删除他们创作的内容的其他人创作的)。 【参考方案1】:

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

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

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

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

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

如果您有节,则还将节更改保存在数组中,然后按顺序应用更改:删除(降序)、sectionDelete(降序)、sectionInserts(升序)、插入(升序)、更新(任何顺序)。部分不能移动或更新。

总结:

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

    在controllerWillChangeContent中清除所有数组

    在控制器中:didChangeObject:将 indexPaths 添加到数组中(移动是删除和插入。更新使用 newIndexPath)

    在控制器中:didChangeSection 中将节添加到 sectionInserts 或 rowDeletes 数组中

    在controllerDidChangeContent中:处理如下:

    按降序排列行删除 排序部分删除降序 对部分插入升序排序 对 rowInserts 进行升序排序

    然后在一个 performBatchUpdates 块中将更改应用到 collectionView:rowDelete、sectionDelete、sectionInserts、rowInserts 和 rowUpdates 的顺序。

【讨论】:

谢谢。这似乎有效。将它作为一个单独的对象实施和测试,因为那时我可以对它进行干净的单元测试。在这里发布:gist.github.com/jaanus/c28fa29ba24d44e1a2fff2d62e0493e6 我有一个实现这个的简单项目。可以在这里找到:github.com/jon513/FetchedResultsControllerNeverCrash 很好的答案。并且合乎逻辑。这种详细程度应该在 Apple 的文档中。【参考方案2】:

希望 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

这个例子从一个数组中删除两个字符串(以及它们的 相应的行)并将三个字符串插入到数组中(沿 及其相应的行)。下一节,排序 操作和索引路径,解释行的特定方面(或 section) 插入和删除行为。

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

苹果documentation can be found here,以及标题下的上述示例:“批量插入和删除操作的示例”

希望这有助于为您指明正确的方向。

【讨论】:

不知道UITableView和NSTableView有什么行为区别?我正在处理 NSTableView 和 AppKit。我的代码与此示例等效(我认为 - 我能看到的唯一区别是,使用一个索引路径多次调用 removeRows,而不是使用多个索引路径调用一次,尽管多次调用仍在 beginupdates/endupdates 块内) .我注意到该方法在 UITableView 中称为 deleteRows,但在 NSTableView 中称为 removeRows - 这是否也表明存在行为差异? @Jaanus 可能,但是查看 NSTableView 的 AppKit 文档,原理似乎是相同的,您能否显示最终导致该委托在更改时触发的代码数据源?我只是好奇它是否有一组索引要删除并一个一个地处理它们直到完成等等。

以上是关于为啥将删除的行作为 NSFetchedResultsControllerDelegate 处理时 NSTableView 会崩溃?的主要内容,如果未能解决你的问题,请参考以下文章

为啥合并不相等匹配的行不适用于本地数据集?

为啥最新的 phpmyadmin 删除了 sql 作为导出格式?

删除作为其他行子字符串的行

删除作为后续行的子字符串的行

UITableView 不会在使用 NSFetchedResultsController 更改模型时重新加载

为啥 Safari 将某些短语拆分为单独的行?