StatusBar als Singleton: Globaler Zustand ohne N+1 (Teil 23)

Eine Info-Leiste am unteren Fensterrand zeigt dauerhaft Statistiken: wie viele Serien abonniert sind, wie viele Folgen gehört oder offen sind. Diese Zahlen müssen an jeder Stelle der App gleich sein — und sie sollen sich aktualisieren, wenn der Benutzer in einer anderen Ansicht eine Episode als gehört markiert. Das klingt nach einer einfachen Aufgabe, führt aber zu einer wichtigen Architektur-Entscheidung: Welche Lifetime soll das ViewModel haben?

Warum Singleton statt Transient?

Normale ViewModels in dieser App sind als Transient registriert: Bei jeder Navigation zu einer Seite entsteht eine neue Instanz. Das ist sinnvoll für Seiten wie die Mediathek — die braucht keine gemeinsame Instanz, weil ihre Daten unabhängig von anderen Seiten sind. Für die Info-Leiste gilt das nicht. Sie ist dauerhaft sichtbar, unabhängig davon, welche Seite gerade geöffnet ist. Und sie soll aktualisiert werden können, wenn in einer anderen Ansicht der Status einer Episode geändert wird. Das ist nur möglich, wenn alle Teile der App auf dieselbe Instanz des ViewModels zugreifen.

Die Registrierung in der DI-Konfiguration ist entsprechend:

builder.Services.AddSingleton<StatusBarViewModel>();

Mit Singleton erzeugt der DI-Container die Instanz einmalig beim ersten Zugriff und liefert immer dieselbe Instanz zurück.

Das Singleton-Problem mit Scoped Services

Singletons dürfen keine Scoped-Services direkt injiziert bekommen. Das wäre ein Scope-Leak: Ein Scoped-Service existiert nur für die Dauer eines Requests oder Scopes — ein Singleton aber lebt für die gesamte App-Laufzeit. Würde der DbContext direkt injiziert, würde er niemals disposed werden. Die Lösung ist das IServiceScopeFactory-Muster: Für jeden Datenbankzugriff öffnet das Singleton einen eigenen, kurzlebigen Scope und schließt ihn danach wieder.

using IServiceScope scope = _scopeFactory.CreateScope();
ISeriesDataService seriesService = scope.ServiceProvider.GetRequiredService<ISeriesDataService>();
// ... Daten laden ...
// Scope wird am Ende des using-Blocks disposed

Dieses Muster ist in der gesamten App konsequent eingesetzt: Überall dort, wo ein langlebiger Service (Singleton) auf kurzlebige Services (Scoped) zugreifen muss, wird IServiceScopeFactory verwendet. Das gilt auch für den PlayerService und den SyncService.

RefreshAsync als zentraler Aktualisierungspunkt

Das ViewModel stellt eine RefreshAsync()-Methode öffentlich bereit. Andere ViewModels können sie aufrufen, nachdem sie Daten verändert haben — zum Beispiel nach dem Markieren einer Episode als gehört. Da alle ViewModels auf dieselbe Singleton-Instanz zeigen, sehen danach alle Teile der App die aktualisierten Zahlen.

await _statusBarViewModel.RefreshAsync();

N+1-Problem vermeiden

Für die Episoden-Statistiken müssen mehrere Tabellen gelesen werden: die abonnierten Serien, die dazugehörigen Episoden und die Wiedergabestände. Naiv würde man für jede Serie einzeln alle Episoden abfragen und für jede Episode den Wiedergabestand holen — das sind N×M Datenbankabfragen. Der Ansatz hier: Alle Wiedergabestände werden einmalig in einem Schritt geladen. Dann wird ein HashSet<Guid> der abgeschlossenen Episode-IDs aufgebaut. Beim Iterieren der Episoden genügt ein HashSet.Contains() statt einer weiteren Datenbankabfrage.

IReadOnlyList<PlaybackState> allStates = await playbackService.GetAllAsync();

HashSet<Guid> completedEpisodeIds = allStates
    .Where(s => s.IsCompleted)
    .Select(s => s.EpisodeId)
    .ToHashSet();

foreach (Episode episode in episodes)
{
    bool isCompleted = completedEpisodeIds.Contains(episode.Id);
    // ...
}

Das HashSet.Contains() läuft in O(1) — egal wie viele Episoden es gibt. Statt N×M Datenbankabfragen braucht es genau zwei: eine für alle Episoden, eine für alle Wiedergabestände. Das ist ein klassisches Beispiel dafür, wie man das N+1-Problem löst, indem man die Daten vorab in eine effiziente Datenstruktur lädt.

Berechnete Anzeige-Texte als Properties

Die Statistiken werden als Integer-Properties gehalten. Die formatierten Texte für die Anzeige (zum Beispiel „★ 12 Serien“) sind berechnete get-Properties.

public string SubscribedSeriesText => $"u2605 {_subscribedSeriesCount} Serien";

Wenn sich SubscribedSeriesCount ändert, ruft der Setter OnPropertyChanged(nameof(SubscribedSeriesText)) auf. Die Bindung in XAML ({x:Bind StatusBar.SubscribedSeriesText, Mode=OneWay}) bekommt damit automatisch den neuen Wert. Dieses Muster — Integer im Backing-Field, String als berechnete Property — hält die Logik im ViewModel und vermeidet Converter in XAML.

Neue Folgen: Badge nur bei Bedarf

Das „Neu“-Badge in der Info-Leiste soll nur erscheinen, wenn es tatsächlich neue Folgen gibt. Dafür gibt es eine Visibility-Property im ViewModel.

public Visibility NewEpisodesVisibility =>
    _newEpisodesCount > 0 ? Visibility.Visible : Visibility.Collapsed;

In XAML wird das direkt gebunden. Das ist das bewährte Muster aus diesem Projekt: keine Converter, stattdessen berechnete Visibility-Properties im ViewModel.

<TextBlock
    Text="{x:Bind StatusBar.NewEpisodesText, Mode=OneWay}"
    Visibility="{x:Bind StatusBar.NewEpisodesVisibility, Mode=OneWay}"/>

Sprachwechsel mit App-Neustart

WinUI 3 kann Ressourcendateien (.resw) nicht zur Laufzeit nachladen. Ein Sprachwechsel erfordert deshalb einen Neustart der App. Der neue Sprachcode wird zuerst in AppSettings gespeichert und dann in PrimaryLanguageOverride gesetzt — beides bevor der Neustart ausgelöst wird.

Windows.Globalization.ApplicationLanguages.PrimaryLanguageOverride = languageCode;
Microsoft.Windows.AppLifecycle.AppInstance.Restart(string.Empty);

Beim nächsten Start liest die App PrimaryLanguageOverride und lädt alle .resw-Texte in der neuen Sprache. Das ist eine Einschränkung des WinUI-3-Frameworks — andere Frameworks wie WPF können Ressourcen zur Laufzeit wechseln, aber WinUI 3 erfordert hier den harten Schnitt.

Die gezeigten Code-Beispiele dienen zur Veranschaulichung. Nutzung auf eigene Verantwortung. Mehr dazu