Lektion 4.2 · Stufe 4 – Profi

Lebensdauer von Views und State

ca. 15 Minuten Lesezeit

Lernziel

Nach dieser Lektion kennst du den Unterschied zwischen dem kurzlebigen View-Struct und der langlebigen View auf dem Bildschirm. Du weißt, wann init, body, onAppear, .task und onDisappear laufen, wann @State entsteht, erhalten bleibt und verschwindet, und vermeidest die typischen Fallen: teure Arbeit im Initializer, @State aus Parametern und Objekte, die zu oft erzeugt werden.

Das Problem: Warum läuft mein Code so oft?

Setz in eine beliebige View ein paar Ausgaben:

Swift
struct RecipeRow: View {
    let name: String

    init(name: String) {
        self.name = name
        print("init \(name)")
    }

    var body: some View {
        let _ = print("body \(name)")
        Text(name)
            .onAppear { print("onAppear \(name)") }
    }
}

let _ = print(...) ist ein kleiner Trick, um in body etwas auszugeben, ohne eine View zurückzugeben.

Bau damit eine Liste, scroll ein wenig und tipp auf ein paar Buttons, die irgendeinen anderen State ändern. In der Konsole siehst du: init läuft ständig, viel öfter, als die Zeile neu erscheint. Und onAppear läuft beim Scrollen immer wieder.

Wer das nicht erwartet, baut schnell Fehler ein: Netzwerkaufrufe im Initializer, die dutzendfach starten. Objekte, die bei jedem Update neu erzeugt werden. Zustand, der sich nicht aktualisiert. In dieser Lektion schauen wir uns an, was wann passiert.

Zwei Arten von „View“

Das Wort „View“ meint in SwiftUI zwei verschiedene Dinge:

  1. Das View-Struct, also dein struct RecipeRow: View. Es ist eine Beschreibung: ein kleiner, billiger Wert, der erzeugt, mit dem vorherigen verglichen und sofort wieder weggeworfen wird. Er lebt nur Bruchteile einer Sekunde.
  2. Die View auf dem Bildschirm, die SwiftUI aus diesen Beschreibungen verwaltet. Sie lebt, solange ihre Identität besteht (Lektion 4.1). An ihr hängen State, Animationen und laufende Tasks.

Ein Bild dafür

Das View-Struct ist wie ein Bestellzettel im Restaurant. Die Küche bekommt ständig neue Zettel: „Tisch 4: Pasta, ohne Käse“. Die Zettel landen nach dem Lesen im Müll. Was bleibt, ist der Tisch 4 mit seinen Gästen, und der ändert sich nur, wenn die Zettel etwas Neues sagen.

Daraus folgen die wichtigsten Regeln dieser Lektion.

Der Initializer: oft und billig

Der Initializer eines View-Structs läuft jedes Mal, wenn die Eltern-View ihr body neu berechnet. Das kann sehr oft sein. Deshalb gilt:

Keine Arbeit im init

Im Initializer einer View nur Werte speichern. Kein Netzwerk, kein Laden aus Dateien oder Datenbanken, keine teuren Berechnungen, keine Timer. Für Arbeit, die beim Erscheinen passieren soll, gibt es .task und .onAppear.

Swift
struct RecipeDetail: View {
    let id: String
    @State private var recipe: Recipe?

    init(id: String) {
        self.id = id
        // ❌ Task { recipe = try await api.load(id) }
    }

    var body: some View {
        content
            .task(id: id) {                               // ✅
                recipe = try? await api.load(id: id)
            }
    }
}

.task läuft, wenn die View auf dem Bildschirm erscheint, nicht wenn ein Struct erzeugt wird, und wird beim Verschwinden automatisch abgebrochen (Lektion 3.7).

body: so oft wie nötig

body wird aufgerufen, wenn SwiftUI die Beschreibung braucht: beim ersten Erscheinen und immer dann, wenn sich etwas geändert hat, wovon die View abhängt. Wann genau das ist, ist Thema von Lektion 4.3.

Für jetzt reicht: body sollte schnell sein und keine Nebenwirkungen haben. Kein Zustand ändern, nichts speichern, nichts laden. Nur beschreiben.

