← Alle Beitrage

@Observable Internals: Das Makro, der Registrar und was ObservableObject falsch gemacht hat

Das Observation-Framework, eingeführt mit iOS 17 und Swift 5.9, ersetzte das Combine-basierte ObservableObject-Modell durch ein makro-getriebenes System mit Tracking pro Property-Zugriff1. Die Änderung wirkt an der Aufrufstelle gering (ein @Observable-Makro statt : ObservableObject plus überall @Published), das Laufzeitverhalten unterscheidet sich jedoch in einer Weise, die Performance, Korrektheit und den Migrationspfad betrifft. Die Verschiebung in einem Satz: Views, die eine geänderte Property nicht gelesen haben, werden nicht mehr neu ausgewertet, wenn sich diese Property ändert.

Der Beitrag durchläuft die Interna des Frameworks anhand der Apple-Dokumentation und des SE-0395-Vorschlags2. Der Rahmen lautet „was das Makro tatsächlich generiert und warum”, denn die meisten Teams führen @Observable wegen der Syntax ein und übersehen die strukturelle Verschiebung in der Update-Propagierung — und genau dort liegen der eigentliche Performance-Gewinn (und die Migrationsfallen).

TL;DR

  • @Observable ist ein Swift-Makro, das eine Klasse zu einem Typ erweitert, der dem Marker-Protokoll Observable entspricht, mit einer als gespeicherte Property synthetisierten _$observationRegistrar: ObservationRegistrar-Instanz3.
  • Der Getter jeder Property umschließt _$observationRegistrar.access(self, keyPath:). Der Setter umschließt _$observationRegistrar.withMutation(of:keyPath:_:). Der Registrar verfolgt, welche Scopes auf welche Key Paths zugegriffen haben.
  • Das Ersatzvokabular: class Foo: ObservableObject wird zu @Observable class Foo. @Published var name wird zu var name. @StateObject var foo = Foo() wird zu @State var foo = Foo(). @EnvironmentObject wird zu @Environment(Foo.self). @ObservedObject var foo wird zur reinen Verwendung der Property.
  • @Bindable ist der neue Property Wrapper, um Bindings zu den Properties einer Observable-Instanz zu erstellen (ersetzt einige @ObservedObject-Anwendungsfälle für Bindings).
  • Die Migrationsfalle: @State mit einem Referenztyp verhält sich in subtilen Aspekten rund um die View-Identität anders als @StateObject. Apps, die diese blind austauschen, können bei View-Rebuilds verwirrendes Initialisierungsverhalten erzeugen.

Die Makro-Expansion

Wenn der Compiler @Observable sieht, erweitert er den Typ um drei Dinge3:

@Observable
class UserProfile {
    var name: String = ""
    var email: String = ""
    var preferences: [String] = []
}

Die Expansion erzeugt (vereinfacht):

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
            }
        }
    }
    // ... gleiches Muster für email und 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)
    }
}

Drei strukturelle Änderungen:

Der Registrar. Eine private ObservationRegistrar-Instanz besitzt den Tracking-Zustand. Der Registrar ist die Brücke zwischen Mutationen am Modell und Neuauswertungen abhängiger Scopes. Das Makro markiert ihn als @ObservationIgnored, damit der Registrar selbst nicht getrackt wird.

Umschreiben des Property-Speichers. Jede deklarierte gespeicherte Property wird zu einem privaten Backing-Field plus einer berechneten Property, deren Getter und Setter den Registrar aufrufen. Die compilergenerierten Accessors sind das, was das Tracking pro Property überhaupt ermöglicht.

Konformität zu Observable. Das Marker-Protokoll, das die API des Registrars erwartet. Das Protokoll hat keine Anforderungen; es ist eine Konformitätsprüfung, kein Interface-Vertrag.

Die Aufgabe des Registrars

ObservationRegistrar erledigt zwei Dinge3:

Zugriffe verfolgen. Wenn withObservationTracking { ... } onChange: { ... } (die zugrunde liegende Tracking-API, die SwiftUI für View-Bodies verwendet) den Closure ausführt, zeichnet der Registrar jedes gelesene (self, keyPath)-Paar auf. Die Menge der zugegriffenen Pfade ist der „Abhängigkeits-Footprint” des Scopes.

Invalidierung auslösen. Wenn eine Property mutiert wird, findet der Registrar jeden Scope, der auf genau diesen keyPath zugegriffen hat, und löst dessen onChange-Closure aus. Scopes, die den keyPath nicht angefasst haben, bleiben unberührt.

