Tutorial: JSON-Daten in SwiftUI per async/await laden

von @ralfebert · aktualisiert am 27. September 2021

Dieses Tutorial zeigt Schritt für Schritt das neue Swift-Sprachfeature async/await für nebenläufige Programmierung anhand des Ladens von JSON-Daten im Hintergrund. Die JSON-Daten werden mit der URLSession geladen, mit der JSONDecoder-Klasse dekodiert und mit SwiftUI angezeigt.

Varianten: SwiftUI & Xcode 13 SwiftUI & Xcode 12 UIKit & Xcode 12

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 und einigen statischen Beispieldaten hinzu:

    struct Country: Identifiable {
    
        var id: String
        var name: String
    
        static let allCountries = [
            Country(id: "be", name: "Belgien"),
            Country(id: "bg", name: "Bulgarien"),
            Country(id: "el", name: "Griechenland"),
            Country(id: "lt", name: "Litauen"),
            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. Rufe im Browser die Beispiel-JSON-Daten auf und mache Dich mit dem Format der Daten vertraut:

    Anzeige der JSON-Beispieldaten im Browser
  7. Erstelle eine 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 und 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/v2/countries.json")!
        let urlSession = URLSession.shared
        let (data, response) = try! await urlSession.data(from: url)
    }
    

    Hinweis: Der Rückgabetyp dieser Methode ist ein ↗ Tupel und mit der Syntax oben können direkt die zwei Teile - die geladenen Daten und der HTTP Response - übernommen 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-Block 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 sondern benötigen einen Task. 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 und 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 da 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()
            }
        }
    }