Performance-Optimierung für WPF Anwendungen – Teil 2

von | 02.02.2019

Nachdem wir uns im Teil 1 der Performance-Optimierungen bei WPF Anwendungen ObservableCollections in ItemsControl-basierten Views, Binding-Overheads und das ICommand.CanExecute() angeschaut haben, werfen wir nun einen Blick auf Visuals, die Verringerung der ResourceDictionary Lookups sowie die Entlastung von UI-Threads.

Anzahl der Visuals optimieren

Je mehr Visuals WPF in einer Anwendung verwalten muss, umso langsamer wird die Oberfläche. Dazu zählen LayoutUpdates mit Measure()/Arrange des UI-Threads und Zeichnen durch den Renderthread. Daher lohnt es sich mit Tools wie dem Visual Studio Live Visual Tree oder Third Party Tools wie Snoop einen Blick auf die Anzahl der Visuals zu werfen:

Unnötige Visuals entfernen

Manchmal bleiben aus einem Refactoring unnötige Layouter und Controls übrig, die ähnlich wie Dead Code gar keine Funktion erfüllen. Es gibt einige typische Fälle, die auftreten können, sofern Sie die unnötigen Visuals nicht entfernen:

  • TextBlock anstelle von Labels verwenden: TextBlock ist nur ein einziger Visual, Labels sind komplexer.
  • TextBlock.Text direkt setzen anstelle eines TextBlocks mit einem Run.
  • Visibility=“Collapsed“ entfernt das Element aus dem VisualTree, Hidden nicht und wird somit weiterhin in Measure-Aufrufen berechnet.

Für reine Darstellungen den VisualTree durch Custom Control mit OnRender() optimieren

Sollten Sie eine große Menge Visuals haben, können Sie die Performance signifikant erhöhen, indem sie das Xaml-Template durch ein einzelnes Control ersetzen, welches ein ViewModel als DataContext erhält und in OnRender() alles manuell zeichnet. Ein gutes Beispiel sind sehr komplexe Diagramme oder koordinatenbasierte Formulare zur reinen Ansicht.

Ich habe in solchen Fällen den Aufbau der Oberfläche für diese Elemente von gefühlten ein bis zwei Sekunden auf 40ms optimieren können.
Natürlich ist hier der Aufwand hoch, weil Sie selbst das Layouten auf Basis von Koordinaten durchführen müssen. Aber für einen sehr kritischen Teil der Anwendung lässt sich die User Experience dadurch nachhaltig verbessern.

ResourceDictionary Lookups verringern

Nutzen Sie ein User Control oder ein Fenster, welches viele ResourceDictionaries lädt, kann das Laden und Entladen des Controls relativ lange dauern. In einem Fall hatte ich ein User Control, das fünf ResourceDctionaries mit jeweils einer Hand voll DataTemplates lud. Das führte dazu, dass sich das Control in etwa drei bis vier Sekunden aufbaute und in etwa derselben Zeit wieder benötigte, um sich auszublenden. Nachdem ich alle ResourceDictionaries direkt in das Control als Resource hinterlegt hatte, öffnete und schloss sich das Control wieder nahezu ohne Verzögerung.

UI-Thread entlasten

Im eigentlichen Sinne ist die Entlastung von UI-Threads keine Performance-Optimierung, dennoch erhöht sie die Reaktivität des Systems merklich. Oftmals gibt es Operationen, die direkt auf die Reaktion eines WPF-Events, ICommand.Execute() oder einem Property-Change aus dem View in das ViewModel ausgelöst werden. Wenn diese Operationen potenziell länger als ein paar 100 ms dauern, wirkt die Anwendung träge.

Potenziell längere Operationen im Hintergrund ausführen

