JSON-Daten mit async/await laden
Dieses Tutorial zeigt Schritt für Schritt das neue Swift-Sprachfeature async/await für nebenläufige Programmierung. Als Beispiel wird das Laden von JSON-Daten im Hintergrund verwendet. Die JSON-Daten werden mit der URLSession geladen, mit der JSONDecoder-Klasse dekodiert und mit SwiftUI angezeigt.
Dieses Tutorial setzt Swift & SwiftUI-Vorkenntnisse voraus. Sofern Du noch nicht mit SwiftUI gearbeitet hast, empfehle ich zuerst das → einführende SwiftUI-Tutorial durchzuarbeiten.
-
Verwende für dieses Tutorial die aktuelle Version von Xcode 13 (dieses Tutorial wurde zuletzt getestet am 27. September 2021 mit Xcode 13).
-
Erstelle ein neues App-Projekt Countries basierend auf SwiftUI.
-
Füge dem Projekt eine neue Swift-Datei mit einem Datentyp Country hinzu. Deklariere hier einige statischen Beispieldaten:
struct Country: Identifiable { var id: String var name: String static let allCountries = [ Country(id: "be", name: "Belgium"), Country(id: "bg", name: "Bulgaria"), Country(id: "el", name: "Greece"), Country(id: "lt", name: "Lithuania"), Country(id: "pt", name: "Portugal"), ] }
-
Implementiere in ContentView mit einem ↗ List-View eine Listendarstellung der Länder:
struct ContentView: View { var body: some View { List(Country.allCountries) { country in Text(country.name) } } }
-
Benenne das ContentView via Refactor » Rename in CountriesView um.
-
Öffne die Beispiel-JSON-Daten im Browser und mache Dich mit dem Format der Daten vertraut:
-
Erstelle eine neue Klasse CountriesModel, die für das Laden und Halten der Daten zuständig ist. Lasse diese von ↗ ObservableObject erben und deklariere eine ↗ @Published-Eigenschaft countries. Dadurch wird das Objekt beobachtbar - wenn sich die Liste der Länder später ändert, kann das View darauf reagieren.
class CountriesModel: ObservableObject { @Published var countries = Country.allCountries }
-
Verwende dieses Objekt für die Länderliste im View. Deklariere ein Property als ↗ @StateObject, damit SwiftUI die Instanz von dem Objekt verwaltet und bei Änderungen das View automatisch aktualisiert:
struct CountriesView: View { @StateObject var countriesModel = CountriesModel() var body: some View { List( countriesModel.countries) { country in Text(country.name) } } }
-
Erstelle eine Methode reload im CountriesModel. Deklariere diese als ↗ async - dies ist ein neues Feature von iOS 15 für asynchon ablaufende Vorgänge:
class CountriesModel: ObservableObject { @Published var countries = Country.allCountries func reload() async { } }
-
Verwende die neue asynchrone Methode ↗ data(from:) der ↗ URLSession um einen Ladevorgang zu starten. Verwende das Schlüsselwort await, um an der Stelle die Ausführung der Methode zu unterbrechen und erst fortzusetzen, wenn die Daten geladen wurden:
func reload() async { let url = URL(string: "https://www.ralfebert.de/examples/v3/countries.json")! let urlSession = URLSession.shared let (data, response) = try! await urlSession.data(from: url) }
Hinweis: Der Rückgabetyp dieser Methode ist ein ↗ Tupel das zwei Werte enthält: die geladenen Daten und der HTTP Response. Mit der obigen Syntax können direkt die zwei Teile zugewiesen werden.
-
Füge eine provisorische Fehlerbehandlung hinzu, damit die App nicht durch das try! im Fehlerfall crasht:
do { let (data, response) = try await urlSession.data(from: url) } catch { // Error handling in case the data couldn't be loaded // For now, only display the error on the console debugPrint("Error loading \(url): \(String(describing: error))") }
-
Entferne in Countries.swift die Eigenschaft allCountries mit den Beispieldaten und deklariere den Typ als Codable:
struct Country : Identifiable, Codable { var id: String var name: String }
-
Passe die countries-Eigenschaft im CountriesModel so an, dass diese zunächst mit einer leeren Liste initialisiert wird:
class CountriesModel: ObservableObject { @Published var countries : [Country] = [] // ... }
-
Ergänze nach dem await-Aufruf das Dekodieren der geladenen JSON-Daten. Hier werden durch das neue async/await-Sprachfeature keine umständlichen Completion-Handler mehr benötigt sondern es kann direkt mit den geladenen Daten weitergearbeitet werden. Verwende hier einen → JSONDecoder um die geladenen Daten zu dekodieren:
func reload() async { let url = URL(string: "https://www.ralfebert.de/examples/v2/countries.json")! let urlSession = URLSession.shared do { let (data, response) = try await urlSession.data(from: url) self.countries = try JSONDecoder().decode([Country].self, from: data) } catch { // Error handling in case the data couldn't be loaded // For now, only display the error on the console debugPrint("Error loading \(url): \(String(describing: error))") } }
-
Füge im CountriesView testweise einen onAppear-Modifier hinzu, um das Laden der Daten auszulösen:
struct CountriesView: View { @StateObject var countriesModel = CountriesModel() var body: some View { List(countriesModel.countries) { country in Text(country.name) } .onAppear { self.countriesModel.reload() } } }
Dies wird einen Fehler verursachen: als async deklarierte Methoden dürfen nicht einfach so aufgerufen werden. Nur als Teil von einem Task funktioniert das Pausieren und Fortsetzen nach dem async/await-Prinzip:
-
Verwende stattdessen den ↗ .task-Modifier um das Daten der Laden auszulösen. Ergänze zudem einen ↗ .refreshable-Modifier um das Neuladen der Daten durch ein Herunterziehen des Views zu unterstützen:
struct CountriesView: View { @StateObject var countriesModel = CountriesModel() var body: some View { List(countriesModel.countries) { country in Text(country.name) } .task { await self.countriesModel.reload() } .refreshable { await self.countriesModel.reload() } } }
-
Würde die App so ausgeführt werden, käme es zu einer Warnung Publishing changes from background threads is not allowed; make sure to publish values from the main thread (via operators like receive(on:)) on model updates kommen, weil die Fortsetzung der Ausführung der reload-Methode nach der Unterbrechung mittels await durch den Hintergrund-Thread erfolgt, der die Daten geladen hat.
Dieses Problem lässt sich beheben, indem die gesamte Klasse als ↗ @MainActor deklariert wird, um sicherzustellen, das alle Methoden dieser Klasse auf dem Main-Thread ausgeführt werden:
@MainActor class CountriesModel: ObservableObject { // ... }
-
Starte die App mit Product » Run ⌘R und prüfe, dass die Länder geladen und angezeigt werden. Es sollte zudem ein Neuladen der Daten durch Ziehen nach unten möglich sein:
struct Country: Identifiable, Codable { var id: String var name: String } @MainActor class CountriesModel: ObservableObject { @Published var countries : [Country] = [] func reload() async { let url = URL(string: "https://www.ralfebert.de/examples/v2/countries.json")! let urlSession = URLSession.shared do { let (data, _) = try await urlSession.data(from: url) self.countries = try JSONDecoder().decode([Country].self, from: data) } catch { // Error handling in case the data couldn't be loaded // For now, only display the error on the console debugPrint("Error loading \(url): \(String(describing: error))") } } } struct CountriesView: View { @StateObject var countriesModel = CountriesModel() var body: some View { List(countriesModel.countries) { country in Text(country.name) } .task { await self.countriesModel.reload() } .refreshable { await self.countriesModel.reload() } } }