NSFetchedResultsController 故障:尝试从第 0 节中删除项目,该项目在使用 userInfo(空)更新之前仅包含 1 个项目

NSFetchedResultsController fault: attempt to delete item from section 0 which only contains 1 items before the update with userInfo (null)

我有一个使用 Core Data 的应用程序。其中有posts,每个post有很多标签。每个标签都有很多 post。

我有一个主视图控制器,它显示所有标签的集合视图。此 collectionView 的数据源由 NSFetchedResultsController

提供支持
class HomeViewController: UIViewController, UICollectionViewDelegate, UICollectionViewDataSource,NSFetchedResultsControllerDelegate {

    @IBOutlet weak var latestTagsCollectionView: UICollectionView!

    var fetchedResultsController: NSFetchedResultsController<Tag>!
    var blockOperations: [BlockOperation] = []

    override func viewDidLoad() {
        super.viewDidLoad()
        self.latestTagsCollectionView.dataSource = self
        self.latestTagsCollectionView.delegate = self
    }

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        //1
        guard let appDelegate =
            UIApplication.shared.delegate as? AppDelegate else {
                return
        }
        let managedContext =
            appDelegate.persistentContainer.viewContext
        //2
        let fetchRequest =
            NSFetchRequest<NSManagedObject>(entityName: "Tag")
        //3
        fetchRequest.sortDescriptors = []

        fetchedResultsController = NSFetchedResultsController(fetchRequest: fetchRequest, managedObjectContext: managedContext, sectionNameKeyPath: nil, cacheName: nil) as? NSFetchedResultsController<Tag>
        fetchedResultsController.delegate = self

        do {
            try fetchedResultsController.performFetch()
        } catch let error as NSError {
            print("Could not fetch. \(error), \(error.userInfo)")
        }
    }

    func configure(cell: UICollectionViewCell, for indexPath: IndexPath) {

        guard let cell = cell as? TagCollectionViewCell else {
            return
        }
        print(indexPath,"indexPath")
        let tag = fetchedResultsController.object(at: indexPath)
        guard let timeAgo = tag.mostRecentUpdate as Date? else { return }
        cell.timeAgo.text = dateFormatter.string(from: timeAgo)
        if let imageData = tag.mostRecentThumbnail {
            cell.thumbnail.image = UIImage(data:imageData as Data,scale:1.0)
        } else {
            cell.thumbnail.image = nil
        }
        cell.tagName.text = tag.name
        cell.backgroundColor = UIColor.gray
    }

    //CollectionView Stuff
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        guard let sectionData = fetchedResultsController.sections?[section] else {
            return 0
        }
        return sectionData.numberOfObjects
    }

    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = latestTagsCollectionView.dequeueReusableCell(withReuseIdentifier: "latestTagCell", for: indexPath) as! TagCollectionViewCell
        configure(cell: cell, for: indexPath)
        return cell
    }

    func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>,
                             didChange anObject: Any,
                             at indexPath: IndexPath?,
                             for type: NSFetchedResultsChangeType,
                             newIndexPath: IndexPath?){
        switch type {
        case .insert:
            print("Insert Object: \(String(describing: newIndexPath))")
            blockOperations.append(
                BlockOperation(block: { [weak self] in
                    if let this = self {
                        this.latestTagsCollectionView!.insertItems(at: [newIndexPath!])
                    }
                })
            )
        case .update:
            blockOperations.append(
                BlockOperation(block: { [weak self] in
                    if let this = self {
                        this.latestTagsCollectionView!.reloadItems(at: [newIndexPath!])
                    }
                })
            )
        case .move:
            blockOperations.append(
                BlockOperation(block: { [weak self] in
                    if let this = self {
                        this.latestTagsCollectionView!.moveItem(at: indexPath!, to: newIndexPath!)
                    }
                })
            )
        case .delete:
            print("deleted record")
            blockOperations.append(
                BlockOperation(block: { [weak self] in
                    if let this = self {
                        this.latestTagsCollectionView!.deleteItems(at: [newIndexPath!])
                    }
                })
            )
        default: break
        }
    }

    func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
        latestTagsCollectionView!.performBatchUpdates({ () -> Void in
            for operation: BlockOperation in self.blockOperations {
                operation.start()
            }
        }, completion: { (finished) -> Void in
            self.blockOperations.removeAll(keepingCapacity: false)
        })
    }

    deinit {
        // Cancel all block operations when VC deallocates
        for operation: BlockOperation in blockOperations {
            operation.cancel()
        }
        blockOperations.removeAll(keepingCapacity: false)
    }