Der Kontrast zu ObservableObject ist die strukturelle Verschiebung. Der objectWillChange-Publisher von ObservableObject feuert bei jeder @Published-Mutation, und alle Subscriber erhalten die Benachrichtigung. Die View-Body-Maschinerie von SwiftUI nutzt den Publisher, um zu wissen: „etwas hat sich geändert; neu auswerten”. Die Neuauswertung läuft gegen die gesamte View; SwiftUI berechnet anschließend, welche abhängigen Views sich tatsächlich geändert haben, und aktualisiert nur diese — aber die Body-Neuauswertung hat bereits stattgefunden. Mit @Observable ist die Body-Neuauswertung selbst gefiltert: hat der Body die geänderte Property nicht gelesen, läuft er nicht erneut.

Bei einem UserProfile mit drei Properties und einer View, die nur name liest, ist der Unterschied real: Ein @ObservableObject-Modell löst auch bei Änderungen an email und preferences eine Body-Neuauswertung aus; ein @Observable-Modell tut das nicht. In einer komplexen App mit vielen Modellen und vielen Views sind die kumulierten Einsparungen erheblich.

Migrations-Mapping

Das Migrationsvokabular im direkten Vergleich4:

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 (oder @Bindable var foo: Foo für Bindings)
@EnvironmentObject var foo: Foo @Environment(Foo.self) var foo
.environmentObject(foo) .environment(foo)

Der @Bindable-Wrapper verdient eine eigene Anmerkung. Er ist der neue Weg, Bindings zu den Properties einer @Observable-Instanz zu erstellen:

@Bindable var profile: UserProfile

TextField("Name", text: $profile.name)
TextField("Email", text: $profile.email)

Ohne @Bindable funktioniert die $profile.name-Syntax nicht, weil @Observable-Typen nicht automatisch Projected Values bereitstellen. Mit ihm bekommt jede Property eine Binding-Form. Verwenden Sie @Bindable, wenn eine Child-View ein bidirektionales Binding in das Observable-Modell eines Parents braucht; verwenden Sie eine einfache Referenz (var profile: UserProfile), wenn die Child-View nur liest.

Die Falle @State vs. @StateObject

Die Migrationszeile, die in der Produktion die meisten Bugs verursacht: Aus @StateObject var foo = Foo() wird @State var foo = Foo(). Die Änderung kompiliert. Das Verhalten weicht durch einen subtilen Mechanismus voneinander ab: wie der Default-Wert-Ausdruck ausgewertet wird5.

Sowohl @State als auch @StateObject bewahren die Instanz über die View-Rebuilds von SwiftUI hinweg, solange die Identität der View stabil bleibt; beide identitätsbasierten Backing-Stores verwerfen vom Parent angestoßene Re-Initialisierungen. Der Unterschied liegt darin, wann der Initializer-Ausdruck ausgeführt wird.

@StateObject deklariert seinen Parameter über @autoclosure. Der Foo()-Initializer-Ausdruck wird umschlossen und nur dann ausgewertet, wenn SwiftUI die Instanz tatsächlich konstruieren muss. Bei Parent-Rebuilds, bei denen die Identität der View erhalten bleibt und die existierende Instanz wiederverwendet wird, wird der Ausdruck nie aufgerufen. Der teure Initializer feuert nie.

@State ist nicht in einen Autoclosure verpackt. Der Foo()-Initializer-Ausdruck wird eifrig jedes Mal ausgewertet, wenn das init der View läuft (was bei jedem Parent-Rebuild geschieht, selbst wenn die Identität der View erhalten bleibt und die existierende Instanz im Speicher behalten wird). Die Foo()-Allokation passiert; SwiftUI verwirft die neue Instanz und arbeitet mit der gespeicherten weiter. Bei Modellen mit billigem init() ist die verschwendete Allokation unsichtbar. Bei Modellen mit teurem init() (Netzwerk-Requests, großer Daten-Load, im init angestoßene asynchrone Arbeit) ist der Unterschied der zwischen einer App, die funktioniert, und einer App, die ihr eigenes Backend bei jedem Parent-Rebuild DDoSt.

Das defensive Muster: das init() des Modells billig halten, damit der Unterschied keine Rolle spielt, oder das teure Modell einmal auf App-Ebene initialisieren und es per .environment() weiterreichen. Modelle, die teure Setup-Arbeit benötigen, sollten diese Arbeit ohnehin nicht im init ausführen, unabhängig davon, welcher Property Wrapper sie hält; Lazy-Initialisierung oder explizite Setup-Methoden sind das richtige Muster, sowohl für @State- als auch für @StateObject-Fälle.

withObservationTracking für explizites Tracking

Außerhalb von SwiftUI ist die Tracking-Primitive 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)

