Fiche 09.05 — SwiftData en pratique (iOS 17+)
Objectif (2-3 lignes)
SwiftData est le framework de persistance par défaut pour tout projet neuf iOS 17+ : un @Model sur une classe, un ModelContainer injecté sur l'App, @Query dans tes vues et la réactivité est gratuite. Core Data reste l'existant à savoir lire (NSManagedObject/NSFetchRequest) ; SwiftData en est la surcouche moderne et Swift-native. On voit ici modélisation, container/context, query, fetch hors-vue, migration et limites.
1. Modéliser avec @Model
La macro @Model transforme une classe normale en entité persistée. Pas de fichier .xcdatamodeld, le schéma est ton code.
- Toutes les propriétés stockées sont persistées par défaut. Pour exclure :
@Transient var draft: String = "". - Types supportés : valeurs
Codable, enums,Date,Data, autres@Model. Une structCodableest stockée en blob. @Attribute(.unique)impose l'unicité ET fait l'upsert sur insert (remplace au lieu de dupliquer).
Réflexe UIKit/Core Data :
@Modelremplace l'entité du.xcdatamodeld+ la sous-classeNSManagedObjectgénérée. Plus d'éditeur graphique, le modèle vit dans le code.
2. Relations avec @Relationship
Pour un one-to-many, déclare le var collection côté "one" et l'optionnel côté "many". @Relationship précise la delete rule.
.cascade: supprime les enfants..nullify(défaut) : met l'inverse ànilsans détruire l'enfant.inverse:lie explicitement les deux côtés. Le déclarer d'un seul côté suffit, SwiftData infère l'autre.- Tu manipules la relation comme un tableau Swift :
list.tasks.append(task)insère et relie automatiquement.
3. ModelContainer et modelContext
Le container charge le schéma et le store (SQLite par défaut). Tu l'injectes une fois sur l'App ; chaque vue récupère le modelContext depuis l'environnement.
context.save()est automatique : SwiftData sauvegarde en fin de cycle d'événement (autosave activé via le container). Tu appellestry context.save()seulement si tu as besoin de persister immédiatement (avant un fetch programmatique, un export, etc.).- Pour un store en mémoire (preview/tests) :
.modelContainer(for: Task.self, inMemory: true).
Réflexe UIKit/Core Data :
ModelContainer≈NSPersistentContainer,modelContext≈viewContext(NSManagedObjectContext).insert/delete≈context.insert(_:)/context.delete(_:).
4. @Query : lecture réactive dans une vue
@Query fetch + observe. Toute insertion/suppression/modif rafraîchit la vue automatiquement, sans @Observable ni notification.
#Predicateest typé et vérifié à la compilation (contrairement àNSPredicateen string).- Tri multi-critères : passe plusieurs
SortDescriptordans le tableausort:. - Filtre dynamique (dépend d'un
@State) :@Queryne prend pas de valeur runtime dans son initialiseur de propriété. Construis-le dansinit:
Réflexe UIKit :
@Query≈NSFetchRequest+NSFetchedResultsControllerréunis — fetch initial et mise à jour auto de l'UI, sans delegate.
5. Fetch programmatique (hors-vue)
Dans un ViewModel @Observable, un service ou une Task { … } (concurrence), pas de @Query. Tu utilises le ModelContext directement avec un FetchDescriptor.
Attention au nom : ici le
@Models'appelleTask, ce qui masque leSwift.Taskde la concurrence. En vrai projet, nomme plutôt ton modèleTodoTask/Itempour éviter l'ambiguïté.
- Récupère le
ModelContextdepuis le container :container.mainContext, ou crée-le côté vue et passe-le au store. fetchCountcompte sans charger les objets.fetchLimit/fetchOffsetpour la pagination.- Hors du main thread : utilise un
ModelActor(acteur dédié) pour un contexte background isolé — voir limites.
6. Migration et limites actuelles
Migration légère (automatique) : ajouter une propriété optionnelle ou une nouvelle entité passe tout seul. Renommer/supprimer/changer un type = migration explicite via VersionedSchema + SchemaMigrationPlan.
Le MigrationStage.lightweight(...) ou .custom(...) décrit le passage V1 → V2, branché sur le container via migrationPlan:.
Limites à connaître (entretien) :
- Pas d'équivalent direct mature à
NSFetchedResultsControllersectionné, ni à toutes les options avancées de Core Data. - Concurrence stricte (Swift 6) :
@Modeln'est pasSendable; ne fais pas circuler un objet entre contextes/threads, passe sonPersistentIdentifier. #Predicatene couvre pas toutNSPredicate(certaines opérations string/agrégats manquent ou ont évolué selon les versions iOS).- Pas d'undo/redo aussi riche, et le contrôle fin du store (multi-store, configs nommées) est plus limité.
- API encore jeune : des bugs/changements entre iOS 17 et 18 — vise iOS 17+ et teste sur device.
Comparatif Core Data vs SwiftData
| Critère | Core Data | SwiftData |
|---|---|---|
| Définition modèle | .xcdatamodeld + NSManagedObject | @Model sur une classe Swift |
| Container | NSPersistentContainer | ModelContainer (.modelContainer(for:)) |
| Contexte | NSManagedObjectContext (viewContext) | ModelContext (@Environment / mainContext) |
| Lecture en vue | NSFetchRequest + NSFetchedResultsController | @Query (réactif auto) |
| Prédicat | NSPredicate (string, runtime) | #Predicate (typé, compile-time) |
| Concurrence | contextes perform/performBackgroundTask | ModelActor |
| Maturité / contrôle fin | très mature, riche, verbeux | jeune, concis, encore limité |
| Cas idéal | app existante, besoins avancés (CloudKit fin, perfs poussées) | projet neuf iOS 17+, code Swift-native |
Quand l'un, quand l'autre : projet neuf ciblant iOS 17+ → SwiftData par défaut. App existante en Core Data, ou cible < iOS 17, ou besoin d'une fonctionnalité Core Data non encore couverte → Core Data (les deux peuvent même cohabiter sur le même store). SwiftData étant bâti sur Core Data, savoir lire Core Data reste indispensable.
Points à connaître (2-4 pièges)
@Queryne prend pas de valeur runtime dans la déclaration de propriété : pour un filtre dynamique, construis leQuerydans l'initde la vue. Sinon ton predicate est figé.- Ne pas oublier le container :
.modelContainer(for:)doit lister tous les modèles racine ; un@Modellié par relation est inclus automatiquement, mais une entité orpheline non listée provoque un crash au démarrage. - Save automatique mais pas instantané : si tu fais
context.insertpuis unfetchprogrammatique dans la foulée, l'objet est déjà visible dans le même contexte, mais pour traverser les contextes/threads pense ausave()explicite. - Concurrence (Swift 6) : un
@Modeln'est pasSendable. Ne le passe pas entre threads ; passe sonpersistentModelIDet re-fetch dans l'autre contexte.
Exercice (concret, autonome, 10-20 min)
Modélise une TodoList et affiche-la.
- Crée deux
@Model:TodoList(avecnameet@Relationship(deleteRule: .cascade) var items: [TodoItem]) etTodoItem(avectitle: String,isDone: Bool,createdAt: Dateet l'inversevar list: TodoList?). - Sur l'
App, ajoute.modelContainer(for: [TodoList.self, TodoItem.self]). - Dans une vue,
@Query(sort: \TodoList.name) var lists: [TodoList]et affiche-les dans uneListavec unNavigationLinkpar liste. - Dans le détail, affiche
list.itemstriés parcreatedAt, avec un bouton qui faitlet item = TodoItem(title: "…"); item.list = list; context.insert(item). - Ajoute le swipe-to-delete :
context.delete(item). Vérifie que l'UI se rafraîchit sans code supplémentaire (réactivité@Query).
Bonus : ajoute @Attribute(.unique) var id: UUID sur TodoItem et un @Query filtré #Predicate { !$0.isDone } pour n'afficher que les items en cours.
Question d'entretien (1-3 Q AVEC réponse modèle courte)
Q : SwiftData ou Core Data sur un projet neuf ? Quelles différences ?
R : Sur un projet neuf ciblant iOS 17+, SwiftData par défaut : modèle en code via @Model (plus de .xcdatamodeld), #Predicate typé à la compilation, et @Query qui fetch et observe la donnée pour une UI réactive sans NSFetchedResultsController. Core Data reste pertinent pour l'existant, une cible < iOS 17, ou des besoins avancés que SwiftData ne couvre pas encore. SwiftData est en réalité une surcouche de Core Data : même store SQLite, ils peuvent cohabiter, et savoir lire du Core Data (NSManagedObject, NSFetchRequest) reste nécessaire.
Q : Comment faire un fetch hors d'une vue, sans @Query ?
R : Avec le ModelContext (ex. container.mainContext ou un contexte injecté) et un FetchDescriptor typé : try context.fetch(FetchDescriptor<Task>(predicate:sortBy:)). fetchCount pour compter sans charger, fetchLimit/fetchOffset pour paginer. En background, on passe par un ModelActor car un @Model n'est pas Sendable.
Résumé (puces)
@Modelsur une classe = entité persistée ; propriétés stockées par défaut,@Transientpour exclure,@Attribute(.unique)pour l'unicité/upsert.@Relationship(deleteRule:inverse:)pour les relations ;.cascadesupprime les enfants,.nullify(défaut) les détache..modelContainer(for:)sur l'App;@Environment(\.modelContext)dans les vues ;insert/delete;save()auto, manuel seulement si besoin immédiat.@Query(filter:sort:)lit ET observe (réactivité gratuite) ;#Predicatetypé ; filtre dynamique → construire leQuerydansinit.- Hors-vue :
ModelContext+FetchDescriptor(fetch,fetchCount,fetchLimit). - Migration légère auto pour les ajouts ;
VersionedSchema+SchemaMigrationPlansinon. - Ponts UIKit :
ModelContainer≈NSPersistentContainer,modelContext≈viewContext,@Query≈NSFetchRequest+NSFetchedResultsController,#Predicate≈NSPredicate(mais typé). - Défaut sur projet neuf iOS 17+ ; Core Data = l'existant à savoir lire, et la fondation sous SwiftData.