Performance-Optimierung für WPF Anwendungen – Teil 1

von | 26.01.2019 | Softwareentwicklung | 0 Kommentare

„Premature optimization is the root of all evil“ hat Donald Knuth einmal gesagt. Immer wenn ich mich mit dem Thema Performance-Optimierung beschäftige, erinnere ich mich an diesen bekannten Satz. Grundsätzlich empfiehlt es sich, nur dann eine Optimierung der Anwendung vorzunehmen, wenn ein Performance-Problem vorliegt. Performance-Optimierungen können den Charakter haben, dass man zu Gunsten des Geschwindigkeitsvorteils Architekturbrüche, erhöhten Speicherverbrauch oder Redundanzen im Programmcode als Kompromiss eingehen muss. Leider ist dies manchmal der Preis, den Sie eventuell bezahlen müssen. Oftmals wird dann auch von einem sogenannten „Trade-Off“ gesprochen – tatsächlich können Sie sich im wahrsten Sinne des Wortes mit Optimierungen ganz neue Probleme einhandeln, insbesondere wenn vorhandene Features komplex sind.

Im Folgenden möchte ich primär auf Optimierungsmöglichkeiten eingehen, die in einer WPF Anwendung vorkommen und dabei erläutern wann sie sinnvoll sein können. In Teil 1 geht es heute um die Optimierung von ObservableCollections in ItemsControl-basierten Views, um Binding-Overheads und das ICommand.CanExecute().

ObservableCollections optimieren

Ein häufig auftretendes Problem sind Controls, die sich an ObservableCollections binden, um dynamisch auf neu hinzugefügte oder entfernte Objekte zu reagieren und die Oberfläche zu aktualisieren. Wenn Sie eine sehr große Menge von Elementen einfügen, wird für jedes Element der View einzeln darüber informiert und ein Refresh der Oberfläche inklusive Measure/Arrange und Invalidate ausgeführt. Je nach Anwendungsfall kann dies sehr langsam sein.

Hierfür gibt es verschiedene Lösungsszenarien:

  • ObservableCollection durch eine List<> ersetzen und die komplette Collection neu erstellen
    Wenn Sie bei Updates die gesamte Collection neu aufbauen oder die meisten Elemente verwerfen und wieder einfügen wollen, ergibt dies Sinn. Der Performancevorteil hängt dabei auch vom Control ab, kann sich aber deutlich bemerkbar machen.
  • ObservableCollection ableiten und AddRange() implentieren
    Von der Schnittstelle her unterstützt das NotifyCollectionChanged() event das Einfügen mehrerer Elemente, aber es gibt keine AddRange() Methode, die genau das übernimmt. Selbiges gilt natürlich für Methoden wie RemoveRange().

 

Binding-Overhead reduzieren

Data Bindings können ein Performanceproblem darstellen, wenn zu viele PropertyChanges ausgelöst und somit Views aktualisiert werden müssen. Jedes sich aktualisierende Data Binding löst potenziell die Aktualisierung von Layoutberechnungen und Zeichenaufrufe aus, so dass in der Folge insbesondere bei der Aktualisierung von WPF-Elementen merklich Zeit in Anspruch genommen wird.

OnPropertyChanged() nur auslösen, wenn das Property sich wirklich ändert

Oftmals findet man solchen Code in Projekten:

Neben der Tatsache, dass Sie anstelle von „TextContext“ lieber nameof(TextContent) verwenden sollten – C# 6.0 vorausgesetzt – wird hier unabhängig davon, ob sich _textContent geändert hat oder nicht, das Binding aktualisiert. Somit wäre folgendes besser:

Oder noch besser, Sie legen sich eine Template-Methode an, die Sie in einer Basisklasse bereitstellen, so dass die Einhaltung der Regel viel einfacher gelingt:

Ich habe hier bewusst auf das Parameter-Attribute [CallerMemberName] verzichtet, um den Fokus auf die Property-Änderungen zu setzen.

Gebundene Objekte nicht tauschen, sondern Properties aktualisieren

