← Zurück zum Blog

Diffable Data Sources mit Core Data

Diffable data sources haben verändert, wie ich über Listen-Updates denke. Statt einer Collection View zu sagen, wie sie mutieren soll — diese index paths einfügen, jene löschen und hoffen, dass die Zählungen stimmen —, geben Sie ihr einen snapshot, der den aktuellen Datenzustand beschreibt. UIKit berechnet den Diff und animiert ihn. Die fehleranfällige Buchführung mit performBatchUpdates fällt damit weg.

Mit Core Data wird das interessanter, weil ein snapshot aus Identifiers aufgebaut ist, und diese Identifiers stabil bleiben müssen, während Objekte kommen und gehen. So greifen die beiden Konzepte ineinander — und das sind die drei Situationen, die mir dabei immer wieder begegnen.

Ein snapshot — NSDiffableDataSourceSnapshot<Section, Item> — ist im Grunde eine Liste von Abschnitts-Identifiers, unter denen jeweils die zugehörigen Item-Identifiers hängen. Beide müssen Hashable sein. Der Kniff bei Core Data ist die Wahl von Item: Legen Sie nicht das managed object in den snapshot, sondern seine NSManagedObjectID. Sie ist stabil, hashbar und lässt sich sicher weiterreichen; im cell provider lösen Sie sie wieder zu einem lebenden Objekt auf. Die andere Hälfte besteht darin, Änderungen in die data source zu bekommen — und seit iOS 13 liefert NSFetchedResultsController jedes Mal einen fertigen snapshot, wenn sich die Fetch-Ergebnisse ändern.

Datenfluss von Core Data durch einen NSFetchedResultsController, der einen snapshot aus Abschnitts-Bezeichnern und NSManagedObjectIDs ausgibt, angewendet auf eine diffable data source, die Einfügungen, Löschungen und Verschiebungen in der collection view animiert; der cell provider schlägt jedes managed object anhand seiner object ID nach

Diese Brücke macht den Großteil des Codes aus:

let dataSource = UICollectionViewDiffableDataSource<String, NSManagedObjectID>(
    collectionView: collectionView
) { collectionView, indexPath, objectID in
    let object = try? context.existingObject(with: objectID)
    // configure and return a cell from `object`
}

// NSFetchedResultsControllerDelegate
func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>,
                didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) {
    dataSource.apply(
        snapshot as NSDiffableDataSourceSnapshot<String, NSManagedObjectID>,
        animatingDifferences: true
    )
}

Sie richten den fetched-results controller ein, setzen sich als delegate, und jedes Einfügen, Aktualisieren und Löschen in Core Data wird zu einem animierten UI-Update. Was sich zwischen den drei Beispielen unten ändert, ist vor allem die Modellierung der Abschnitte.

Eine einzige Entität & ein einzelner Abschnitt ↴

Der Ausgangsfall: eine Entität, ein Abschnitt. Geben Sie dem fetched-results controller einen sectionNameKeyPath von nil, und der erzeugte snapshot hat genau einen Abschnitt — eine flache Liste, die sich animiert, während sich der Store ändert.

frc = NSFetchedResultsController(fetchRequest: request,
                                 managedObjectContext: context,
                                 sectionNameKeyPath: nil,
                                 cacheName: nil)

// in controller(_:didChangeContentWith:) — collapse everything into one section
var snapshot = NSDiffableDataSourceSnapshot<String, NSManagedObjectID>()
snapshot.appendSections(["Memories"])
snapshot.appendItems(databaseSnapshot.itemIdentifiers, toSection: "Memories")
dataSource.apply(snapshot, animatingDifferences: true)

Vollständiger Beispiel-ViewController →

Eine einzige Entität & mehrere Abschnitte ↴

Dieselbe Entität, aber gruppiert — zum Beispiel Aufgaben nach Status oder Kontakte nach Anfangsbuchstaben. Setzen Sie den sectionNameKeyPath des controllers auf das Attribut, nach dem Sie gruppieren möchten (und sortieren Sie zuerst danach), dann erzeugt er einen Abschnitt pro eindeutigem Wert. Die Abschnitts-Identifiers des snapshots sind diese Gruppennamen; eigene Gruppierungslogik schreiben Sie dafür nicht.

frc = NSFetchedResultsController(fetchRequest: request,
                                 managedObjectContext: context,
                                 sectionNameKeyPath: "type",
                                 cacheName: nil)

// the controller already grouped by `type`, so apply its snapshot directly
dataSource.apply(snapshot as NSDiffableDataSourceSnapshot<String, NSManagedObjectID>,
                 animatingDifferences: true)

Vollständiger Beispiel-ViewController →

Mehrere Entitäten & mehrere Abschnitte ↴

Das ist der Fall, der nicht in einen einzigen fetched-results controller passt, denn ein FRC fetched immer einen Entitätstyp. Hier verwenden Sie einen controller pro Entität und führen die Ergebnisse in einem snapshot zusammen, den Sie selbst bauen — indem Sie die object IDs jeder Entität unter ihrem eigenen Abschnitt anhängen. Sobald Sie Entitätstypen mischen, ist es klarer, den snapshot direkt selbst zu verwalten.

// one controller per entity, merged on every change
var snapshot = NSDiffableDataSourceSnapshot<String, NSManagedObjectID>()
snapshot.appendSections(["Memories", "Plans"])
snapshot.appendItems(memoriesController.fetchedObjects?.map(\.objectID) ?? [],
                     toSection: "Memories")
snapshot.appendItems(plansController.fetchedObjects?.map(\.objectID) ?? [],
                     toSection: "Plans")
dataSource.apply(snapshot, animatingDifferences: true)

Vollständiger Beispiel-ViewController →

Der Gewinn ist in allen drei Fällen derselbe: keine index-path-Rechnerei, keine batch-update-Abstürze und Animationen, die Ihren Daten automatisch folgen. Die vollständigen Beispiel-View-Controller sind unter jedem Abschnitt verlinkt.