MVVM in WinUI 3: ObservableObject, RelayCommand und x:Bind (Teil 7)

Moderne Windows-Anwendungen mit WinUI 3 werden nach dem MVVM-Muster gebaut: Model–View–ViewModel. Das Muster trennt die Anzeige (View) von der Logik (ViewModel) und den Daten (Model). EchoPlay setzt MVVM komplett ohne externes Framework wie CommunityToolkit.MVVM um — alles handgeschrieben. Das klingt nach Mehraufwand, gibt dir aber die volle Kontrolle über jeden Mechanismus.

Das Grundprinzip

Eine WinUI-3-Page (die View) zeigt Daten an, die aus einem ViewModel kommen. Das ViewModel lädt die Daten, bereitet sie auf und stellt sie als Properties bereit. Die Page bindet sich per x:Bind an diese Properties. Das ViewModel kennt die View nicht — es arbeitet mit Interfaces und Services, ohne WinUI-Controls oder XAML-Abhängigkeiten. Genau das macht es testbar: Du kannst das ViewModel in einem Unit-Test aufrufen, ohne eine Benutzeroberfläche zu starten.

ObservableObject: Die Basis aller ViewModels

Damit die UI erkennt, wenn sich ein Wert im ViewModel geändert hat, muss das ViewModel INotifyPropertyChanged implementieren. Das ist ein Interface aus dem .NET-Framework, das ein einziges Event definiert: PropertyChanged. Wenn dieses Event gefeuert wird, weiß die UI: „Dieser Wert hat sich geändert, ich muss die Anzeige aktualisieren.“ Statt das in jedem ViewModel neu zu schreiben, erbt in EchoPlay alles von einer gemeinsamen Basisklasse ObservableObject:

public abstract class ObservableObject : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler? PropertyChanged;

    protected bool SetProperty<T>(ref T field, T value, [CallerMemberName] string? propertyName = null)
    {
        if (EqualityComparer<T>.Default.Equals(field, value))
        {
            return false;
        }

        field = value;
        OnPropertyChanged(propertyName);
        return true;
    }

    protected void OnPropertyChanged([CallerMemberName] string? propertyName = null)
    {
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
    }
}

Die Kernmethode ist SetProperty. Sie vergleicht den alten mit dem neuen Wert. Nur wenn sie sich unterscheiden, wird das PropertyChanged-Event ausgelöst. Das verhindert unnötige UI-Aktualisierungen — wenn du zweimal hintereinander IsLoading = false setzt, passiert beim zweiten Mal nichts.

Das Attribut [CallerMemberName] ist ein Compiler-Trick: Es füllt den Parameter propertyName automatisch mit dem Namen der aufrufenden Property. Du musst den Property-Namen nie als String schreiben — kein Tippfehler, keine kaputten Bindings.

Eine Property im ViewModel

So sieht eine typische ViewModel-Property aus:

private bool _isLoading;

public bool IsLoading
{
    get => _isLoading;
    private set => SetProperty(ref _isLoading, value);
}

Das Backing-Field _isLoading enthält den eigentlichen Wert. Der Getter gibt ihn zurück. Der Setter ruft SetProperty auf — das Event wird nur gefeuert, wenn sich etwas ändert. private set bedeutet: Von außen kann IsLoading nicht gesetzt werden. Nur das ViewModel selbst darf den Wert ändern — die View kann ihn nur lesen.

x:Bind: Kompilierte Bindungen

WinUI 3 bietet zwei Binding-Varianten. {Binding} arbeitet dynamisch zur Laufzeit per Reflection — das ist langsam und ohne Compile-Prüfung. {x:Bind} hingegen wird zur Build-Zeit kompiliert — schnell und typsicher. EchoPlay nutzt ausschließlich {x:Bind}:

<TextBlock Text="{x:Bind MiniPlayer.TrackTitle, Mode=OneWay}" />
<Grid Visibility="{x:Bind MiniPlayer.MiniPlayerVisibility, Mode=OneWay}" />

Mode=OneWay bedeutet: Die UI aktualisiert sich, wenn das ViewModel sich ändert — aber nicht umgekehrt. Für Eingabefelder würdest du Mode=TwoWay verwenden, damit Änderungen in der UI auch ins ViewModel zurückfließen. Der große Vorteil von {x:Bind}: Wenn MiniPlayer.TrackTitle nicht existiert, schlägt der Build fehl — kein Laufzeitfehler, der erst beim Testen auffällt.

Commands: Buttons ohne Code-Behind

Buttons in XAML brauchen einen Event-Handler — oder einen Command. Commands sind das MVVM-Pendant zu Click-Events: Das ViewModel stellt sie bereit, die View bindet sich daran. So bleibt die gesamte Logik im ViewModel, und die Page braucht keinen Code-Behind.

