CoreData 学习笔记七-CoreData+TableView差量数据源更新

这篇笔记是介绍使用 CoreData+TableView 中使用差量数据源(Diffable Datasource)。使用差量数据源的起因是,在 CoreData+TableView+iCloud 使用过程中遇到的一个 Crash 的问题。经常在同步的时候遇到下面这个崩溃报错:

** Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'attempt to delete item 4 from section 0 which only contains 4 items before the update' terminating with uncaught exception of type NSException

这特别像以前使用 TableView/CollectionView 中的一个使用报错:

Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'Invalid update: invalid number of rows in section 0. The number of rows contained in an existing section after the update (1) must be equal to the number of rows contained in that section before the update (1), plus or minus the number of rows inserted or deleted from that section (0 inserted, 1 deleted) and plus or minus the number of rows moved into or out of that section (0 moved in, 0 moved out).

这个报错的本质原因是在 update tableview/collectionView 的时候数据源和视图操作没有匹配导致的。但是 CoreData 中的这个报错是怎么回事呢?本质也是类似的,还是数据一致性没有做好。但是像这种 case,因为我们是使用 NSFetchedResultsController 来管理数据同步操作,所以这种问题并不好解决,怎么办?在网上搜索到遇到类似问题的解决方案 (opens new window),即使用差量数据源的方案。关于差量数据源的说明,可以参考我之前的这篇笔记UITableView学习笔记一-差量数据源 (opens new window)

回到这篇笔记的主题,如何在 CoreData+TableView 中使用差量数据源。总的过程和 UITableView 中使用差量数据源的步骤类似,不过需要结合 NSFetchedResultsController 的代理方法。下面是代码部分,代码说明部分有详细的说明

enum Section {
    case main
}

lazy var collectionView:UICollectionView = {
    let collectionView = UICollectionView.init(xxx)
    collectionView.delegate = self
    return collectionView
}()
var fetchedResultsController:NSFetchedResultsController<Student>!
var diffableDataSource:UICollectionViewDiffableDataSource<Section, NSManagedObjectID>!

override func viewDidLoad() {
	super.viewDidLoad()
	setCollectionViewDataSource()
	setupFetchedResultsController()
}

//设置数据源
func setCollectionViewDataSource() {
    self.diffableDataSource = UICollectionViewDiffableDataSource<Section, NSManagedObjectID>(collectionView: collectionView) { [weak self]  (collectionView, indexPath, objectID) -> UICollectionViewCell? in
        guard let wself = self else { return nil }
        guard let copyItem = try? managedContext().existingObject(with: objectID) as? CopyItem else {
            fatalError("Managed object should be available")
        }
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cellid", for: indexPath) 
        //config cell
        return cell
    }
    collectionView.dataSource = diffableDataSource
}

/// 配置结果控制器
func setupFetchedResultsController() {
    let fetchRequest:NSFetchRequest<Student> = Student.fetchRequest()
    fetchRequest.sortDescriptors = [NSSortDescriptor.init(key: "score", ascending: true)]
    fetchedResultsController = NSFetchedResultsController(fetchRequest: fetchRequest, managedObjectContext: managedContext(), sectionNameKeyPath: nil, cacheName: nil)
    fetchedResultsController.delegate = self
    do {
        try fetchedResultsController.performFetch()
    } catch {
        NSLog("fetch results error: \(error)")
    }
}

/// 实现 NSFetchedResultsControllerDelegate
extension ViewController:NSFetchedResultsControllerDelegate {
    func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) {
        guard let dataSource = collectionView.dataSource as? UICollectionViewDiffableDataSource<Section, NSManagedObjectID> else {
            assertionFailure("The data source has not implemented snapshot support while it should")
            return
        }
        var snapshot = snapshot as NSDiffableDataSourceSnapshot<Section, NSManagedObjectID>
        diffableDataSource.apply(snapshot, animatingDifferences: true)
    }
}

代码说明

  1. 差量数据源(Diffable DataSources)的类型声明需要为 UICollectionViewDiffableDataSource<Section, NSManagedObjectID>,需要关注的是 ItemIdentifierType 不能为最终的数据类型,而是 NSManagedObjectID,否则会在后续的 NSDiffableDataSourceSnapshot 类型转换中报错

    Could not cast value of type '_NSCoreDataTaggedObjectID' (0x1eaaefba0) to 'Demo.Student' (0x100e50f60).

  2. 在差量数据源构建的 cell provider 回调中,需要通过 NSManagedObjectID 实例来获取待渲染的 Entity 实例。

  3. 使用差量数据源后 reloadData 以及 performUpdates 不能再继续使用了,基本上可以告别历史舞台。

  4. 如果你看过参考地址 How-to use Diffable Data Sources with Core Data (opens new window) 中的 NSFetchedResultsControllerDelegate 的实现,他的代码里面有个新老snapshot对比的过程,我理解是因为他因为频繁的数据变更导致了性能低下,所以它自己先对比之后在新 snapshot 中先 reload。这里为了方便理解我就用了比较简单的代码,而且大部分时候我们遇不上这种性能问题,遇到了再优化不迟。

分享在具体开发中遇到的一个问题是,当向列表中新增数据(假设是 Student 实例)调用 managedContext.save() 的时候,会触发 NSFetchedResultsControllerDelegate 代理方法 controller(_ controller:, didChangeContentWithSnapshot:),我们在方法最后正常 apply(),页面正常添加数据完毕后。用户如果接着做了某个操作触发了列表的更新操作,更新操作中复用当前 snapshot.itemIdentifiers ,则有可能在差量数据源的 cell provider 回调中通过 managedContext.existingObject(with:objectId) 会拿到空的数据,影响我们接下来对 cell 进行配置操作。出现这种问题的原因是 Student 实例的 objectId 在 managedContext.save() 前后是不一样的。更新触发的列表操作还是拿着之前实例的 objectId (可以理解为保存本地前的内存中 objectId)去找所以是找不到的,解决方案是,在用户触发操作的时候先对 snapshot.itemIdentifiers 先筛一遍过滤掉已经失效的 objectId。

上面如果 cell provider 中通过 managedContext.object(with:objectId) 方法会拿到一个占位数据。具体参考两个 API 的区别。我自己理解 managedContext.existingObject(with:objectId) 相对更加靠谱一些。

总的来说 CoreData+TableView+DiffableDataSource 的更新方式更加简化了老式 NSFetchedResultsControllerDelegate 协议的实现方式,对开发者来说用起来更加简单,也不会再遇到开头那种崩溃的问题了。

参考地址

  1. Using NSFetchedResultsController’s diffable snapshot with UICollectionView’s diffable data source (opens new window)
  2. How-to use Diffable Data Sources with Core Data (opens new window)