Log-Viewer: DispatcherTimer, IDisposable und NumberBox (Teil 25)

Der Protokolle-Tab in der Einstellungsseite zeigt die aktuellen Log-Einträge der App und erlaubt es, ältere Log-Dateien zu öffnen. Es gibt einen Live-Modus, der die Anzeige automatisch alle zwei Sekunden aktualisiert. Diese Kombination aus UI-Timer, Datei-Auswahl und ViewModel-Lebenszyklus enthält einige interessante Entwurfsentscheidungen.

Log-Datei-Auswahl mit einer eigenen Typ-Hierarchie

Die ComboBox zur Dateiauswahl enthält zwei Arten von Einträgen: die Live-Ansicht (aktueller In-Memory-Puffer) und historische Log-Dateien auf der Festplatte. Beide Arten werden als LogFileOption-Record modelliert:

public sealed record LogFileOption(string DisplayName, string? FilePath)
{
    public bool IsLive => FilePath is null;
}

null als FilePath signalisiert die Live-Ansicht — keine separate Unterklasse, kein Flag-Enum. Das ist ein Beispiel für den Einsatz von null als bewusstes Datenmerkmal statt als Fehlerwert. Das ViewModel befüllt die Liste beim Laden, wobei Task.Run den Dateisystem-Zugriff auf einen Thread-Pool-Thread auslagert — das Lesen eines Verzeichnisses ist synchron und blockierend, und ohne Task.Run würde es den UI-Thread sperren.

private async Task LoadLogFilesAsync()
{
    string absolutePath = Path.GetFullPath(_loggerManager.LogDirectory);
    string[] files = await Task.Run(() =>
        Directory.Exists(absolutePath)
            ? Directory.GetFiles(absolutePath, "*.log")
            : []);

    List<LogFileOption> options = [new LogFileOption("Aktuell (Live)", null)];

    foreach (string file in files.OrderByDescending(f => f))
    {
        options.Add(new LogFileOption(Path.GetFileName(file), file));
    }

    AvailableLogFiles = options;
}

Path.GetFullPath wandelt einen relativen Pfad (z.B. „logs“) in einen absoluten um. Das ist nötig, weil MSIX-Apps in einem Sandbox-Verzeichnis gestartet werden und relative Pfade dort unvorhersehbare Ergebnisse liefern.

DispatcherTimer für den Live-Modus

Im Live-Modus soll die Anzeige automatisch aktualisiert werden. Dafür bietet WinUI 3 den DispatcherTimer — ein Timer, der auf dem UI-Thread läuft und damit direkt Properties setzen darf:

private DispatcherTimer? _liveViewTimer;

private void StartLiveViewTimer()
{
    if (_liveViewTimer is not null)
    {
        return;
    }

    _liveViewTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(2) };
    _liveViewTimer.Tick += (_, _) => OnLiveViewTimerTick();
    _liveViewTimer.Start();
}

private void OnLiveViewTimerTick()
{
    if (SelectedLogFile?.IsLive == true)
    {
        RefreshLogs();
    }
}

IDisposable für ViewModel-Cleanup

Wenn die Einstellungsseite verlassen wird, muss der Timer gestoppt werden. Läuft er weiter, versucht er alle zwei Sekunden auf ein ViewModel zuzugreifen, das längst nicht mehr aktiv ist — das führt zu unnötiger CPU-Last und potenziell zu Abstürzen. Das ViewModel implementiert deshalb IDisposable:

public void Dispose()
{
    if (_liveViewTimer is not null)
    {
        _liveViewTimer.Stop();
        _liveViewTimer.Tick -= (_, _) => OnLiveViewTimerTick();
        _liveViewTimer = null;
    }
}

Die Page ruft Dispose in OnNavigatingFrom auf — also bevor die neue Seite angezeigt wird. Der Unterschied zu OnNavigatedFrom: Der Timer wird gestoppt, bevor die neue Seite lädt. Das vermeidet ein mögliches Tick zwischen dem Verlassen der Seite und dem tatsächlichen Aufräumen.

NumberBox für ganzzahlige Werte

Die Aufbewahrungszeit für Log-Dateien ist ein ganzzahliger Wert (Tage). WinUI 3 hat dafür die NumberBox, die automatisch eine Eingabevalidierung mitbringt. Der Haken: NumberBox.Value ist vom Typ double, nicht int. Eine direkte TwoWay-Bindung an eine int-Property schlägt fehl. Die Lösung ist ein Event-Handler statt eines Bindings:

<NumberBox
    x:Uid="LogRetentionDaysBox"
    Value="{x:Bind ViewModel.LogRetentionDays, Mode=OneWay}"
    ValueChanged="OnLogRetentionDaysChanged"
    Minimum="1" Maximum="365" SpinButtonPlacementMode="Inline"/>
private void OnLogRetentionDaysChanged(NumberBox sender, NumberBoxValueChangedEventArgs args)
{
    // NaN tritt auf, wenn der Nutzer ein ungültiges Zeichen eingibt
    if (!double.IsNaN(args.NewValue))
    {
        ViewModel.LogRetentionDays = (int)args.NewValue;
    }
}

double.NaN ist der Wert, den NumberBox setzt, wenn die Eingabe ungültig ist. Würde man diesen Wert in int umwandeln, käme 0 heraus. Der Guard verhindert das. Ein pragmatischer Mix aus deklarativem XAML für die Liste der Optionen und imperativem C# für den Anfangszustand.

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