Swift
var body: some View {
    let _ = count += 1   // ❌ Zustand in body ändern
    Text("Hallo")
}

Zustand in body zu ändern, löst ein neues body aus, das wieder Zustand ändert … SwiftUI warnt dann in der Konsole: „Modifying state during view update, this will cause undefined behavior.“

Die Lebensdauer der View auf dem Bildschirm

Die View auf dem Bildschirm durchläuft drei Momente:

MomentWas passiertWerkzeug
Erscheinenwird eingefügt und sichtbar.onAppear, .task startet
Aktualisierenihre Daten ändern sichbody läuft neu, .onChange
Verschwindenwird entfernt oder unsichtbar.onDisappear, .task wird abgebrochen

Wichtig ist das Wort sichtbar. onAppear heißt nicht „wurde erzeugt“, sondern „ist jetzt auf dem Bildschirm“. Und das kann mehrfach passieren:

  • In einer Liste oder LazyVStack laufen onAppear und onDisappear jedes Mal, wenn eine Zeile herein- oder herausscrollt.
  • In einem NavigationStack läuft onDisappear der Liste, wenn du eine Detailseite öffnest, und onAppear wieder, wenn du zurückkommst. Die Liste selbst lebt aber weiter, mit ihrem State.
  • In einer TabView laufen beide beim Wechseln der Tabs.

Tipp

Verlass dich nicht darauf, dass onAppear nur einmal läuft. Soll etwas wirklich nur einmal passieren, etwa das erste Laden, prüf vorher, ob es schon passiert ist: if recipes.isEmpty { await load() }.

Wann @State entsteht und verschwindet

@State gehört zur View auf dem Bildschirm, nicht zum Struct. Deshalb:

  • Entsteht, wenn die View zum ersten Mal mit dieser Identität eingefügt wird. Erst dann wird der Startwert verwendet.
  • Bleibt erhalten bei allen Updates, auch wenn das Struct tausendfach neu erzeugt wird.
  • Verschwindet, wenn die Identität endet: Die View wird aus einem if entfernt, das Sheet schließt, die Detailseite wird vom Stack genommen, .id() ändert sich.

Wie lange das in typischen Containern ist:

ContainerState lebt …
NavigationStacksolange die Seite auf dem Stack liegt. „Zurück“ zerstört ihn.
.sheetsolange das Sheet offen ist. Beim nächsten Öffnen ist er frisch.
TabViewsolange die TabView existiert, auch wenn der Tab nicht sichtbar ist.
List, LazyVStackin der Regel solange die Liste existiert, auch für herausgescrollte Zeilen. Bei sehr langen Listen kann SwiftUI aber Speicher freigeben. Wichtige Daten gehören deshalb ins Modell, nicht in den State einer Zeile.

Falle 1: @State aus einem Parameter

Ein Klassiker, der fast jeden einmal erwischt:

Swift
struct EditableTitle: View {
    @State private var text: String

    init(title: String) {
        _text = State(initialValue: title)
    }

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

Beim ersten Erscheinen steht der richtige Titel im Feld. Ändert die Eltern-View danach title, passiert nichts. Das Feld zeigt weiter den alten Text.

Warum? State(initialValue:) ist nur ein Startwert. Er wird verwendet, wenn der State entsteht, und danach ignoriert. Das neue Struct mit dem neuen Titel wird zwar erzeugt, aber der State gehört der View auf dem Bildschirm, und die hat ihren Wert schon.

Je nachdem, was du willst, gibt es drei Lösungen:

  1. Der Wert gehört der Eltern-View: Nimm @Binding statt @State (Lektion 2.10). Dann gibt es nur eine Quelle der Wahrheit.
  2. Lokaler Entwurf, der beim Wechsel des Datensatzes neu beginnen soll: Behalte @State und setz .id(datensatz.id) an die View (Lektion 4.1).
  3. Lokaler Entwurf, der Änderungen von außen übernehmen soll: .onChange(of: title) { _, new in text = new }.

Tipp

Das Formular-Muster aus Lektion 3.15 umgeht das Problem, indem es die Werte in .onAppear übernimmt. Weil ein Sheet bei jedem Öffnen neu entsteht, ist das dort genau richtig.

Falle 2: Objekte, die zu oft erzeugt werden

Swift
struct LibraryScreen: View {
    @State private var model = LibraryModel()

