.NET Undo-Redo Framework

Schon immer hatte mich es gereizt, einmal eine Undo-Redo Funktionalität zu implementieren, denn ich empfand das immer als so komplex, dass man es nicht mal einfach so locker runter-programmieren kann. Stattdessen ist ein wohlüberlegter Algorithmus gefragt.

Bei der Entwicklung von Quality Spy hatte ich nun endlich einmal die konkrete Motivation diese Kopfarbeit zu leisten, denn dort stellt ein funktionierendes Undo-Redo einen wahnsinnig erhöhten Bedienkomfort dar – und ich wollte diese Funktion einfach haben und umsetzen!

Zwar war das Hauptziel die Implementierung für Quality Spy, aber schon früh wollte ich dies so generisch wie möglich gestalten – und tada – das Ergebnis ist ein Undo-Redo-Framework:

Das Framework ist Teil des Projektes PurpleBox-Common, welches auf Sourceforge unter der MIT-Lizenz veröffentlicht ist.

Das PurpleBox-Common Projekt ist eine sehr schlanke Bibliothek und dient als Basis für Quality Spy und System Composer, beides WPF-Anwendungen.

Das Undo-Redo Framework ist dabei zwar nicht zwingend auf WPF, jedoch auf Desktop-Anwendungen eingeschränkt, die eine dateibasierte Persistenz verwenden bzw. das gesamte Objektmodell in den Hauptspeicher laden.

In Quality Spy habe ich das Undo-Redo bereits erfolgreich integriert! Intern ein umfangreiches Feature, welches sich in der Oberfläche über zwei dezente Buttons dem Nutzer präsentiert:

Realisierung

Wie bei jedem guten Algorithmus ist die grundsätzliche Funktionsweise nicht kompliziert.

Auf der Ebene der Geschäftsobjekte werden alle Änderungen an Properties und Collections aufgezeichnet und in ein Journal gespeichert. Das Journal besteht aus Einträgen, die das Replay – das eigentliche Undo und Redo - dann umsetzen:

Recording

Betrachten wir zunächst das Journaling von Nicht-Collections. Jede Entität muss von der Basisklasse ModelBase erben und seine Properties wie folgt implementieren:

OnPropertyChanged löst dabei zum einen das PropertyChanged-Event aus, speichert aber des Weiteren einen Änderungseintrag im Journal:

Interessant ist hierbei der Einsatz von GetRoot(). Es gilt der Grundsatz, dass das Änderungsjournal im Root-Objekt gehalten wird. Für Quality Spy ist dies die Entity TestProject. Damit das Root-Objekt ermittelt werden kann, muss jede konkrete Entity die Methode GetParent() implementieren:

Recording von Collection-Änderungen

Um Änderungen an Collections zu protokollieren, muss die spezielle Collection UndoRedoRecordingCollection verwendet werden, welche von ObservableCollection abgeleitet ist:

Die Implementierung dieser Collection ist ebenso denkbar einfach. Es wird lediglich OnCollectionChanged überschrieben und die Änderung ins Journal hinzugefügt:

Change Sets

Standardmäßig wird jede einzelne Änderung ins Journal aufgenommen und repräsentiert dann für den Nutzer einen Button-Klick für das Undo/Redo.

Oft ist es gewünscht, eine Menge von Änderungen zusammenzufassen. In Quality Spy könnte  z.B. ein Plugin-Command, der einen Import aus FreeMind ausführt, aus 250 Einzeländerungen bestehen, der Nutzer soll den einen Import jedoch nur als Ganzes rückgängig machen können.

Dazu kann ein ChangeScope erstellt werden:

Es kann im ChangeJournal dazu BeginScope und EndScope aufgerufen oder alternativ die using-Syntax verwendet werden.

Die Change Scopes sind verschachtelbar. Erst wenn der letzte Change Scope wieder verlassen wurde, werden die Änderungen endgültig ins Journal aufgenommen.

Replay

Undo und Redo unterscheiden sich nicht wesentlich voneinander. Das Journal durchläuft dabei die Änderungseinträge lediglich vorwärts oder rückwärts und ruft für jeden einzelnen Eintrag Undo oder Redo auf.

Für Property Changes ist die Implementierung fast trivial:

Für Collections ist die Logik nur unwesentlich komplizierter:

Und für ein Change Set ist sie wiederum äußerst trivial:

Integration in View-Models

Zuguterletzt habe ich den View-Models noch eine vereinfachte Integration für die Erstellung eines Change Scopes spendiert. PurpleBox besitzt eine Vereinfachung für die Erstellung von Commands. Zur Definition reicht die Ausreichnung einer Methode mit dem Attribut ExposeAsCommand, den Rest erledigt Reflection-Magie:

Zusätzlich zu diesem Attribut, kann man nun das Attribut UndoRedoScope hinzufügen:

Résumé

Insgesamt bin ich sehr froh, was für eine schlanke Umsetzung entstanden ist. Interessant ist auch, wie sich das Design sich von der initialen Idee noch einmal verändert hat. Und jetzt hat Quality Spy ein wirkliches Killer-Feature, was andere Testing-Tools einfach alt aussehen lässt!

Es würde mich freuen, wenn das Framework auch in anderen Tools nützlich ist!

TODOs

Wie in jeder 1.0-Implementierung mussten einige Punkte außen vor gelassen werden.

Meine Top-Prioritäten sind:

  • Automatisches Zusammenfassen von Änderungen (z.B. 5 Sekunden-Regel)
  • Limit für Undo/Redo (Begrenzung Speicherverbrauch)

In Bezug auf Quality Spy gibt es auch einige offene Punkte:

  • In der UI sollte eine Liste der Änderungen mit Textbeschreibung sichtbar sein, sodass der Nutzer besser feststellen kann, was er mit Undo/Redo ändert
  • Manche Änderungen z.B. an Volltexten oder auch das Ändern von Farbslidern benötigt dringend eine gröbere Aggregation
  • Auswirkungen auf den Speicherverbrauch müssen geprüft und optimiert werden
  • Änderungen an UI-State Eigenschaften (z.B. aktuelle Seite, Selektion) sollte in das Undo-Redo mit einbezogen werden
Links