Développement Mobile

Gérer l’état d’une app avec @Observable et le framework Observation

11 دقائق للقراءة

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 — @Observable exige 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.

  1. Remplacer class X: ObservableObject { … } par @Observable final class X { … } — ajouter import Observation.
  2. 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.
  3. Dans les vues qui détenaient le modèle : @StateObject private var modele = X() devient @State private var modele = X().
  4. Dans les vues qui consommaient sans posséder : @ObservedObject var modele: X devient simplement var modele: X (ou @Bindable si on veut des bindings vers ses propriétés).
  5. @EnvironmentObject var modele: X devient @Environment(X.self) private var modele.
  6. 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

Ressources officielles

Sponsoriser ce contenu

Cet emplacement est à vous

Position premium en fin d'article — c'est l'instant où les lecteurs sont le plus engagés. Réservez cet espace pour votre marque, votre formation ou votre offre.

Recevoir nos tarifs
Publicité