跳轉到內容

為初學者編寫 Cocoa Mac OS X 程式/基於文件的應用程式

來自 Wikibooks,開放世界中的開放書籍

上一頁:圖形 - 使用 Quartz 繪圖 | 下一頁:實現 Wikidraw

到目前為止,我們已經看到 Cocoa 支援“基於文件的”多視窗型別的應用程式,我們將以此為基礎構建我們的繪圖程式 Wikidraw。更深入地理解這一點將有助於我們完成設計實現。

文件本身不是視窗。視窗用於檢視文件的內容,在許多簡單的應用程式中,NSDocument(管理文件的類)和 NSWindow(視窗類)之間存在一對一的對映關係。但是,這不是唯一的方案 - 應用程式可以提供幾種檢視文件的替代方法,因此支援每個文件多個視窗。這是使用兩個不同物件的原因之一。

在 Wikidraw 中,我們將對 NSDocument 進行子類化,以便我們有一個地方來儲存繪製時的繪圖資料,以及一個地方來放置我們需要執行的各種操作,例如剪下和貼上等。我們視窗中的檢視只提供了一種顯示文件當前狀態的方式。理想情況下,我們希望最大限度地減少這兩個類之間的相互依賴性,以便文件的內部機制不會暴露給檢視,反之亦然,這符合封裝原則。由於檢視不僅提供了物件的繪製,還提供了與使用者的互動,因此檢視需要了解一些有關物件的資訊,以便它可以將滑鼠點選等傳遞給它們。

委託和協議

[編輯 | 編輯原始碼]

Cocoa 廣泛使用一種稱為委託的常見設計模式。在這種模式下,一個物件依賴於另一個物件來補充有關其狀態等的一些詳細資訊。輔助物件被稱為第一個物件的委託。與子類化不同,委託可以是任何其他合適的物件,而執行時方法繫結意味著原始物件只需要瞭解委託的模糊資訊即可從中獲取資訊。

在 Wikidraw 中,我們可以使文件成為我們檢視的輔助物件。這樣,檢視就可以向其委託詢問基本資訊,例如繪圖中的物件列表或選定物件的列表,而無需瞭解太多有關文件物件本身的資訊。這兩個物件都同意遵守一個用於交換資訊的協議。這僅僅是某些方法名稱的約定。由於該協議僅僅是一種非正式協議,因此被稱為非正式協議。Objective-C 支援更嚴格型別的協議,稱為正式協議,使用“@protocol”關鍵字,但我們這裡不使用它。

實現 MyDocument

[編輯 | 編輯原始碼]

在設定基於文件的應用程式時,Xcode 和 Interface Builder 已經為我們提供了起點,一個名為“MyDocument”的類。這是一個 NSDocument 的子類,我們將使用它來管理每個 Wikidraw 文件。如果您在 XCode(Wikidraw->Classes->MyDocument.h)中找到此檔案並選擇它,您會看到它是一個 NSDocument 的空子類。選擇 .m 檔案以顯示當前實現。它包含一些骨架方法來幫助您入門,儘管已經包含了足夠的內容使基本功能實際工作,例如顯示視窗。

現在,我們需要向 MyDocument.h 新增一些資料成員和方法,以便它能夠開始處理管理繪圖的任務。

@interface MyDocument : NSDocument
{
	NSMutableArray*	_objects;
	NSMutableArray*	_selection;
}

/* drawing maintenance */

- (void)		addObject:(id) object;
- (void)		removeObject:(id) object;
- (NSArray*)	objects;

/* selection maintenance */

- (void)		selectObject:(id) object;
- (void)		deselectObject:(id) object;
- (void)		selectAll;
- (void)		deselectAll;
- (NSArray*)	selection;

/* clicks */

- (id)			objectUnderMousePoint:(NSPoint) mp;

@end

我們稍後會新增更多內容,但這些是我們需要的基本方法。有兩個資料成員,每個都是一個可變陣列。一個包含繪圖中的所有物件,另一個只包含選定物件。當我們建立一個新物件時,使用 addObject: 將其新增到 _objects 列表中,如果我們刪除一個物件,則使用 removeObject: 將其刪除。我們可以透過在這些操作之前記錄 _objects 陣列的狀態來輕鬆地使這些操作可撤銷。我們稍後會介紹這一點,但現在,瞭解為了使撤銷正常工作,需要定義幾個明顯的資料更改位置就足夠了。我們將在實際物件被編輯時(例如,透過更改其大小或位置)在這些物件中執行相同操作。

