AsyncView – Asynchrone Ladevorgänge in SwiftUI
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.
-
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.
-
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.
-
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))") } } }
-
Verwende den Result-Typ, um in der Klasse CountriesModel den Zustand „ein Fehler ist aufgetreten“ abzubilden.
@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) } } }
-
Passe das View entsprechend an, so dass im Fehlerfall eine Fehlermeldung angezeigt wird.
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() } } }
-
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) }
-
Passe das CountriesModel entsprechend an.
@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) } } }
-
Passe das View entsprechend an.
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() } } }
-
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) } } }
-
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) } } }
-
AsyncModel kann nun im View verwendet werden, 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() } } }
-
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)
}
}
)
}
}