Fiche 02.11 — @Observable, le ViewModel moderne (iOS 17)
Objectif
Faire de @Observable ton réflexe par défaut en 2026 pour tout ViewModel SwiftUI, comprendre la nouvelle famille de property wrappers de possession/passage, et savoir lire le legacy ObservableObject quand tu tombes dessus.
1. Le point de bascule
@Observable (macro du framework Observation, iOS 17+) remplace ObservableObject. La différence clé : tu n'annotes plus chaque propriété avec @Published. Toute propriété stockée d'une classe @Observable est observée automatiquement.
Pont UIKit : en UIKit, ton "ViewModel" notifiait la vue à la main (delegate, closure onChange, ou KVO). Avec @Observable, la vue s'abonne toute seule aux propriétés qu'elle lit dans son body. C'est du KVO automatique et invisible.
Legacy (à savoir lire) :
Moderne (le défaut) :
2. Possession : @State remplace @StateObject
La vue qui crée et possède le ViewModel utilise @State. Plus de @StateObject.
Comme @StateObject avant lui, @State garantit que le VM est instancié une seule fois et survit aux re-renders de la vue parente. Tu ne perds rien de la sémantique : tu changes juste de wrapper.
Détail moderne : tu peux maintenant fournir une valeur par défaut directement dans l'init du wrapper (
@State private var vm = CounterViewModel()), comme tu le faisais avec@StateObject.
3. Passage : un simple let remplace @ObservedObject
Quand une vue reçoit un VM déjà créé ailleurs, tu le passes comme une propriété normale. Plus de @ObservedObject.
L'observation reste active : si body lit vm.count et qu'il change, la vue se met à jour. C'est ça la magie d'@Observable — l'observation est portée par l'objet, pas par le wrapper de la vue.
Anti-pattern (pas idéal) :
Préférable :
4. Binding : @Bindable pour faire $vm.text
Avec ObservableObject, @ObservedObject/@StateObject te donnaient gratuitement le $ pour créer des bindings ($vm.text). Avec @Observable, un simple let n'expose pas $. Tu utilises @Bindable.
Cas fréquent : si tu possèdes déjà le VM via @State, tu peux créer un @Bindable local dans le body pour un binding ponctuel :
Règle simple : pas de binding → let suffit. Besoin de $ → @Bindable.
5. Environnement : @Environment(MyType.self)
@EnvironmentObject devient @Environment(_:) typé par le type.
Pont UIKit : pense à @Environment comme à une injection de dépendances descendante dans l'arbre de vues, l'équivalent moderne et type-safe de ce que tu bricolais via un singleton ou en passant des refs de coordinator en coordinator.
Pour binder une propriété d'un objet d'environnement : récupère-le avec
@Environment, puis fais@Bindable var session = sessiondans le body.
6. L'avantage perf décisif : granularité d'invalidation
C'est l'argument à sortir en entretien. Avec ObservableObject, n'importe quel @Published qui change envoie objectWillChange → toutes les vues abonnées à cet objet se réévaluent, même celles qui ne lisent pas la propriété modifiée.
Avec @Observable, le tracking est par propriété lue. Seules les vues dont le body a effectivement lu la propriété modifiée sont invalidées.
Ici, faire vm.unreadCount += 1 ne re-render pas TitleView, car son body ne lit jamais unreadCount. Avec l'ancien ObservableObject, ça l'aurait re-render. Moins de recompositions inutiles → app plus fluide, gratuitement.
Self._printChanges() (à mettre dans le body) imprime en console ce qui a déclenché chaque réévaluation : pratique pour traquer les re-renders parasites.
7. @MainActor sur le ViewModel
Un ViewModel d'UI mute de l'état lu par la vue : il doit vivre sur le main actor. Annote la classe :
@MainActor te garantit que toutes les mutations de users/isLoading se font sur le thread principal, sans DispatchQueue.main.async manuel. C'est obligatoire dès que tu vises Swift 6 strict concurrency.
8. Encadré MIGRATION
Tableau de correspondance, à mémoriser :
| Legacy (ObservableObject) | Moderne (@Observable) |
|---|---|
class VM: ObservableObject | @Observable class VM |
@Published var x | var x (rien à annoter) |
@StateObject private var vm | @State private var vm |
@ObservedObject var vm | let vm |
@EnvironmentObject var vm | @Environment(VM.self) var vm |
| (binding gratuit via wrapper) | @Bindable var vm pour $vm.x |
.environmentObject(vm) | .environment(vm) |
Pièges classiques de migration :
- Oublier
@Bindable: tu migres@ObservedObjectenlet, et soudain$vm.textne compile plus. Passe en@Bindable. - Propriété d'une classe imbriquée non observée :
@Observablene track que les propriétés stockées directes de la classe annotée. Sivar address: Addresspointe vers une classe non@Observable, muteraddress.cityne déclenche rien. Annote aussiAddressavec@Observable. - Garder un
@Statesur un type qui n'est pas@Observable:@Statesur une classe nue ne réagit pas. La classe doit porter la macro. - Mélange : ne mets pas
@Publisheddans une classe@Observable, et ne conforme pas@ObservableàObservableObject"au cas où" — choisis un monde.
9. Quand ObservableObject reste légitime
@Observable est le défaut, mais tu garderas le legacy dans deux cas :
- Support iOS < 17 : la macro
Observationexige iOS/iPadOS 17, macOS 14, etc. Si ton deployment target est plus bas, tu restes surObservableObject+@Published. - Pipeline Combine : si ton VM s'appuie sur des opérateurs Combine et publie via
@Published(qui est unPublisher),ObservableObjects'intègre directement.@Observablen'expose pas de publisher Combine — il faut alors bricoler (ex.withObservationTracking, qui ne notifie qu'un seul changement et doit être ré-armé à chaque fois).
Dans tout le reste — c'est-à-dire l'immense majorité d'une app iOS 17+ — utilise @Observable.
Points à connaître
@Observabletrack les propriétés lues dans le body, pas l'objet entier : c'est la source du gain de perf et la cause des bugs si une sous-classe imbriquée n'est pas annotée.@State(possession) vslet(passage) vs@Bindable(binding) vs@Environment(VM.self)(injection) : quatre rôles, ne les confonds pas.@StateObject/@ObservedObject/@EnvironmentObjectn'existent plus côté@Observable.- Pas de
@Bindable= pas de$. C'est l'erreur n°1 en migration. - Mets
@MainActorsur la classe du VM, pas sur chaque méthode, dès que tu vises Swift 6.
Exercice
Migre ce ViewModel et sa vue vers @Observable + @State (sans changer le comportement) :
Indice : la classe change d'annotation, @StateObject devient @State, et $vm.query t'oblige à introduire @Bindable.
Question d'entretien
Q : Différence entre @StateObject et @State avec @Observable ?
R : Même rôle (posséder l'objet, instancié une fois, survit aux re-renders du parent), mais @StateObject est réservé aux types ObservableObject, alors que @State est le wrapper de possession pour un type @Observable. Depuis iOS 17, on possède un ViewModel @Observable avec @State private var vm = VM().
Q : Pourquoi @Observable est-il plus performant ?
R : Granularité d'invalidation. ObservableObject émet objectWillChange à chaque @Published modifié → toutes les vues abonnées se réévaluent. @Observable track la lecture par propriété : seules les vues dont le body lit la propriété qui a changé sont invalidées. Résultat : beaucoup moins de recompositions superflues. On peut le vérifier avec Self._printChanges().
Q : Comment exposer un binding $vm.text à partir d'un VM @Observable reçu par let ?
R : Avec @Bindable. Soit en déclarant la propriété @Bindable var vm: VM, soit en créant un @Bindable var vm = vm local dans le body. Un let seul n'expose pas l'opérateur $.
Résumé
@Observable(frameworkObservation, iOS 17+) est le défaut 2026 ;ObservableObjectest du legacy à savoir lire.- Plus de
@Published: toute propriété stockée d'une classe@Observableest observée. - Possession
@State(ex-@StateObject), passagelet(ex-@ObservedObject), binding@Bindable($vm.text), injection@Environment(VM.self)(ex-@EnvironmentObject). - Gain perf majeur : invalidation par propriété lue, pas par objet entier ; visualise avec
Self._printChanges(). - Annote la classe du VM avec
@MainActor. - Pièges : oublier
@Bindable, et ne pas annoter une classe imbriquée avec@Observable. - Reste sur
ObservableObjectseulement pour iOS < 17 ou un pipeline Combine@Published.