Lernziel
Nach dieser Lektion kannst du Abhängigkeiten in einer SwiftUI-App sauber austauschbar machen: über Initializer, über die Environment mit @Entry, und an einer zentralen Stelle, an der du für App, Previews und Tests jeweils die passenden Dienste zusammenstellst. Du kennst die Vor- und Nachteile von Singletons und weißt, welches Muster wann passt.
Das Problem: Überall .shared
Schau dir an, wie Code oft entsteht:
struct RecipeListView: View {
@State private var recipes: [Meal] = []
var body: some View {
List(recipes) { ... }
.task {
recipes = (try? await MealAPI.shared.meals(in: "Dessert")) ?? []
}
}
}
struct FavoriteButton: View {
let meal: Meal
var body: some View {
Button("Favorit") {
FavoritesStore.shared.toggle(id: meal.id)
Analytics.shared.track("favorite")
}
}
}Jede View holt sich, was sie braucht, über ein globales .shared-Objekt, ein sogenanntes Singleton. Das ist bequem. Aber:
- Previews laden echte Daten aus dem Netz, sind langsam und funktionieren offline gar nicht.
- Tests (Lektion 4.11) können die Dienste nicht ersetzen.
- Du kannst nicht sehen, wovon eine View abhängt, ohne jede Zeile zu lesen.
- Willst du die API austauschen, etwa gegen eine Version mit Cache, musst du jede Stelle ändern.
Die Lösung heißt Dependency Injection, auf Deutsch etwa „Abhängigkeiten von außen hineingeben“. Der Name klingt kompliziert, die Idee ist einfach: Ein Baustein holt sich seine Abhängigkeiten nicht selbst, er bekommt sie.
Ein Bild dafür
Eine Lampe mit fest angelötetem Kabel funktioniert nur an genau einer Steckdose. Eine Lampe mit Stecker funktioniert überall: zu Hause, im Büro, an einem Prüfgerät in der Werkstatt. Dependency Injection gibt deinem Code einen Stecker.
Weg 1: Über den Initializer
Den einfachsten Weg kennst du schon aus Lektion 4.11: Die Abhängigkeit kommt als Parameter, mit einem sinnvollen Standardwert.
@Observable
class MealListModel {
private let service: any MealService
init(service: any MealService = MealAPI()) {
self.service = service
}
}Das ist ideal für Modelle und Logik: Jede Abhängigkeit ist im Initializer sichtbar, und Tests übergeben einfach einen Ersatz.
Für Views wird es aber mühsam. Braucht eine View tief unten im Baum den Dienst, müsste jede View auf dem Weg dorthin ihn als Parameter durchreichen, auch wenn sie ihn selbst gar nicht braucht. Das ist dasselbe Problem wie beim Durchreichen von Daten, das du aus Lektion 3.3 kennst.
Weg 2: Über die Environment
Für Views ist die Environment der natürliche Weg (Lektion 3.3). Du kennst sie für @Observable-Objekte. Für Dienste, die über ein Protokoll beschrieben sind, eignet sich ein eigener Environment-Schlüssel mit @Entry:
extension EnvironmentValues {
@Entry var mealService: any MealService = MealAPI()
}@Entryerzeugt den Schlüssel mit einem Standardwert, hier die echte API.- Jede View kann ihn lesen, egal wie tief sie im Baum steckt.
struct RecipeListView: View {
@Environment(\.mealService) private var service
@State private var recipes: [Meal] = []
var body: some View {
List(recipes) { ... }
.task {
recipes = (try? await service.meals(in: "Dessert")) ?? []
}
}
}Und an jeder Stelle im Baum kannst du ihn ersetzen:
#Preview("Mit Beispieldaten") {
RecipeListView()
.environment(\.mealService, PreviewMealService())
}
#Preview("Fehler") {
RecipeListView()
.environment(\.mealService, FailingMealService())
}Erwartetes Ergebnis
Die Previews erscheinen sofort, ohne Netzwerk, mit genau den Daten, die du sehen willst: einmal eine Liste mit Beispielrezepten, einmal der Fehlerzustand. Die App selbst verwendet weiterhin die echte API, weil das der Standardwert ist.
Die Ersatzdienste sind kleine Structs:
struct PreviewMealService: MealService {
func meals(in category: String) async throws -> [Meal] {
[
Meal(id: "1", name: "Pfannkuchen", thumbnail: nil),
Meal(id: "2", name: "Apfelstrudel", thumbnail: nil),
Meal(id: "3", name: "Kaiserschmarrn", thumbnail: nil)
]
}
}
struct FailingMealService: MealService {
func meals(in category: String) async throws -> [Meal] {
throw URLError(.notConnectedToInternet)
}
}Environment und ViewModels
Was, wenn ein ViewModel den Dienst braucht? Ein ViewModel ist keine View und kann @Environment nicht lesen. Die View liest den Dienst und gibt ihn weiter:
struct RecipeScreen: View {
@Environment(\.mealService) private var service
@State private var model: MealListModel?
var body: some View {
Group {
if let model {
MealListContent(model: model)
} else {
ProgressView()
}
}
.task {
if model == nil {
model = MealListModel(service: service)
}
}
}
}Das Modell wird erst in .task erzeugt, wenn die Environment verfügbar ist, und nur einmal. Im Initializer der View geht das nicht, weil @Environment dort noch keinen Wert hat.
Tipp
Dieses Muster ist etwas umständlich. Oft ist es einfacher, das Modell eine Ebene höher zu erzeugen, wo der Dienst ohnehin bekannt ist, etwa in der App-Struktur, und das fertige Modell über .environment(model) weiterzugeben.
Weg 3: Eine zentrale Stelle für alle Dienste
Hat eine App mehrere Dienste, also API, Favoriten, Analyse, Benachrichtigungen, lohnt es sich, sie an einer Stelle zusammenzustellen. Diese Stelle heißt oft Composition Root, die Wurzel, an der alles zusammengesteckt wird.
struct AppDependencies {
var mealService: any MealService
var favorites: FavoritesStore
var analytics: any AnalyticsService
static func live() -> AppDependencies {
AppDependencies(
mealService: MealAPI(),
favorites: FavoritesStore(defaults: .standard),
analytics: ConsoleAnalytics()
)
}
static func preview() -> AppDependencies {
AppDependencies(
mealService: PreviewMealService(),
favorites: FavoritesStore(defaults: UserDefaults(suiteName: "preview")!),
analytics: NoAnalytics()
)
}
}Für analytics gibt es wie für mealService einen eigenen @Entry-Schlüssel. Ein kleiner Modifier hängt dann alle Dienste auf einmal an:
extension View {
func dependencies(_ deps: AppDependencies) -> some View {
self
.environment(\.mealService, deps.mealService)
.environment(\.analytics, deps.analytics)
.environment(deps.favorites)
}
}In der App:
@main
struct RecipesApp: App {
@State private var deps = AppDependencies.live()
var body: some Scene {
WindowGroup {
ContentView()
.dependencies(deps)
}
}
}Und in jeder Preview:
#Preview {
RecipeDetail(meal: .sample)
.dependencies(.preview())
}Der große Vorteil: Auf einen Blick siehst du alle Dienste der App, und für jede Umgebung, ob echte App, Previews, UI-Tests (Lektion 4.12) oder später eine Demo-Version, gibt es genau eine Stelle, an der festgelegt wird, was verwendet wird.
UserDefaults austauschbar machen
FavoritesStore(defaults:) bekommt seine UserDefaults von außen. Mit UserDefaults(suiteName: "preview") entsteht ein eigener, getrennter Speicher. Previews und Tests überschreiben so nicht die echten Favoriten. In Tests kannst du den Speicher am Ende mit removePersistentDomain(forName:) wieder leeren.
Und Singletons?
Sind Singletons also verboten? Nein. Apple selbst verwendet sie: URLSession.shared, UserDefaults.standard, FileManager.default. Sie sind in Ordnung, wenn:
- es den Dienst wirklich nur einmal geben kann und
- du ihn in Tests und Previews nicht ersetzen musst.
Das Problem ist nicht das Singleton selbst, sondern wenn Code es sich überall direkt holt. Ein guter Kompromiss: Das Singleton darf es geben, aber nur an der zentralen Stelle wird es verwendet, als Standardwert, und alle anderen bekommen es übergeben.
init(session: URLSession = .shared) { ... } // ✅ austauschbar, bequemWelches Muster wann?
| Situation | Muster |
|---|---|
| Modelle, ViewModels, Logik | Initializer mit Standardwert |
| Views, die einen Dienst brauchen | Environment mit @Entry |
| Geteilter, beobachtbarer Zustand (Favoriten, Router) | @Observable über .environment(objekt) |
| Mehrere Dienste, verschiedene Umgebungen | zentrale Stelle mit AppDependencies |
Typische Fehler
Preview lädt echte Daten oder stürzt ab
Die View holt sich einen Dienst über .shared oder erzeugt ihn selbst. Lies ihn aus der Environment und ersetze ihn in der Preview.
@Environment im Initializer ist leer
Environment-Werte stehen erst in body, .task oder .onAppear zur Verfügung, nicht im init der View. Erzeuge abhängige Modelle in .task oder eine Ebene höher.
Abhängigkeiten werden überall einzeln durchgereicht
Lange Parameterketten durch mehrere Views, die den Dienst gar nicht selbst brauchen. Für Views ist die Environment der bessere Weg.
Fehlendes Objekt in der Environment
No Observable object of type FavoritesStore found.Ein @Environment(FavoritesStore.self) wird gelesen, aber nirgends darüber mit .environment(...) gesetzt. Passiert oft in Previews. Mit einem dependencies(.preview())-Modifier ist das ein für alle Mal gelöst.
FAQ
Anfänger
Was bedeutet das überhaupt?Ist das nicht viel Aufwand für eine kleine App? Für eine Übungs-App mit einem Bildschirm schon. Sobald du Previews mit festen Daten willst oder Tests schreibst, lohnt sich zumindest der Environment-Schlüssel für den wichtigsten Dienst. Die zentrale Stelle wird nützlich, sobald es drei oder mehr Dienste gibt.
Was ist der Unterschied zwischen @Entry und .environment(objekt)?
.environment(objekt) legt ein @Observable-Objekt nach seinem Typ ab. Views beobachten seine Eigenschaften. @Entry legt einen Wert unter einem Namen ab, der beliebigen Typ haben darf, auch ein Protokoll wie any MealService. Für austauschbare Dienste hinter Protokollen ist @Entry die passende Wahl.
Fortgeschritten
Warum verhält es sich so?Kann ich Abhängigkeiten auch in einem Teilbaum ersetzen?
Ja, das ist eine Stärke der Environment. Ein .environment(\.mealService, CachedMealService()) an einem Teilbaum gilt nur für die Views darunter. So kannst du etwa einem einzelnen Bildschirm einen anderen Dienst geben.
Was ist mit Bibliotheken für Dependency Injection? Es gibt Bibliotheken, die Abhängigkeiten global registrieren und per Property Wrapper abrufen. Sie sparen in großen Projekten Schreibarbeit. Für die meisten SwiftUI-Apps reichen Initializer und Environment völlig aus und sind ohne zusätzliche Abhängigkeit verständlich.
Profi
Was passiert unter der Haube?Wie stelle ich sicher, dass ein any MealService sicher über Actor-Grenzen geht?
Das Protokoll erbt von Sendable (Lektion 4.11). Damit verlangt der Compiler, dass jede Umsetzung sicher geteilt werden kann. Ein Struct ohne veränderlichen Zustand erfüllt das automatisch, eine Klasse braucht final und unveränderliche Eigenschaften oder einen eigenen Actor.
Wie wähle ich die Abhängigkeiten in UI-Tests?
Über Startargumente (Lektion 4.12): In der App-Struktur prüfst du CommandLine.arguments und wählst AppDependencies.live() oder eine Variante wie .uiTesting() mit festen Daten und einer Datenbank im Arbeitsspeicher. So steuert der UI-Test die App vollständig, ohne Netzwerk.
Übungen
Übung 1: Preview ohne Netzwerk
Gib der Rezeptliste aus Projekt D einen Environment-Schlüssel mealService und baue zwei Previews: eine mit drei Beispielrezepten und eine mit dem Fehlerzustand. Prüfe, dass beide auch im Flugmodus funktionieren.
Übung 2: Langsamer Dienst
Baue einen SlowMealService, der zwei Sekunden wartet, bevor er Beispieldaten liefert, und eine Preview dafür. So kannst du deinen Ladezustand ansehen, ohne dein Netzwerk zu drosseln.
Lösung Übung 2Erst selbst probieren!
struct SlowMealService: MealService {
var delay: Duration = .seconds(2)
var meals: [Meal] = [Meal(id: "1", name: "Pfannkuchen", thumbnail: nil)]
func meals(in category: String) async throws -> [Meal] {
try await Task.sleep(for: delay)
return meals
}
}
#Preview("Langsam") {
NavigationStack {
RecipeListView()
}
.environment(\.mealService, SlowMealService())
}Die Preview zeigt zwei Sekunden lang den Ladezustand und danach die Liste. Mit delay: .seconds(60) kannst du den Ladezustand in Ruhe gestalten.
Übung 3: Zentrale Abhängigkeiten
Stell die Rezept-App auf eine AppDependencies-Struktur mit live(), preview() und uiTesting() um. Die App wählt uiTesting(), wenn sie mit -uiTesting gestartet wird. Ersetze alle .shared-Zugriffe in Views.
Zusammenfassung
- Dependency Injection: Ein Baustein bekommt seine Abhängigkeiten, statt sie sich selbst zu holen.
- Initializer mit Standardwert für Modelle und Logik: sichtbar, einfach, testbar.
- Environment mit
@Entryfür Dienste, die Views brauchen, ersetzbar in Previews und Teilbäumen. @Environmenthat iminitnoch keinen Wert: abhängige Modelle in.taskoder eine Ebene höher erzeugen.- Eine zentrale Stelle wie
AppDependenciesstellt alle Dienste für App, Previews und Tests zusammen. - Singletons sind in Ordnung als Standardwert, aber nicht als direkter Zugriff überall im Code.
Spickzettel
// Initializer
init(service: any MealService = MealAPI()) { self.service = service }
// Environment
extension EnvironmentValues {
@Entry var mealService: any MealService = MealAPI()
}
@Environment(\.mealService) private var service
.environment(\.mealService, PreviewMealService())
// Zentrale Stelle
struct AppDependencies {
var mealService: any MealService
static func live() -> Self { .init(mealService: MealAPI()) }
static func preview() -> Self { .init(mealService: PreviewMealService()) }
}
extension View {
func dependencies(_ d: AppDependencies) -> some View {
environment(\.mealService, d.mealService)
}
}
#Preview { MyView().dependencies(.preview()) }