Lektion 4.3 · Stufe 4 – Profi

Wie SwiftUI Änderungen erkennt

ca. 15 Minuten Lesezeit

Lernziel

Nach dieser Lektion verstehst du, wann SwiftUI das body einer View neu berechnet: über Abhängigkeiten zu State, Bindings, Environment und beobachteten Eigenschaften und über den Vergleich der Eingaben einer View. Du kannst mit Self._printChanges() herausfinden, warum eine View neu berechnet wird, und Views so zerlegen, dass Änderungen nur dort ankommen, wo sie gebraucht werden.

Das Problem: Was löst ein Update aus?

In Lektion 2.9 hast du gelernt: Ändert sich der Zustand, aktualisiert SwiftUI die Oberfläche. Aber welche Teile? Die ganze App? Den ganzen Bildschirm? Nur das eine Textfeld?

Die Antwort entscheidet darüber, ob eine App flüssig läuft. Ein Timer, der zehnmal pro Sekunde eine Zahl ändert, ist kein Problem, wenn nur ein Text neu berechnet wird. Er ist ein großes Problem, wenn dabei jedes Mal eine Liste mit 500 Zeilen und Bildern neu beschrieben wird.

SwiftUI ist darin sehr gut, Arbeit zu sparen. Aber es kann nur so gut sein, wie dein Code es zulässt. In dieser Lektion schaust du unter die Haube.

Der Abhängigkeitsgraph

SwiftUI führt intern Buch darüber, welche View wovon abhängt. Diese Buchhaltung heißt Attribut-Graph. Du musst seine Einzelheiten nicht kennen, nur das Prinzip:

Eine View hängt von allem ab, was sie in body liest. Ändert sich etwas davon, wird ihr body neu berechnet. Sonst nicht.

Das Wort liest ist entscheidend. Schauen wir uns an, was als Lesen zählt.

Quelle 1: @State und @Binding

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

    var body: some View {
        VStack {
            Text("Zähler: \(count)")
            Button("Plus") {
                count += 1
                taps += 1
            }
        }
    }
}

body liest count. Ändert sich count, wird body neu berechnet.

Und taps? Es wird nur in der Button-Aktion gelesen und geschrieben, nicht in body selbst. Die Aktion ist eine Closure, die später läuft, wenn jemand tippt. SwiftUI zählt sie nicht als Abhängigkeit. Würde sich nur taps ändern, passierte gar nichts.

Lesen in body vs. Lesen in Aktionen

Was du in body liest, ist eine Abhängigkeit. Was du in Aktionen, .task, .onAppear oder anderen Closures liest, die später laufen, ist keine. Das ist ein Grund, warum SwiftUI so sparsam sein kann.

Quelle 2: @Observable

Bei @Observable-Objekten (Lektion 3.1) ist die Abhängigkeit noch feiner: Sie gilt pro Eigenschaft.

Swift
@Observable
class PlayerModel {
    var title = "Song"
    var progress = 0.0      // ändert sich 10-mal pro Sekunde
}

struct TitleView: View {
    let player: PlayerModel
    var body: some View {
        Text(player.title)          // liest nur title
    }
}

struct ProgressBar: View {
    let player: PlayerModel
    var body: some View {
        ProgressView(value: player.progress)   // liest nur progress
    }
}

Ändert sich progress, wird nur ProgressBar neu berechnet. TitleView liest progress nicht und bleibt unberührt, obwohl beide dasselbe Objekt haben.

Das ist der große Vorteil von @Observable gegenüber dem älteren ObservableObject (Lektion 3.4). Dort löst jede Änderung an irgendeiner @Published-Eigenschaft ein Update aller Views aus, die das Objekt beobachten. Mit @Observable musst du dich darum kaum noch kümmern.

Berechnete Eigenschaften lesen mit

Swift
@Observable
class Cart {
    var items: [Item] = []
    var total: Double { items.reduce(0) { $0 + $1.price } }
}

Liest eine View cart.total, liest sie in Wirklichkeit items. Sie hängt also von jeder Änderung an items ab, auch wenn die Summe gleich bleibt. Das ist meist in Ordnung, aber gut zu wissen.

Quelle 3: Environment

Liest eine View einen Environment-Wert, etwa @Environment(\.colorScheme) oder @Environment(\.dynamicTypeSize), hängt sie von diesem Wert ab. Bei einem @Observable-Objekt aus der Environment, @Environment(Store.self), gilt wieder: abhängig von den Eigenschaften, die body tatsächlich liest.