Der Closure läuft einmal und zeichnet jeden Observable-Zugriff auf. Wenn sich eine der Quellen-Properties dieser Zugriffe ändert, feuert onChange genau einmal (es ist ein One-Shot-Callback). Um erneut zu tracken, muss der Closure neu eingerichtet werden. Das Muster ist genau das, was SwiftUI intern verwendet, um View-Body-Abhängigkeiten zu verfolgen; für Nicht-SwiftUI-Code (NSWindowController, Cocoa-Apps, Kommandozeilen-Tools) ist withObservationTracking die richtige Primitive.

Wann ObservableObject weiterhin die richtige Wahl ist

Drei Fälle, in denen ObservableObject seinen Platz behält:

Apps mit Zielplattform iOS 16 oder älter. Das Observation-Framework ist iOS 17+. Apps mit älteren Deployment-Zielen brauchen ObservableObject. Sobald das Deployment-Ziel auf 17+ angehoben wird, ist die Migration sicher.

Modelle, die Benachrichtigungen außerhalb des Wertegraphen veröffentlichen müssen. Der objectWillChange von ObservableObject ist ein Combine-Publisher; Code, der über Combine-Pipelines „auf jede Änderung” subscriben möchte (Debouncing, Throttling, Transformation des Event-Streams), bekommt das mit ObservableObject geschenkt und müsste das Äquivalent mit @Observable neu aufbauen. Das Observation-Framework priorisiert Effizienz bei der View-Neuauswertung gegenüber beliebigen Publisher-Subscriptions.

Bestehende Codebases, in denen die Migrationskosten den Nutzen übersteigen. Eine funktionierende ObservableObject-Codebase, in der kein Performance-Problem gemessen wurde, gewinnt durch die Migration nicht genug, um den Audit zu rechtfertigen. Migrieren Sie, wenn Sie die Datei ohnehin anfassen oder wenn das Profiling einen Hot Spot identifiziert.

Für neuen Code auf iOS-17+-Zielen ist @Observable der moderne Standard, und der Migrationspfad ist klar.

Was dieses Muster für iOS-26+-Apps bedeutet

Drei Erkenntnisse.

  1. Standardmäßig @Observable für neuen Code verwenden. Das Makro ist knapp, das Tracking pro Property verbessert die Performance für gängige Fälle, und das Migrationsvokabular ist klar. Neue Modelle in iOS-17+-Codebases sollten @Observable sein.

  2. @StateObject@State-Migrationen auf View-Identität prüfen. Der Tausch kompiliert sauber, kann aber in Views mit bedingter Struktur überraschende Re-Initialisierungen erzeugen. Modelle, die teure init()-Arbeit erledigen, brauchen eine sorgfältige Migration; Modelle, die das nicht tun, sind sicher.

  3. @Bindable bewusst einsetzen. Es ist das neue Muster für bidirektionale Bindings in Observable-Modelle. Greifen Sie in Child-Views darauf zurück, die das Modell des Parents mutieren müssen; behalten Sie die einfache Referenz (var foo: Foo) für nur lesende Views.

Das vollständige Apple-Ecosystem-Cluster: typisierte App Intents; MCP-Server; die Routing-Frage; Foundation Models; die Unterscheidung Laufzeit vs. Tooling-LLM; drei Oberflächen; das Single-Source-of-Truth-Muster; Zwei MCP-Server; Hooks für Apple-Entwicklung; Live Activities; die watchOS-Laufzeit; SwiftUI-Interna; RealityKits räumliches Mental Model; SwiftData-Schema-Disziplin; Liquid-Glass-Muster; Multi-Platform-Shipping; die Plattform-Matrix; Vision-Framework; Symbol Effects; Core-ML-Inferenz; Writing-Tools-API; Swift Testing; Privacy Manifest; Accessibility als Plattform; SF-Pro-Typografie; visionOS-räumliche Muster; Speech-Framework; SwiftData-Migrationen; tvOS-Focus-Engine; worüber ich nicht zu schreiben weigere. Der Hub befindet sich unter Apple Ecosystem Series. Für den breiteren Kontext iOS mit AI-Agenten siehe den iOS Agent Development Guide.

FAQ

Warum hat Apple ObservableObject ersetzt?

Zwei Gründe. Erstens: Performance. Der objectWillChange-Publisher von ObservableObject feuert bei jeder @Published-Mutation und löst eine Body-Neuauswertung in jeder abhängigen View aus, unabhängig davon, ob die View die geänderte Property überhaupt liest. Das Tracking pro Property von @Observable filtert die Body-Neuauswertung anhand der Property, auf die die View tatsächlich zugreift. Zweitens: Syntax. Die @Published-Annotation pro Property und die Leiter aus @StateObject/@ObservedObject/@EnvironmentObject waren ausschweifend für etwas, das konzeptionell eine einzige Idee ist („das ist veränderbarer geteilter Zustand”). @Observable plus @State plus @Environment ist kürzer.

