Lernziel
Nach dieser Lektion kannst du asynchrone Arbeit mit .task an die Lebensdauer einer View koppeln, sie bei Änderungen eines Werts neu starten und mit @MainActor sicherstellen, dass die Oberfläche nur vom Main Thread aus aktualisiert wird. Du baust einen typischen Ladeablauf mit einem ViewModel.
Das Problem: Wann und wo wird geladen?
In der letzten Lektion hast du Task { ... } kennengelernt. Damit kannst du aus einem Button heraus asynchron laden. Aber oft soll das Laden automatisch passieren, sobald ein Bildschirm erscheint. Naheliegend wäre:
.onAppear {
Task {
await loadData()
}
}Das funktioniert, hat aber zwei Probleme:
- Verlässt der Nutzer den Bildschirm, bevor das Laden fertig ist, läuft die Aufgabe trotzdem weiter. Sie verschwendet Akku und Datenvolumen, und ihr Ergebnis landet womöglich in einer View, die es gar nicht mehr gibt.
- Und wo genau wird das Ergebnis eigentlich in die Oberfläche geschrieben? Auf welchem Thread?
Für beides hat SwiftUI eine elegante Lösung.
.task: Laden an die View gekoppelt
struct QuoteView: View {
@State private var quote = "Lädt…"
var body: some View {
Text(quote)
.padding()
.task {
quote = await loadQuote()
}
}
}
func loadQuote() async -> String {
try? await Task.sleep(for: .seconds(1))
return "Jeder Profi war einmal Anfänger."
}.task { ... } ist ein Modifier, der:
- seine Closure asynchron startet, sobald die View erscheint. Du darfst darin direkt
awaitverwenden. - die Aufgabe automatisch abbricht, wenn die View verschwindet.
Erwartetes Ergebnis
Zuerst steht „Lädt…“, eine Sekunde später der Spruch.
Das ist der Standardweg, um in SwiftUI Daten zu laden. Kein Task, kein onAppear, kein manuelles Abbrechen.
Neu laden, wenn sich etwas ändert
Oft hängt das, was geladen wird, von einem Wert ab, etwa einer ausgewählten Kategorie. Ändert sich der Wert, soll neu geladen werden:
struct CategoryView: View {
@State private var category = "Pasta"
@State private var recipes: [String] = []
var body: some View {
VStack {
Picker("Kategorie", selection: $category) {
Text("Pasta").tag("Pasta")
Text("Suppen").tag("Suppen")
Text("Desserts").tag("Desserts")
}
.pickerStyle(.segmented)
List(recipes, id: \.self) { Text($0) }
}
.task(id: category) {
recipes = await loadRecipes(for: category)
}
}
}.task(id: category) läuft beim Erscheinen und jedes Mal, wenn sich category ändert. Läuft gerade noch ein alter Ladevorgang, wird er abgebrochen, bevor der neue startet. Wechselt der Nutzer schnell zwischen Kategorien, werden alte, nicht mehr gebrauchte Anfragen also automatisch verworfen.
Tipp
.task(id:) ist ein unterschätztes Werkzeug. Mit ihm lassen sich viele Fälle lösen, für die man sonst .onChange und manuell verwaltete Tasks bräuchte: Suchbegriffe, ausgewählte Elemente, Filter.
Der Main Thread
Jetzt zur zweiten Frage: Auf welchem Thread läuft das alles?
Jede App hat einen besonderen Thread, den Main Thread (Hauptthread). Auf ihm läuft alles, was mit der Oberfläche zu tun hat: Views zeichnen, auf Tippen reagieren, animieren. Eine eiserne Regel lautet:
Die Oberfläche darf nur vom Main Thread aus verändert werden.
Würden zwei Threads gleichzeitig an der Oberfläche arbeiten, könnte das zu Darstellungsfehlern oder Abstürzen führen. Gleichzeitig darf der Main Thread nicht mit langer Arbeit blockiert werden, sonst friert die App ein (Lektion 3.6).
Also: Langsame Arbeit neben dem Main Thread, Ergebnisse auf dem Main Thread in die Oberfläche schreiben.
@MainActor
Swift drückt „läuft auf dem Main Thread“ mit @MainActor aus. Ein Actor ist, vereinfacht gesagt, ein geschützter Bereich, in dem immer nur ein Stück Code gleichzeitig läuft. Der Main Actor ist der Bereich des Main Threads.
Das Gute: SwiftUI-Views sind bereits @MainActor. Alles, was du in body, in Button-Aktionen und in .task schreibst, läuft auf dem Main Actor. Deshalb durftest du oben einfach quote = await loadQuote() schreiben: Das Zuweisen passiert auf dem Main Thread.
Wartet .task mit await auf eine Funktion, gibt es den Main Thread frei, solange es wartet. Die App bleibt flüssig. Kommt das Ergebnis, geht es auf dem Main Thread weiter.
ViewModels mit @MainActor
Lagerst du das Laden in ein ViewModel aus (Lektion 3.5), schreibt dieses ViewModel Werte, die die Oberfläche liest. Deshalb markierst du es mit @MainActor:
@MainActor
@Observable
class QuoteViewModel {
var quote = "Lädt…"
var isLoading = false
func load() async {
isLoading = true
quote = await loadQuote()
isLoading = false
}
}
struct QuoteScreen: View {
@State private var viewModel = QuoteViewModel()
var body: some View {
VStack(spacing: 16) {
if viewModel.isLoading {
ProgressView()
}
Text(viewModel.quote)
}
.task {
await viewModel.load()
}
}
}@MainActorvor der Klasse sagt: „Alle Eigenschaften und Methoden dieser Klasse gehören auf den Main Actor.“ Damit ist garantiert, dassquoteundisLoadingnur vom Main Thread aus geändert werden.ProgressView()ohne Parameter zeigt den bekannten drehenden Ladekreis.- Die View ruft mit
.task { await viewModel.load() }die asynchrone Methode auf.
Neue Projekte und der Main Actor
Neuere Xcode-Versionen legen neue App-Projekte so an, dass dein Code standardmäßig auf dem Main Actor läuft. Dann ist @MainActor vor einem ViewModel überflüssig, schadet aber auch nicht. In der nächsten Lektion schauen wir uns an, wie du prüfst, welche Einstellung dein Projekt hat, und was das bedeutet.
Laden mit Knopfdruck
Nicht alles soll automatisch geladen werden. Für Aktionen des Nutzers verwendest du Task in der Button-Aktion:
Button("Neuer Spruch") {
Task {
await viewModel.load()
}
}
.disabled(viewModel.isLoading).disabled(viewModel.isLoading) verhindert, dass der Nutzer mehrmals hintereinander tippt und mehrere Ladevorgänge gleichzeitig startet.
Achtung
Anders als bei .task wird ein Task aus einem Button nicht automatisch abgebrochen, wenn die View verschwindet. Für kurze Aktionen ist das in Ordnung. Für lange Aufgaben kannst du dir den Task merken und in .onDisappear selbst abbrechen.
Ein vollständiges Muster
So sieht ein typischer Bildschirm aus, der beim Erscheinen lädt, einen Ladezustand zeigt, Fehler behandelt und neu laden kann:
enum QuoteError: Error {
case unavailable
}
func fetchQuote() async throws -> String {
try await Task.sleep(for: .seconds(1))
if Bool.random() { throw QuoteError.unavailable }
return "Kleine Schritte sind auch Schritte."
}
@MainActor
@Observable
class QuoteLoader {
var quote: String?
var errorMessage: String?
var isLoading = false
func load() async {
isLoading = true
errorMessage = nil
defer { isLoading = false }
do {
quote = try await fetchQuote()
} catch {
errorMessage = "Der Spruch konnte nicht geladen werden."
}
}
}
struct QuoteLoaderView: View {
@State private var loader = QuoteLoader()
var body: some View {
VStack(spacing: 20) {
if loader.isLoading {
ProgressView("Lädt…")
} else if let quote = loader.quote {
Text(quote)
.font(.title2)
.multilineTextAlignment(.center)
} else if let message = loader.errorMessage {
Text(message)
.foregroundStyle(.red)
}
Button("Neu laden") {
Task { await loader.load() }
}
.disabled(loader.isLoading)
}
.padding()
.task {
await loader.load()
}
}
}defer { isLoading = false }läuft garantiert am Ende der Funktion, egal ob mit Erfolg oder Fehler (Lektion 1.17, 🟡-FAQ). So bleibt der Ladekreis nie hängen.- Die View beschreibt für jeden Zustand, was zu sehen ist.
Mit drei getrennten Eigenschaften quote, errorMessage und isLoading können allerdings widersprüchliche Kombinationen entstehen. In Lektion 3.11 machst du daraus ein sauberes Enum.
Typische Fehler
Task in .onAppear statt .task
Funktioniert, bricht aber nicht ab, wenn die View verschwindet. Nimm .task.
ViewModel ohne @MainActor
Je nach Projekteinstellung meldet Xcode Warnungen oder Fehler wie:
Achtung
Main actor-isolated property 'quote' can not be mutated from a nonisolated context
oder, ohne Prüfung durch den Compiler, zur Laufzeit eine violette Warnung: Publishing changes from background threads is not allowed. Markiere ViewModels, die Oberflächenzustand halten, mit @MainActor.
Mehrfaches Laden
Ein Button startet bei jedem Tipp einen neuen Ladevorgang. Deaktiviere ihn, solange geladen wird.
.task wird mehrfach ausgeführt
.task läuft jedes Mal, wenn die View erscheint, also auch beim Zurücknavigieren zu ihr. Soll nur einmal geladen werden, prüf zuerst, ob schon Daten da sind: guard loader.quote == nil else { return }.
FAQ
Anfänger
Was bedeutet das überhaupt?Was ist der Unterschied zwischen .task und .onAppear?
Beide laufen, wenn die View erscheint. .onAppear ist synchron, .task asynchron und wird beim Verschwinden automatisch abgebrochen. Für alles mit await nimm .task.
Woher weiß ich, ob mein Code auf dem Main Thread läuft?
In Views: immer. In eigenen Klassen: wenn sie @MainActor sind oder dein Projekt das als Standard eingestellt hat. Xcode warnt dich, wenn du Oberflächenzustand von außerhalb ändern willst.
Muss ich ProgressView immer anzeigen?
Bei sehr schnellen Ladevorgängen unter einer Viertelsekunde kann ein aufblitzender Ladekreis sogar störend wirken. Bei allem, was spürbar dauert, ist eine Ladeanzeige aber wichtig, damit der Nutzer weiß, dass etwas passiert.
Fortgeschritten
Warum verhält es sich so?Läuft der Code in .task auf dem Main Thread, auch der langsame Teil?
Der Code in .task läuft auf dem Main Actor. Bei await wartet er aber nur. Die eigentliche Arbeit, etwa die Netzwerkanfrage, erledigt das System woanders. Problematisch wird es nur, wenn du rechenintensiven Code ohne await direkt in .task schreibst. Der blockiert dann tatsächlich den Main Thread. Wie du solche Arbeit auslagerst, zeigt die nächste Lektion.
Kann ich .task eine Priorität geben?
Ja: .task(priority: .background) { ... }. Für unwichtige Arbeit wie Vorladen von Bildern bekommt sie dann weniger Rechenzeit als Aufgaben, die der Nutzer gerade sieht.
Profi
Was passiert unter der Haube?Wie bricht .task eine Aufgabe ab?
SwiftUI hält den Task intern fest und ruft beim Verschwinden der View cancel() darauf auf. Weil Abbruch kooperativ ist (Lektion 3.6), endet die Aufgabe beim nächsten Punkt, der auf Abbruch prüft, etwa einem Task.sleep oder einer Netzwerkanfrage. Danach geschriebene Werte landen in einem @State, den es womöglich nicht mehr gibt, was aber harmlos ist.
Warum sind Views @MainActor?
Das Protokoll View ist seit einigen Swift-Versionen vollständig mit @MainActor markiert. Dadurch erbt jede eigene View diese Isolation, und der Compiler kann prüfen, dass Oberflächencode nie versehentlich von einem anderen Thread aus aufgerufen wird.
Übungen
Übung 1: Willkommensnachricht
Baue eine View, die beim Erscheinen mit .task zwei Sekunden wartet und dann „Willkommen zurück!“ anzeigt. Während des Wartens ist eine ProgressView zu sehen.
Lösung Übung 1Erst selbst probieren!
struct WelcomeView: View {
@State private var message: String?
var body: some View {
Group {
if let message {
Text(message).font(.title)
} else {
ProgressView()
}
}
.task {
try? await Task.sleep(for: .seconds(2))
message = "Willkommen zurück!"
}
}
}Übung 2: Neu laden bei Auswahl
Baue einen segmentierten Picker mit drei Städten. Mit .task(id:) wird bei jeder Auswahl eine simulierte Temperatur geladen (eine Sekunde warten, Zufallszahl zwischen −5 und 30). Während des Ladens steht „Lädt…“. Wechsle schnell zwischen den Städten. Was passiert mit den alten Ladevorgängen?
Übung 3: ViewModel mit Abbrechen
Baue ein @MainActor @Observable-ViewModel, das eine lange Aufgabe simuliert: zehn Schritte à 0,5 Sekunden, nach jedem Schritt wird ein progress-Wert erhöht. Die View zeigt eine ProgressView(value:) und Buttons zum Starten und Abbrechen. Merk dir dafür den Task im ViewModel und ruf beim Abbrechen cancel() darauf auf.
Zusammenfassung
.task { ... }startet asynchrone Arbeit, wenn eine View erscheint, und bricht sie automatisch ab, wenn sie verschwindet..task(id: wert)startet bei jeder Änderung des Werts neu und bricht alte Vorgänge ab.- Die Oberfläche darf nur vom Main Thread aus geändert werden.
- SwiftUI-Views sind
@MainActor. ViewModels, die Oberflächenzustand halten, markierst du ebenfalls mit@MainActor. - Aus Buttons startest du asynchrone Arbeit mit
Task { ... }und deaktivierst sie währenddessen. ProgressViewzeigt, dass etwas lädt.defersetzt Ladezustände zuverlässig zurück.
Spickzettel
.task {
await viewModel.load()
}
.task(id: selection) {
items = await load(selection)
}
@MainActor
@Observable
class Loader {
var isLoading = false
func load() async {
isLoading = true
defer { isLoading = false }
...
}
}
Button("Neu laden") { Task { await loader.load() } }
.disabled(loader.isLoading)