作者:Tomasz Szulc,原文鏈接,原文日期:2015-09-13
譯者:Yake;校對:numbbbbb;定稿:
在相當長的一段時間內 NSUndoManager 對我來說都是一個很神秘的東西。我想學習使用它,但是一直沒有時間。一直到今天,我寫了一個簡單的應用,這個應用創建了一個可以移動的矩形,并且還可以修改矩形的屬性,例如背景色或者圓角。
你可以在這兒獲取到示例應用
這兒是一段小視頻,你可以看看這個示例應用是怎么工作的。

NSUndoManager
NSUndoManager
允許記錄用戶執行的操作并且反轉這類操作。
當你調用一個可以改變一些東西的方法或者是執行一個改變屬性值的動作(例如 setter 方法)時,你可以注冊這個操作來進行撤銷。
一個撤銷操作包含了接收消息的對象,發送消息以及參數 - 通常你會傳入原始值。
NSUndoManager
實例支持重做操作,所以才能逆轉操作。你可以認為這個管理器擁有兩個棧。實際上,它管理兩個棧,undo
(撤銷)棧和redo
(重做)棧 - 對應NSUndoManager
的私有屬性_undoStack
和_redoStack
,里面存儲著一些操作。
注冊undo
操作時,它會被添加到undo
棧中。當調用undo()
方法時管理器就會進行撤銷,執行棧中的操作并把這個操作移動到redo
棧中,這樣你就可以重做它。當你擁有多個undo
操作時,按照逆序來撤銷和重做這些操作。你肯定不會將操作直接注冊到redo
棧中,實際上這根本無法實現。
你可以為undo
操作設置一個級別,這指的是一個管理器可以在它的棧中存儲多少undo
操作。如果添加的undo
操作數量超過了這個級別,最早加入的那個操作將會從棧中移除。
你可以通過canUndo
和canRedo
來檢查undo
和redo
棧的狀態。這些狀態很重要,你可能會基于這些棧的狀態來更新 UI。
假設你已經設置了undo
操作的級別并且redo
棧中還有一個操作,如果undo
操作超過了這個級別,那你就需要使用canUndo
和canRedo
來檢測可用性。之所以要這樣做,是因為在這種情況下NSUndoManager
將會移除redo
操作,因為它是歷史操作中最新的undo
操作。(校對注:這里確實很繞,大家可以類比一下編輯器的撤銷和重做操作,如果你在撤銷之后進行了新的改動,那之前撤銷過的操作其實已經無法再被重做了,因此可以被直接刪掉,從而把更多的空間留給undo
操作。)
注冊undo
操作
API提供了兩種注冊操作的方法。
第一種是使用registerUndoWithTarget(_:selector:object:)
方法:
func registerUndoAddFigure(figure: FigureView) {
undoManager.registerUndoWithTarget(self, selector: Selector(“removeFigure:”), object: figure)
undoManager.setActionName(“Add Figure”)
}
第二種撤銷方法是基于NSInvocation
。你可以使用prepareWithInvocationTarget(_:)
方法來注冊此類操作。
func registerUndoAddFigure(figure: FigureView) {
undoManager.prepareWithInvocationTarget(self).removeFigure(figure)
undoManager.setActionName("Add Figure")
}
你將會得到一個NSUndoManagerProxy
類型對象,可以用它調用任何方法(但是只能調用目標對象遵守的那些協議方法,否則應用會拋出運行時異常)。注冊之后代理對象將會在內部創建NSInvocation
對象來記錄你的操作,這個對象會在傳入的目標對象執行undo
操作時被調用。
值得強調的是,在注冊過程中目標對象沒有被持有,需要你去管理它。如果undo
操作被調用而目標對象已經被銷毀,就會產生運行時異常。
當對象將要被銷毀時你需要調用removeAllActionsWithTarget(_:)
來移除與目標對象相關聯的一些操作,或者調用removeAllActions()
來移除undo
和redo
棧中所有的操作
將操作分組
分組操作是一件很有用的事情。默認情況下操作是通過事件進行分組的。這就意味著操作將會通過每一輪運行時循環來分組。你可以關閉自動分組,調用beginUndoGrouping()
和endUndoGrouping()
方法來手動操作分組。
命名并顯示操作
NSUndoManager
支持存儲操作的名稱。你可以調用setActionName(_:)
方法來為操作命名。管理器已經包含Undo
和Redo
這兩個單詞的多語言版本,可以使用 API 直接獲取對應語言的Undo/Redo
字符串。
下面這個方法來自示例應用,每一個新的undo
操作被注冊或者undo``redo
操作被執行之后,將會更新undo
和redo
按鈕。
private func updateUndoAndRedoButtons() {
undoButton.enabled = undoManager.canUndo == true
if undoManager.canUndo {
undoButton.setTitle(undoManager.undoMenuTitleForUndoActionName(undoManager.undoActionName), forState: .Normal)
} else {
undoButton.setTitle(undoManager.undoMenuItemTitle, forState: .Normal)
}
redoButton.enabled = undoManager.canRedo == true
if undoManager.canRedo {
redoButton.setTitle(undoManager.redoMenuTitleForUndoActionName(undoManager.redoActionName), forState: .Normal)
} else {
redoButton.setTitle(undoManager.redoMenuItemTitle, forState: .Normal)
}
}
通知
管理器有幾個你可以觀察的通知類型。在示例應用中我關注的是NSUndoManagerDidUndoChangeNotification
和NSUndoManagerDidRedoChangeNotification
。為了讓應用完美運行,我可能需要觀察所有will
或者did
類型的通知,因為操作可能要執行一段時間,并且一部分代碼可能是異步的。在這些情況下應用要正確展示 UI 就需要使用這些通知來刷新Undo
和Redo
按鈕。
上下文
應用在不同的上下文中可能有不同的管理器。示例應用在不同的上下文中用了兩個管理器。
第一個上下文是塊展板,展板用來展示矩形并且可以在上面移動這個矩形。在這個展板上下文中可能發生的操作就是添加、移動或者移除一個矩形。
第二個上下文是這個矩形自己。你可以改變它的顏色和圓角。我決定追蹤展板的背景色以及圓角,忽略掉它在展板中的位置。
這樣你就可以添加一個矩形,移動它,改變它的顏色和圓角,使用undo
來撤銷移動操作但是不會撤銷掉背景色和圓角的改變。你所使用的上下文數量取決于你的應用是怎么樣的。
響應鏈
每一個UIView
對象繼承自UIResponder
類型,這個類定義了響應對象的接口并且處理事件。
UIResponder
類聲明了undoManager
屬性。當應用接收到undo
事件,UIResponder
搭建起響應者鏈并通過undoManager
返回一個NSUndoManager
類型的對象來找到這個響應者。找到的第一個響應者將被用來處理undo
或者redo
操作。
為了使用響應者鏈你需要重載canBecomeFirstResponder()
屬性并且設置返回值為true
,然后通過調用becomeFirstResponder()
方法使持有undoManager
的對象成為第一響應者。如果你已經正確設置好了一切,可以執行一個搖晃手勢,應用會出現一個警告框詢問你是否要執行undo
操作。

