Im lokalen Editor funktioniert Undo, doch beim Testen auf einem Cloud-Mac muss ein einziger Ziehvorgang plötzlich zehnmal rückgängig gemacht werden – oder Undo funktioniert, Redo aber nicht. Bevor Sie die Schaltflächenlogik erweitern, trennen Sie Bearbeitungsaktionen, Verlauf und Oberflächenaktualisierung voneinander. Prüfen Sie dann, wie viele Verlaufsgruppen tatsächlich zu einer Benutzeraktion gehören. Das folgende Minimalbeispiel verwendet ganzzahlige Positionen statt Koordinaten auf der Zeichenfläche und definiert einen Vertrag, der sich direkt in einem iOS-Unit-Test-Target prüfen lässt.
Die Aktionseinheit für Undo festlegen
Unterscheiden Sie drei Arten von Eingaben: eine einzelne Änderung per Schaltfläche, einen zusammenhängenden Ziehvorgang und das programmgesteuerte Laden eines Dokuments. Eine Änderung per Schaltfläche bildet eine Gruppe. Ein Ziehvorgang bildet vom Beginn bis zum Ende ebenfalls eine Gruppe. Das Laden eines Dokuments gehört nicht in den Bearbeitungsverlauf. Nicht jeder Positions-Callback ist eine eigene Benutzeraktion.
UndoManager kann Aktionen standardmäßig automatisch nach Durchläufen der Ereignisschleife gruppieren. Verlassen sich Tests auf dieses Verhalten, können sich die Gruppengrenzen mit dem Ausführungszeitpunkt verschieben. Deshalb deaktivieren wir hier groupsByEvent und lassen die Einstiegspunkte der Bearbeitungslogik Gruppen explizit beginnen und beenden. Die produktive Oberfläche muss dieselbe Strategie verwenden; die Einstellung darf nicht nur in den Tests deaktiviert werden.
Alle Modelländerungen und Verlaufsoperationen werden auf dem Main Actor ausgeführt. Prüfen Sie vor dem Test, dass groupingLevel null ist, und führen Sie Undo nicht innerhalb einer noch offenen Gruppe aus. Ob ein abgebrochener Ziehvorgang zur Ausgangsposition zurückkehrt oder die aktuelle Position beibehält, muss zuerst als Produktregel feststehen. Erst danach wird der entsprechende Ablauf implementiert.
Das Abnahmekriterium lautet nicht „Die Schaltfläche lässt sich anklicken“, sondern „Einmal Undo stellt den Zustand vor der Aktion wieder her, einmal Redo den Zustand danach“. Anzeige, Modellwert und Verlaufsstapel müssen diesen Vertrag gleichzeitig erfüllen.
Die Gegenoperation über denselben Einstiegspunkt registrieren
Legen Sie im Test-Target eine neue Datei an. Nach dem Anpassen der Beispielklassennamen können Sie den Test ausführen. Der Anfangswert wird direkt auf null gesetzt, ohne den Änderungseinstieg aufzurufen. Andernfalls würde auch die Initialisierung zu einer rückgängig machbaren Aktion. Der Änderungseinstieg speichert den Wert vor der Änderung, statt den aktuellen Wert des Ziels erst später auszulesen.
import Foundation
import XCTest
@MainActor
final class PositionEditor {
private(set) var position = 0
let history = UndoManager()
init() {
history.groupsByEvent = false
}
func setPosition(_ next: Int) {
guard next != position else { return }
let previous = position
history.registerUndo(withTarget: self) { target in
target.setPosition(previous)
}
position = next
}
}
final class PositionHistoryTests: XCTestCase {
@MainActor
func testGroupedDragCanUndoAndRedo() {
let editor = PositionEditor()
let history = editor.history
history.beginUndoGrouping()
editor.setPosition(10)
editor.setPosition(20)
history.endUndoGrouping()
XCTAssertEqual(history.groupingLevel, 0)
XCTAssertEqual(editor.position, 20)
XCTAssertTrue(history.canUndo)
history.undo()
XCTAssertEqual(editor.position, 0)
XCTAssertTrue(history.canRedo)
history.redo()
XCTAssertEqual(editor.position, 20)
XCTAssertFalse(history.canRedo)
}
}
Bei Undo werden die Operationen innerhalb der Gruppe in umgekehrter Reihenfolge ausgeführt: zunächst zurück auf zehn, dann auf null. Der Callback durchläuft erneut den Änderungseinstieg. Während der Undo-Phase legt UndoManager die dabei neu registrierten Gegenoperationen auf dem Redo-Stapel ab. Ersetzen Sie diesen Aufruf deshalb nicht durch eine direkte Zuweisung, um vermeintlich „Rekursion zu vermeiden“. Dadurch würde zwar der Wert wiederhergestellt, der Redo-Eintrag ginge jedoch verloren.
Im Beispiel wird der Callback synchron durch die Verlaufsoperationen auf dem Main Actor ausgelöst. Behalten Sie diese Aufrufgrenze auch bei der Anbindung an die tatsächliche Oberfläche bei. Verlagern Sie den Callback nicht in einen asynchronen Task: Zum Zeitpunkt der Registrierung könnte die Undo-Phase bereits beendet sein.
Verzweigungen im Verlauf mit Assertions prüfen
Behalten Sie zunächst den obigen Erfolgsfall bei und ergänzen Sie drei unabhängige Tests. Jeder Test erstellt ein neues Modell. Der Verlaufsstapel eines vorherigen Testfalls darf nicht übernommen werden.
Erstens: Schließen Sie eine Gruppe ab, machen Sie sie rückgängig und schreiben Sie anschließend in einer neuen Gruppe einen anderen Wert. Prüfen Sie, dass der neue Wert übernommen wurde und canRedo falsch ist. Die neue Bearbeitung hat einen neuen Zweig im Verlauf erzeugt; der aufgegebene Pfad darf nicht mehr per Redo wiederhergestellt werden.
Zweitens: Erstellen Sie nacheinander zwei Gruppen, die jeweils die Position ändern. Einmal Undo darf nur zum Zustand vor Beginn der zweiten Gruppe zurückführen, nicht direkt zum Anfangszustand. Erst ein zweites Undo stellt den Anfangswert wieder her. So lässt sich erkennen, ob der Einstiegspunkt einer Schaltfläche vergessen hat, die Gruppe zu beenden.
Drittens: Übergeben Sie denselben Wert wie den aktuellen. Die vorzeitige Rückkehr im Beispiel muss verhindern, dass ein weiterer wirksamer Verlaufseintrag entsteht. Führen Sie die Änderung mit demselben Wert auf einem frisch erstellten Modell aus und prüfen Sie, dass canUndo falsch ist. Prüfen Sie nicht nur, ob der Wert unverändert geblieben ist: Auch eine leere Verlaufsaktion könnte die Schaltfläche fälschlicherweise aktivieren.
Setzen Sie Assertions nach jeder einzelnen Operation, statt nur das Endergebnis zu prüfen. Zwei fehlerhafte Operationen können den Wert zufällig wieder zum richtigen Endzustand führen.
Dokumentwechsel und asynchrone Abläufe sauber abgrenzen
Erstellen Sie beim Dokumentwechsel nach Möglichkeit ein neues Modell mit einem neuen UndoManager für das neue Dokument. Falls die Instanz wiederverwendet werden muss, beenden Sie zuerst die aktuelle Bearbeitungsaktion, löschen Sie anschließend den Verlauf und laden Sie dann den neuen Wert. So stellen Sie sicher, dass Callbacks des alten Dokuments das neue Dokument nicht verändern können. Wechseln Sie das Objekt nicht, solange ein Undo-Callback noch nicht abgeschlossen ist.
Bei einem dauerhaft laufenden Editor lässt sich mit levelsOfUndo die Anzahl der Verlaufsgruppen begrenzen. Das ist jedoch keine Obergrenze für den Speicherverbrauch in Bytes. Werden ganze Bilder oder große Arrays als vorherige Werte gespeichert, müssen Sie weiterhin prüfen, wie viele Daten jede Gruppe im Speicher hält.
Eine asynchrone Berechnung darf keine Gruppe offen halten, die über die Benutzer-Geste hinausreicht. Speichern Sie beim Start der Berechnung die Dokumentkennung und die Revisionsnummer. Prüfen Sie nach Eingang des Ergebnisses zunächst, ob beide noch übereinstimmen, und verwerfen Sie veraltete Ergebnisse. Für ein gültiges Ergebnis beginnen Sie auf dem Main Actor eine vollständige Gruppe, führen die Änderung aus und beenden die Gruppe wieder.
Die entsprechenden Tests warten nicht mit Sleep-Aufrufen. Lassen Sie stattdessen ein Test-Double den Abschluss-Callback zwischenspeichern. Wechseln Sie das Dokument oder bearbeiten Sie es weiter und lösen Sie den Callback anschließend gezielt aus. Prüfen Sie, dass weder Zustand noch Verlauf durch das alte Ergebnis verändert wurden.
In der Cloud ausführen und die Oberfläche abnehmen
Fügen Sie die Modelltests dem vorhandenen iOS-Test-Target hinzu und führen Sie sie mit der Gerätekennung eines bereits gestarteten Simulators aus. Ersetzen Sie Projektpfad und Scheme durch die tatsächlichen Werte aus dem Repository.
set -euo pipefail
: "${SIMULATOR_UDID:?Set SIMULATOR_UDID}"
mkdir -p artifacts
RESULT_PATH="artifacts/undo-$(uuidgen).xcresult"
xcodebuild test \
-project EditorApp.xcodeproj \
-scheme EditorApp \
-destination "platform=iOS Simulator,id=$SIMULATOR_UDID" \
-only-testing:EditorAppTests/PositionHistoryTests \
-resultBundlePath "$RESULT_PATH"
Der eindeutige Ergebnispfad verhindert, dass wiederholte Läufe vorhandene Testergebnisse überschreiben. Prüfen Sie bei einem Fehler zuerst, ob die Wertwiederherstellung, die Gruppengrenzen oder die Redo-Funktion nicht dem Vertrag entsprechen. Verdecken Sie den Fehler nicht, indem Sie den gesamten Verlauf löschen.
Ergänzen Sie abschließend eine Prüfung der Oberfläche: Machen Sie einen Ziehvorgang rückgängig und kontrollieren Sie die Position auf der Zeichenfläche sowie den Zustand der Schaltflächen. Wiederholen Sie die Prüfung nach Redo. Bestehen die Modelltests, ohne dass sich die Zeichenfläche aktualisiert, untersuchen Sie die Änderungsbenachrichtigungen und die UI-Bindings, statt die Verlaufsoperationen erneut zu registrieren. Sobald der Modellvertrag stabil ist, prüfen Sie auf einem echten Gerät, ob die Gruppengrenzen bei zusammenhängenden Gesten den Erwartungen der Benutzer entsprechen.
Häufig gestellte Fragen
Muss der Undo-Handler erneut eine Gegenoperation registrieren?
Ja. Wird der alte Wert über dieselbe Änderungsmethode wiederhergestellt, registriert diese den ersetzten Wert. Während Undo legt UndoManager diese Registrierung im Redo-Verlauf ab. Eine direkte Zuweisung erstellt keine passende Redo-Aktion.
Ersetzen Simulatortests die Prüfung von Undo-Gesten am Gerät?
Nein. Modelltests prüfen Gruppen und Zustandswiederherstellung, UI-Tests die Schaltflächen. Bedienbarkeit und sinnvolle Grenzen einer fortlaufenden Ziehbewegung müssen zusätzlich am Gerät geprüft werden.
Build-Warteschlange an einen exklusiven Cloud-Mac übergeben
Wählen Sie einen physischen Knoten, ein Modell und einen Abrechnungszeitraum. So laufen Xcode-Builds und Automatisierungsaufgaben in Ihrer eigenen macOS-Umgebung. Der aktuelle Verfügbarkeitsstatus wird in Echtzeit in der Konsole angezeigt.