为初学者编写 Cocoa Mac OS X 程序/基于文档的应用程序
上一页:图形 - 使用 Quartz 绘图 | 下一页:实现 Wikidraw
到目前为止,我们已经看到 Cocoa 支持“基于文档的”多窗口类型的应用程序,我们将以此为基础构建我们的绘图程序 Wikidraw。更深入地理解这一点将有助于我们完成设计实现。
文档本身不是窗口。窗口用于查看文档的内容,在许多简单的应用程序中,NSDocument(管理文档的类)和 NSWindow(窗口类)之间存在一对一的映射关系。但是,这不是唯一的方案 - 应用程序可以提供几种查看文档的替代方法,因此支持每个文档多个窗口。这是使用两个不同对象的原因之一。
在 Wikidraw 中,我们将对 NSDocument 进行子类化,以便我们有一个地方来存储绘制时的绘图数据,以及一个地方来放置我们需要执行的各种操作,例如剪切和粘贴等。我们窗口中的视图只提供了一种显示文档当前状态的方式。理想情况下,我们希望最大限度地减少这两个类之间的相互依赖性,以便文档的内部机制不会暴露给视图,反之亦然,这符合封装原则。由于视图不仅提供了对象的绘制,还提供了与用户的交互,因此视图需要了解一些有关对象的信息,以便它可以将鼠标点击等传递给它们。
Cocoa 广泛使用一种称为委托的常见设计模式。在这种模式下,一个对象依赖于另一个对象来补充有关其状态等的一些详细信息。辅助对象被称为第一个对象的委托。与子类化不同,委托可以是任何其他合适的对象,而运行时方法绑定意味着原始对象只需要了解委托的模糊信息即可从中获取信息。
在 Wikidraw 中,我们可以使文档成为我们视图的辅助对象。这样,视图就可以向其委托询问基本信息,例如绘图中的对象列表或选定对象的列表,而无需了解太多有关文档对象本身的信息。这两个对象都同意遵守一个用于交换信息的协议。这仅仅是某些方法名称的约定。由于该协议仅仅是一种非正式协议,因此被称为非正式协议。Objective-C 支持更严格类型的协议,称为正式协议,使用“@protocol”关键字,但我们这里不使用它。
在设置基于文档的应用程序时,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”的扩展名。请注意,该文件将不包含任何内容,因为我们还没有构建足够多的应用程序来实际创建任何图形。
我们将在接下来解决这个问题。