編輯器在本機可以正常復原,改到雲端 Mac 測試後,卻出現「一次拖曳要復原十次」或「復原成功,但無法重做」。先別急著替按鈕補上邏輯:把編輯操作、歷史紀錄與介面更新拆開,確認一次使用者操作究竟對應幾個歷史群組。以下用整數位置代替畫布座標,建立可放進 iOS 單元測試目標的最小契約。
先確定復原的操作單位
先列出三種輸入:單次按鈕調整、連續拖曳,以及程式載入文件。按鈕調整形成一個群組;拖曳從開始到結束形成一個群組;載入文件則不納入編輯歷史。不要把每一次位置回呼都當成一次使用者操作。
UndoManager 預設可以依事件迴圈自動分組。測試若依賴這項行為,執行時機一變,群組邊界就可能跟著改變。這裡關閉 groupsByEvent,由應用程式的操作入口明確配對群組的開始與結束;正式介面也必須採用相同策略,不能只在測試中關閉。
所有模型修改與歷史操作都放在主執行器上執行。開始測試前,先確認 groupingLevel 為零,不要在群組尚未結束時執行復原。取消拖曳時要回到起點,還是保留目前位置,應先訂成產品規則,再實作對應的處理流程。
驗收的不是「按鈕可以點擊」,而是「復原一次回到操作前,重做一次回到操作後」。介面顯示、模型值與歷史堆疊都必須同時符合這項契約。
透過同一入口註冊反向操作
在測試目標中新增檔案,替換範例類別名稱即可執行。初始值直接設為零,不呼叫修改入口,否則初始化也會變成可復原的操作。修改入口儲存的是修改前的值,而不是稍後才讀取目標的目前值。
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)
}
}
復原時,群組內的操作會依相反順序執行:先回到十,再回到零。回呼再次經過修改入口,UndoManager 便會在復原階段將新的反向操作放進重做堆疊。因此,不能為了「避免遞迴」而改成直接賦值;那樣雖然能還原資料,卻會遺失重做紀錄。
範例中的回呼由主執行器上的歷史操作同步觸發。整合到實際介面後,也要維持這個呼叫邊界,不要把回呼改成非同步任務,否則註冊反向操作時,可能已經離開復原階段。
將歷史分支納入斷言
先保留上述正常流程的測試,再新增三個獨立測試。每個測試都要建立新的模型,不能共用上一個測試案例留下的歷史堆疊。
第一,完成一個群組後復原,再開啟新群組寫入不同的值。斷言新值已生效,且 canRedo 為假:新的編輯已形成歷史分支,不應再重做已捨棄的路徑。
第二,連續建立兩個群組,分別調整位置。復原一次只能回到第二個群組開始前,不能直接回到初始狀態;再復原一次,才會還原初始值。這能找出按鈕操作入口忘記結束群組的問題。
第三,提交相同的值。範例中的提前返回應確保不會新增有效的歷史紀錄;在全新模型上執行同值修改後,斷言 canUndo 為假。不要只檢查數值沒有改變,否則沒有實際變更的歷史操作,仍可能讓按鈕錯誤地啟用。
每次操作後都要加入斷言,而不是只檢查最終結果。兩次錯誤操作也可能碰巧把值還原到正確的終點。
管好文件生命週期與非同步邊界
切換文件時,優先為新文件建立新的模型與 UndoManager。若必須重複使用同一個實例,應先結束目前的編輯操作,再清空歷史並載入新值,確保舊文件的回呼無法修改新文件。不要在復原回呼尚未完成時切換物件。
長時間運作的編輯器可以設定 levelsOfUndo,限制歷史群組的數量,但這不是以位元組計算的記憶體上限。若將整張圖片或大型陣列儲存為舊值,仍須評估每個群組保留的資料量。
非同步計算不能讓同一個群組跨越使用者手勢,持續保持開啟。發起計算時,記錄文件識別碼與修訂編號;結果傳回後,先檢查兩者是否仍相符,過期的結果就捨棄。通過檢查的結果,再於主執行器內開啟群組、套用修改並結束群組,完成一個完整的操作群組。
對應的測試不靠休眠等待:讓測試替身暫存完成回呼,切換文件或繼續編輯後,再主動觸發回呼,並斷言狀態與歷史都未受到舊結果影響。
在雲端執行並驗收介面
將模型測試加入現有的 iOS 測試目標,使用已啟動模擬器的裝置識別碼執行;專案路徑與 Scheme 請換成儲存庫中的實際值。
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"
每次使用唯一的結果路徑,能避免重複執行時覆寫先前的測試現場。測試失敗時,先確認是值未正確還原、群組邊界不符預期,還是重做能力有問題,不要用清空全部歷史的方式掩蓋錯誤。
最後補上一項介面驗收:拖曳後執行復原,檢查畫布位置與按鈕狀態;重做後再檢查一次。若模型測試通過,畫布卻沒有更新,應檢查變更通知與介面綁定,而不是重新註冊歷史。模型契約穩定後,再用實機確認連續手勢的群組邊界符合使用者預期。
常見問題
復原的回呼中也需要註冊反向操作嗎?
需要。透過同一修改入口恢復舊值時,應註冊恢復前的值;UndoManager 會在復原階段將這次註冊放入重做紀錄。直接賦值只能恢復資料,不能建立對應的重做操作。
模擬器測試能取代觸控手勢驗收嗎?
不能。模型測試可驗證群組、操作紀錄與狀態恢復,模擬器介面測試可檢查按鈕。手勢是否自然、連續拖曳的操作邊界是否合理,仍需實機驗收。
將建置佇列交給專屬雲端 Mac
選擇實體節點、機型與計費週期,讓 Xcode 建置和自動化工作在專屬的 macOS 環境中執行。實際可用狀態請以控制台即時回報為準。