İçeriğe geç / Skip to content / Zum Inhalt
Ahmet Balaman LogoAhmet Balaman

SwiftUI @State, @Binding und @Observable: Datenfluss erklärt

Ahmet Balaman

7 Min. Lesezeit

SwiftSwiftUIStateBindingObservableiOS
SwiftUI @State, @Binding und @Observable: Datenfluss erklärt

In SwiftUI ist der Bildschirm eine Funktion Ihrer Daten: Die Daten ändern sich, body wird neu ausgewertet, die Anzeige aktualisiert sich. Die eigentliche Frage lautet deshalb nie „Wie aktualisiere ich die View?“, sondern „Wem gehören diese Daten?“. @State, @Binding, @Observable, @Bindable und @Environment sind fünf verschiedene Antworten darauf. Wählen Sie die falsche, schweigt der Compiler meistens, und der Bildschirm antwortet mit Feldern, die sich selbst zurücksetzen, oder mit Texten, die sich nie ändern.

Dieser Beitrag behandelt zuerst den aktuellen Ansatz (das Observation-Framework), danach die ObservableObject-Familie, die Ihnen in vielen Tutorials und Codebasen weiterhin begegnet, die direkte Zuordnung zwischen beiden und die Fehler, die ich am häufigsten sehe. Wenn Views für Sie noch neu sind, beginnen Sie mit der ersten App mit SwiftUI.

@State: Daten, die der View gehören

@State ist für kleine Werte gedacht, die nur eine einzige View betreffen: ein Zähler, der Zustand eines Schalters, der Entwurfstext eines Feldes. Eine View ist ein struct, das bei jeder Aktualisierung neu erzeugt wird, und kann den Wert daher nicht in sich selbst aufbewahren. @State verlagert den Wert in einen von SwiftUI verwalteten Speicher und hält ihn am Leben, während das View-Struct kommt und geht.

import SwiftUI

struct CounterView: View {
    @State private var count = 0

    var body: some View {
        VStack(spacing: 12) {
            Text("Zähler: \(count)")
            Button("Erhöhen") { count += 1 }
        }
    }
}

Machen Sie private zur Gewohnheit. @State bedeutet, dass Sie Eigentümer der Daten sind; eine @State-Eigenschaft, die von außen gesetzt werden kann, ist fast immer ein Zeichen für das falsche Werkzeug.

@Binding: Daten ändern, die jemand anderem gehören

Soll eine Kind-View einen Wert lesen und ändern, der aber der Eltern-View gehört, verwenden Sie @Binding. Ein Binding ist keine Kopie, sondern eine Verbindung in beide Richtungen zum Wert beim Eigentümer. Die Eltern-View erzeugt es mit dem Präfix $.

struct NotificationToggle: View {
    @Binding var isOn: Bool

    var body: some View {
        Toggle("Mitteilungen", isOn: $isOn)
    }
}

struct SettingsView: View {
    @State private var notificationsEnabled = true

    var body: some View {
        Form {
            NotificationToggle(isOn: $notificationsEnabled)
            Text(notificationsEnabled ? "An" : "Aus")
        }
    }
}

Zeigt die Kind-View den Wert nur an, verzichten Sie auf das Binding und übergeben einen einfachen let-Parameter. Betrachten Sie ein Binding als Schreibrecht und vergeben Sie es nur dort, wo es gebraucht wird.

@Observable: ein Modell, das mehrere Bildschirme teilen

Sobald die Daten über einen einzelnen Wert hinauswachsen, etwa ein Warenkorb, eine Sitzung oder eine Liste, wandern sie in eine Klasse. Mit dem Observation-Framework genügt dafür @Observable vor der Klasse. Verfügbar ist es ab iOS 17.

import Observation

struct CartItem: Identifiable {
    let id = UUID()
    var name: String
    var price: Double
    var quantity = 1
}

@Observable
final class CartModel {
    var items: [CartItem] = []
    var note = ""

    var total: Double {
        items.reduce(0) { $0 + $1.price * Double($1.quantity) }
    }

