Dependency Injection in einer WinUI-3-Desktop-App (Teil 6)
Stell dir vor, ein ViewModel braucht einen Datenbankservice. Der Datenbankservice braucht einen DbContext. Der DbContext braucht eine Verbindungszeichenkette. Wer baut das alles zusammen? Die Antwort ist Dependency Injection — ein Muster, bei dem Objekte ihre Abhängigkeiten nicht selbst erzeugen, sondern von außen bekommen. Das macht den Code testbar, wartbar und entkoppelt.
Der Generic Host: Alles zusammen
EchoPlay nutzt den Generic Host aus Microsoft.Extensions.Hosting. Er ist die zentrale Infrastruktur, die Konfiguration, Services und Logging zusammenbringt:
HostApplicationBuilder builder = Host.CreateApplicationBuilder();
builder.Configuration
.AddJsonFile("appsettings.json", optional: false)
.AddJsonFile("appsettings.Development.json", optional: true);
// Services registrieren...
IHost host = builder.Build();
Der Host wird einmalig beim App-Start gebaut — in App.xaml.cs, der sogenannten Composition Root der Anwendung. Das ist der eine zentrale Ort, an dem alle Abhängigkeiten verdrahtet werden. Danach steht der ServiceProvider zur Verfügung, über den registrierte Services aufgelöst werden können.
Lebensdauern: Transient, Scoped und Singleton
Jeder Service wird mit einer Lebensdauer registriert. Die Lebensdauer bestimmt, wann eine neue Instanz erstellt wird — und das hat massive Auswirkungen auf Speicherverbrauch und Korrektheit.
Transient bedeutet: Jedes Mal eine neue Instanz. ViewModels sind in EchoPlay als Transient registriert, weil jede Navigation zur SeriesListPage ein frisches ViewModel mit leerem Zustand bekommen soll:
builder.Services.AddTransient<SeriesListViewModel>();
Scoped bedeutet: Eine Instanz pro Scope. In Web-Anwendungen ist ein Scope typischerweise ein HTTP-Request. In EchoPlay — einer Desktop-Anwendung — wird ein Scope manuell erstellt und wieder geschlossen. Der EchoPlayDbContext ist Scoped registriert, weil er für genau einen Datenbankvorgang leben und danach freigegeben werden soll — nicht dauerhaft offen bleiben:
// EF Core registriert DbContext automatisch als Scoped
builder.Services.AddEchoPlayData();
Singleton bedeutet: Eine einzige Instanz für die gesamte App-Laufzeit. Der PlayerService ist Singleton, weil es nur einen aktiven Audioplayer geben darf — zwei MediaPlayer-Instanzen würden gleichzeitig spielen:
builder.Services.AddSingleton<IPlayerService, PlayerService>();
Das Scoped-Problem: Singleton nutzt Scoped
Hier steckt das häufigste DI-Problem in Projekten wie EchoPlay: Ein Singleton darf keinen Scoped-Service direkt injizieren. Der Singleton lebt die ganze App-Laufzeit, aber der Scoped-Service sollte nach jedem Vorgang freigegeben werden. Wenn ein Singleton einen Scoped-Service direkt enthält, wird dieser nie freigegeben — das nennt sich Captive Dependency und ist ein klassisches Speicherleck.
// FALSCH: PlayerService ist Singleton, DbContext ist Scoped
public PlayerService(EchoPlayDbContext context) // DbContext niemals in Singleton!
EchoPlay löst das mit IServiceScopeFactory. Statt den Scoped-Service direkt zu injizieren, bekommt der Singleton nur die Factory — die selbst ein Singleton ist. Für jeden Datenbankvorgang wird dann ein frischer Scope erstellt und am Ende des using-Blocks freigegeben:
public PlayerService(IServiceScopeFactory scopeFactory, ILoggerFactory loggerFactory)
{
_scopeFactory = scopeFactory;
_logger = loggerFactory.CreateLogger("PlayerService");
}
private async Task SavePlaybackStateAsync()
{
using IServiceScope scope = _scopeFactory.CreateScope();
IPlaybackStateDataService service = scope.ServiceProvider.GetRequiredService<IPlaybackStateDataService>();
// Datenbankzugriff hier...
} // scope.Dispose() gibt DbContext frei
Der DbContext wird so korrekt entsorgt, und es bleiben keine Datenbankverbindungen offen hängen.
Interfaces statt konkreter Klassen
EchoPlay registriert Services immer gegen Interfaces:
builder.Services.AddSingleton<IThemeService, ThemeService>();
Das hat einen praktischen Vorteil: Der Rest der Anwendung kennt nur IThemeService. Ob dahinter ThemeService oder ein Fake für Tests steckt, ist irrelevant. In Tests kannst du IThemeService durch eine eigene Implementierung ersetzen, ohne den produktiven Code zu ändern.
Manchmal braucht der Code aber auch den konkreten Typ — etwa wenn ThemeService-spezifische Methoden aufgerufen werden sollen, die nicht im Interface definiert sind. EchoPlay registriert dann beide Varianten:
builder.Services.AddSingleton<IThemeService, ThemeService>();
builder.Services.AddSingleton<ThemeService>(provider =>
(ThemeService)provider.GetRequiredService<IThemeService>());
Die zweite Registrierung zeigt auf dieselbe Instanz — es werden keine zwei ThemeService-Objekte erstellt. Egal ob du über IThemeService oder ThemeService auflöst, du bekommst immer dasselbe Objekt.
Keyed Services: Mehrere Implementierungen eines Interfaces
EchoPlay hat zwei Import-Provider: Spotify und Apple Music. Beide implementieren dasselbe Interface ISeriesImportSearch. Der ImportService muss zur Laufzeit den richtigen auswählen — abhängig von einer Einstellung. Normale DI kann das nicht lösen: Wenn zwei Typen für dasselbe Interface registriert sind, gewinnt der zuletzt registrierte.
Die Lösung sind Keyed Services, ein Feature aus .NET 8. Jede Implementierung wird mit einem Schlüssel registriert:
services.AddKeyedScoped<ISeriesImportSearch, SpotifySeriesImportSearch>("Spotify");
services.AddKeyedScoped<ISeriesImportSearch, AppleMusicSeriesImportSearch>("AppleMusic");
Zur Laufzeit wird dann mit dem passenden Key aufgelöst:
string providerKey = settings.ActiveProvider.ToString(); // "Spotify" oder "AppleMusic"
ISeriesImportSearch search = scope.ServiceProvider
.GetRequiredKeyedService<ISeriesImportSearch>(providerKey);
Vor .NET 8 hätte man dafür eine eigene Factory-Klasse oder ein Dictionary mit Implementierungen bauen müssen. Keyed Services machen das überflüssig und halten die Auflösung im DI-Container.
GetRequiredService vs. GetService
Beim Auflösen von Services gibt es zwei Varianten:
// GetRequiredService: Wirft InvalidOperationException wenn nicht registriert
ISeriesDataService service = scope.ServiceProvider.GetRequiredService<ISeriesDataService>();
// GetService: Gibt null zurück wenn nicht registriert
MemorySink? sink = provider.GetService<MemorySink>();
EchoPlay nutzt GetRequiredService für alle Pflicht-Abhängigkeiten. Das Verhalten bei fehlender Registrierung ist ein harter Fehler beim Start — und das ist gewollt. Besser ein klarer Fehler sofort als eine mysteriöse NullReferenceException irgendwo tief im Code zur Laufzeit. GetService wird nur für optionale Abhängigkeiten verwendet, etwa den MemorySink, der nur existiert, wenn ein bestimmtes Diagnose-Feature aktiviert ist.
Der App.Services-Accessor
WinUI 3 hat keine eingebaute DI-Unterstützung für Pages. Beim Navigieren zu einer Page mit ContentFrame.Navigate(typeof(SeriesListPage)) ruft WinUI den Standardkonstruktor auf — ohne Injection. Es gibt keinen Mechanismus, dem Framework zu sagen: „Bitte erzeuge die Page über den DI-Container.“
EchoPlay umgeht das mit einem statischen Accessor:
public static IServiceProvider Services =>
_host?.Services ?? throw new InvalidOperationException("Host wurde noch nicht initialisiert.");
In der Page wird dann manuell aufgelöst:
_viewModel = App.Services.GetRequiredService<SeriesListViewModel>();
Das ist streng genommen kein echtes Dependency Injection mehr, sondern das Service-Locator-Muster — die Page holt sich ihre Abhängigkeiten aktiv selbst, statt sie injiziert zu bekommen. Für WinUI 3 ohne Drittanbieter-Framework ist das aber die pragmatische Lösung. Die Alternative wäre ein Framework wie Prism oder ein eigener Page-Resolver, was für ein Projekt dieser Größe überdimensioniert wäre.
Die gezeigten Code-Beispiele dienen zur Veranschaulichung. Nutzung auf eigene Verantwortung. Mehr dazu