← Retour au blog

Diffable Datasource avec Core Data

Les diffable data sources ont changé ma façon de penser la mise à jour d'une liste. Au lieu de dire à une collection view comment muter — insérer ces index paths, supprimer ceux-là, et espérer que les comptes tombent juste — vous lui remettez un snapshot décrivant les données telles qu'elles sont maintenant, et UIKit calcule le diff et l'anime. La comptabilité fragile de performBatchUpdates disparaît tout simplement.

Core Data rend la chose plus intéressante, car un snapshot se construit à partir d'identifiants, et ces identifiants doivent rester stables à mesure que les objets vont et viennent. Voici comment les deux s'articulent, ainsi que les trois situations que je rencontre régulièrement.

Un snapshot — NSDiffableDataSourceSnapshot<Section, Item> — n'est qu'une liste d'identifiants de section et, sous chacun, les identifiants d'éléments. Les deux doivent être Hashable. L'astuce avec Core Data, c'est le choix de Item : ne mettez pas l'objet géré dans le snapshot, mettez son NSManagedObjectID. Il est stable, hachable, et sans danger à faire circuler, et vous le résolvez en un objet vivant dans le cell provider. L'autre moitié, c'est de faire entrer les changements — et depuis iOS 13, NSFetchedResultsController vous remet un snapshot prêt à l'emploi chaque fois que les résultats récupérés changent.

Flux de données depuis Core Data à travers un NSFetchedResultsController, qui émet un snapshot d'identifiants de section et de NSManagedObjectID, appliqué à une diffable data source qui anime les insertions, suppressions et déplacements dans la collection view ; le cell provider retrouve chaque objet géré par son object ID

Ce pont, c'est l'essentiel du code :

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

Mettez en place le fetched-results controller, devenez son delegate, et chaque insertion, mise à jour et suppression dans Core Data se répercute en une mise à jour animée de l'UI. Ce qui change entre les trois exemples ci-dessous, c'est surtout la façon dont les sections sont modélisées.

Une seule entité & une seule section ↴

Le cas de base : une entité, une section. Donnez au fetched-results controller un sectionNameKeyPath à nil et le snapshot qu'il produit n'a qu'une seule section — une liste plate qui s'anime d'elle-même à mesure que le store change.

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)

Exemple complet de ViewController →

Une seule entité & plusieurs sections ↴

Même entité, mais groupée — disons des tâches réparties par statut, ou des contacts par première lettre. Donnez au sectionNameKeyPath du controller l'attribut sur lequel vous voulez grouper (et triez d'abord dessus), et il produit une section par valeur distincte. Les identifiants de section du snapshot sont ces noms de groupe ; vous n'avez écrit aucune logique de regroupement vous-même.

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)

Exemple complet de ViewController →

Plusieurs entités & plusieurs sections ↴

C'est le cas qui ne tient pas dans un seul fetched-results controller, car un FRC récupère un seul type d'entité. Ici, vous exécutez un controller par entité et fusionnez leurs résultats dans un snapshot que vous construisez vous-même — en ajoutant les object IDs de chaque entité sous sa propre section. Dès que vous mêlez des types d'entités, posséder le snapshot directement est la voie la plus claire.

// 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)

Exemple complet de ViewController →

Le bénéfice est le même dans les trois cas : pas de calcul d'index paths, pas de plantages de batch-update, et des animations qui suivent vos données gratuitement. Les view controllers d'exemple complets sont liés sous chaque section.