    func add(_ item: CartItem) {
        items.append(item)
    }
}

Ein @Published gibt es hier nicht. Das Makro macht jede gespeicherte Eigenschaft beobachtbar, und SwiftUI merkt sich, welche Eigenschaften eine View in body tatsächlich liest. Ändert sich note, werden nur die Views neu gezeichnet, die note lesen. Warum das Modell eine class und kein struct ist, erklärt der Beitrag zu struct und class ausführlich: Mehrere Bildschirme müssen hier dasselbe Objekt teilen, wir wollen also Referenzsemantik.

Die View, die das Modell erzeugt, hält es mit @State. Eine View, die das Modell nur entgegennimmt, braucht gar keinen Wrapper:

struct CartScreen: View {
    @State private var cart = CartModel()

    var body: some View {
        List {
            ForEach(cart.items) { item in
                Text(item.name)
            }
            CartNoteField(cart: cart)
            CartTotalRow(cart: cart)
        }
    }
}

struct CartTotalRow: View {
    let cart: CartModel

    var body: some View {
        Text("Summe: \(cart.total, format: .number.precision(.fractionLength(2)))")
    }
}

@Bindable: Bindings auf Eigenschaften des Modells

TextField und Toggle erwarten ein Binding. Um es mit $ aus einer Eigenschaft eines @Observable-Modells zu erzeugen, kennzeichnen Sie das Modell mit @Bindable:

struct CartNoteField: View {
    @Bindable var cart: CartModel

    var body: some View {
        TextField("Bestellnotiz", text: $cart.note)
    }
}

@Environment: das Modell durch den Baum reichen

Statt das Modell durch fünf Ebenen von Initialisierern zu schleusen, legen Sie es in die Umgebung. An der Wurzel wird es mit .environment(_:) eingesetzt und dort, wo es gebraucht wird, über den Typ gelesen:

@main
struct ShopApp: App {
    @State private var cart = CartModel()

    var body: some Scene {
        WindowGroup {
            CartBadge()
                .environment(cart)
        }
    }
}

struct CartBadge: View {
    @Environment(CartModel.self) private var cart

    var body: some View {
        Text("\(cart.items.count) Artikel im Warenkorb")
    }
}

Brauchen Sie ein Binding auf ein Modell aus der Umgebung, deklarieren Sie in body ein lokales @Bindable:

struct CheckoutNote: View {
    @Environment(CartModel.self) private var cart

    var body: some View {
        @Bindable var cart = cart
        TextField("Bestellnotiz", text: $cart.note)
    }
}

Der ältere Ansatz: die ObservableObject-Familie

Projekte, die Versionen vor iOS 17 unterstützen, und die meisten älteren Tutorials arbeiten mit dem Combine-basierten Aufbau. Die Idee ist dieselbe, die Namen unterscheiden sich:

final class LegacyCartModel: ObservableObject {
    @Published var items: [CartItem] = []
    @Published var note = ""
}

struct LegacyCartScreen: View {
    @StateObject private var cart = LegacyCartModel()   // Eigentümer

    var body: some View {
        LegacyCartSummary(cart: cart)
            .environmentObject(cart)
    }
}

struct LegacyCartSummary: View {
    @ObservedObject var cart: LegacyCartModel            // kein Eigentümer

    var body: some View {
        Text("\(cart.items.count) Artikel im Warenkorb")
    }
}

struct LegacyCartBadge: View {
    @EnvironmentObject private var cart: LegacyCartModel

    var body: some View {
        Text("\(cart.items.count)")
    }
}

Zwei Unterschiede sind wichtig. Erstens macht bei ObservableObject die Änderung irgendeiner @Published-Eigenschaft jede View ungültig, die das Objekt beobachtet, während Observation pro Eigenschaft verfolgt. Zweitens sind Eigentum und Beobachtung auf der alten Seite getrennte Wrapper (@StateObject und @ObservedObject), und wer sie verwechselt, erzeugt den klassischen Fehler, der weiter unten beschrieben ist.