Environment-Werte ändern sich selten, etwa beim Umschalten auf Dark Mode. Problematisch wird es nur, wenn du selbst schnell wechselnde Werte in die Environment legst, zum Beispiel eine Scrollposition per @Entry. Dann werden alle Views neu berechnet, die diesen Schlüssel lesen.

Quelle 4: Die Eingaben einer View

Die dritte und subtilste Quelle: Die Eltern-View berechnet ihr body neu und erzeugt dabei ein neues Struct der Kind-View. Muss jetzt auch das Kind sein body neu berechnen?

SwiftUI vergleicht dazu das neue Struct mit dem alten, Eigenschaft für Eigenschaft:

Swift
struct Parent: View {
    @State private var count = 0

    var body: some View {
        VStack {
            Text("\(count)")
            Button("Plus") { count += 1 }
            Header(title: "Willkommen")
        }
    }
}

struct Header: View {
    let title: String
    var body: some View {
        let _ = print("Header body")
        Text(title).font(.largeTitle)
    }
}

Bei jedem Tipp läuft Parent.body neu und erzeugt ein neues Header(title: "Willkommen"). SwiftUI vergleicht: title ist gleich geblieben. Also wird Header.body nicht neu berechnet. In der Konsole erscheint „Header body“ nur einmal.

Das funktioniert gut für einfache Werte wie Strings, Zahlen und Structs daraus. Es funktioniert schlecht für Closures:

Swift
struct Header: View {
    let title: String
    let onTap: () -> Void      // ⚠️
    ...
}

Header(title: "Willkommen", onTap: { count += 1 })

Closures lassen sich nicht sinnvoll vergleichen. Jedes Mal entsteht eine neue Closure, und SwiftUI muss davon ausgehen, dass sich etwas geändert hat. Header.body läuft jetzt bei jedem Update der Eltern-View mit.

Bei kleinen Views ist das bedeutungslos. Bei großen Views mit vielen Kindern kann es sich summieren. Lektion 4.5 zeigt, wie du damit umgehst.

Equatable

Du kannst SwiftUI den Vergleich abnehmen: Mach die View Equatable und schreib selbst, wann zwei Structs „gleich“ sind, etwa nur anhand von title, und ignoriere die Closure. SwiftUI verwendet dann deinen Vergleich. Das ist ein Werkzeug für gezielte Optimierungen, nicht für jede View.

Warum wurde meine View neu berechnet?

Statt zu raten, kannst du SwiftUI fragen. Setz in body diese Zeile:

Swift
var body: some View {
    let _ = Self._printChanges()
    ...
}

Bei jeder Neuberechnung schreibt SwiftUI in die Konsole, was sich geändert hat:

Text
CounterView: @self, @identity, _count changed.
CounterView: _count changed.
ProgressBar: @dependencies changed.
Header: @self changed.
  • _count changed: Der @State count hat sich geändert.
  • @self changed: Das Struct selbst hat neue Werte, also haben sich die Eingaben von der Eltern-View geändert, oder sie ließen sich nicht vergleichen (Closures!).
  • @identity: Die View ist neu entstanden (Lektion 4.1).
  • @dependencies changed: Eine beobachtete Eigenschaft eines @Observable-Objekts oder ein Environment-Wert hat sich geändert.

Achtung

Der Unterstrich am Anfang von _printChanges heißt: Diese Methode ist nur zum Debuggen gedacht und kein offizieller Teil der API. Entferne sie, bevor du die App veröffentlichst.

Abhängigkeiten eng halten: Views zerlegen

Das wichtigste Werkzeug für schnelle SwiftUI-Apps ist überraschend einfach: kleine Views. Erinnere dich an Lektion 2.21. Zerlegen ist nicht nur gut für die Lesbarkeit, sondern auch für die Leistung.

Swift
struct PlayerScreen: View {
    let player: PlayerModel

    var body: some View {
        VStack {
            AlbumArtwork(url: player.artworkURL)   // teuer
            Text(player.title)
            ProgressView(value: player.progress)   // ändert sich ständig
            Controls(player: player)
        }
    }
}

Weil PlayerScreen.body player.progress liest, wird der ganze Bildschirm zehnmal pro Sekunde neu beschrieben. Zwar vergleicht SwiftUI danach die Kinder und lässt das Albumcover in Ruhe, aber es ist unnötige Arbeit.

