ObservedObject vs. StateObject

Wenn es um die Integration eigens definierter Model-Klassen in SwiftUI-Views geht, spielen die beiden Property Wrapper ObservedObject und StateObject eine maßgebliche Rolle. Augenscheinlich machen sie mehr oder weniger das selbe: Sie achten auf Änderungen der Model-Instanz und aktualisieren die zugehörige View, sobald solche Änderungen bemerkt werden. Falls man daraus den Schluss zieht, man könne die beiden Property Wrapper beliebig nach eigenem Gusto einsetzen, der irrt. Ob man ObservedObject oder StateObject einsetzt, hängt maßgeblich davon ab, wie eine Model-Instanz in eine View integriert ist.

Zur Erläuterung der Unterschiede zwischen den beiden Property Wrappern dient die folgende Person-Klasse als Ausgangslage. Sie wird in den kommenden Beispielen in verschiedene SwiftUI-Views integriert, um die Funktionsweise von ObservedObject und StateObject zu erläutern.

class Person: ObservableObject, Identifiable {
    @Published var firstName: String
    @Published var lastName: String
    
    var fullName: String {
        "\(firstName) \(lastName)"
    }
    
    init(firstName: String = "", lastName: String = "") {
        self.firstName = firstName
        self.lastName = lastName
    }
}

ObservedObject

ObservedObject ist dazu konzipiert, Model-Instanzen zu verwalten, die von außen an eine View übergeben werden. Die View benötigt in diesem Fall also eine entsprechende Model-Instanz, erstellt diese aber nicht selbst. Stattdessen wird sie der View bei ihrer Initialisierung als Parameter übergeben.

Ein typisches Beispiel für den Einsatz von ObservedObject skizziert das nachfolgende Listing. PersonView dient zur Anzeige und Bearbeitung einer Person-Instanz. PersonView legt zu diesem Zweck aber nicht selbst fest, welche Person-Instanz zu bearbeiten ist. Stattdessen wird ihr eine solche aus einer List-View heraus zugewiesen, in der vorhandene Personen aufgeführt sind. Abhängig von der gewählten Zelle erhält PersonView die passende Person-Instanz.

struct ContentView: View {
    private var persons = [
        Person(firstName: "Max", lastName: "Mustermann"),
        Person(firstName: "Thomas", lastName: "Sillmann")
    ]
    
    var body: some View {
        NavigationStack {
            List(persons) { person in
                NavigationLink(person.fullName) {
                    PersonView(person: person)
                }
            }
        }
    }
}

struct PersonView: View {
    @ObservedObject var person: Person
    
    var body: some View {
        Form {
            TextField("First name", text: $person.firstName)
            TextField("Last name", text: $person.lastName)
        }
    }
}

StateObject

Bei StateObject liegt der Fall anders. StateObject kommt in Views zum Einsatz, die selbst eine Model-Instanz erstellen und sie somit nicht von außen erhalten. Ein passendes Beispiel für StateObject ist die nachfolgende CreatePersonView. Sie dient dazu, eine neue Person-Instanz zu erstellen, die bis dato noch gar nicht existiert. CreatePersonView erzeugt diese neue Person-Instanz und sorgt dank des StateObject-Property Wrappers dafür, dass sich die Eigenschaften der Person bearbeiten lassen.

struct CreatePersonView: View {
    @StateObject private var person = Person()
    
    var body: some View {
        Form {
            Section {
                TextField("First name", text: $person.firstName)
                TextField("Last name", text: $person.lastName)
            }
            Section {
                Button("Save") {}
            }
        }
    }
}

Der feine Unterschied

Einfach zusammengefasst lässt sich daraus folgende Regel für den Einsatz von ObservedObject und StateObject ableiten: ObservedObject erhält eine Model-Instanz von außen und definiert sie nicht selbst, während StateObject umgekehrt eine neue Model-Instanz erzeugt.

Doch warum überhaupt diese Unterscheidung? Warum gab es die Notwendigkeit, StateObject in der zweiten Iteration von SwiftUI neben ObservedObject zu ergänzen?

