Cocoa程式設計入門/新增細節
到目前為止,我們已經構建了一個相當粗糙的繪圖程式,但它開始看起來像一個真正的應用程式了。我們已經涵蓋了編寫任何應用程式所需的許多Cocoa核心原則。如果你看看我們實際編寫了多少程式碼,其實並不多,但我們的應用程式已經相當功能齊全了。在本節中,我們將探討如何新增細節——這些細節將真正設計良好且實現良好的應用程式與我們毫無疑問都見過的那些較差的業餘作品區分開來。
首先要解決的是撤銷。一個真正的應用程式應該允許幾乎所有操作都可撤銷,本著寬容使用者的精神。如果沒有撤銷功能,你的應用程式很可能會被放棄,轉而使用另一個具有該功能的應用程式,因為懲罰使用者犯錯或不允許他們嘗試是重大的設計錯誤!
撤銷傳統上是實現應用程式的“難點”之一。Cocoa 在這裡為我們提供了巨大的幫助,因此撤銷並不那麼難。通常,事後新增撤銷功能不是一個好主意——你需要從頭開始設計你的應用程式以應對撤銷任務。到目前為止,我們還沒有提到撤銷,但實際上我們已經設計了我們的應用程式,以便可以輕鬆地新增它。
撤銷的原理很簡單——每當我們對資料模型的狀態進行任何更改時,都會記錄先前的狀態,以便可以將其恢復。我們可以透過不同的方式記錄此先前狀態,要麼只記錄整個狀態,要麼記錄其部分狀態以及標籤以告訴我們發生了什麼變化,或者我們可以記錄操作並執行相反的操作來實現撤銷。我們將使用這些方法的組合。Cocoa 會自動將多個可撤銷的“記錄”組合成每個事件的一個可撤銷操作,這確實使我們的生活變得非常簡單——我們只需要確保在每個可編輯的興趣點都發生記錄。
我們希望以下操作可以撤銷
- 新增新物件
- 刪除物件
- 更改物件的位置或大小
- 更改筆觸和填充顏色
- 更改筆觸寬度
請注意,我們也可以使選擇本身可撤銷,但在此練習中我們不會這樣做。
在 Cocoa 中,負責處理撤銷的物件是撤銷管理器。它體現在 NSUndoManager 類中。它的工作原理是儲存呼叫。我們已經討論了 Objective-C 如何使用類方法的執行時繫結。呼叫只是對方法呼叫的儲存描述。當呼叫“回放”時,實際上會進行儲存的方法呼叫。呼叫不僅記錄要呼叫的目標和方法,還記錄呼叫記錄時所有方法的引數。因此,我們可以建立一個呼叫,當它被呼叫時,將撤消我們正在執行的當前操作。操作和撤消同一操作的呼叫同時建立。
首先讓我們新增撤銷新增和刪除物件的程式碼。以下是 MyDocument.m 中為 addObject 和 removeObject 修改後的程式碼
- (void) addObject:(id) object
{
if(![_objects containsObject:object])
{
[[self undoManager] registerUndoWithTarget:self
selector:@selector(removeObject:)
object:object];
[_objects addObject:object];
[_mainView setNeedsDisplayInRect:[object drawBounds]];
}
}
- (void) removeObject:(id) object
{
if([_objects containsObject:object])
{
NSRect br = [object drawBounds];
[[self undoManager] registerUndoWithTarget:self
selector:@selector(addObject:)
object:object];
[self deselectObject:object];
[_objects removeObject:object];
[_mainView setNeedsDisplayInRect:br];
}
}
我們可以透過呼叫 [self undoManager] 獲取撤銷管理器。每個文件都有自己的撤銷管理器例項已設定,我們只需要使用它即可。然後我們使用 registerUndoWithTarget:selector:object: 方法構建撤銷管理器儲存的呼叫。當我們新增物件時,我們構建一個呼叫以 removeObject。當我們刪除物件時,我們構建一個呼叫以 addObject。換句話說,我們記錄了我們正在執行操作的相反操作。我們還在這裡添加了程式碼,以便在物件出現和消失時重新整理主檢視,以便我們可以看到我們的操作產生的效果。
編譯並執行專案。建立一些形狀。現在撤銷它們。你會發現你可以撤銷和重做任意次數的操作——一切按預期的方式為撤銷命令工作。僅僅兩行程式碼就能做到這一點,還不錯吧!
請注意,當執行撤銷時,它會呼叫,例如 removeObject,而 removeObject 又會記錄另一個 addObject 撤銷操作。這就是重做工作的方式——NSUndoManager 知道它被呼叫的上下文,因此可以將任務新增到相應的堆疊中。現在讓我們使可編輯操作可撤銷。為此,我們需要向 WKDShape 新增程式碼,目前形狀沒有撤銷管理器,也不知道它們所屬的文件,因此它們無法獲取一個。因此,我們需要做的第一件事是提供一種方法,讓形狀能夠獲取適當的撤銷管理器。
在 WKDShape.h 中,向名為 _owner 的類新增一個數據成員,型別為 NSDocument*。新增訪問器方法 setOwner: 和 owner。在 .m 檔案中,新增實現
- (void) setOwner:(NSDocument*) doc
{
_owner = doc;
}
- (NSDocument*) owner
{
return _owner;
}
在 MyDocument.m 中,向 addObject 新增一行以呼叫 setOwner 並使用 self——這確保了每當將物件新增到文件時,都會使用其所屬的文件更新物件。請注意,由於任何向文件新增物件的內容(例如貼上)都會呼叫此方法,因此此資訊始終是最新的。這是一個強大的設計原則——始終嘗試最大程度地減少程式碼中更改資料模型的位置,方法是將程式碼分解成幾個關鍵方法。然後,你可以在瞭解這些方法將始終被呼叫並且沒有任何內容可以透過“後門”進入的情況下向這些方法新增程式碼。如果我們沒有這樣做,那麼實現撤銷將比到目前為止要困難得多。
現在我們有了讓 WKDShape 獲取其擁有文件的 undoManager 的方法,我們可以輕鬆地實現撤銷。
- (void) setFillColour:(NSColor*) colour
{
[[[self owner] undoManager] registerUndoWithTarget:self
selector:@selector(setFillColour:)
object:_fillColour];
[colour retain];
[_fillColour release];
_fillColour = colour;
[self repaint];
}
在這裡,我們沒有使用不同的方法設定撤銷,而是使用了相同的方法,但使用了顏色的舊值。當呼叫撤銷命令時,它會呼叫相同的方法,並將原始顏色傳回。筆觸顏色的程式碼相同。
筆觸寬度的案例稍微複雜一些,因為撤銷管理器不會直接儲存簡單的標量值(如浮點數)。相反,我們需要將值打包到一個物件中。Cocoa 提供了一個簡單的類來做到這一點——NSNumber。由於我們需要能夠呼叫一個採用物件引數的方法,因此我們現在必須建立一個。我們將重構程式碼,以便所有對 setStrokeWidth 的呼叫現在都透過它進行。
- (void) setStrokeWidth:(float) width
{
[[[self owner] undoManager] registerUndoWithTarget:self
selector:@selector(setStrokeWidthObject:)
object:[NSNumber numberWithFloat:_strokeWidth]];
_strokeWidth = width;
[self repaint];
}
- (void) setStrokeWidthObject:(NSNumber*) value
{
[self setStrokeWidth:[value floatValue]];
}
不要忘記將方法 setStrokeWidthObject: 新增到你的類宣告中。現在,如果任何人呼叫 setStrokeWidth,舊值將轉換為 NSNumber 物件,並用於在 setStrokeWidthObject: 上構建撤銷呼叫。當呼叫撤銷時,setStrokeWidthObject: 會解包封裝的值並呼叫 setStrokeWidth:,因此一切正常。
編譯並轉到測試,以驗證使用檢查器更改屬性現在是否完全可撤銷。它們應該是!
注意 Cocoa 如何為你將每個編輯操作分離到不同的撤銷命令中。例如,如果你在顏色選擇器中拖動,物件的顏色會發生很多次更改,直到你停止拖動。但是,只需要一個撤銷命令即可恢復以前的顏色——它不會明顯“回放”所有單獨的中間顏色。
但是,有一件事現在不太好。如果我們撤消編輯更改,檢查器不會更改!如果我們仔細考慮一下,原因很明顯——之前我們只設置了檢查器以響應選擇更改,這些更改也設定了控制元件的狀態。之後,檢查器更改物件的狀態,因此它們始終保持“同步”。撤銷不會更改選擇狀態,但它在檢查器背後更改了物件的狀態,因此我們需要告訴檢查器在發生這種情況時重新同步。為此,我們需要新增一個新的通知,檢查器可以對其做出響應。
這與“重繪”通知非常相似——實際上在這種情況下可以使用它,但由於在語義上它確實具有不同的含義,因此最好將其作為單獨的通知。如果你以後擴充套件設計,你可能會發現組合通知會導致棘手的問題。因此,建立一個新的通知名稱和一個傳送它的方法。我將其命名為 resynch。從物件狀態可以從其中更改的每個位置呼叫此方法——setFillColour:、setStrokeColour:、setStrokeWidth: 在檢查器中,建立一個新的響應器方法——我將其命名為 resynch:——獲取單個 NSNotification 引數。像以前一樣在 awakeFromNib 方法中訂閱 resynch 通知。resynch: 方法如下所示
- (void) resynch:(NSNotification*) note
{
WKDShape* shp = (WKDShape*)[note object];
if ( shp == editShape )
[self setupWithShape:shp];
}
如你所見,這非常簡單——我們找出哪個物件發生了更改。如果它實際上是我們當前正在編輯的物件,我們只需呼叫我們的設定方法,以便控制元件狀態與物件的狀態匹配。再次編譯並執行以驗證這次撤消編輯操作是否也反映了檢查器中的更改。
最後,我們需要撤消物件的移動和調整大小。透過將所有這些資訊透過 setBounds: 方法傳遞,我們只有一個地方可以新增撤銷記錄。與 setStrokeWidth 一樣,我們需要使用 NSValue 物件來儲存舊的矩形。
以下是 WKDShape 中重構和修改後的程式碼
- (void) setBounds:(NSRect) bounds
{
[self repaint];
[[[self owner] undoManager] registerUndoWithTarget:self
selector:@selector(setBoundsObject:)
object:[NSValue valueWithRect:[self bounds]]];
_bounds = bounds;
[self repaint];
[self resynch];
}
- (void) setBoundsObject:(id) value
{
[self setBounds:[value rectValue]];
}
- (void) setLocation:(NSPoint) loc
{
NSRect br = [self bounds];
br.origin = loc;
[self setBounds:br];
}
- (void) offsetLocationByX:(float) x byY:(float) y
{
NSRect br = [self bounds];
br.origin.x += x;
br.origin.y += y;
[self setBounds:br];
}
- (void) setSize:(NSSize) size
{
NSRect br = [self bounds];
br.size = size;
[self setBounds:br];
}
編譯並執行...建立一個形狀並移動它。現在撤銷。操作是否以你期望的方式撤銷?不,不是。該操作是可撤銷的,但與顏色案例不同,每個單獨的小量運動都被記錄為一個單獨的撤銷任務。撤銷管理器未能將這些運動組合成一個大的單一操作。這是為什麼呢?NSUndoManager 將在單個事件內發生的全部撤銷操作進行分組。但是,拖動物件包含多個事件。我們無法更改這一點,但可以透過提供一些提示來更改撤銷管理器對事物進行分組的方式。
在 WKDDrawView.m 中,在方法 mouseDown: 中,在方法頂部新增以下行
[[[self delegate] undoManager] beginUndoGrouping];
並在 mouseUp: 方法中,在方法底部新增以下行
[[[self delegate] undoManager] endUndoGrouping];
這將解決問題——編譯並執行以進行驗證。它的作用是告訴撤銷管理器將從現在開始到呼叫 endUndoGrouping 之前收到的任何撤銷記錄分組。我們在滑鼠按下時開始分組,在滑鼠抬起時結束分組,因此在拖動過程中發生的所有操作都將最終位於同一組中,並且將在單個撤銷命令下回放。形狀的每個單獨偏移量仍然會被記錄,但在回放時會回放整個移動,因此使用者的效果是預期的行為。
最後,你可能已經注意到選單命令只顯示一個通用的“撤銷”。如果我們能給使用者一個更有意義的指示,說明撤銷操作將完成什麼,那就太好了。為此,我們將一個字串傳遞給撤銷管理器,它用於構建選單命令文字。在組中接收到的最後一個字串將是使用的那個。該方法是 NSUndoManager 的 setActionName: 方法。現在我們只對每個操作的字串進行硬編碼 - 在一個真實的應用程式中,我們需要允許它進行本地化。
在 WKDShape 中,這很簡單。例如,在 setFillColour 中,新增
[[[self owner] undoManager] setActionName:@"Change Fill Colour"];
緊接在當前撤銷記錄操作之後。對於所有類似的狀態更改,您可以新增一行帶有字串,指示實際更改的內容。請記住,使用者會看到這個;您不需要包含“撤銷”或“重做”。為了區分調整大小和移動形狀,您可以在相應的例程中包含此行 - 它們不需要直接放在 setBounds: 中;另外,不要忘記程式碼,其中控制代碼以互動方式拖動 - mouseDragged
MyDocument 的更改類似,但有一個小問題 - 因為我們使用的是“相反”方法,我們需要區分撤銷/重做和正常呼叫,並確保我們只在正常呼叫時更新字串 - 即不是來自撤銷呼叫。為此,我們使用
if (! [[self undoManager] isUndoing] && ! [[self undoManager] isRedoing]) [[self undoManager] setActionName:@"Delete Object"];
再次執行以檢查所有內容是否按預期工作。
作為獎勵,您會注意到,現在,對文件的更改會自動詢問您是否要在退出或關閉文件時儲存更改。這是因為透過記錄撤銷任務,您還在通知文件其狀態與磁碟檔案不同。以前,它無法分辨。Cocoa 現在介入併為您“免費”添加了這一個小功能。不錯!
進一步練習
- 新增撤銷選擇更改的功能。在實踐中,這通常會導致更易用的應用程式。
真正的應用程式應該可以列印。同樣,在傳統的 Mac 應用程式中,新增列印功能通常是一項艱鉅的工作,並且經常作為事後考慮,使其變得更加困難!Cocoa 則簡單得多,因為它的螢幕圖形模型和列印模型之間沒有重大差異。事實上,我們已經編寫了大部分程式碼。
列印由稱為 NSPrintOperation 的物件處理,與 NSView 協同工作。檢視將內容繪製到紙張上。對於我們的簡單練習,我們只需要很少的程式碼,因為跨頁面平鋪的預設分頁方案就足夠了。正如您可能想象的那樣,Cocoa 提供了許多替代方法,但這些方法超出了使基本列印工作的範圍。
由於我們有一個基於文件的應用程式,因此我們使用 printDocument: action 方法來啟動操作。以下是程式碼
- (IBAction) printDocument:(id) sender
{
NSPrintOperation *op = [NSPrintOperation printOperationWithView:_mainView
printInfo:[self printInfo]];
[op runOperationModalForWindow:[self windowForSheet]
delegate:self
didRunSelector:
@selector(printOperationDidRun:success:contextInfo:)
contextInfo:NULL];
}
我們建立一個 NSPrintOperation,並將我們的主檢視(將呈現繪圖)傳遞給它。獲得此檢視後,我們只需使用工作表對話方塊執行它即可。這將處理其餘部分。工作表需要一個完成例程,但在這種情況下,我們不需要它實際執行任何操作,因此我們只需將其提供為空方法
- (void) printOperationDidRun:(NSPrintOperation*) printOperation
success:(BOOL)success
contextInfo:(void *)info
{
}
不要忘記將其新增到您的類定義中。
就是這樣!編譯並執行它,您會發現您可以打印出您的圖形。
這裡有一件事不太好,那就是它列印了選擇手柄。實際上,我們不需要在列印圖形時看到這些手柄,因此我們現在只需修改主檢視繪圖程式碼,以便在列印時它不會費心渲染這些手柄。事實證明這非常簡單
while( shape = [iter nextObject])
{
if ( NSIntersectsRect( rect, [shape drawBounds]))
[shape drawWithSelection:[selection containsObject:shape] &&
[NSGraphicsContext currentContextDrawingToScreen]];
}
這是我們檢視的 drawRect: 方法內的主迴圈。我們只需檢查當前圖形上下文是否正在繪製到螢幕上。如果不是,則它必須繪製到印表機,因此物件在不顯示手柄的情況下呈現,而不管其選擇狀態如何。
我們已經瞭解了 Cocoa 如何自動啟用可以在當前上下文中找到目標的選單項。這非常巧妙,但並不總是足夠。例如,當我們實現剪下和貼上時,我們發現即使沒有要剪下的選擇或剪貼簿中沒有我們可以貼上的內容,這些命令也始終處於啟用狀態。我們現在將瞭解如何處理這種情況。
每當顯示選單時,目標都會有機會覆蓋正常的啟用行為。它透過實現名為 validateMenuItem: 的方法來做到這一點,該方法會為每個專案呼叫。您可以返回 YES 以啟用該專案,返回 NO 以停用它。因此,在 MyDocument 中,我們新增
- (BOOL) validateMenuItem:(NSMenuItem*) item
{
SEL act = [item action];
if ( act == @selector(cut:) ||
act == @selector(copy:) ||
act == @selector(delete:))
return ([[self selection] count] > 0 );
else if ( act == @selector(paste:))
{
NSPasteboard* cb = [NSPasteboard generalPasteboard];
NSString* type = [cb availableTypeFromArray:[NSArray arrayWithObjects:@"wikidrawprivate", nil]];
return ( type != nil );
}
else
return YES;
}
如這裡所示,最好比較操作選擇器以確定選單項的目標操作,而不是檢視選單項字串或位置。另一種方法是使用專案的標記,但這需要您在 IB 中仔細設定這些標記。透過使用選擇器,您可以免受可能對選單進行的文字和位置更改的影響 - 只要它仍然以相同操作方法為目標,它在該方法實際實現的內容方面就會表現正確。
在這裡,如果該專案以剪下、複製或刪除方法為目標,我們會檢視選擇陣列中是否存在任何專案。如果有,則啟用該專案,否則停用它。對於貼上情況,我們檢視剪貼簿以檢視它是否具有型別為“wikidrawprivate”的資料可用 - 如果有,我們可以貼上,因此我們啟用該專案。
您可以在選單命令的任何目標中實現類似的方法,以便對選單項的啟用進行精細控制。如果您願意,您還可以利用此機會更改選單項的文字,以反映您在資料模型中的一些資訊。