Ein gutes, simples Beispiel ist das Aktualisieren einer Liste von Datensätzen von einem Server. Wenn der Benutzer einen Button drückt, wird die Liste aktualisiert. In der Regel dauert das ca. 500ms, bei schlechter Netzwerkverbindung oder erhöhter Last auf den Servern sowie in Abhängigkeit der Datenmenge manchmal auch mehrere Sekunden – ganz abgesehen von einem Re-Trial wenn der Server kurzzeitig nicht verfügbar ist.

Hierfür können Sie im Command-Handler die Anfrage zum Server bspw. über eine Task asynchron laden und am Ende das Ergebnis über den Dispatcher wieder im UI-Thread zurückführen, so dass sich die Oberfläche aktualisiert. Während die Daten asynchron geladen werden, sollten Sie eine Information anzeigen, dass die Daten gerade geladen werden und entsprechend die Oberfläche sperren, so dass keine weiteren asynchronen Anfragen oder inkonsistente Zustände erzeugt werden können.

Ein weitere Vorteil neben erhöhter Reaktivität ist, dass andere im UI-Thread dispatchte Aufgaben eher eine Chance haben, verarbeitet zu werden, was auch die Abarbeitung anderer Aufgaben im UI-Thread ebenfalls beschleunigen kann – z.B. Dispatcher-BeginInvoke() Aufrufe von niedriger Priorität.

Debouncing auf Basis von DispatcherTimer

Ein Debouncer ist ein Instrument, mit dem ein Ereignis erst ausgelöst wird, wenn es nicht mindestens innerhalb eines gewissen Zeitraums wieder ausgelöst wird. Ein ganz phantastisches Beispiel ist die Suche bei Google: Sobald ich anfange Text einzugeben, werden asynchron Suchergebnisse herausgesucht. Sollte ich allerdings zu schnell tippen, wird erst wieder gesucht, sobald ich aufhöre zu tippen. Dadurch werden nicht permanent neue Suchanfragen losgeschickt und die Suchergebnisse eingetragen, was unnötige Ausführung von teuren Operationen unterbindet.

Nehmen wir bspw. an, Sie haben eine Selektion in einer Liste. Wenn Sie ein Element aus der Liste ausgewählt haben, wollen Sie detaillierte Informationen anzeigen, die leider im UI-Thread berechnet werden müssen und etwas Zeit beanspruchen. Wenn Sie sich jetzt mit der Tastatur schnell von einem Element zum nächsten bewegen, wird die Anwendung etwas hakelig, da Sie erst zum nächsten Element springen können, wenn die Berechnung und Darstellung für die Details fertig ist. Hier könnten Sie über einen Debouncer dafür sorgen, dass erst dann die Details geladen werden, wenn die Selektion einen Moment nicht geändert wurde, bspw. nach 500ms.

Beispiel¹:

public class MyViewModel : ViewModel
{
    private MyDetailViewModel _details;
    private readonly IDetailService _detailService;

    public MyDetailViewModel Details
    {
        get => _details;
        set => SetValue(nameof(Details), ref _details, value);
    }

    public MyViewModel(IDetailService detailService)
    {
        _detailService = detailService;
    }

    public void OnSelectedElementChanged(MyElement selectedElement)
    {
        LoadDetails(selectedElement);
    }

    private void LoadDetails(MyElement selectedElement)
    {
        var detailData = _detailService.Load(element.Id);

        Details = new MyDetailViewModel(detailData);
    }
}

Die folgende Klasse repräsentiert ein ViewModel, welches beim Aufruf von OnSelectedElementChanged() über eine Serviceklasse Daten aufbereitet und ein ViewModel erstellt. Die Serviceklasse braucht potenziell lange und muss aufgrund ihrer Implementierung im UI-Thread ausgeführt werden und kann daher nicht asynchron geladen werden.

Der Debouncer benutzt den DispatcherTimer, um über eine einstellbare Priorität nach einem Zeitintervall eine Action aufzurufen:

public class Debouncer
{
    private readonly DispatcherPriority _dispatcherPriority;
    private readonly Dispatcher _dispatcher;
    private DispatcherTimer _timer;