示例代碼
當我寫這個示例代碼的時候我注意自己花費了很多時間去考慮“具有唯一目的”的方法。當你需要支持撤銷和重做操作時這其實很重要,因為你調用那些方法就是出于特定的目的。
下面的示例代碼來自那個示例應用,它展示了在展板中的添加,移除以及移動操作都是怎么實現的。下面是所有與undo manager
相關的代碼:
/// MARK: Actions on Figures
func addFigure(figure: FigureView) {
registerUndoAddFigure(figure)
boardView.addSubview(figure)
figures.append(figure)
updateUndoAndRedoButtons()
}
func removeFigure(figure: FigureView) {
registerUndoRemoveFigure(figure)
figure.removeFromSuperview()
if let index = figures.indexOf(figure) {
figures.removeAtIndex(index)
}
}
func moveFigure(figure: FigureView, center: CGPoint) {
registerUndoMoveFigure(figure)
figure.center = center
}
/// MARK: Undo Manager
override func canBecomeFirstResponder() -> Bool {
return true
}
private var _undoManager = NSUndoManager()
override var undoManager: NSUndoManager {
return _undoManager
}
private func observeUndoManager() {
NSNotificationCenter.defaultCenter().addObserver(self, selector: Selector("updateUndoAndRedoButtons"), name: NSUndoManagerDidUndoChangeNotification, object: undoManager)
NSNotificationCenter.defaultCenter().addObserver(self, selector: Selector("updateUndoAndRedoButtons"), name: NSUndoManagerDidRedoChangeNotification, object: undoManager)
}
@objc private func updateUndoAndRedoButtons() {
undoButton.enabled = undoManager.canUndo == true
if undoManager.canUndo {
undoButton.setTitle(undoManager.undoMenuTitleForUndoActionName(undoManager.undoActionName), forState: .Normal)
} else {
undoButton.setTitle(undoManager.undoMenuItemTitle, forState: .Normal)
}
redoButton.enabled = undoManager.canRedo == true
if undoManager.canRedo {
redoButton.setTitle(undoManager.redoMenuTitleForUndoActionName(undoManager.redoActionName), forState: .Normal)
} else {
redoButton.setTitle(undoManager.redoMenuItemTitle, forState: .Normal)
}
}
/// MARK: Undo Manager Actions
func registerUndoAddFigure(figure: FigureView) {
undoManager.prepareWithInvocationTarget(self).removeFigure(figure)
undoManager.setActionName("Add Figure")
}
func registerUndoRemoveFigure(figure: FigureView) {
undoManager.prepareWithInvocationTarget(self).addFigure(figure)
undoManager.setActionName("Remove Figure")
}
func registerUndoMoveFigure(figure: FigureView) {
undoManager.prepareWithInvocationTarget(self).moveFigure(figure, center: figure.center)
undoManager.setActionName("Move to \(figure.center)")
}
我創建了一些undo
相關的簡單方法。這樣做效果很好,注冊undo
操作的邏輯和操作本身的邏輯相分離,代碼精簡為一句函數調用。
我決定放棄使用registerUndoWithTarget(_:selector:object:)
方法,因為Selector
是一個字符串,這樣做很危險。而prepareWithInvocationTarget(_:)
看起來更好一些,既安全又便于使用。
不過,當你需要設置屬性時可能要用帶Selector
的方法。
你需要直接調用想要記錄的方法,但是這樣做不能設置屬性(因為只能調用方法)。有兩種解決方法:第一種是添加類似setPropertyName(_:)
的方法,第二種是使用registerUndoWithTarget(_:selector:object:)
方法并將Selector
設置為setPropertyName:
,作為參數傳入。
結論
NSUndoManager
是一種強大的機制,我們可以簡單地向應用中加入undo
和redo
方法。它需要你謹慎地設計應用的結構,因為你需要使用“具有唯一目的性”的方法來將用戶的操作設置為undo
或者是redo
。但是總體來說這是個好事,不是嗎?這會改善代碼設計。