EchoPlay nutzt dafür eine eigene RelayCommand-Klasse:

<Button Command="{x:Bind MiniPlayer.PreviousCommand}" ToolTipService.ToolTip="Vorheriger Track">
    <FontIcon Glyph="&#xE892;" FontSize="16"/>
</Button>

RelayCommand kapselt eine Aktion und ein optionales „Kann ausgeführt werden“-Prädikat:

public RelayCommand PreviousCommand { get; }

// Im Konstruktor:
PreviousCommand = new RelayCommand(
    execute: () => _playerService.SkipToPrevious(),
    canExecute: () => _playerService.IsPlaying
);

Wenn canExecute den Wert false zurückgibt, wird der Button automatisch deaktiviert — ohne eine Zeile Code-Behind. Die UI fragt den Command, ob er gerade ausführbar ist, und passt den visuellen Zustand des Buttons entsprechend an.

IServiceScopeFactory im ViewModel

ViewModels sind als Transient registriert — sie haben keinen dauerhaften Scope. Der DbContext ist Scoped. Das bedeutet: Ein ViewModel kann keinen DbContext direkt injizieren, weil das eine Captive Dependency wäre. Stattdessen erhält das ViewModel eine IServiceScopeFactory und erstellt bei jedem Datenbankzugriff einen eigenen Scope:

public async Task LoadAsync()
{
    IsLoading = true;

    try
    {
        using IServiceScope scope = _scopeFactory.CreateScope();
        ISeriesDataService service = scope.ServiceProvider.GetRequiredService<ISeriesDataService>();
        IReadOnlyList<Series> dbSeries = await service.GetAllAsync();

        // Aus Entitäten ViewModel-Objekte bauen
        List<SeriesCardViewModel> cards = new(dbSeries.Count);
        foreach (Series series in dbSeries)
        {
            cards.Add(new SeriesCardViewModel
            {
                Id = series.Id,
                Title = series.Title,
                CoverImage = BuildCoverImage(series)
            });
        }

        _allSeries = cards;
        ApplyFilter();
    }
    finally
    {
        // IsLoading immer zurücksetzen — auch bei Fehlern
        IsLoading = false;
    }
}

Der using-Block stellt sicher, dass der Scope — und damit der DbContext — nach dem Ladevorgang freigegeben wird. Keine offenen Datenbankverbindungen, kein Speicherleck.

Clientseitige Filterung

EchoPlay lädt alle Serien einmalig aus der Datenbank und filtert danach im Speicher. Das vermeidet Datenbank-Roundtrips bei jeder Tastatureingabe:

public string SearchText
{
    get => _searchText;
    set
    {
        if (SetProperty(ref _searchText, value))
        {
            ApplyFilter();
        }
    }
}

private void ApplyFilter()
{
    if (string.IsNullOrWhiteSpace(_searchText))
    {
        Series = _allSeries;
        return;
    }

    List<SeriesCardViewModel> filtered = [];
    foreach (SeriesCardViewModel card in _allSeries)
    {
        if (card.Title.Contains(_searchText, StringComparison.OrdinalIgnoreCase))
        {
            filtered.Add(card);
        }
    }

    Series = filtered;
}

Bei jeder SearchText-Änderung wird ApplyFilter() aufgerufen. _allSeries enthält die unveränderliche Gesamtliste, Series ist die gefilterte Ansicht, die an die UI gebunden ist. Durch StringComparison.OrdinalIgnoreCase funktioniert die Suche unabhängig von Groß- und Kleinschreibung.

Häufige Fehler

Der häufigste Fehler: Das Backing-Field direkt setzen statt SetProperty zu verwenden. Wenn du das tust, bekommt die UI nie mit, dass sich etwas geändert hat — die Anzeige bleibt auf dem alten Wert stehen:

// FALSCH: Kein PropertyChanged
private bool _isLoading;
public bool IsLoading
{
    get => _isLoading;
    set { _isLoading = value; } // UI aktualisiert sich nie
}

Ein weiterer Stolperstein: async void im ViewModel ohne Try-Catch. Async-Methoden sollten immer Task zurückgeben, nicht void. Bei async void werden Exceptions entweder verschluckt oder steigen unkontrolliert hoch und bringen die Anwendung zum Absturz:

// PROBLEMATISCH: Exception verloren, wenn sie fliegt
public async void LoadAsync() { ... }

// BESSER: Exception geht an den Aufrufer
public async Task LoadAsync() { ... }

ObservableCollection und CollectionChanged