Es ist deutlich einfacher, wenn Sie Daten aus der Business-Schicht bekommen, diese über einen simplen Linq Select Ausdruck in ein ViewModel konvertieren und dann in die Collection packen. Allerdings muss WPF dann an dieser Stelle meistens den kompletten Visual Tree neu erstellen; sind hier viele Visuals beteiligt, dann kann das ziemlich lange dauern. 

Wenn Sie anstelle dessen nur die Properties der Objekte aktualisieren und neue Objekte anfügen oder fehlende Objekte entfernen, reduzieren sich potenziell die Kosten zum Update der Oberfläche deutlich. Ich habe teilweise allein durch diese Optimierungen Geschwindigkeitsvorteile beim Refresh um Faktor 5 erreicht – das ist natürlich komplett vom Anwendungsfall abhängig.

Binding Errors entfernen

Binding Errors treten auf, wenn der Binding-Path nicht zu einem Property auf dem gebunden Objekt aufgelöst werden kann. Das passiert bspw. beim Umbenennen von Properties, wenn Sie vergessen, in der entsprechenden Xaml-Datei den Binding-Pfad zu aktualisieren. Viele Binding Errors können die Anwendung verlangsamen und sollten bereinigt werden. Besonders wenn Sie mit Visual Studio im Debugger arbeiten, sind Binding Errors besonders teuer.

Um Binding Errors zu finden, müssen Sie lediglich in die Visual Studio Ausgabe schauen, hier werden solche Probleme sehr präzise angezeigt, so dass Sie nach diesen im Code suchen und entsprechende Fehler beheben können.

Wenn der Binding Fehler auftritt, weil an dieser Stelle verschiedene ViewModel-Typen gebunden sind, wobei ein ViewModeltyp das Property besitzt und das andere nicht, ist der Fehler ggf. etwas schwerer zu beheben. Die richtige Lösung wäre dafür zu sorgen, dass für jeden ViewModel-Typen eigene Templates mit eigenen Bindings verwendet werden. Ist dies aufgrund des Aufbaus der Anwendung nicht so einfach möglich, könnten Sie alternativ noch mit FallbackValue oder mit dem sogenannten Priority Binding arbeiten.

ICommand.CanExecute() schlank und effizient

Die Methode ICommand.CanExecute() wird von WPF aufgerufen, um zu prüfen, ob ein gebundenes Kommando auf dem View verfügbar ist. Typisches Beispiel sind Kontextmenüeinträge oder Buttons, die auf Klick ICommand.Execute() aufrufen. Wenn ein Kommando nicht verfügbar ist, wird der entsprechende Button oder Kontextmenü-Eintrag deaktiviert und kann durch den Benutzer nicht gedrückt werden. Problematisch wird es, wenn sehr viele Kommandos gebunden sind und in der Methode ICommand.CanExecute() Code ist, der intensive Operationen macht, bspw. eine Abfrage an die Datenbank, das Dateisystem oder einen Server.

CanExecute() wird sehr häufig aufgerufen, auch beim Aufbau und Aktualisieren von Oberflächen, so dass teure CanExecute() Aufrufe die Oberfläche sehr träge machen können. Sehen können Sie das am besten durch die Verwendung eines Performance-Profilers. Hierbei ist es anwendungsfallabhängig wie Sie die Performance optimieren. Bspw. können Sie

  • Datenbank oder Netzwerk-Anfragen cachen,
  • teure Anfrage erst beim Execute() ausführen und dann ggf. eine Meldung an den Nutzer ausgeben,
  • oder Laufzeitoptimierung des Prüfalgorithmus im Allgemeinen durchführen.

In Teil 2 der Performance-Optimierung von WPF Anwendungen werfe ich einen Blick auf unnötige Visuals und die Optimierung des VisualTrees durch das Custom Control mit OnRender(). Darüber hinaus geht es um die Verringerung der ResourceDictionary Lookups und die Entlastung von UI-Threads. Hier geht’s zu Teil 2.

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.

Gefällt Ihnen dieser Beitrag?

Melden Sie sich für unsere News-Updates an und erhalten Sie einmal pro Woche Tipps und Meinungen von Fachleuten direkt in Ihren Posteingang.

Gefällt Ihnen dieser Beitrag?

Share This