Une app non triviale dépasse vite la portée d’un seul écran. Plusieurs vues partagent alors un même état : une liste de tâches, le profil utilisateur, le panier d’un magasin, l’historique d’un chat. Le framework Observation, livré par Apple depuis iOS 17, fournit la mécanique recommandée pour modéliser ces données partagées. Sa pièce centrale est la macro @Observable qui remplace l’ancien protocole ObservableObject, @Published et @ObservedObject. Ce tutoriel détaille la migration et l’usage moderne, étape par étape, avec un mini-projet de gestion de tâches.
📘 Guide principal de la série : Développer une application iOS avec Swift et SwiftUI : panorama 2026. Ce tutoriel suppose acquise la base SwiftUI couverte dans Construire sa première vue SwiftUI.
Prérequis
- Xcode 26 sur macOS Tahoe.
- Cible de déploiement iOS 17 minimum —
@Observableexige le framework Observation introduit avec iOS 17. - Bases SwiftUI :
@State,@Binding, modifiers. - Temps estimé : 60 à 90 minutes.
Étape 1 — Comprendre la différence avec l’ancien modèle
Avant iOS 17, on déclarait un modèle observable avec le protocole ObservableObject, on marquait chaque propriété observée avec @Published, et les vues consommaient via @ObservedObject ou @StateObject. SwiftUI re-rendait alors la vue à chaque modification d’une seule propriété @Published, même si la vue n’utilisait pas cette propriété.
La macro @Observable change cette mécanique. À la compilation, elle réécrit la classe pour suivre individuellement chaque accès à chaque propriété depuis le body de la vue. Résultat : seule la vue qui lit effectivement une propriété est redessinée quand cette propriété change. Sur une app moyenne, le gain de performance est sensible — moins de re-rendus inutiles, moins de calculs de diff, moins de rebonds dans la hiérarchie.
Au passage, la syntaxe se simplifie : plus de @Published sur chaque propriété, plus de distinction @ObservedObject / @StateObject, on revient à @State dans la vue qui possède le modèle.
Étape 2 — Créer un modèle @Observable
Le modèle est une classe annotée avec la macro @Observable. Toutes ses propriétés deviennent automatiquement observables, sauf celles marquées @ObservationIgnored. C’est un changement de défaut : on choisit ce qu’on n’observe pas, plutôt que ce qu’on observe.
import SwiftUI
import Observation
struct Tache: Identifiable {
let id = UUID()
var titre: String
var faite: Bool = false
}
@Observable
final class ListeTaches {
var taches: [Tache] = []
var filtre: Filtre = .toutes
enum Filtre {
case toutes, enCours, terminees
}
var tachesAffichees: [Tache] {
switch filtre {
case .toutes: return taches
case .enCours: return taches.filter { !$0.faite }
case .terminees: return taches.filter { $0.faite }
}
}
func ajouter(_ titre: String) {
guard !titre.isEmpty else { return }
taches.append(Tache(titre: titre))
}
func basculer(_ id: UUID) {
if let idx = taches.firstIndex(where: { $0.id == id }) {
taches[idx].faite.toggle()
}
}
func supprimer(at offsets: IndexSet) {
taches.remove(atOffsets: offsets)
}
}
Le mot-clé final n’est pas obligatoire mais conseillé : il indique au compilateur que cette classe ne sera pas sous-classée, ce qui ouvre des optimisations. La propriété calculée tachesAffichees est, elle aussi, observée — SwiftUI saura recalculer la vue quand taches ou filtre changent, parce que tachesAffichees les lit dans son corps.
Étape 3 — Consommer le modèle dans une vue
Pour qu’une vue possède une instance de modèle observable et la garde en vie, on utilise @State — pas @StateObject comme dans l’ancien monde. Ce changement déroute au premier contact mais simplifie le mental model : @State gère tout ce qui appartient à la vue.
struct ListeView: View {
@State private var modele = ListeTaches()
@State private var nouveauTitre: String = ""
var body: some View {
VStack {
HStack {
TextField("Nouvelle tâche", text: $nouveauTitre)
.textFieldStyle(.roundedBorder)
Button("Ajouter") {
modele.ajouter(nouveauTitre)
nouveauTitre = ""
}
.disabled(nouveauTitre.isEmpty)
}
.padding()
Picker("Filtre", selection: $modele.filtre) {
Text("Toutes").tag(ListeTaches.Filtre.toutes)
Text("En cours").tag(ListeTaches.Filtre.enCours)
Text("Terminées").tag(ListeTaches.Filtre.terminees)
}
.pickerStyle(.segmented)
.padding(.horizontal)
List {
ForEach(modele.tachesAffichees) { t in
HStack {
Image(systemName: t.faite ? "checkmark.circle.fill" : "circle")
.foregroundStyle(t.faite ? .green : .gray)
.onTapGesture { modele.basculer(t.id) }
Text(t.titre)
.strikethrough(t.faite)
}
}
.onDelete(perform: modele.supprimer)
}
}
}
}
Le binding $modele.filtre fonctionne directement — Swift dérive automatiquement un binding sur n’importe quelle propriété d’un type @Observable détenu par @State. Plus besoin de wrapper intermédiaire. Cette simplification est l’une des raisons pour lesquelles passer à @Observable rend le code sensiblement plus lisible.
Étape 4 — Partager le modèle entre vues avec @Bindable
Une sous-vue qui doit modifier le modèle peut le recevoir comme paramètre normal et l’utiliser via @Bindable. Ce wrapper crée des bindings vers les propriétés du modèle, équivalent fonctionnel des $model.property du parent.
struct EditeurTache: View {
@Bindable var tache: Tache // ⚠ ne marche que si Tache est une class @Observable
var body: some View {
Form {
TextField("Titre", text: $tache.titre)
Toggle("Terminée", isOn: $tache.faite)
}
}
}
Attention : dans notre projet, Tache est une struct. Pour utiliser @Bindable, il faudrait soit la convertir en classe @Observable, soit passer un binding @Binding var tache: Tache depuis le parent. Le choix dépend du besoin — pour une donnée éphémère copiée à l’édition, une struct + binding suffit ; pour une donnée partagée entre plusieurs écrans, une classe @Observable est plus pratique.
Étape 5 — Injecter le modèle dans toute une hiérarchie via @Environment
Quand un même modèle doit être accessible depuis plusieurs écrans (par exemple un panier visible depuis plusieurs pages d’un magasin), on l’injecte dans l’environnement plutôt que de le passer manuellement à chaque sous-vue. Cette mécanique remplace l’ancien @EnvironmentObject.
@main
struct MonAppApp: App {
@State private var modele = ListeTaches()
var body: some Scene {
WindowGroup {
ListeView()
.environment(modele)
}
}
}
struct AutreEcran: View {
@Environment(ListeTaches.self) private var modele
var body: some View {
Text("\(modele.taches.count) tâches au total")
}
}
La méthode .environment(modele) rend l’instance disponible à toutes les sous-vues. @Environment(ListeTaches.self) récupère l’instance ; si elle n’a pas été fournie, l’app crash au runtime — c’est un trade-off accepté pour la concision. Pour des modèles optionnels, déclarer la propriété ListeTaches?.
Étape 6 — Migrer un projet existant depuis ObservableObject
Pour un projet legacy qui utilise ObservableObject et @Published, la migration suit un schéma mécanique. Apple a publié un guide officiel détaillé, mais les grandes lignes tiennent en quelques règles.
- Remplacer
class X: ObservableObject { … }par@Observable final class X { … }— ajouterimport Observation. - Supprimer toutes les annotations
@Published— elles deviennent inutiles. Le compilateur ne signale pas immédiatement leur présence ; les laisser ne casse pas la compilation, mais elles ne servent plus. - Dans les vues qui détenaient le modèle :
@StateObject private var modele = X()devient@State private var modele = X(). - Dans les vues qui consommaient sans posséder :
@ObservedObject var modele: Xdevient simplementvar modele: X(ou@Bindablesi on veut des bindings vers ses propriétés). @EnvironmentObject var modele: Xdevient@Environment(X.self) private var modele.- L’injection dans la hiérarchie passe de
.environmentObject(modele)à.environment(modele).
Cette migration peut se faire écran par écran ; les deux mécaniques cohabitent sans interférer dans le même projet.
Étape 7 — Tester le re-rendu sélectif
Pour vérifier le gain de performance de @Observable, on peut comparer le nombre de re-rendus avant et après. Une astuce pratique consiste à logger chaque appel à body.
struct CompteurView: View {
var modele: ListeTaches
var body: some View {
let _ = print("CompteurView re-rendue à \(Date())")
return Text("\(modele.taches.count) tâches")
}
}
La ligne let _ = print(…) exploite le fait que body retourne une View ; on peut insérer des effets de bord juste avant le return. À l’exécution, on observe que CompteurView n’est re-rendue que quand taches.count change — pas quand on bascule le filtre, par exemple. Avec l’ancien ObservableObject, chaque modification d’une propriété @Published aurait re-rendu la vue.
Étape 8 — Utiliser Observations pour réagir à long terme
iOS 26 introduit une struct Observations qui permet à un objet non-View de souscrire aux changements d’un modèle @Observable. Ce mécanisme remplace les anciens Combine subscriptions dans plusieurs cas et reste pur Swift, sans dépendance à Combine.
import Observation
final class StatistiquesService {
private let modele: ListeTaches
init(modele: ListeTaches) {
self.modele = modele
Task { await ecouter() }
}
private func ecouter() async {
let stream = Observations { [modele] in
modele.taches.filter { $0.faite }.count
}
for await termineeCount in stream {
print("Tâches terminées : \(termineeCount)")
}
}
}
Observations prend une closure qui calcule une valeur dépendant de propriétés observables ; le stream émet la valeur courante puis chaque nouvelle valeur quand une dépendance change. C’est très utile pour des effets de bord persistants (logging, synchronisation, notifications) qui ne sont pas liés à une vue particulière. Disponible sur iOS 26 et ultérieur uniquement.
Quand choisir @Observable plutôt qu’un simple @State
La règle de pouce en SwiftUI est de commencer simple. Une donnée locale à un écran (le texte saisi dans un formulaire, l’index de l’onglet courant, un drapeau de confirmation) appartient à @State. Dès qu’une donnée doit survivre au-delà de cet écran, ou être lue depuis une autre vue qui ne descend pas linéairement de l’écran d’origine, le terrain de @Observable commence. Un panier qu’on consulte depuis la liste des produits et depuis l’écran de paiement est l’archétype : la donnée n’appartient à aucune des deux vues, elle vit dans un modèle partagé. La macro @Observable sur une classe transforme cette classe en source de vérité passée par référence — toutes les vues qui la lisent restent synchronisées sans aller-retour explicite.
Le piège classique consiste à anticiper trop tôt et à transformer un état strictement local en modèle observable « au cas où ». Cette inflation est coûteuse : elle introduit une classe, une initialisation, parfois une injection via @Environment, là où trois propriétés @State auraient suffi. La discipline saine consiste à promouvoir un état local vers un modèle observable seulement quand une seconde vue commence à le lire ou à le modifier. La refactorisation est mécanique et le coût de la migration tardive reste très inférieur au coût cumulé d’une architecture sur-dimensionnée dès le départ.
Erreurs fréquentes avec @Observable
| Erreur | Cause | Solution |
|---|---|---|
| « Cannot find type ‘@Observable’ in scope » | Pas d’import du framework | Ajouter import Observation en haut du fichier (souvent inclus via import SwiftUI, mais à vérifier) |
| L’app crash au lancement avec « No observable object of type X » | @Environment(X.self) sans injection dans la hiérarchie |
Ajouter .environment(instance) dans le parent |
| La vue ne se met pas à jour | Le modèle est une struct mutée localement |
@Observable ne fonctionne que sur des class ; convertir si besoin |
| « Cannot use mutating member on immutable value: ‘modele’ » | Tentative de muter une propriété d’une let classe |
Si modele est une classe, let suffit pour la muter (la référence ne change pas) |
| « Argument passed to call that takes no arguments » | Confusion entre @Bindable et @Binding |
@Bindable pour une classe @Observable, @Binding pour une valeur |
| Cible iOS 16 ou antérieure | @Observable nécessite iOS 17+ |
Augmenter le Deployment Target ou rester sur ObservableObject pour cette app |
Foire aux questions
Faut-il migrer immédiatement tout un projet vers @Observable ?
Non. La migration peut être progressive — modèle par modèle. Les deux mécaniques cohabitent. Migrer en priorité les modèles les plus partagés et ceux où les re-rendus excessifs causent des ralentissements perceptibles.
@Observable fonctionne-t-il sur iOS 16 ?
Non. La macro et le framework Observation exigent iOS 17 minimum. Pour cibler iOS 16 ou antérieur, rester sur ObservableObject. À partir d’iOS 26 le framework gagne encore en puissance avec la struct Observations.
Quelle est la différence entre @State, @Bindable et @Environment ?
@State possède l’instance dans la vue. @Bindable reçoit une instance déjà créée ailleurs et expose des bindings vers ses propriétés. @Environment(X.self) récupère une instance injectée plus haut dans la hiérarchie. Trois rôles, trois usages, complémentaires.
Peut-on observer une propriété d’une struct ?
Non, @Observable ne s’applique qu’aux classes. C’est cohérent avec la sémantique : une struct est copiée à chaque affectation, il n’y a pas d’identité partagée à observer. Pour observer une valeur, on utilise @State dans la vue qui la détient.
Comment tester un modèle @Observable ?
Un modèle @Observable est une classe ordinaire ; on l’instancie dans un test, on appelle ses méthodes, on vérifie l’état résultant. Pas de mock observable spécifique à monter. Le tutoriel Tester une app SwiftUI avec Swift Testing en montre un exemple complet.
Quelle différence avec Combine ?
Combine reste utile pour les pipelines de transformation de données (debounce, throttle, merge). Pour l’observation d’état partagé entre vues, @Observable est plus simple et plus performant. Les deux frameworks peuvent cohabiter dans le même projet.
Tutoriels suivants conseillés
- Naviguer entre écrans avec NavigationStack et NavigationSplitView — propager le modèle observable entre écrans.
- Consommer une API REST en Swift avec async/await et URLSession — connecter le modèle à un backend.
Ressources officielles
- Documentation Observation — référence officielle.
- Migrating from ObservableObject to the Observable macro — guide officiel pas-à-pas.
- WWDC23 — Discover Observation in SwiftUI — session de présentation initiale.
- 🔝 Retour au guide principal : Développer une application iOS avec Swift et SwiftUI : panorama 2026