Lektion 4.13 · Stufe 4 – Profi

Dependency Injection

ca. 15 Minuten Lesezeit

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:

Swift
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.

Swift
@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:

Swift
extension EnvironmentValues {
    @Entry var mealService: any MealService = MealAPI()
}
  • @Entry erzeugt den Schlüssel mit einem Standardwert, hier die echte API.
  • Jede View kann ihn lesen, egal wie tief sie im Baum steckt.
Swift
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:

Swift
#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:

Swift
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:

Swift
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.

Swift
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:

Swift
extension View {
    func dependencies(_ deps: AppDependencies) -> some View {
        self
            .environment(\.mealService, deps.mealService)
            .environment(\.analytics, deps.analytics)
            .environment(deps.favorites)
    }
}

In der App:

Swift
@main
struct RecipesApp: App {
    @State private var deps = AppDependencies.live()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .dependencies(deps)
        }
    }
}

Und in jeder Preview:

Swift
#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.

Swift
init(session: URLSession = .shared) { ... }   // ✅ austauschbar, bequem

Welches Muster wann?

SituationMuster
Modelle, ViewModels, LogikInitializer mit Standardwert
Views, die einen Dienst brauchenEnvironment mit @Entry
Geteilter, beobachtbarer Zustand (Favoriten, Router)@Observable über .environment(objekt)
Mehrere Dienste, verschiedene Umgebungenzentrale 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

Text
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

Anfänger

Ü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.

Fortgeschritten

Ü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!
Swift
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.

Profi

Ü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 @Entry für Dienste, die Views brauchen, ersetzbar in Previews und Teilbäumen.
  • @Environment hat im init noch keinen Wert: abhängige Modelle in .task oder eine Ebene höher erzeugen.
  • Eine zentrale Stelle wie AppDependencies stellt alle Dienste für App, Previews und Tests zusammen.
  • Singletons sind in Ordnung als Standardwert, aber nicht als direkter Zugriff überall im Code.

Spickzettel

Swift
// 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()) }

Lektion geschafft?

Markiere sie als erledigt, damit du deinen Fortschritt siehst.