    var body: some View { ... }
}

Das ist das empfohlene Muster für @Observable-Objekte (Lektion 3.2), und es funktioniert: SwiftUI behält das erste LibraryModel und verwendet es über die ganze Lebensdauer.

Aber: Der Ausdruck LibraryModel() wird bei jedem Erzeugen des Structs ausgewertet. Das neue Objekt wird sofort wieder verworfen, weil der State schon einen Wert hat. Ist der Initializer billig, ist das egal. Lädt er aber Daten, öffnet eine Datenbank oder startet einen Timer, passiert das bei jedem Update der Eltern-View, ohne dass du es merkst.

Swift
@Observable
class LibraryModel {
    var books: [Book] = []

    init() {
        print("LibraryModel erzeugt")   // erscheint überraschend oft
    }

    func load() async { ... }           // ✅ Arbeit hier, nicht im init
}

Die Lösung ist dieselbe wie bei Views: Initializer billig halten und die Arbeit in eine Methode verlegen, die du aus .task aufrufst.

Wo Objekte entstehen sollten

Objekte, die für die ganze App leben (ein Store, ein Router, eine Datenbankverbindung), erzeugst du am besten ganz oben, in der App-Struktur, und gibst sie über die Environment weiter (Lektion 3.3). Die App-Struktur wird praktisch nie neu erzeugt.

Typische Fehler

Netzwerkaufruf läuft mehrfach

Er steht im init einer View oder eines Objekts, das in @State erzeugt wird. Verschieb ihn in .task.

Textfeld übernimmt Änderungen von außen nicht

@State wurde aus einem Parameter initialisiert. Verwende @Binding, .id() oder .onChange, je nachdem, wem der Wert gehört.

Daten laden bei jedem Zurückkehren neu

Das Laden steht in .onAppear oder .task einer Liste in einem NavigationStack und läuft bei jeder Rückkehr. Prüf vorher, ob schon Daten da sind, oder trenne „erstes Laden“ und „Aktualisieren“ (Projekt D).

„Modifying state during view update“

Zustand wird in body oder in einem Initializer geändert. Verschieb die Änderung in eine Aktion, .onAppear, .task oder .onChange.

FAQ

Anfänger

Was bedeutet das überhaupt?

Ist es schlimm, dass init und body so oft laufen? Nein, das ist so gewollt. View-Structs sind winzig und billig zu erzeugen. Schlimm wird es nur, wenn du dort teure Arbeit oder Nebenwirkungen unterbringst.

Was ist der Unterschied zwischen .onAppear und .task? .task ist für asynchrone Arbeit gedacht und wird beim Verschwinden automatisch abgebrochen. .onAppear ist synchron und läuft einfach durch. Für Laden aus dem Netz oder der Datenbank nimm .task.

Fortgeschritten

Warum verhält es sich so?

Wird onDisappear immer aufgerufen? Nicht garantiert, etwa wenn die App beendet wird. Verlass dich für wichtiges Speichern nicht auf onDisappear, sondern speichere bei Änderungen oder reagiere auf @Environment(\.scenePhase), wenn die App in den Hintergrund geht.

Warum lebt der State einer Detailseite nicht weiter, wenn ich zurückgehe und sie wieder öffne? Weil „Zurück“ die Seite vom Stack nimmt und damit ihre Identität beendet. Beim erneuten Öffnen ist es eine neue View. Was erhalten bleiben soll, gehört in ein Modell außerhalb der Seite.

Profi

Was passiert unter der Haube?

Warum funktioniert @State private var model = Model() überhaupt, wenn der Ausdruck jedes Mal ausgewertet wird? @State speichert beim ersten Einfügen den Wert in einem Speicherplatz, der zur Identität gehört. Bei späteren Structs übergibt der Property Wrapper den neuen Startwert zwar, SwiftUI verbindet ihn aber mit dem bestehenden Speicher und verwirft den neuen Wert. Der Ausdruck wird trotzdem ausgewertet, weil er Teil der Struct-Initialisierung ist.

Wann gibt eine List den State herausgescrollter Zeilen frei? Das ist ein internes Detail, das sich zwischen iOS-Versionen ändern kann. List und Lazy-Container recyceln die Darstellung, halten den State aber meist pro Identität vor. Code, der davon abhängt, ist zerbrechlich. Die robuste Regel lautet: Zeilen sind Darstellungen von Modelldaten, ihr eigener State ist höchstens flüchtig, etwa „ist gerade aufgeklappt“.

Übungen

Anfänger

Übung 1: Mitzählen

Baue eine Liste von 50 Zeilen mit einer eigenen Zeilen-View, die in init, body, onAppear und onDisappear etwas ausgibt. Scroll durch die Liste und beobachte die Konsole. Notiere, was dich überrascht.

Fortgeschritten

Übung 2: Den Titel reparieren

Baue EditableTitle aus der Lektion zusammen mit einer Eltern-View, die über einen Picker zwischen drei Titeln wechselt. Zeig das Problem und behebe es auf zwei Arten: einmal mit @Binding, einmal mit .id(). Erkläre, wann welche Lösung die richtige ist.

Lösung Übung 2Erst selbst probieren!
Swift
// Variante 1: Der Wert gehört der Eltern-View
struct EditableTitle: View {
    @Binding var text: String
    var body: some View { TextField("Titel", text: $text) }
}

// Variante 2: Lokaler Entwurf, der beim Wechsel neu beginnt
struct DraftTitle: View {
    @State private var text: String
    init(title: String) { _text = State(initialValue: title) }
    var body: some View { TextField("Titel", text: $text) }
}

struct Parent: View {
    let titles = ["Einkauf", "Urlaub", "Projekt"]
    @State private var selected = "Einkauf"