Um diese Frage zu klären, soll das nachfolgende Beispiel als Ausgangslage dienen. Es zeigt eine ContentView mit zwei Sections. Die erste enthält einen simplen Counter, der sich durch Druck auf einen Button hoch zählen lässt. Der aktuelle Wert ist in der State-Property counter gespeichert. Eine Änderung des Counters führt entsprechend zu einer Aktualisierung der View.

Zusätzlich gibt es noch eine PersonSection, die als zweite Section innerhalb der Form-View eingebunden ist. PersonSection definiert eine neue Person-Instanz, die sich über zwei Textfelder bearbeiten lässt. Entgegen der zuvor genannten Regel kommt zu Testzwecken für die Deklaration der person-Property der ObservedObject-Property Wrapper zum Einsatz (und nicht, wie es eigentlich sein sollte, StateObject).

struct ContentView: View {
    @State private var counter = 0
    
    var body: some View {
        Form {
            Section {
                Text("Counter: \(counter)")
                Button("Count") {
                    counter += 1
                }
            }
            PersonSection()
        }
    }
}

struct PersonSection: View {
    @ObservedObject var person = Person()
    
    var body: some View {
        Section {
            TextField("First name", text: $person.firstName)
            TextField("Last name", text: $person.lastName)
        }
    }
}

Wenn man diesen Code einmal testet, wirkt er auf den ersten Blick korrekt und funktional. Der Counter lässt sich mithilfe des Buttons hoch zählen und Vor- und Nachname der Person lassen sich bearbeiten. Also alles bestens, oder? Keine Notwendigkeit, auf StateObject zurückzugreifen.

Diese Annahme dürfte sich ändern, wenn man nach Bearbeitung von Vor- und/oder Nachname erneut den Counter-Button betätigt. Dann nämlich verschwinden die getätigten Eingaben für die Person und werden auf den Standard (sprich jeweils einen leeren String) zurückgesetzt.

Warum ist das so? ObservedObject hält nicht die Referenz zu der zugewiesenen Model-Instanz. Wird nun PersonSection aktualisiert (was im Falle der Counter-Erhöhung der Fall ist, da PersonSection Teil der sich durch den Counter ändernden ContentView ist), wird auch erneut die ObservedObject-Zuweisung einer neuen Person-Instanz ausgeführt. Die ursprüngliche Instanz geht verloren und wird durch die neue überschrieben.

Und genau hierin besteht der entscheidende Unterschied zu StateObject. StateObject hält die ihr zugewiesene Model-Instanz für den gesamten Lifecycle einer View. Auch bei einer von außen angestoßenen Aktualisierung wie im Falle des Counters behält StateObject die Model-Instanz bei. Um das zu überprüfen, reicht es, für die person-Property innerhalb von PersonSection statt ObservedObject den StateObject-Property Wrapper zu verwenden:

struct PersonSection: View {
    @StateObject var person = Person()
    
    var body: some View {
        Section {
            TextField("First name", text: $person.firstName)
            TextField("Last name", text: $person.lastName)
        }
    }
}

Fazit

Auch wenn ObservedObject und StateObject eine ähnliche Aufgabe erfüllen, sind sie für zwei unterschiedliche Anwendungsfälle konzipiert. ObservedObject erhält eine Model-Instanz immer von außen, während StateObject sie selbst erzeugt. Anhand dieser einfachen Regel kann man im Entwickler-Alltag auch fix entscheiden, welcher der beiden Property Wrapper der Richtige ist.

Warum ein „falscher“ Einsatz der Property Wrapper problematisch sein kann, zeigte das Counter-Beispiel. Ein ObservedObject führt eine mögliche Wertzuweisung bei jeder View-Aktualisierung erneut aus, was zu ungewünschtem und fehlerhaften Verhalten führen kann. Nicht zuletzt aus diesem Grund wird Apple sich mit Version 2 von SwiftUI dazu entschieden haben, mit StateObject einen alternativen Property Wrapper zur Verfügung zu stellen, der für genau jene Problematik eine passende Lösung bietet.

Euer Thomas


Kommentare

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert