AsyncView – Asynchrone Ladevorgänge in SwiftUI

von @ralfebert · veröffentlicht am 16. November 2021
SwiftUI, Xcode 13 & iOS 15
Fortgeschrittene iOS-Entwickler*innen
Deutsch

Im folgenden Tutorial wird eine SwiftUI-View-Komponente zur Behandlung von Fehlern und Progress-Anzeige beim asynchronen Laden von Daten in SwiftUI-Apps entwickelt, basierend auf einem Beispielprojekt, das JSON-Daten per async/await lädt. Dies dient als Übung zur Erstellung von Abstraktionen und zur praktischen Anwendung von Swift-Generics.

Die resultierende Komponente eignet sich als fertiges Package für den Einsatz in Projekten, die lediglich Daten von URL-Endpunkten laden und mit SwiftUI anzeigen wollen, sowie als Ausgangspunkt für Projekte, die eine komplexere Struktur benötigen.

  1. Lade den Start-Stand von dem Countries-Projekt. Dieses implementiert das Laden einer Liste von Ländern im JSON-Format per async/await. Mache Dich mit dem Code in dem Projekt vertraut. Sollte hier noch etwas Fragen aufwerfen, kannst Du Dich mit dem Tutorial → JSON-Daten mit async/await laden in dieses Projekt einarbeiten.

    Ergebnis Länderanzeige via JSON
  2. In dem Projekt fehlt das Fehlerhandling und eine Progress-Anzeige während des Ladevorgangs. Im CountriesModel werden Fehler lediglich auf der Konsole ausgegeben:

    @MainActor
    class CountriesModel: ObservableObject {
        @Published var countries: [Country] = []
    
        func reload() async {
            let url = URL(string: "https://www.ralfebert.de/examples/v3/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))")
            }
        }
    }
    

    Im Folgenden wird eine Abstraktion entwickelt für die Fehleranzeige und Progress-Anzeige von diesem Ladevorgang:

    Am Ende findest Du Beispiele für das resultierende AsyncView-Package.

  3. Extrahiere die URL- und Dekodierlogik in einen separaten Typ CountriesEndpoints:

    struct CountriesEndpoints {
        let urlSession = URLSession.shared
        let jsonDecoder = JSONDecoder()
    
        func countries() async throws -> [Country] {
            let url = URL(string: "https://www.ralfebert.de/examples/v3/countries.json")!
            let (data, _) = try await urlSession.data(from: url)
            return try jsonDecoder.decode([Country].self, from: data)
        }
    }
    

    und verwende diesen im CountriesModel:

    @MainActor
    class CountriesModel: ObservableObject {
        @Published var countries: [Country] = []
    
        func reload() async {
            do {
                let endpoints = CountriesEndpoints()
    self.countries = try await endpoints.countries()
            } catch {
                // Error handling in case the data couldn't be loaded
                // For now, only display the error on the console
                debugPrint("Error: \(String(describing: error))")
            }
        }
    }
    
  4. Verwende den Result-Typ, um in der Klasse CountriesModel den Zustand „ein Fehler ist aufgetreten“ abzubilden.

    Lösung anzeigen

    @MainActor
    class CountriesModel: ObservableObject {
        @Published var result: Result<[Country], Error> = .success([])
    
        func reload() async {
            do {
                let endpoints = CountriesEndpoints()
                self.result = .success(try await endpoints.countries())
            } catch {
                self.result = .failure(error)
            }
        }
    }
    
  5. Passe das View entsprechend an, so dass im Fehlerfall eine Fehlermeldung angezeigt wird.

    Lösung anzeigen

    struct CountriesView: View {
        @StateObject var countriesModel = CountriesModel()
    
        var body: some View {
            Group {
        switch countriesModel.result {
            case let .success(countries):
                List(countries) { country in
                    Text(country.name)
                }
            case let .failure(error):
                Text(error.localizedDescription)
        }
    }
            .task {
                await self.countriesModel.reload()
            }
            .refreshable {
                await self.countriesModel.reload()
            }
        }
    }
    
  6. Definiere einen eigenen Enum-Typ ähnlich zu dem Swift-Result-Typ und ergänze ein case für die Zustände „Ladevorgang läuft“ sowie „Leer/noch nicht geladen“:

    enum AsyncResult<Success> {
        case empty
        case inProgress
        case success(Success)
        case failure(Error)
    }
    
  7. Passe das CountriesModel entsprechend an.

    Lösung anzeigen

    @MainActor
    class CountriesModel: ObservableObject {
        @Published var result: AsyncResult<[Country]> = .empty
    
        func reload() async {
            self.result = .inProgress
            do {
                let endpoints = CountriesEndpoints()
                self.result = .success(try await endpoints.countries())
            } catch {
                self.result = .failure(error)
            }
        }
    }
    
  8. Passe das View entsprechend an.

    Lösung anzeigen

    struct CountriesView: View {
        @StateObject var countriesModel = CountriesModel()
    
        var body: some View {
            Group {
                switch countriesModel.result {
                    case .empty:
        EmptyView()
    case .inProgress:
        ProgressView()
                    case let .success(countries):
                        List(countries) { country in
                            Text(country.name)
                        }
                    case let .failure(error):
                        Text(error.localizedDescription)
                }
            }
            .task {
                await self.countriesModel.reload()
            }
            .refreshable {
                await self.countriesModel.reload()
            }
        }
    }
    
  9. Extrahiere den switch/case-Block, der die verschiedenen Zustände behandelt, in ein allgemeines, wiederverwendbares View AsyncResultView, das folgendermaßen benutzt werden kann:

    struct CountriesView: View {
        @StateObject var countriesModel = CountriesModel()
    
        var body: some View {
            AsyncResultView(countriesModel.result) { countries in
        List(countries) { country in
            Text(country.name)
        }
    }
            .task {
                await self.countriesModel.reload()
            }
            .refreshable {
                await self.countriesModel.reload()
            }
        }
    }
    

    Dies ist etwas knifflig, da sowohl der Datentyp für den Erfolgsfall als auch der zugehörige View-Typ als generisches Argument deklariert werden muss, um dieses View mit beliebigen Datentypen und beliebigen Views verwenden zu können:

    struct AsyncResultView<Success, Content: View>: View {
        let result: AsyncResult<Success>
        let content: (_ item: Success) -> Content
    
        init(result: AsyncResult<Success>, @ViewBuilder content: @escaping (_ item: Success) -> Content) {
            self.result = result
            self.content = content
        }
    
        var body: some View {
            switch result {
                case .empty:
                    EmptyView()
                case .inProgress:
                    ProgressView()
                case let .success(value):
                    content(value)
                case let .failure(error):
                    Text(error.localizedDescription)
            }
        }
    }
    
  10. Aus CountriesModel kann nun ein generischer Typ AsyncModel werden. Dieser führt die asynchrone Operation aus, die als Block übergeben wird:

    @MainActor
    class AsyncModel<Success>: ObservableObject {
        @Published var result: AsyncResult<Success> = .empty
    
        typealias AsyncOperation = () async throws -> Success
    
    var operation : AsyncOperation
    
    init(operation : @escaping AsyncOperation) {
        self.operation = operation
    }
    
        func reload() async {
            self.result = .inProgress
    
            do {
                self.result = .success( try await operation())
            } catch {
                self.result = .failure(error)
            }
        }
    }
    
  11. AsyncModel kann nun im View verwenden, um den Ladevorgang zu koordinieren:

    struct CountriesView: View {
        @StateObject var countriesModel = AsyncModel { try await CountriesEndpoints().countries() }
    
        var body: some View {
            AsyncResultView(result: countriesModel.result) { countries in
                List(countries) { country in
                    Text(country.name)
                }
            }
            .task {
                await self.countriesModel.reload()
            }
            .refreshable {
                await self.countriesModel.reload()
            }
        }
    }
    
  12. Extrahiere einen generischen Typ AsyncModelView aus dem CountriesView der sich folgendermaßen verwenden lässt:

    struct CountriesView: View {
        @StateObject var countriesModel = AsyncModel { try await CountriesEndpoints().countries() }
    
        var body: some View {
            AsyncModelView(model: countriesModel) { countries in
       List(countries) { country in
           Text(country.name)
       }
    }
        }
    }
    

    Implementierung:

    struct AsyncModelView<Success, Content: View>: View {
        @ObservedObject var model: AsyncModel<Success>
        let content: (_ item: Success) -> Content
    
        var body: some View {
            AsyncResultView(
                result: model.result,
                content: content
            )
            .task {
                await model.reload()
            }
            .refreshable {
                await model.reload()
            }
        }
    }
    

Package AsyncView

Die generischen Typen aus diesem Tutorial habe ich als Package AsyncView bereitgestellt. Mit diesem lässt sich eine asynchrone Ladeoperation inkl. Fehlerhandling und Progress-Anzeige folgendermaßen implementieren:

import SwiftUI
import AsyncView

struct CountriesView: View {
    @StateObject var countriesModel = AsyncModel { try await CountriesEndpoints().countries() }

    var body: some View {
        AsyncModelView(model: countriesModel) { countries in
    List(countries) { country in
        Text(country.name)
    }
}
    }
}

Es ist auch möglich, das Model als separate Klasse zu definieren:

class CountriesModel: AsyncModel<[Country]> {
    override func asyncOperation() async throws -> [Country] {
        try await CountriesEndpoints().countries()
    }
}

struct CountriesView: View {
    @StateObject var countriesModel = CountriesModel()

    var body: some View {
        AsyncModelView(model: countriesModel) { countries in
            List(countries) { country in
                Text(country.name)
            }
        }
    }
}

Wenn es lediglich um das Laden von URL-Daten ohne weitere zusätzliche Logik geht, lässt sich dies noch weiter verkürzen zu:

import SwiftUI
import AsyncView

struct CountriesView: View {
    var body: some View {
        AsyncView(
    operation: { try await CountriesEndpoints().countries() },
    content: { countries in
        List(countries) { country in
            Text(country.name)
        }
    }
)
    }
}