選擇的工作原理類似。物件被新增到選擇陣列中或從中刪除。當檢視開始繪製繪圖時,它會檢查這兩個陣列。如果在一個數組中都找到了物件,它就會“知道”應該以其選定狀態繪製該物件。物件本身處理它自己的繪製,因此檢視只需請求繪製時是否使用選定狀態突出顯示即可。透過這種方式,檢視僅僅充當文件(維護哪些物件被選定)和實際物件(知道如何描繪選定狀態)之間的中介。檢視本身對選定狀態不感興趣。我們發現這種方法非常簡單和優雅,儘管它不是唯一可能的方案。(一種常見的替代方案是在每個儲存物件內使用布林標誌來表示選定狀態,但這通常不是此類應用程式的最佳方法。許多文件級命令適用於選定物件集,因此使用我們的方案來實現這些命令涉及對 _selection 陣列進行簡單迭代。使用標誌方法,我們必須遍歷整個列表,檢查哪些物件設定了標誌,並忽略其他物件。通常,這會影響繪圖變大時的效能)。

“objectUnderMousePoint:” 方法作為一種簡單的方法提供給檢視,用於確定點選了什麼。檢視本身將處理基本的滑鼠事件,例如點選和拖動,此方法允許它確定操作正在處理哪個物件。一旦獲得該資訊,檢視就可以透過簡單地將點選和拖動傳遞給物件本身來實現點選和拖動,而物件本身知道該怎麼做(調整大小、移動等)。文件不需要知道使用者在做什麼,它只需要跟蹤繪圖中包含的內容即可。您會發現,我們正在將盡可能多的“智慧”推到繪圖物件本身。

從檔案儲存和恢復

[編輯 | 編輯原始碼]

文件的一項關鍵功能是將其內部表示轉換為相同資料的檔案表示,反之亦然。您將在 MyDocument.m 中看到,已經提供了兩種用於執行此操作的骨架方法

- (NSData*)   dataRepresentationOfType:(NSString*)  aType
- (BOOL)       loadDataRepresentation:(NSData*) data ofType:(NSString*) aType

這些是 NSDocument 的覆蓋方法,因此未在我們的類定義中宣告。第一個負責儲存到檔案,第二個負責從檔案讀取。當我們有多種可以讀寫檔案型別時,“type”字串會使用到 — 許多實際應用程式將支援不同的格式。在這裡,我們沒有多種型別,我們只使用自己的型別。傳遞的字串實際上是檔案的副檔名,因此如果您開啟一個 JPEG 檔案,型別將是字串“jpg”。

那麼,我們如何建立檔案表示或從檔案讀取呢?這很簡單。Cocoa 使用一種稱為歸檔器的東西來支援這一點。大多數物件都可以歸檔,這意味著它們已經知道如何將自身讀寫到檔案流中。執行此操作只需要使用鍵將值寫入字典,我們已經知道如何執行此操作。繪圖中的每個物件都被要求歸檔自身,因此它必須記錄儘可能多的資訊,以便以後完全重新建立該物件 — 它的位置、大小、顏色等。每個值都將獲得一個唯一的鍵,通常只是一個字串。當檔案稍後被讀回來時,每個物件都會被重新例項化,並有機會從流中重新建立其資料值。使用相同的鍵,將值讀回來,從而將物件恢復到儲存的狀態。

在文件級別,我們只需要歸檔“_objects”陣列即可。當陣列被歸檔時,它包含的所有物件也會自動被歸檔。我們會發現,在這個級別幾乎不需要做什麼 — 所有繁重的工作再次由繪圖物件本身完成。選擇狀態不會儲存,因此我們不需要關心它。

由於整個 _objects 陣列將從檔案中重新建立,因此 MyDocument 需要一種方法來一次設定整個陣列。明顯的方法是名為 setObjects 的方法,所以讓我們將其新增到我們的定義中。

- (void)		setObjects:(NSMutableArray*) arr;

現在,這足以開始我們的文件實現。我們需要擴充套件我們在實現檔案中宣告的方法。一種方法是從標頭檔案中剪下貼上它們,然後新增大括號將其轉換為實際方法。現在就去做。

@implementation MyDocument

- (id)		init
{
    self = [super init];
    if (self)
	{
		_objects = [[NSMutableArray alloc] init];
		_selection = [[NSMutableArray alloc] init];
    }
    return self;
}


- (void)	dealloc
{
	[_objects release];
	[_selection release];
	[super dealloc];
}

- (NSString *)	windowNibName
{
    return @"MyDocument";
}

- (void)		windowControllerDidLoadNib:(NSWindowController *) aController
{
    [super windowControllerDidLoadNib:aController];
}