Situation Observation (ab iOS 17) Älterer Ansatz
Einfacher Wert, nur für eine View @State @State
Kind ändert den Wert der Eltern-View @Binding @Binding
View erzeugt das Modell und besitzt es @State + @Observable-Klasse @StateObject
View erhält das Modell und liest es einfaches let / var @ObservedObject
$-Binding auf ein erhaltenes Modell @Bindable @ObservedObject
App-weit geteiltes Modell .environment(_:) + @Environment(Typ.self) .environmentObject(_:) + @EnvironmentObject
Beobachtete Eigenschaft im Modell keine Kennzeichnung nötig @Published

Wann verwenden – und wann nicht?

Bei einem neuen Projekt mit iOS 17 oder neuer als Mindestziel beginnen Sie mit Observation: weniger Code und weniger unnötiges Neuzeichnen. Müssen Sie eine ältere Version unterstützen, funktioniert ObservableObject weiterhin einwandfrei, und beide können im selben Projekt nebeneinander bestehen, sodass Sie Bildschirm für Bildschirm umstellen können.

Alles in ein Modell zu verschieben ist ebenfalls ein Fehler. Ob ein Sheet angezeigt wird, der Fokus eines Feldes oder ein Animations-Flag gehören zur View und bleiben @State. Umgekehrt gilt: Zeigen zwei Bildschirme dieselben Daten, halten Sie nicht in jedem ein eigenes @State, sondern verlagern Sie die Daten zu einem einzigen Eigentümer. Reservieren Sie @Environment für Modelle, die wirklich breit genutzt werden. Wer alles dort ablegt, versteckt Abhängigkeiten, und eine View, die ein nie eingesetztes Modell liest, stürzt zur Laufzeit ab.

Häufige Fehler

1. Das Objekt mit @ObservedObject erzeugen

struct BadProfileView: View {
    @ObservedObject var model = LegacyCartModel()   // falsch
    var body: some View { Text(model.note) }
}

Symptom: Bei jedem Neuzeichnen der Eltern-View entsteht das Modell von Grund auf neu; eingegebene Daten verschwinden, die Netzwerkanfrage startet erneut. @ObservedObject speichert das Objekt nicht, es beobachtet es nur. Lösung: @StateObject in der View, die das Objekt erzeugt, oder @State, wenn Sie Observation verwenden.

2. Einen Wert der Eltern-View in @State kopieren

struct NameEditorBad: View {
    @State private var name: String

    init(name: String) {
        _name = State(initialValue: name)
    }

    var body: some View {
        TextField("Name", text: $name)
    }
}

Symptom: Der Name ändert sich in der Eltern-View, das Feld zeigt aber weiter den alten Wert, und Eingaben im Feld erreichen die Eltern-View nie. @State verwendet seinen Anfangswert nur beim ersten Erscheinen der View. Lösung: Gehören die Daten der Eltern-View, deklarieren Sie @Binding var name: String und übergeben $name.

3. State in einer kurzlebigen View halten

Eine View in einem if-Zweig verlässt den Baum, sobald die Bedingung falsch wird, und ihr @State wird mit ihr verworfen. Symptom: Sie klappen einen Detailbereich zu und wieder auf, und die eingetippte Notiz ist weg. Dasselbe passiert bei Views, deren .id(...)-Wert sich ändert. Lösung: Verlagern Sie den Wert, der erhalten bleiben muss, in eine Eltern-View, die auf dem Bildschirm bleibt, und reichen Sie ein Binding nach unten.

4. @Bindable vergessen

struct CartNoteFieldBad: View {
    let cart: CartModel
    var body: some View {
        TextField("Notiz", text: $cart.note)   // Fehler
    }
}

Symptom: Der Compiler meldet cannot find '$cart' in scope. Lösung: @Bindable var cart statt let cart. Ein weiteres Detail: In @State private var model = Model() läuft der Ausdruck Model() bei jeder Erzeugung des View-Structs, SwiftUI behält nur die erste Instanz. Erledigen Sie deshalb keine aufwendige Arbeit im init des Modells, sondern laden Sie Daten in .task. Wie das geht, zeigt der Leitfaden zu async/await.