在不同的视图控制器上,我允许用户添加 posts。对于每个 post,用户可以添加许多不同的标签。这是保存 post 时调用的方法:

func save(){
    guard let appDelegate =
    UIApplication.shared.delegate as? AppDelegate else {
        return
    }
    let managedContext =
        appDelegate.persistentContainer.viewContext
    var postTags:[Tag] = []

    if let tokens = tagsView.tokens() {
        for token in tokens {
            let tagFetchRequest: NSFetchRequest<Tag> = Tag.fetchRequest()
            tagFetchRequest.predicate = NSPredicate(format: "name == %@", token.title)
            do {
                let res = try managedContext.fetch(tagFetchRequest)
                var tag: Tag!
                if res.count > 0 {
                    tag = res.first
                } else {
                    tag = Tag(context: managedContext)
                    tag.name = token.title
                    tag.mostRecentUpdate = NSDate()
                    tag.mostRecentThumbnail = UIImage(named: "Plus")!.pngData() as! NSData
                }
                postTags.append(tag)
            } catch let error as NSError {
                print("Could not fetch. \(error), \(error.userInfo)")
                return
            }
        }
    }
    let post = Post(context: managedContext)
    for tag in postTags {
        tag.addToPosts(post)
        post.addToTags(tag)
    }
    post.mediaURI = URL(string: "https://via.placeholder.com/150")
    post.notes = "some notes..."
    post.timeStamp = Calendar.current.date(byAdding: .day, value: 8, to: Date()) as! NSDate
    do {
        try managedContext.save()
    } catch let error as NSError {
        print("Could not save. \(error), \(error.userInfo)")
    }
}

如您所见,每个标签要么在数据库中被识别,要么在不存在时被创建。对于这个错误,我从数据库中没有数据开始。首先,我创建了一个带有标签 "one" 的 post。然后我回到主视图控制器的视图。我看到创建了一个名为 "One" 的新标签。

Insert Object: Optional([0, 0])

在 .insert 案例应用于 NSFetchedResultsController 的回调方法时打印。

然后我添加一个带有两个标签的 post:"One"、"Two"。

Insert Object: Optional([0, 0])
2018-12-05 12:51:16.947569-0800 SweatNetOffline[71327:19904799] *** Assertion failure in -[UICollectionView _endItemAnimationsWithInvalidationContext:tentativelyForReordering:animator:], /BuildRoot/Library/Caches/com.apple.xbs/Sources/UIKitCore_Sim/UIKit-3698.84.15/UICollectionView.m:5908
2018-12-05 12:51:16.949957-0800 SweatNetOffline[71327:19904799] [error] fault: Serious application error.  An exception was caught from the delegate of NSFetchedResultsController during a call to -controllerDidChangeContent:.  attempt to delete item 1 from section 0 which only contains 1 items before the update with userInfo (null)

为什么要在这里删除第 1 项...我真的不明白这个错误消息。我相信它应该只是在新索引路径中插入一个项目,因为在数据库中创建了一个新标签 "Two"。这里发生了什么?

您的问题是由于在 .update 案例中使用 newIndexPath 而不是 indexPath 引起的。

当您将现有标签分配给 post 时,该 Tag 对象会更新。这会导致 .updated 事件被 post 编辑到 NSFetchResultsControllerDelegate

在delegate方法中newIndexPath表示对象的索引路径after插入已经处理,而indexPath表示对象的索引路径 插入之前。

performBatchUpdates:completion 的文档指出:

Deletes are processed before inserts in batch operations. This means the indexes for the deletions are processed relative to the indexes of the collection view’s state before the batch operation, and the indexes for the insertions are processed relative to the indexes of the state after all the deletions in the batch operation.

由于插入是最后执行的,因此当您尝试重新加载时 newIndexPath 您会遇到异常,因为您正在尝试重新加载尚未插入的元素。

在这种情况下,将您的代码更改为引用 indexPath 即可解决您的问题。

 case .update:
     blockOperations.append(
         BlockOperation(block: { [weak self] in
             if let this = self {
                 this.latestTagsCollectionView!.reloadItems(at: [indexPath!])
             }
         })
     )

另外,您只需要更新PostTag;由于存在反向引用,Core Data 将负责更新其他对象