Bisher war in den Beispielen nur von IReadOnlyList<T> die Rede: Das ViewModel hält eine unveränderliche Liste, ersetzt sie komplett und feuert PropertyChanged. Für die meisten Fälle reicht das aus. Aber es gibt Situationen, in denen die UI einzelne Elemente in einer Liste verschieben, hinzufügen oder entfernen muss — und das ViewModel davon erfahren soll. Genau dafür existiert ObservableCollection<T>.

ObservableCollection<T> ist eine spezielle Listenklasse aus dem .NET-Framework, die das Interface INotifyCollectionChanged implementiert. Jedes Mal, wenn ein Element hinzugefügt, entfernt oder verschoben wird, feuert die Collection ein CollectionChanged-Event. WinUI-3-Controls wie ListView können dieses Event abonnieren und die Anzeige automatisch aktualisieren, ohne dass die gesamte Liste neu geladen werden muss.

Der wichtigste Unterschied zu IReadOnlyList<T>: Eine IReadOnlyList signalisiert der UI nur über PropertyChanged, dass sich die gesamte Liste geändert hat — die UI muss dann alle Elemente neu rendern. Eine ObservableCollection hingegen teilt der UI mit, was genau sich geändert hat — „Element an Position 3 wurde entfernt“ oder „Element an Position 1 eingefügt“. Das ist effizienter und ermöglicht Funktionen wie animierte Übergänge.

In EchoPlay wird ObservableCollection<T> gezielt nur dort eingesetzt, wo die UI die Sammlung direkt verändern muss. Das Dashboard nutzt sie für die Neuerscheinungen-Gruppen, weil das ListView-Control dort Drag & Drop unterstützt. Wenn der Benutzer eine Kachelreihe an eine andere Position zieht, verschiebt das ListView das Element innerhalb der ObservableCollection — und das ViewModel bekommt das über CollectionChanged mit.

Konkret sieht das im ViewModel so aus:

private ObservableCollection<NewEpisodesGroupViewModel> _newEpisodeGroups = [];

public ObservableCollection<NewEpisodesGroupViewModel> NewEpisodeGroups
{
    get => _newEpisodeGroups;
    private set
    {
        _newEpisodeGroups.CollectionChanged -= OnNewEpisodeGroupsReordered;

        if (SetProperty(ref _newEpisodeGroups, value))
        {
            value.CollectionChanged += OnNewEpisodeGroupsReordered;
        }
    }
}

Im Setter passiert etwas Wichtiges: Bevor die alte Collection ersetzt wird, wird der CollectionChanged-Handler abgemeldet. Ohne dieses Abmelden würde der Handler auf der alten, nicht mehr verwendeten Collection weiterleben — ein klassisches Speicherleck bei Events in .NET. Nach dem Ersetzen wird der Handler auf der neuen Collection registriert.

Auf der XAML-Seite aktiviert das ListView die Drag-&-Drop-Sortierung mit zwei Attributen:

<ListView
    ItemsSource="{x:Bind ViewModel.NewEpisodeGroups, Mode=OneWay}"
    CanReorderItems="True"
    AllowDrop="True"
    SelectionMode="None">

CanReorderItems="True" erlaubt es dem Benutzer, Einträge per Drag & Drop zu verschieben. AllowDrop="True" aktiviert das Drop-Ziel. WinUI 3 kümmert sich um die komplette Drag-Visualisierung — das ViewModel muss nichts über Mauskoordinaten oder Animationen wissen.

Wenn das ListView ein Element verschiebt, führt es intern zwei Operationen auf der ObservableCollection aus: Erst ein Remove (Element aus der alten Position entfernen), dann ein Add (Element an der neuen Position einfügen). Jede Operation feuert ein eigenes CollectionChanged-Event. Das ViewModel reagiert nur auf das Add-Event, weil zu diesem Zeitpunkt die neue Reihenfolge feststeht:

private void OnNewEpisodeGroupsReordered(object? sender, NotifyCollectionChangedEventArgs e)
{
    if (e.Action == NotifyCollectionChangedAction.Add)
    {
        _ = SaveNewEpisodeGroupOrderAsync();
    }
}

Wann also ObservableCollection<T> statt IReadOnlyList<T>? Die Faustregel ist einfach: Wenn die UI die Sammlung nur anzeigen soll, reicht IReadOnlyList<T> mit PropertyChanged. Wenn die UI die Sammlung verändern können soll — zum Beispiel durch Drag & Drop, Inline-Löschen oder Hinzufügen — brauchst du ObservableCollection<T> mit CollectionChanged. In EchoPlay betrifft das nur die Neuerscheinungen auf dem Dashboard; alle anderen Listen werden als IReadOnlyList<T> gehalten, weil dort keine Umsortierung durch den Benutzer vorgesehen ist.

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