SCours SwiftUI
Fiche 02.11SwiftUI uniquement

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) :

Swift
@MainActor final class CounterViewModel: ObservableObject { @Published var count = 0 // chaque propriété observée = @Published func increment() { count += 1 } }

Moderne (le défaut) :

Swift
import Observation @MainActor @Observable final class CounterViewModel { var count = 0 // observé automatiquement, pas de @Published func increment() { count += 1 } }

2. Possession : @State remplace @StateObject

La vue qui crée et possède le ViewModel utilise @State. Plus de @StateObject.

Swift
struct CounterView: View { @State private var vm = CounterViewModel() // possède le VM (1 seule fois) var body: some View { Button("Count: \(vm.count)") { vm.increment() } } }

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.

Swift
struct CounterBadge: View { let vm: CounterViewModel // reçu du parent, pas @ObservedObject var body: some View { Text("\(vm.count)") // l'observation marche quand même } }

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) :

Swift
@ObservedObject var vm: CounterViewModel // ❌ ne compile pas : @Observable n'est pas ObservableObject

Préférable :

Swift
let vm: CounterViewModel // ✅

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.

Swift
@MainActor @Observable final class FormViewModel { var text = "" } struct FormView: View { @Bindable var vm: FormViewModel // débloque $vm.text var body: some View { TextField("Nom", text: $vm.text) // binding vers une propriété du VM } }

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 :

Swift
struct ProfileView: View { @State private var vm = FormViewModel() var body: some View { @Bindable var vm = vm // ré-emballe localement pour le $ TextField("Nom", text: $vm.text) } }

Règle simple : pas de binding → let suffit. Besoin de $@Bindable.


5. Environnement : @Environment(MyType.self)

@EnvironmentObject devient @Environment(_:) typé par le type.

Swift
@MainActor @Observable final class Session { var user: String? } // Injection ContentView() .environment(Session()) // pas .environmentObject(...) // Lecture struct ProfileView: View { @Environment(Session.self) private var session var body: some View { Text(session.user ?? "Invité") } }

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 = session dans 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 objectWillChangetoutes 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.

Swift
@MainActor @Observable final class DashboardViewModel { var title = "Accueil" var unreadCount = 0 } struct TitleView: View { let vm: DashboardViewModel var body: some View { let _ = Self._printChanges() // log des causes de re-render Text(vm.title) // ne lit QUE title } }

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 :

Swift
@MainActor @Observable final class UsersViewModel { var users: [String] = [] var isLoading = false func load() async { isLoading = true defer { isLoading = false } try? await Task.sleep(for: .seconds(1)) // API moderne, préférée à sleep(nanoseconds:) users = ["Guillaume", "Sarah"] } }

@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 xvar x (rien à annoter)
@StateObject private var vm@State private var vm
@ObservedObject var vmlet 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 @ObservedObject en let, et soudain $vm.text ne compile plus. Passe en @Bindable.
  • Propriété d'une classe imbriquée non observée : @Observable ne track que les propriétés stockées directes de la classe annotée. Si var address: Address pointe vers une classe non @Observable, muter address.city ne déclenche rien. Annote aussi Address avec @Observable.
  • Garder un @State sur un type qui n'est pas @Observable : @State sur une classe nue ne réagit pas. La classe doit porter la macro.
  • Mélange : ne mets pas @Published dans 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 Observation exige iOS/iPadOS 17, macOS 14, etc. Si ton deployment target est plus bas, tu restes sur ObservableObject + @Published.
  • Pipeline Combine : si ton VM s'appuie sur des opérateurs Combine et publie via @Published (qui est un Publisher), ObservableObject s'intègre directement. @Observable n'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

  • @Observable track 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) vs let (passage) vs @Bindable (binding) vs @Environment(VM.self) (injection) : quatre rôles, ne les confonds pas. @StateObject/@ObservedObject/@EnvironmentObject n'existent plus côté @Observable.
  • Pas de @Bindable = pas de $. C'est l'erreur n°1 en migration.
  • Mets @MainActor sur 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) :

Swift
@MainActor final class SearchViewModel: ObservableObject { @Published var query = "" @Published var results: [String] = [] func search() { results = ["a", "b"].filter { _ in !query.isEmpty } } } struct SearchView: View { @StateObject private var vm = SearchViewModel() var body: some View { TextField("Rechercher", text: $vm.query) // ⚠️ pense au binding // ... } }

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 (framework Observation, iOS 17+) est le défaut 2026 ; ObservableObject est du legacy à savoir lire.
  • Plus de @Published : toute propriété stockée d'une classe @Observable est observée.
  • Possession @State (ex-@StateObject), passage let (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 ObservableObject seulement pour iOS < 17 ou un pipeline Combine @Published.