Les internes de @Observable : la macro, le registrar et ce qu'ObservableObject a fait de travers
Le framework Observation, introduit dans iOS 17 et Swift 5.9, a remplacé le modèle ObservableObject basé sur Combine par un système piloté par macro et fondé sur le suivi d’accès propriété par propriété1. Le changement paraît minime au point d’appel (une seule macro @Observable au lieu de : ObservableObject plus @Published partout), mais le comportement à l’exécution est différent d’une manière qui affecte les performances, la correction et le chemin de migration. Le glissement, en une phrase : les vues qui n’ont pas lu une propriété modifiée ne sont plus réévaluées lorsque cette propriété change.
Cet article parcourt les internes du framework en s’appuyant sur la documentation d’Apple et la proposition SE-03952. Le cadrage est « ce que la macro génère réellement et pourquoi », parce que la plupart des équipes adoptent @Observable pour la syntaxe et passent à côté du changement structurel dans la propagation des mises à jour, qui est précisément l’endroit où se trouvent le véritable gain de performance (et les pièges de migration).
TL;DR
@Observableest une macro Swift qui étend une classe en un type conforme au protocole marqueurObservable, avec une instance_$observationRegistrar: ObservationRegistrarsynthétisée comme propriété stockée3.- Le getter de chaque propriété encapsule
_$observationRegistrar.access(self, keyPath:). Le setter de chaque propriété encapsule_$observationRegistrar.withMutation(of:keyPath:_:). Le registrar suit quelles portées ont accédé à quels key paths. - Le vocabulaire de remplacement :
class Foo: ObservableObjectdevient@Observable class Foo.@Published var namedevientvar name.@StateObject var foo = Foo()devient@State var foo = Foo().@EnvironmentObjectdevient@Environment(Foo.self).@ObservedObject var foodevient simplement l’utilisation de la propriété. @Bindableest le nouveau property wrapper pour créer des bindings vers les propriétés d’une instance observable (il remplace certains usages de@ObservedObjectpour le binding).- Le piège de la migration :
@Stateavec un type référence se comporte différemment de@StateObjectde manières subtiles autour de l’identité de la vue. Les apps qui les remplacent aveuglément peuvent produire un comportement d’initialisation déroutant lors des reconstructions de vue.
L’expansion de la macro
Lorsque le compilateur voit @Observable, il étend le type en ajoutant trois choses3 :
@Observable
class UserProfile {
var name: String = ""
var email: String = ""
var preferences: [String] = []
}
L’expansion (simplifiée) génère :
class UserProfile: Observable {
@ObservationIgnored private let _$observationRegistrar = ObservationRegistrar()
private var _name: String = ""
var name: String {
get {
access(keyPath: \.name)
return _name
}
set {
withMutation(keyPath: \.name) {
_name = newValue
}
}
}
// ... same pattern for email and preferences
func access<Member>(keyPath: KeyPath<UserProfile, Member>) {
_$observationRegistrar.access(self, keyPath: keyPath)
}
func withMutation<Member, T>(
keyPath: KeyPath<UserProfile, Member>,
_ mutation: () throws -> T
) rethrows -> T {
try _$observationRegistrar.withMutation(of: self, keyPath: keyPath, mutation)
}
}
Trois changements structurels :
Le registrar. Une instance privée d’ObservationRegistrar détient l’état de suivi. Le registrar est le pont entre les mutations sur le modèle et les réévaluations des portées dépendantes. La macro le marque @ObservationIgnored afin que le registrar lui-même ne soit pas suivi.
Réécriture du stockage des propriétés. Chaque propriété stockée déclarée devient un champ de stockage privé plus une propriété calculée dont le getter et le setter appellent le registrar. Les accesseurs générés par le compilateur sont ce qui rend possible le suivi propriété par propriété.
Conformité à Observable. Le protocole marqueur que l’API du registrar attend. Le protocole n’a aucune exigence ; c’est une vérification de conformité, pas un contrat d’interface.
Le rôle du registrar
ObservationRegistrar fait deux choses3 :
Suivre les accès. Lorsque withObservationTracking { ... } onChange: { ... } (l’API de suivi sous-jacente que SwiftUI utilise pour les corps de vue) exécute la closure, le registrar enregistre chaque paire (self, keyPath) qui est lue. L’ensemble des paths consultés constitue l’« empreinte de dépendance » de la portée.
Déclencher l’invalidation. Lorsqu’une propriété est mutée, le registrar trouve chaque portée qui a accédé à ce keyPath spécifique et déclenche sa closure onChange. Les portées qui n’ont pas accédé au keyPath ne sont pas affectées.
Le contraste avec ObservableObject est le glissement structurel. Le publisher objectWillChange d’ObservableObject se déclenche à chaque mutation @Published, et tous les abonnés reçoivent la notification. La machinerie des corps de vue de SwiftUI utilise le publisher pour savoir « quelque chose a changé ; réévaluer ». La réévaluation s’exécute sur la vue entière ; SwiftUI calcule ensuite quelles vues dépendantes ont réellement changé et ne met à jour que celles-ci, mais la réévaluation du body a déjà eu lieu. Avec @Observable, la réévaluation du body elle-même est conditionnée : si le body n’a pas lu la propriété modifiée, il ne se réexécute pas.
Pour un UserProfile avec trois propriétés et une vue qui ne lit que name, la différence est tangible : un modèle @ObservableObject déclenche également la réévaluation du body sur les changements de email et preferences ; un modèle @Observable non. Dans une app complexe avec de nombreux modèles et de nombreuses vues, les économies cumulées sont significatives.
Tableau de migration
Le vocabulaire de migration, côte à côte4 :
| ObservableObject | @Observable |
|---|---|
class Foo: ObservableObject |
@Observable class Foo |
@Published var name: String |
var name: String |
@StateObject var foo = Foo() |
@State var foo = Foo() |
@ObservedObject var foo: Foo |
var foo: Foo (ou @Bindable var foo: Foo pour les bindings) |
@EnvironmentObject var foo: Foo |
@Environment(Foo.self) var foo |
.environmentObject(foo) |
.environment(foo) |
Le wrapper @Bindable mérite une note à part. C’est la nouvelle façon de créer des Bindings vers les propriétés d’une instance @Observable :
@Bindable var profile: UserProfile
TextField("Name", text: $profile.name)
TextField("Email", text: $profile.email)
Sans @Bindable, la syntaxe $profile.name ne fonctionne pas parce que les types @Observable ne fournissent pas automatiquement de valeurs projetées. Avec lui, chaque propriété obtient une forme de binding. Utilisez @Bindable lorsqu’une vue enfant a besoin d’un binding bidirectionnel vers le modèle observable d’un parent ; utilisez une référence simple (var profile: UserProfile) lorsque l’enfant ne fait que lire.
Le piège @State vs @StateObject
La ligne de migration qui cause le plus de bugs en production : @StateObject var foo = Foo() devient @State var foo = Foo(). Le changement compile. Le comportement diverge par un mécanisme subtil : la manière dont l’expression de valeur par défaut est évaluée5.
@State et @StateObject préservent tous deux l’instance à travers les reconstructions de vue de SwiftUI lorsque l’identité de la vue est stable ; les deux mémoires de stockage indexées par identité écartent les ré-initialisations pilotées par le parent. La différence se situe dans le moment où l’expression d’initialisation s’exécute.
@StateObject déclare son paramètre via @autoclosure. L’expression d’initialisation Foo() est encapsulée et n’est évaluée que lorsque SwiftUI a réellement besoin de construire l’instance. Lors des reconstructions parentes où l’identité de la vue est préservée et où l’instance existante est réutilisée, l’expression n’est jamais invoquée. L’initialiseur coûteux ne se déclenche jamais.
@State n’est pas encapsulé en autoclosure. L’expression d’initialisation Foo() est immédiatement évaluée à chaque exécution de l’init de la vue (ce qui se produit à chaque reconstruction parente, même lorsque l’identité de la vue est préservée et que l’instance existante est conservée en stockage). L’allocation de Foo() a lieu ; SwiftUI jette la nouvelle instance et continue à utiliser celle stockée. Pour les modèles avec un init() peu coûteux, l’allocation gaspillée est invisible. Pour les modèles avec un init() coûteux (requêtes réseau, chargement de données volumineuses, travail asynchrone lancé dans init), la différence est celle qui sépare une app qui fonctionne d’une app qui DDoSe son propre backend à chaque reconstruction parente.
Le pattern défensif : garder l’init() du modèle peu coûteux pour que la différence n’ait pas d’importance, ou initialiser le modèle coûteux une fois au niveau de l’app et le faire descendre via .environment(). Les modèles qui nécessitent une configuration coûteuse ne devraient pas exécuter ce travail dans init indépendamment du property wrapper qui les contient ; l’initialisation paresseuse ou les méthodes de configuration explicites sont le bon pattern aussi bien pour les cas @State que @StateObject.
withObservationTracking pour le suivi explicite
En dehors de SwiftUI, la primitive de suivi est withObservationTracking { ... } onChange: { ... }6 :
import Observation
let profile = UserProfile()
withObservationTracking {
print("Name: \(profile.name)")
} onChange: {
print("Something we read changed")
}
profile.name = "Alice" // Triggers onChange
profile.email = "..." // Does NOT trigger onChange (we didn't read it)
La closure s’exécute une fois et enregistre chaque accès observable. Lorsque l’une des propriétés source de ces accès change, onChange se déclenche exactement une fois (c’est un callback à usage unique). Pour ré-effectuer le suivi, la closure doit être réinstallée. Le pattern est celui que SwiftUI utilise en interne pour suivre les dépendances des corps de vue ; pour le code non-SwiftUI (NSWindowController, apps Cocoa, outils en ligne de commande), withObservationTracking est la bonne primitive.
Quand ObservableObject reste le bon choix
Trois cas où ObservableObject garde sa place :
Apps ciblant iOS 16 et antérieurs. Le framework Observation est iOS 17+. Les apps avec des cibles de déploiement plus anciennes ont besoin d’ObservableObject. Une fois la cible de déploiement passée à 17+, la migration est sûre.
Modèles qui doivent publier des notifications en dehors du graphe de valeurs. Le objectWillChange d’ObservableObject est un publisher Combine ; le code qui veut s’abonner à « tout changement » via des pipelines Combine (debouncing, throttling, transformation du flux d’événements) l’obtient gratuitement avec ObservableObject et devrait reconstruire l’équivalent avec @Observable. Le framework Observation privilégie l’efficacité de la réévaluation des vues plutôt que les abonnements de publisher arbitraires.
Bases de code existantes où le coût de la migration l’emporte sur le bénéfice. Une base de code ObservableObject fonctionnelle qui n’a pas mesuré de problème de performance ne gagne pas suffisamment d’une migration pour justifier l’audit. Migrez quand vous touchez déjà au fichier ou quand le profilage identifie un point chaud.
Pour le nouveau code, sur des cibles iOS 17+, @Observable est la valeur par défaut moderne et le chemin de migration est clair.
Ce que ce pattern signifie pour les apps iOS 26+
Trois enseignements.
-
Par défaut,
@Observablepour le nouveau code. La macro est concise, le suivi propriété par propriété améliore les performances pour les cas courants et le vocabulaire de migration est clair. Les nouveaux modèles dans les bases de code iOS 17+ devraient être@Observable. -
Auditez les migrations
@StateObject→@Statepour l’identité de vue. Le remplacement compile proprement mais peut produire une ré-initialisation surprenante dans les vues à structure conditionnelle. Les modèles qui font un travailinit()coûteux nécessitent une migration soigneuse ; ceux qui n’en font pas sont sûrs. -
Utilisez
@Bindabledélibérément. C’est le nouveau pattern pour les bindings bidirectionnels vers les modèles observables. Tournez-vous vers lui dans les vues enfants qui ont besoin de muter le modèle du parent ; gardez la référence simple (var foo: Foo) pour les vues en lecture seule.
Le cluster Apple Ecosystem complet : les App Intents typés ; les serveurs MCP ; la question du routage ; les Foundation Models ; la distinction LLM runtime vs outillage ; les trois surfaces ; le pattern source unique de vérité ; Two MCP Servers ; les hooks pour le développement Apple ; les Live Activities ; le runtime watchOS ; les internes de SwiftUI ; le modèle mental spatial de RealityKit ; la discipline de schéma SwiftData ; les patterns Liquid Glass ; la livraison multi-plateforme ; la matrice des plateformes ; le framework Vision ; les Symbol Effects ; l’inférence Core ML ; l’API Writing Tools ; Swift Testing ; le Privacy Manifest ; l’accessibilité comme plateforme ; la typographie SF Pro ; les patterns spatiaux visionOS ; le framework Speech ; les migrations SwiftData ; le moteur de focus tvOS ; ce sur quoi je refuse d’écrire. Le hub se trouve dans la série Apple Ecosystem. Pour un contexte plus large iOS-avec-agents-IA, voir le guide iOS Agent Development.
FAQ
Pourquoi Apple a-t-il remplacé ObservableObject ?
Deux raisons. Premièrement, les performances : le publisher objectWillChange d’ObservableObject se déclenche à chaque mutation @Published, déclenchant la réévaluation du body sur chaque vue dépendante, que la vue lise réellement la propriété modifiée ou non. Le suivi propriété par propriété d’@Observable conditionne la réévaluation du body sur la propriété à laquelle la vue accède réellement. Deuxièmement, la syntaxe : l’annotation @Published par propriété et l’échelle @StateObject/@ObservedObject/@EnvironmentObject étaient verbeuses pour ce qui est conceptuellement une seule idée (« ceci est un état partagé mutable »). @Observable plus @State plus @Environment est plus court.
Est-ce que @Observable fonctionne avec les structs ?
Non. @Observable requiert une sémantique de référence ; les structs ne se qualifient pas. La macro est destinée aux classes qui détiennent un état mutable à travers les vues. Pour un état de type valeur dans une seule vue, utilisez @State directement avec le type valeur.
Puis-je utiliser @Observable et ObservableObject dans la même app ?
Oui. Ils coexistent sans conflit. Une migration peut se dérouler fichier par fichier. La frontière est par type : une classe est soit ObservableObject, soit @Observable, jamais les deux, mais différentes classes dans la même app peuvent utiliser des approches différentes.
Et les propriétés @Published qui alimentent des pipelines Combine ?
@Observable ne fournit pas d’équivalent publisher Combine pour les propriétés individuelles. Le code qui utilise les patterns $foo.publisher à partir de propriétés @Published doit reconstruire cet abonnement différemment avec @Observable (par exemple, encapsuler la propriété dans un modèle de type valeur et l’observer via le cycle de mise à jour de SwiftUI, ou utiliser withObservationTracking à plusieurs reprises). Pour les chemins de code lourds en Combine, la migration est un véritable travail d’ingénierie.
Comment @Observable interagit-il avec le @Model de SwiftData ?
Les types @Model (SwiftData) sont automatiquement @Observable. Le framework de persistance ajoute la conformité à Observable dans le cadre de sa génération de code, de sorte que les modèles SwiftData participent au même suivi propriété par propriété que les types @Observable simples. Les vues qui observent les propriétés d’un type @Model obtiennent le même comportement de réévaluation à granularité fine. Les articles du cluster sur les migrations SwiftData et la discipline de schéma SwiftData couvrent le côté persistance de la même surface d’observation.
À quoi sert @ObservationIgnored ?
Il exclut une propriété stockée du suivi d’observation. La macro réécrit normalement chaque propriété stockée pour qu’elle passe par le registrar ; les propriétés marquées @ObservationIgnored conservent un stockage direct sans suivi. Utilisez-le pour les propriétés qui ne devraient pas déclencher de réévaluation de vue : caches, handles de fichiers, compteurs de métriques, le registrar lui-même.
Références
-
Apple Developer Documentation : Observation framework. La référence du framework couvrant le protocole
Observableet la macro@Observable. Disponible iOS 17+, macOS 14+, Swift 5.9+. ↩ -
Swift Evolution : SE-0395 Observability. La proposition Swift acceptée avec la justification de conception, les exigences sémantiques et le contrat de protocole du registrar. ↩
-
Apple Developer Documentation :
ObservationRegistraretObservable. Les types runtime auxquels la macro génère la conformité et l’API du registrar que les accesseurs synthétisés appellent. ↩↩↩ -
Apple Developer Documentation : Migrating from the Observable Object protocol to the Observable macro. Le guide de migration officiel d’Apple couvrant le tableau de correspondance des property wrappers et les changements d’intégration SwiftUI. ↩
-
Apple Developer Documentation :
StateetStateObject. La sémantique d’initialisation documentée des deux property wrappers concernant l’identité de vue et le cycle de reconstruction. ↩ -
Apple Developer Documentation :
withObservationTracking(_:onChange:). La primitive de suivi explicite utilisée en dehors du suivi automatique des corps de vue de SwiftUI. ↩