Besser:

Swift
struct PlayerScreen: View {
    let player: PlayerModel

    var body: some View {
        VStack {
            AlbumArtwork(url: player.artworkURL)
            Text(player.title)
            PlaybackProgress(player: player)       // liest progress selbst
            Controls(player: player)
        }
    }
}

struct PlaybackProgress: View {
    let player: PlayerModel
    var body: some View {
        ProgressView(value: player.progress)
    }
}

Jetzt liest nur PlaybackProgress den schnell wechselnden Wert. PlayerScreen.body läuft nur noch, wenn sich Titel oder Cover ändern.

Die Regel

Lies schnell wechselnde Werte so tief unten wie möglich. Gib Kind-Views lieber das Objekt (oder ein Binding) und lass sie selbst lesen, statt den Wert oben auszulesen und als Zahl weiterzureichen.

Dasselbe gilt für @State: Gehört ein Zustand nur zu einem kleinen Teil des Bildschirms, etwa ob ein Abschnitt aufgeklappt ist, leg ihn in eine kleine Kind-View statt in die große Eltern-View.

Typische Fehler

Ganzer Bildschirm wird ständig neu berechnet

Ein oft wechselnder Wert wird weit oben gelesen, etwa ein Timer oder eine Scrollposition. Verschiebe das Lesen in eine kleine Kind-View.

Kind-View wird trotz gleicher Daten neu berechnet

Sie bekommt eine Closure oder einen anderen nicht vergleichbaren Wert als Eingabe. _printChanges zeigt dann @self changed. Für die meisten Views ist das harmlos. Bei teuren Views hilft Lektion 4.5.

Alles hängt an einem großen ObservableObject

Mit ObservableObject löst jede @Published-Änderung alle Beobachter aus. Steig auf @Observable um (Lektion 3.4).

_printChanges im veröffentlichten Code

Es ist eine interne Debug-Hilfe. Entferne sie vor der Veröffentlichung.

FAQ

Anfänger

Was bedeutet das überhaupt?

Heißt „body neu berechnen“, dass alles neu gezeichnet wird? Nein. body liefert nur eine neue Beschreibung. SwiftUI vergleicht sie mit der alten und ändert auf dem Bildschirm nur, was wirklich anders ist. Das Neuberechnen ist billig, aber nicht kostenlos.

Muss ich jetzt jede View zerlegen? Nein. Zerlege, wenn eine View unübersichtlich wird oder wenn du ein messbares Leistungsproblem hast (Lektion 4.4). Vorzeitige Optimierung macht Code nur komplizierter.

Fortgeschritten

Warum verhält es sich so?

Wie vergleicht SwiftUI die Eingaben einer View genau? Für jede gespeicherte Eigenschaft versucht SwiftUI einen effizienten Vergleich: bei einfachen Werten Byte für Byte, bei Equatable-Typen über ==, bei Klassen über die Objektidentität. Für Closures und manche andere Typen ist kein sinnvoller Vergleich möglich, sie gelten dann als geändert.

Zählt eine Eigenschaft eines @Observable-Objekts als Abhängigkeit, wenn ich sie in einer Hilfsfunktion lese, die body aufruft? Ja. Entscheidend ist, dass sie während der Ausführung von body gelesen wird. Ob direkt oder über eine Funktion, eine berechnete Eigenschaft oder eine Kind-Funktion mit @ViewBuilder, spielt keine Rolle.

Profi

Was passiert unter der Haube?

Wie erkennt @Observable, was gelesen wurde? Das Makro @Observable erzeugt für jede Eigenschaft Zugriffsmethoden, die beim Lesen den Aufruf bei einem ObservationRegistrar melden. SwiftUI führt body innerhalb von withObservationTracking aus, sammelt dabei alle gemeldeten Zugriffe und wird benachrichtigt, sobald sich einer davon ändert. Das ist auch außerhalb von SwiftUI nutzbar.

Was ist ein Attribut-Graph? Ein gerichteter Graph aus Knoten (Attribute), die Werte enthalten oder berechnen: Eingaben, State, Environment, Layout-Ergebnisse, body-Ergebnisse. Ändert sich ein Knoten, werden alle abhängigen Knoten als „veraltet“ markiert und erst bei Bedarf neu berechnet. Die View-Structs sind nur die Rezepte, aus denen dieser Graph gebaut wird.