    public Debouncer(DispatcherPriority priority = DispatcherPriority.ApplicationIdle, 
                        Dispatcher disp = null)
    {
        _dispatcher = disp ?? Dispatcher.CurrentDispatcher;
    }

    public void Debounce(TimeSpan interval, Action<object> action, object parameter = null)
    {
        _timer?.Stop();
        _timer = null;
            
        _timer = new DispatcherTimer(
            interval,
            _dispatcherPriority,
            (s, e) => ExecuteDebouncedAction(action, parameter),
            _dispatcher);

        _timer.Start();
    }
        
    private void ExecuteDebouncedAction(Action<object> action, object parameter)
    {
        if (_timer == null) return;

        _timer?.Stop();
        _timer = null;
        action.Invoke(parameter);
    }
}

Die Action wird erst ausgeführt, wenn der Timer abgelaufen ist und in der Zwischenzeit nicht noch einmal die Debounce() Methode aufgerufen wurde. Wenn die Debounce-Methode aufgerufen wird, bevor der Timer abläuft, wird der Timer neu gestartet.

Mit dieser Klasse ist die Anpassung an unserem ViewModel sehr einfach:

public class MyViewModel : ViewModel
{
    private MyDetailViewModel _details;
    private readonly IDetailService _detailService;
    private readonly Debouncer _debouncer;

    public MyDetailViewModel Details
    {
        get => _details;
        set => SetValue(nameof(Details), ref _details, value);
    }

    public MyViewModel(IDetailService detailService)
    {
        _detailService = detailService;
        _debouncer = new Debouncer();
    }

    public void OnSelectedElementChanged(MyElement selectedElement)
    {
        _debouncer.Debounce(TimeSpan.FromMilliseconds(500), o => LoadDetails((MyElement)o), selectedElement);
    }

    private void LoadDetails(MyElement selectedElement)
    {
        var detailData = _detailService.Load(element.Id);

        Details = new MyDetailViewModel(detailData);
    }
}

Das Praktische an dieser Lösung ist, dass das Ein- und Ausbauen sehr einfach funktioniert und sich wie ein Adapter zwischen die Logik hängt.

Haben Sie Rückfragen oder Hinweise zu den Optimierungen von WPF Anwendungen? Über Feedback würde ich mich sehr freuen.

 

Hinweise:

Interessieren Sie sich für weitere Tipps aus der Praxis? Testen Sie unseren wöchentlichen Newsletter mit interessanten Beiträgen, Downloads, Empfehlungen und aktuellem Wissen.

Wir suchen Softwareentwickler. Berufseinsteigerinnen, Entwickler mit einigen und Expertinnen mit vielen Jahren Erfahrung.

[1] Das Beispiel basiert auf der Veröffentlichung unter https://weblog.west-wind.com/posts/2017/Jul/02/Debouncing-and-Throttling-Dispatcher-Events und wurde um einige Codezeilen ergänzt, um den gewünschten Effekt zu erzeugen.

Peter Friedland hat im t2informatik Blog einige weitere Beiträge veröffentlicht, u. a.

t2informatik Blog: CI/CD Pipeline auf einem Raspberry Pi

CI/CD Pipeline auf einem Raspberry Pi

t2informatik Blog: Avalonia UI – Die Entwicklung von Cross-Platform WPF Anwendungen mit .NET Core

Avalonia UI

t2informatik Blog: Warum ich bei Godot gelandet bin

Warum ich bei Godot gelandet bin

Peter Friedland
Peter Friedland

t2informatik GmbH

Peter Friedland ist bei der t2informatik GmbH als Software Consultant tätig. In verschiedenen Kundenprojekten entwickelt er innovative Lösungen in enger Zusammenarbeit mit Partnern und den Ansprechpartnern vor Ort. Und ab und an bloggt er auch.