    var body: some View {
        Form {
            Picker("Liste", selection: $selected) {
                ForEach(titles, id: \.self) { Text($0) }
            }
            DraftTitle(title: selected)
                .id(selected)
        }
    }
}

Variante 1 passt, wenn jede Änderung sofort „gilt“. Variante 2 passt für Entwürfe, die erst mit „Sichern“ übernommen werden.

Profi

Übung 3: Das teure Modell

Schreib ein @Observable-Modell, das im Initializer „Modell erzeugt“ ausgibt, und halte es mit @State in einer Kind-View. Die Eltern-View hat einen Zähler-Button. Zeig, dass das Modell bei jedem Tipp neu erzeugt (und verworfen) wird. Verschiebe es dann in die App-Struktur und gib es über die Environment weiter.

Zusammenfassung

  • View-Struct: kurzlebige, billige Beschreibung. View auf dem Bildschirm: langlebig, trägt State, Animationen und Tasks.
  • init läuft bei jedem Update der Eltern-View: nur Werte speichern, keine Arbeit.
  • body muss schnell und frei von Nebenwirkungen sein.
  • onAppear/onDisappear folgen der Sichtbarkeit und können mehrfach laufen. .task für asynchrone Arbeit.
  • @State entsteht beim ersten Einfügen, bleibt bei Updates erhalten und endet mit der Identität. Der Startwert wird danach ignoriert.
  • Objekte in @State werden bei jedem Struct neu erzeugt und verworfen: Initializer billig halten, App-weite Objekte ganz oben erzeugen.

Spickzettel

Swift
init(id: String) { self.id = id }        // nur speichern

var body: some View {
    let _ = print("body")                // Debug-Ausgabe in body
    content
        .task(id: id) { await load() }   // Arbeit hier
        .onAppear { }                    // kann mehrfach laufen
        .onDisappear { }
}

// State aus Parameter – nur Startwert!
_text = State(initialValue: title)
// Lösungen: @Binding · .id(title) · .onChange(of: title)

// App-weite Objekte ganz oben
@main struct MyApp: App {
    @State private var store = Store()
    var body: some Scene { WindowGroup { ContentView().environment(store) } }
}

Lektion geschafft?

Markiere sie als erledigt, damit du deinen Fortschritt siehst.