JSON-Daten mit async/await laden

von @ralfebert · aktualisiert am 27. September 2021
SwiftUI, Xcode 13 & iOS 15
Diesen Artikel gibt es auch für:
Fortgeschrittene iOS-Entwickler*innen
Deutsch

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.

  1. Verwende für dieses Tutorial die aktuelle Version von Xcode 13 (dieses Tutorial wurde zuletzt getestet am 27. September 2021 mit Xcode 13).

  2. Erstelle ein neues App-Projekt Countries basierend auf SwiftUI.

  3. 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"),
        ]
    }
    
  4. Implementiere in ContentView mit einem List-View eine Listendarstellung der Länder:

    Beispielprojekt Countries für UITableViewController
    struct ContentView: View {
        var body: some View {
            List(Country.allCountries) { country in
                Text(country.name)
            }
        }
    }
    
  5. Benenne das ContentView via Refactor » Rename in CountriesView um.

  6. Öffne die Beispiel-JSON-Daten im Browser und mache Dich mit dem Format der Daten vertraut:

    Anzeige der JSON-Beispieldaten im Browser
  7. 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
    }
    
  8. 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)
            }
        }
    }
    
  9. 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 {
        }
    }
    
  10. 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.

  11. 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))")
    }
    
  12. 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
    }
    
  13. 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] = []
    
        // ...
    }
    
  14. 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))")
        }
    }
    
  15. 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:

  16. 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()
    }
        }
    }
    
  17. 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 {
        // ...
    }
    
  18. 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:

    Ergebnis Länderanzeige via JSON

    Lösung anzeigen

    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()
            }
        }
    }
    

Weitere Informationen