/*
dataRepresentationOfType: Deprecated in Mac OS X v10.4. Use dataOfType:error: instead.
- (NSData *)dataOfType:(NSString *)typeName error:(NSError **)outError
{
    // Insert code here to write your document to data of the specified type. If the given outError != NULL, ensure that you set *outError when returning nil.

    // You can also choose to override -fileWrapperOfType:error:, -writeToURL:ofType:error:, or -writeToURL:ofType:forSaveOperation:originalContentsURL:error: instead.

    // For applications targeted for Panther or earlier systems, you should use the deprecated API -dataRepresentationOfType:. In this case you can also choose to override -fileWrapperRepresentationOfType: or -writeToFile:ofType: instead.

    if ( outError != NULL ) {
		*outError = [NSError errorWithDomain:NSOSStatusErrorDomain code:unimpErr userInfo:NULL];
	}
	//return nil;
	return[NSKeyedArchiver archivedDataWithRootObject:[self objects]];
}

- (BOOL)readFromData:(NSData *)data ofType:(NSString *)typeName error:(NSError **)outError
{
    // Insert code here to read your document from the given data of the specified type.  If the given outError != NULL, ensure that you set *outError when returning NO.

    // You can also choose to override -readFromFileWrapper:ofType:error: or -readFromURL:ofType:error: instead. 
    
    // For applications targeted for Panther or earlier systems, you should use the deprecated API -loadDataRepresentation:ofType. In this case you can also choose to override -readFromFile:ofType: or -loadFileWrapperRepresentation:ofType: instead.
    
    if ( outError != NULL ) {
		*outError = [NSError errorWithDomain:NSOSStatusErrorDomain code:unimpErr userInfo:NULL];
	}
	
	NSArray* arr = [NSKeyedUnarchiver unarchiveObjectWithData:data];
	NSMutableArray* marr = [arr mutableCopy];
	
	[self setObjects:marr];
	[marr release];
    return YES;
}
*/
- (NSData *)	dataRepresentationOfType:(NSString*) aType
{
	return[NSKeyedArchiver archivedDataWithRootObject:[self objects]];
}

- (BOOL)		loadDataRepresentation:(NSData*) data ofType:(NSString*) aType
{
	NSArray* arr = [NSKeyedUnarchiver unarchiveObjectWithData:data];
	NSMutableArray* marr = [arr mutableCopy];
	
	[self setObjects:marr];
	[marr release];
	return YES;
}


- (void)		addObject:(id) object
{
	if(![_objects containsObject:object])
		[_objects addObject:object];
}

- (void)		removeObject:(id) object
{
	[self deselectObject:object];
	[_objects removeObject:object];
}

- (NSArray*)	objects
{
	return _objects;
}

- (void)		setObjects:(NSMutableArray*) arr
{
	[arr retain];
	[_objects release];
	_objects = arr;
	[self deselectAll];
}

- (void)		selectObject:(id) object
{
	if([_objects containsObject:object] && ![_selection containsObject:object])
		[_selection addObject:object];
}

- (void)		deselectObject:(id) object
{
	[_selection removeObject:object];
}

- (void)		selectAll
{
	[_selection setArray:_objects];
}

- (void)		deselectAll
{
	[_selection removeAllObjects];
}

- (NSArray*)	selection
{
	return _selection;
}

- (id)			objectUnderMousePoint:(NSPoint) mp
{
	NSEnumerator*	iter = [_objects reverseObjectEnumerator];
	id				obj;
	
	while( obj = [iter nextObject])
	{
		if ([obj containsPoint:mp])
			return obj;
	}
	
	return nil;
}

實現非常簡單。在 init 方法中,我們分配 _objects 和 _selection。我們需要一個 dealloc 方法來確保它們在我們的文件被釋放時也被釋放。

addObject: 和 removeObject: 只是對映到 NSMutableArray 方法,儘管我們另外檢查是否沒有多次新增同一個物件。當一個物件被刪除時,它也會被取消選擇,以確保我們不會在選擇列表中留下對已經完全消失的物件的陳舊引用。objects 只是將陣列本身作為只讀型別返回(這繞過了 Objective-C 缺乏“const”運算子的問題)。setObjects: 替換整個陣列,我們將在稍後從檔案讀取時使用它。

選擇方法的工作原理類似。selectObject: 只是將物件新增到選擇列表中,首先檢查它是否尚未被選中,並且確實是繪圖的一部分。deselectObject: 將其從選擇列表中刪除。selectAll 使用 setArray: 使選擇列表與整個繪圖匹配,而 deselectAll 只是使列表為空。

