使用NSUndoManaer, 我们可以给程序以一种优雅的风格添加undo功能. undo管理器跟踪管理一个对象的添加,编辑和删除.这些消息将会发送给undo管理器去做undo. 而当我们请求做undo操作时, undo管理器也会跟踪这些消息,这些消息会被记录用来做redo. 该机制使用两个NSInvocation对像堆栈来实现.
在这么早就讨论这个主题是相当沉重的.(有时候一说起undo.我的头就有点大.),不过因为undo和document架构关联,所以我们先来学习undo是怎么工作的.这样在下一章能更好理解document架构的工作流程.
NSInvocation
正如你所想, 应该有个对象能方便的封装一个消息[就是一个操作] - 包含selector, 接受对象,以及所有的参数 . NSInvocation对象就是这样的对象.
invocation一个非常方便的用途就是转发消息. 当一个对象接受到一个它没法响应的消息[没有实现该方法].message-sending系统不会马上抛出一个异常,它会先检查该对象是否实现了这个方法
- (void)forwardInvocation:(NSInvocation *)x
如果对象实现了该方法.那么这个消息就会被封包成对象NSInvocation-x.来调用forwardInvocation:方法
NSUndoManager是怎样工作的
假定一下用户打开一个新的RaiseMan document,并且做了3个编辑动作
.添加一条记录
.将记录的名字"New Employee" 修改为"Rex Fido"
.将raise改成20
当实现每一次修改时,controller将把一个要做undo的invocation添加到undo栈中.简单的说:"该修改的反向动作添加到undo栈中". 图9.1是在作了上面3个修改后的undo栈
如果这时候用户点击Undo菜单,那么第一个invocation将会抛出并调用.person的 raise会设置成0.如果用户再次点击Undo菜单,那么person的name将会修改回"New Employee"
每一次从Undo栈弹出执行一项时,反向操作将会压入到redo栈中.所以,当执行了上面说的两个undo动作后,undo和redo栈将会是这样的如图9.2
undo manager是非常智能的,当用户做编辑动作时,undo invocation将加入到undo栈中,当用户undo编辑时,undo invocation将加入到redo 栈. 而当用户redo编辑时, undo invocation又加入到undo栈中. 这样操作都是自动完成的. 我们的任务仅仅是提供给undo manager需要做反向操作的invocation.
现在假设我们编写一个方法 makeItHotter, 它的反向操作方法为 makeItColder. 看看是如何实现undo的
- (void)makeItHotter
{
temperature = temperature + 10;
[[undoManager prepareWithInvocationTarget:self] makeItColder];
[self showTheChangesToTheTemperature];
}
你可能猜到了, prepareWithInvocationTarget: 记录了target [self].并且返回undo manager 它自己. undo manager重载了forwardInvocation: 把invocation-makeItColder: 加入到 undo栈中
所以,我们还有实现方法makeItColder
- (void)makeItColder
{
temperature = temperature - 10;
[[undoManager prepareWithInvocationTarget:self] makeItHotter];
[self showTheChangesToTheTemperature];
}
我们在undo manager中注册了反向操作. 在执行undo时,makeItColder将被执行,而它的反向makeItHotter将会添加到redo栈中
每个栈中的invocation会是聚合的. 默认的,当单一事件[做了一个操作]发生时加入到栈中的所有invocation将会是聚合在一起 [这里要理解什么是invocation, 简单来讲,它就是某个对象的某个方法. 所以当你做某个单一操作时,可能会涉及到多个对象,多个方法. 也就是多个invocation]. 所以,当用户的一个操作改变了多个对象时, 如果点击undo 菜单,那么所有的改变都会一次undo完成.
我们也可以来修改菜单 Undo 和Redo 标题. 比如使用Undo Insert来代替简单的Undo. 可以使用如下代码
[undoManager setActionName:@"Insert"];
那么,怎么得到一个undo manager呢?你可以直接创建. 不过注意,NSDocument对象已经有一个自己的undo manager [它也是自己创建的哈]
为RaiseMan添加Undo功能
为了使用户可以使用undo功能: undo点击 Add New Employess 和 Delete.以及undo 对person对象的修改. 我们必须给MyDocument添加代码
当我设计类时, 我会考虑为什么要定义一个成员变量? 一定是下面的4个目的之一
1. 简单的属性: 比如学生的名字. 它们一般会是数字或NSString,NSNumber,NSDate,NSData对象
2. 单一关系: 比如一个学生一定会有一个学校和他相关. 这和1比较像,只是它的类型是一个复杂对象.单一关系使用指针来实现: 学生对象有一个指向学校对象的指针
3. 有序的多元关系: 比如,每个播放列表会有一系列歌曲和它关联. 这些歌曲有特定的顺序. 这样的关系一般使用NSMutableArray来实现
4. 无序的多元关系: 比如,每个部门会有一些雇员,我们可以对雇员按某个方式来排序(比如按照姓氏),不过这样的顺序都不是本质上的顺序. 一般使用NSMutalbeSet来实现.
早些时候,我们讨论了怎样使用key-vaule coding来设置简单属性和单一关系
.当setting 或是 getting fido的值时, key-value coding使用accessor 方法.同样的我们可以为有序的多元关系和无序的多元关系创建accessor方法.
来看看,对象playlist有一个NSMutabelArray变量来存放Song对象.如果你使用key-value coding来操作这个array对象,你将调用mutableArrayVauleForKey: . 得到一个代理对象,这个代理对象表示那个array.
id arrayProxy = [playlist mutableArrayValueForKey:@"songs"];
int songCount = [arrayProxy count];
这个例子中.当调用 count方法时.代理对象将先看playlist对象有没有实现 countOfSongs方法.如果有,那么就会调用该方法并返回结果.如果没有, 那么会调用保存song的array的count 方法如图9.3. 注意.方法countOfSongs的命名不仅仅是因为编码习惯: key-vaule coding机制使用这样的名字来查找
下面是几个例子
id arrayProxy = [playlist mutableArrayValueForKey:@"songs"];
int x = [arrayProxy count]; // is the same as
int x = [playlist countOfSongs]; // if countOfSongs exists
id y = [arrayProxy objectAtIndex:5] // is the same as
id y = [playlist objectInSongsAtIndex:5]; // if the method exists
[arrayProxy insertObject:p atIndex:4] // is the same as
[playlist insertObject:p inSongsAtIndex:4]; // if the method exists
[arrayProxy removeObjectAtIndex:3] // is the same as
[playlist removeObjectFromSongsAtIndex:3] // if the method exists
对于无序多元关系也是一样的如图9.4
id setProxy = [teacher mutableSetValueForKey:@"students"];
int x = [setProxy count]; // is the same as
int x = [teacher countOfStudents]; // if countOfStudents exists
[setProxy addObject:newStudent]; // is the same as
[teacher addStudentsObject:newStudent]; // if the method exists
[setProxy removeObject:expelledStudent]; // is the same as
[teacher removeStudentsObject:expelledStudent]; // if the method exists
因为我们绑定了array controller的contentArray和Mydocument对象的employees. 所A以array controller将会使用key-vaule coding来添加和删除person对象. 我们可以使用这个机制来实现当添加person对象时添加unod invocation到undo栈. 给MyDocument,m添加如下方法
- (void)insertObject:(Person *)p inEmployeesAtIndex:(int)index
{
NSLog(@"adding %@ to %@", p, employees);
// Add the inverse of this operation to the undo stack
NSUndoManager *undo = [self undoManager];
[[undo prepareWithInvocationTarget:self]
removeObjectFromEmployeesAtIndex:index];
if (![undo isUndoing]) {
[undo setActionName:@"Insert Person"];
}
// Add the Person to the array
[employees insertObject:p atIndex:index];
}
- (void)removeObjectFromEmployeesAtIndex:(int)index
{
Person *p = [employees objectAtIndex:index];
NSLog(@"removing %@ from %@", p, employees);
// Add the inverse of this operation to the undo stack
NSUndoManager *undo = [self undoManager];
[[undo prepareWithInvocationTarget:self] insertObject:p
inEmployeesAtIndex:index];
if (![undo isUndoing]) {
[undo setActionName:@"Delete Person"];
}
[employees removeObjectAtIndex:index];
}
当给NSArrayController添加或是删除Person对象时,这些方法会自动调用:例如, 当Create New 和Delete 按钮发送insert: 和 remove: 消息的时候
在MyDocument.h中声明
- (void)removeObjectFromEmployeesAtIndex:(int)index;
- (void)insertObject:(Person *)p inEmployeesAtIndex:(int)index;
由于使用了Person类.所以我们需要告知编译器. 在MyDocument.h中添加
#import <Cocoa/Cocoa.h>
@class Person;
同样,在MyDocument.m中导入Person.h
#import "Person.h"
好了,我们已经可以undo添加和删除了. 对于undo 编辑会有点复杂. 在搞定它前,先编译运行我们的程序.试试undo功能. 注意,redo功能也是可用的
Key-Vaule Observing
在第7章,我们讨论了key-vaule coding. 回忆一下,key-vaule coding是一种通过变量名字来读取和修改变量值的方法. 而key-vaule observing是当这些改变发生时我们能得到通知.
为了实现undo 编辑,我们需要让document对象能够得到改变了Person对象expectedRaise和personName的通知. NSObject的一个方法可以用来注册这样的通知
- (void)addObserver:(NSObject *)observer
forKeyPath:(NSString *)keyPath
options:(NSKeyValueObservingOptions)options
context:(void *)context;
对象observer为要通知的对象, keyPath标识激活通知的改变. options定义了通知包含的内容选项,例如,是否包含改变前的值,是否包含改变后的值. context是一个随着通知一起发送的对象,可以包含任何信息.一般为NULL.
当一个改变发生, observer对象将收到下面的消息
- (void)observeValueForKeyPath:(NSString *)keyPath
ofObject:(id)object
change:(NSDictionary *)change
context:(void *)context;
observer会知道那个对象的那个key path改变了. change是一个dictionary,其中的内容会依据在注册是option指定的值. 可能包含改变前的值和(或)改变后的值. 而context指针就是注册是的context指针,通常情况下,忽略它.
Undo编辑
第一步是将document对象注册观察它自己的person对象改变.在MyDocument.m中添加如下方法
- (void)startObservingPerson:(Person *)person
{
[person addObserver:self
forKeyPath:@"personName"
options:NSKeyValueObservingOptionOld
context:NULL];
[person addObserver:self
forKeyPath:@"expectedRaise"
options:NSKeyValueObservingOptionOld
context:NULL];
}
- (void)stopObservingPerson:(Person *)person
{
[person removeObserver:self forKeyPath:@"personName"];
[person removeObserver:self forKeyPath:@"expectedRaise"];
}
在添加或删除Person对象是调用上面的方法
- (void)insertObject:(Person *)p inEmployeesAtIndex:(int)index
{
// Add the inverse of this operation to the undo stack
NSUndoManager *undo = [self undoManager];
[[undo prepareWithInvocationTarget:self]
removeObjectFromEmployeesAtIndex:index];
if (![undo isUndoing]) {
[undo setActionName:@"Insert Person"];
}
// Add the Person to the array
[self startObservingPerson:p];
[employees insertObject:p atIndex:index];
}
- (void)removeObjectFromEmployeesAtIndex:(int)index
{
Person *p = [employees objectAtIndex:index];
// Add the inverse of this operation to the undo stack
NSUndoManager *undo = [self undoManager];
[[undo prepareWithInvocationTarget:self] insertObject:p
inEmployeesAtIndex:index];
if (![undo isUndoing]) {
[undo setActionName:@"Delete Person"];
}
[self stopObservingPerson:p];
[employees removeObjectAtIndex:index];
}
- (void)setEmployees:(NSMutableArray *)a
{
if (a == employees)
return;
for (Person *person in employees) {
[self stopObservingPerson:person];
}
[a retain];
[employees release];
employees = a;
for (Person *person in employees) {
[self startObservingPerson:person];
}
}
实现编辑修改方法
- (void)changeKeyPath:(NSString *)keyPath
ofObject:(id)obj
toValue:(id)newValue
{
// setValue:forKeyPath: will cause the key-value observing method
// to be called, which takes care of the undo stuff
[obj setValue:newValue forKeyPath:keyPath];
}
实现当Person 对象编辑通知响应方法,
- (void)observeValueForKeyPath:(NSString *)keyPath
ofObject:(id)object
change:(NSDictionary *)change
context:(void *)context
{
NSUndoManager *undo = [self undoManager];
id oldValue = [change objectForKey:NSKeyValueChangeOldKey];
// NSNull objects are used to represent nil in a dictionary
if (oldValue == [NSNull null]) {
oldValue = nil;
}
NSLog(@"oldValue = %@", oldValue);
[[undo prepareWithInvocationTarget:self] changeKeyPath:keyPath
ofObject:object
toValue:oldValue];
[undo setActionName:@"Edit"];
}
好了,现在编译运行程序, undo 和redo功能完全可以工作了.
注意到了吗? 一旦我们修改了document, 窗口标题栏上的红色关闭按钮会出现一个黑点来提示我们,这些改变没有被保存. 在下一个章节,我们来学习把它们保存为文件
添加后里面编辑
我们的程序看上去运行的很好,不过有些用户可能会抱怨"当我插入一条记录后,为什么我必须双击才能开始编辑?很明显的我一定会修改新增person的名字啊."
这会有些复杂,我打算提供所需的代码片段,首先,MyDocument.h中添加一个acton和两个成员变量
@interface MyDocument : NSDocument
{
NSMutableArray *employees;
IBOutlet NSTableView *tableView;
IBOutlet NSArrayController *employeeController;
}
- (IBAction)createEmployee:(id)sender;
保存文件,(我们记住一定要保存.h文件.这样新加的action和outlet才能在Interface Builder中找到)在Interface Builder中Control-drag Add New Employee按钮到File's Owner(MyDocument对象). 设置action为createEmployee: 如图9.5
Control-click file's Owner,设置好outlet tableView 和 employeeController如图9.6
在MyDocument.m中添加 createEmployee:方法
- (IBAction)createEmployee:(id)sender
{
NSWindow *w = [tableView window];
// Try to end any editing that is taking place
BOOL editingEnded = [w makeFirstResponder:w];
if (!editingEnded) {
NSLog(@"Unable to end editing");
return;
}
NSUndoManager *undo = [self undoManager];
// Has an edit occurred already in this event?
if ([undo groupingLevel]) {
// Close the last group
[undo endUndoGrouping];
// Open a new group
[undo beginUndoGrouping];
}
// Create the object
Person *p = [employeeController newObject];
// Add it to the content array of 'employeeController'
[employeeController addObject:p];
[p release];
// Re-sort (in case the user has sorted a column)
[employeeController rearrangeObjects];
// Get the sorted array
NSArray *a = [employeeController arrangedObjects];
// Find the object just added
int row = [a indexOfObjectIdenticalTo:p];
NSLog(@"starting edit of %@ in row %d", p, row);
// Begin the edit in the first column
[tableView editColumn:0
row:row
withEvent:nil
select:YES];
}
不能期望你能理解没一行代码.不过试着浏览这些方法,立即它们的基本原理. 编译运行程序吧.
思考: Windows和Undo Manager
可以把view编辑动作加入到undo manager.例如, NSTextView,可以把文字输入的动作加入到undo manager.使用Interface Builder来激活 如图9.7
那么text view是怎么知道使用哪一个undo manager呢?首先,它会询问delegate. NSTextView的delegate可以实现这个方法
- (NSUndoManager *)undoManagerForTextView:(NSTextView *)tv;
接下来,它会询问他的window. NSWindow有一个方法
- (NSUndoManager *)undoManager;
window的delegate可以实现一个方法来说明是否window可以提供undo manager
- (NSUndoManager *)windowWillReturnUndoManager:(NSWindow *)window;
Undo/redo 菜单项反应了当前key window的undo manager状态(key window也就是大家说的active window. Cocoa 开发者叫它key 是因为用户的键盘输入事件由它接受)