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

von @ralfebert · veröffentlicht am 15. Juli 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 gute Swift & SwiftUI-Kenntnisse voraus. Sofern Du noch nicht mit SwiftUI gearbeitet hast, empfehle ich zuerst das → einführende SwiftUI-Tutorial und die → Übersicht über die Verwendung von JSON-Daten in Swift durchzuarbeiten.

  1. Verwende für dieses Tutorial die aktuelle Version von Xcode 13 (dieses Tutorial wurde zuletzt getestet am 15. Juli 2021 mit Xcode 13 Beta 3).

  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. Deklariere es zudem als ↗ @MainActor um sicherzustellen, das alle Methoden dieser Klasse auf dem Main-Thread ausgeführt werden:

    @MainActor
    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. 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()
            }
        }
    }