objectUnderMousePoint: 方法讓我們有機會使用迭代器。我們遍歷物件陣列,對每個物件呼叫 containsPoint:。只要其中一個返回 YES,該方法就會返回該物件。物件本身負責以合理的方式實現 containsPoint:。為什麼要反向迭代?因為在繪圖中,物件可以放置在相互重疊的位置。對使用者來說,這看起來就像某些物件在其他物件後面。檢視將以正向順序繪製物件列表,這意味著列表末尾的物件將被繪製在任何較早的物件“上面”。透過反向檢測點選,我們可以確保在任何底層物件之前找到最頂層的物件(如果點擊發生在重疊區域)。這意味著使用者可以獲得預期的行為——他們可以點選他們能看到的內容。如果沒有點選任何東西,則返回 nil。

實現檔案歸檔和解檔的方法很簡單。大部分魔力都在這個層面上對我們隱藏了。我們使用 NSKeyedArchiver 的工廠方法“archivedDataWithRootObject:”將我們的物件陣列傳遞給它。它完成它的魔術,並返回一個 NSData 物件,我們將其返回。這就是我們需要做的,Cocoa 處理其餘部分,將資料寫入檔案。當我們反向操作時,我們使用 NSKeyedUnarchiver 的“unarchiveObjectWithData:”工廠方法來反轉該過程。我們得到的是一個自動釋放的 NSArray 物件(因為我們之前儲存了一個 NSArray 物件)。你可能會問歸檔器和解檔器如何知道如何編碼或解碼 WKDShape 物件。答案很簡單:他們不知道。像往常一樣在面向物件程式設計中,WKDShape 物件負責自身的編碼和解碼。解檔器將嘗試呼叫 WKDShape 的相應方法,這些方法尚未實現。這將在 歸檔 章中完成。此時,您可以儲存文件並開啟已儲存的文件,但它將是空白的,不包含任何形狀。

但是,我們需要從解檔器獲得的陣列是可變的,因此我們使用 mutableCopy: 建立一個可變副本。這將傳遞給 setObjects: 方法,替換我們已經擁有的任何物件。因為我們進行了顯式複製,所以我們必須在此處釋放該物件。setObjects: 無論如何都會保留它,所以它仍然存在,但我們不想讓它保留兩次,否則我們會遇到潛在的記憶體洩漏。setObjects: 還會取消選擇所有內容,因為我們是從一個新的新開啟的文件開始的——這只是為了確保沒有遺漏的物件被選中。例如,這可能會在還原後發生。

檔案型別

[編輯 | 編輯原始碼]

Cocoa 如何知道我們正在儲存或讀取哪種型別的檔案?它還不知道——如果我們現在構建並執行,選擇“開啟”會給我們檔案選擇器,但我們無法選擇任何檔案。要設定它,在 Xcode 的左側面板中,從“目標”向下鑽取到 Wikidraw。選擇 Wikidraw 並選擇“獲取資訊”。在開啟的對話方塊中,選擇“屬性”選項卡。在“文件型別”區域中,編輯第一個也是唯一的專案,以便名稱為“Wikidraw 繪圖”,副檔名為“wkd”,OSTypes 設定為“.wkd”,類為 MyDocument。這將建立型別為“.wkd”的檔案與 MyDocument 類之間的對映,該類知道如何處理該檔案型別。(請注意:dataRepresentationOfType: 在 Mac OS X v10.4 中已棄用。請改用 dataOfType:error:。儲存才能工作)

趁我們在這裡,也更改一些其他設定。將“識別符號”更改為“com.wiki.wikidraw”,並將“建立者”更改為“WIKD”。

關閉“獲取資訊”視窗,再次構建並執行。我們仍然無法在開啟對話方塊中使用任何檔案,因為還沒有副檔名為“.wkd”的檔案。所以讓我們儲存一個。執行“另存為”並將檔案儲存為“wikidrawtest”或類似名稱。開啟視窗應更改為您選擇的名稱。現在,當您嘗試開啟時,您應該能夠選擇並開啟此檔案。如果您在 Finder 中找到該檔案,執行“獲取資訊”,您應該能夠檢查它是否確實具有“.wkd”的副檔名。請注意,該檔案將不包含任何內容,因為我們還沒有構建足夠多的應用程式來實際建立任何圖形。

我們將在接下來解決這個問題。

上一頁:圖形 - 使用 Quartz 繪圖 | 下一頁:實現 Wikidraw

華夏公益教科書