Mini-Szenario: eine Aufgabenliste

Sehen wir uns alle fünf Werkzeuge auf einem Bildschirm an. Die Aufgaben liegen in einem app-weit geteilten TodoStore; der Listenbildschirm liest ihn aus der Umgebung, und das Sheet zum Hinzufügen hält seinen eigenen Entwurf in @State.

struct TodoItem: Identifiable {
    let id = UUID()
    var title: String
    var isDone = false
}

@Observable
final class TodoStore {
    var items: [TodoItem] = []

    var remainingCount: Int {
        items.filter { !$0.isDone }.count
    }

    func add(title: String) {
        let trimmed = title.trimmingCharacters(in: .whitespaces)
        guard !trimmed.isEmpty else { return }
        items.append(TodoItem(title: trimmed))
    }
}

struct TodoListScreen: View {
    @Environment(TodoStore.self) private var store
    @State private var isAdding = false

    var body: some View {
        @Bindable var store = store

        NavigationStack {
            List($store.items) { $item in
                Toggle(item.title, isOn: $item.isDone)
            }
            .navigationTitle("Offen: \(store.remainingCount)")
            .toolbar {
                Button("Hinzufügen") { isAdding = true }
            }
            .sheet(isPresented: $isAdding) {
                AddTodoSheet(isPresented: $isAdding)
            }
        }
    }
}

struct AddTodoSheet: View {
    @Environment(TodoStore.self) private var store
    @Binding var isPresented: Bool
    @State private var draft = ""

    var body: some View {
        Form {
            TextField("Aufgabe", text: $draft)
            Button("Sichern") {
                store.add(title: draft)
                isPresented = false
            }
            .disabled(draft.isEmpty)
        }
    }
}

Wem gehört was? isAdding gehört dem Listenbildschirm, draft dem Sheet, die Aufgaben dem TodoStore. Dank des Bindings isPresented kann sich das Sheet selbst schließen, ohne Eigentümer dieses Werts zu sein. List($store.items) gibt jeder Zeile ein Binding auf ihr Element, und wenn ein Toggle umschaltet, aktualisiert sich der Titel, der remainingCount liest, von selbst. Falls Ihnen das guard in add fremd vorkommt, hilft der Beitrag zu Optionals und guard let. Setzen Sie an der Wurzel der App eine einzige, in @State gehaltene Instanz ein, statt .environment(TodoStore()) direkt hinzuschreiben.

Häufig gestellte Fragen

Was ist der Unterschied zwischen @State und @Binding?

@State ist Eigentümer der Daten und legt den Wert im Speicher von SwiftUI ab. @Binding speichert nichts; es gewährt Lese- und Schreibzugriff auf einen Wert, der einer anderen View gehört.

Muss ich ObservableObject noch lernen, wenn es @Observable gibt?

Ja, denn ein großer Teil der bestehenden Projekte und Tutorials nutzt es weiterhin, und für Apps, die Versionen vor iOS 17 unterstützen, ist es die einzige Möglichkeit. Bevorzugen Sie in neuem Code Observation und kennen Sie die ältere API gut genug, um sie lesen zu können.

Kann ich @State anstelle von @StateObject verwenden?

Nur wenn das Modell mit dem Makro @Observable gekennzeichnet ist. Halten Sie eine Klasse, die ObservableObject erfüllt, in @State, aktualisieren Änderungen an Eigenschaften die View nicht; solche Klassen brauchen @StateObject.

Warum aktualisiert sich meine View nicht?

Die drei üblichen Ursachen: Das Modell ist nicht mit @Observable bzw. die Eigenschaft nicht mit @Published gekennzeichnet, die View liest aus einer Kopie (ein Wert der Eltern-View wurde in @State kopiert), oder die Änderung betrifft eine Eigenschaft, die body nie liest.

Kommentare