Übungen

Anfänger

Übung 1: _printChanges ausprobieren

Baue CounterView aus der Lektion und füge Self._printChanges() hinzu. Füge einen zweiten @State hinzu, der nur in der Button-Aktion verwendet wird, und überprüfe, dass er kein Update auslöst. Zeig ihn dann zusätzlich in einem Text an und beobachte, was sich ändert.

Fortgeschritten

Übung 2: Der Stoppuhr-Bildschirm

Baue eine Stoppuhr mit einem @Observable-Modell, das elapsed alle 0,1 Sekunden erhöht, darüber einen großen Titel und darunter eine Liste von 30 „Runden“. Bau es zuerst so, dass ein body elapsed liest und alles enthält. Zerlege es dann so, dass nur die Zeitanzeige neu berechnet wird. Beweise es mit _printChanges.

Lösung Übung 2Erst selbst probieren!
Swift
@Observable
class Stopwatch {
    var elapsed = 0.0
    var laps: [Double] = []
    private var task: Task<Void, Never>?

    func start() {
        task = Task {
            while !Task.isCancelled {
                try? await Task.sleep(for: .milliseconds(100))
                elapsed += 0.1
            }
        }
    }

    func stop() { task?.cancel() }
    func lap() { laps.append(elapsed) }
}

struct StopwatchScreen: View {
    @State private var watch = Stopwatch()

    var body: some View {
        let _ = Self._printChanges()
        VStack {
            Text("Stoppuhr").font(.largeTitle.bold())
            TimeDisplay(watch: watch)
            HStack {
                Button("Start") { watch.start() }
                Button("Runde") { watch.lap() }
                Button("Stopp") { watch.stop() }
            }
            LapList(watch: watch)
        }
    }
}

struct TimeDisplay: View {
    let watch: Stopwatch
    var body: some View {
        let _ = Self._printChanges()
        Text(watch.elapsed, format: .number.precision(.fractionLength(1)))
            .font(.system(size: 60, weight: .light, design: .rounded))
            .monospacedDigit()
    }
}

struct LapList: View {
    let watch: Stopwatch
    var body: some View {
        let _ = Self._printChanges()
        List(watch.laps.indices, id: \.self) { index in
            Text("Runde \(index + 1): \(watch.laps[index], format: .number.precision(.fractionLength(1)))")
        }
    }
}

Während die Uhr läuft, meldet nur TimeDisplay Änderungen. LapList meldet sich nur, wenn eine Runde hinzukommt, und StopwatchScreen gar nicht mehr. Die Indizes als ID sind hier in Ordnung, weil Runden nur angehängt, nie gelöscht oder verschoben werden.

Profi

Übung 3: Equatable-View

Baue eine teure Kind-View mit einem title und einer Closure onSelect. Zeig mit _printChanges, dass sie bei jedem Update der Eltern-View mit @self changed neu berechnet wird. Mach sie dann Equatable mit einem Vergleich nur über title und prüfe, ob sich das Verhalten ändert.

Zusammenfassung

  • SwiftUI führt Buch über Abhängigkeiten: Eine View hängt von allem ab, was sie in body liest.
  • Lesen in Aktionen, .task und anderen späteren Closures ist keine Abhängigkeit.
  • @Observable verfolgt Abhängigkeiten pro Eigenschaft. ObservableObject löst bei jeder Änderung alle Beobachter aus.
  • Neue Eingaben einer Kind-View werden verglichen. Gleiche Werte: kein neues body. Closures lassen sich nicht vergleichen.
  • Self._printChanges() zeigt, warum body neu berechnet wurde. Nur zum Debuggen.
  • Schnell wechselnde Werte so tief unten wie möglich lesen: kleine Views mit eigenen Abhängigkeiten.

Spickzettel

Swift
var body: some View {
    let _ = Self._printChanges()     // Debug: warum neu berechnet?
    ...
}
// Ausgabe: _count changed · @self changed · @dependencies changed · @identity

// ❌ oben lesen → ganzer Bildschirm
Text(...); ProgressView(value: player.progress); BigList()

// ✅ unten lesen → nur die kleine View
PlaybackProgress(player: player)
struct PlaybackProgress: View {
    let player: PlayerModel
    var body: some View { ProgressView(value: player.progress) }
}

Lektion geschafft?

Markiere sie als erledigt, damit du deinen Fortschritt siehst.