Funktioniert @Observable mit Structs?

Nein. @Observable setzt Referenzsemantik voraus; Structs erfüllen das nicht. Das Makro ist für Klassen gedacht, die veränderbaren Zustand über Views hinweg halten. Für Wertetyp-Zustand in einer einzelnen View verwenden Sie @State direkt mit dem Werttyp.

Kann ich @Observable und ObservableObject in derselben App verwenden?

Ja. Sie koexistieren ohne Konflikt. Eine Migration kann Datei für Datei voranschreiten. Die Grenze verläuft pro Typ: Eine Klasse ist entweder ObservableObject oder @Observable, nicht beides — aber unterschiedliche Klassen in derselben App können unterschiedliche Ansätze verwenden.

Was ist mit @Published-Properties, die Combine-Pipelines auslösen?

@Observable liefert kein Combine-Publisher-Äquivalent für einzelne Properties. Code, der $foo.publisher-Muster aus @Published-Properties verwendet, muss diese Subscription mit @Observable anders aufbauen (z. B. die Property in ein Werttyp-Modell verpacken und über den Update-Zyklus von SwiftUI beobachten oder withObservationTracking wiederholt einsetzen). Für Combine-lastige Codepfade ist die Migration echte Engineering-Arbeit.

Wie interagiert @Observable mit dem @Model von SwiftData?

@Model-Typen (SwiftData) sind automatisch @Observable. Das Persistenz-Framework fügt im Rahmen seiner Codegenerierung die Observable-Konformität hinzu, sodass SwiftData-Modelle am gleichen Tracking pro Property teilnehmen wie reine @Observable-Typen. Views, die Properties eines @Model-Typs beobachten, erhalten dasselbe feingranulare Neuauswertungsverhalten. Die Beiträge des Clusters SwiftData-Migrationen und SwiftData-Schema-Disziplin decken die Persistenzseite derselben Observation-Oberfläche ab.

Wofür ist @ObservationIgnored da?

Es nimmt eine gespeicherte Property aus dem Observation-Tracking heraus. Das Makro schreibt normalerweise jede gespeicherte Property so um, dass sie über den Registrar läuft; Properties, die mit @ObservationIgnored markiert sind, behalten direkten Speicher ohne Tracking. Verwenden Sie es für Properties, die keine View-Neuauswertung auslösen sollen: Caches, File Handles, Metrik-Counter, den Registrar selbst.

Referenzen


  1. Apple Developer Documentation: Observation framework. Die Framework-Referenz zum Observable-Protokoll und zum @Observable-Makro. Verfügbar ab iOS 17+, macOS 14+, Swift 5.9+. 

  2. Swift Evolution: SE-0395 Observability. Der angenommene Swift-Vorschlag mit Designbegründung, semantischen Anforderungen und dem Vertrag des Registrar-Protokolls. 

  3. Apple Developer Documentation: ObservationRegistrar und Observable. Die Laufzeittypen, zu denen das Makro Konformität generiert, und die Registrar-API, die die synthetisierten Accessors aufrufen. 

  4. Apple Developer Documentation: Migrating from the Observable Object protocol to the Observable macro. Apples offizieller Migrationsleitfaden mit der Mapping-Tabelle für Property Wrapper und den Änderungen der SwiftUI-Integration. 

  5. Apple Developer Documentation: State und StateObject. Die dokumentierte Initialisierungssemantik beider Property Wrapper rund um View-Identität und Rebuild-Lebenszyklus. 

  6. Apple Developer Documentation: withObservationTracking(_:onChange:). Die explizite Tracking-Primitive, die außerhalb des automatischen View-Body-Trackings von SwiftUI verwendet wird. 

Verwandte Beiträge

HealthKit + SwiftUI on iOS 26: Authorization, Sample Types, and Cross-Platform Patterns

Real production patterns from Water (water tracking, HKQuantitySample) and Return (mindful sessions, HKCategorySample). …

17 Min. Lesezeit

Liquid Glass in SwiftUI: Three Patterns From Shipping Return on iOS 26

Apple's Liquid Glass is a one-line SwiftUI API. Three patterns from Return go beyond .glassEffect(): glass on text via C…

19 Min. Lesezeit

The Cleanup Layer Is the Real AI Agent Market

Charlie Labs pivoted from building agents to cleaning up after them. The AI agent market is moving from generation to pr…

15 Min. Lesezeit