本节书摘来自异步社区《iOS应用开发》一书中的第2章,第2.4节重要的设计模式,作者【美】Richard Warren,更多章节内容可以访问云栖社区“异步社区”公众号查看
2.4 重要的设计模式
iOS应用开发
虽然我们已经掌握了Objective-C的大部分基本特征,不过iOS SDK中还使用了一些常见的设计模式。花一点时间重温这些设计模式是很值得的,当你看到它们的时候就可以更好地理解它们。
2.4.1 模型-视图-控制器
模型-视图-控制器(MVC)是使用图形用户界面创建应用程序时常见的一种架构模式。这个模式将应用划分为3个部分。模型维护应用的状态。通常来说,它不仅管理应用程序在运行时的状态,也包括存储和加载状态(即将对象保存至文件,将数据保存至一个SQL数据库,或者是使用类似Core data的框架来管理对象数据)。
正如其名,视图就是以互动的方式来显示应用程序信息,大多数情况下是指通过GUI显示信息,然而,除此之外视图还包含了打印文档并且生成其他日志和报告。
控制器存在于模型和视图之间,它响应事件(通常来自于视图)并将指令发送至模型(以改变它的状态)。同样,当模型发生改变时,它也会更新相应的视图。按照应用的需求,它的业务逻辑有可能在控制器中实现,也有可能在模型中实现。无论如何,在应用开发的整个过程中,你应该选择一种方式并保持一致性。
理想情况下,MVC的各部分组件应是非常松散地连结在一起。例如,任何多数量的视图都可以观察控制器,触发控制器事件并响应任何变化的通知。无论是在GUI上显示信息还是在写日志文件,控制器不会知道或者关心这些细节。而另一方面,模型和控制器的也应该是类似松散地连接在一起。
在实践中,不同层级往往是更紧密地结合,为了实用性牺牲了一点理想主义。Cocoa Touch也不另外。通常,视图与控制器连结得更加紧密。每个场景都有量身定制的控制器来专门管理(关于场景和视图控制器的更多信息,参见第1章中的“检查Storyboard”一节)。在模型这一端,则有更多的回旋余地。在最简单的应用中,模型将会直接在控制器中实现。但是大部分情况下,我们还是尽可能将它们独立开来。
Cocoa Touch使用UIView子类来实现视图,用UIViewController子类来实现控制器。模型和视图之间的通信通常使用目标/操作的连接。
就模型而言,我们可以使用许多不同的技术来实现模型:自定义类、SQLite或者Core Data。在很多情况下,模型和控制器直接和彼此通信,虽然我们可以使用通知和委托创建更加松散的连接(参见下一节讨论数据源以获得更多的信息)。
2.4.2 委托
委托让你可以扩展或者修改一个对象的行为而不用创建该对象的子类。甚至你可以通过在运行时切换委托从而改变对象的行为,虽然在实践中这样做很罕见。
我们在前面已经接触过委托。例如,为了避免在每个项目中都为UIApplication类创建子类,我们实现一个自定的UIApplication`` Delegate,然后就可以使用通用的UIApplication类了。应用程序委托协议定义了超过20个可选方法,我们可以重载这些方法来监控和改变应用程序的行为。
任何使用委托的类通常都会有一个命名为(毫无意外)delegate的属性。遵循约定,委托属性应该永远使用弱引用。这样有助于避免保留循环。然而,我们需要确保在应用程序中的其他地方有一个委托对象的强引用,否则ARC就会释放它。
@property (nonatomic, weak) IBOutlet id<DelegateProtocol> delegate;
正如这个例子所示,我们通常声明一个委托必须实现的协议。该协议定义了委托类和我们的委托之间的接口。委托类是这个关系中的主动方。它在委托上调用协议的方法。有的调用是将信息传递给委托,有的则是查询委托。通过这种方式,使得两个对象之间可以来回传递信息。
委托方法通常有一个些许刻板的名字。遵照约定,名字以描述委托对象的标识打头。例如,所有UIApplicationDelegate的方法都以application开始。
在很多情况下,方法会向委托对象传递一个引用作为它的第一个参数。UITableViewDelegate就是这么做的。这意味着你可以使用一个UITableViewDelegate来管理多个UITableViews(虽然这么做很罕见)。更重要的是,你可以避免在一个实例变量中存储委托类,虽然委托总是通过这个变量来访问它的委托类。
此外,委托的方法通常在它们的名字中包含will、did或者should。在这三种情况下,系统调用这些方法来响应某些变化。will方法在变化发生之前被调用。d
id方法在变化发生之后被调用。s``hould方法和will方法一样,在变化发生之前被调用,但是它要求返回一个YES或者NO值。如果它返回了一个YES值,变化就会正常进行。如果返回了NO值,那么变化就会被取消。
最后,委托方法几乎总是可选的。结果,委托类应该首先核对,以确保委托在调用方法之前实现了它。下面的代码模拟了典型的实现:
- (void) doSomething {
BOOL shouldDoSomething = YES;
// 询问是否应该执行
if ([self.delegate
respondsToSelector:
@selector(someObjectShouldDoSomething:)]) {
shouldDoSomething =
[self.delegate someObjectShouldDoSomething:self];
}
// 如果委托返回NO,就返回
if (!shouldDoSomething) return;
// 告诉委托我们将要执行
if ([self.delegate
respondsToSelector:
@selector(someObjectWillDoSomething:)]) {
[self.delegate someObjectWillDoSomething:self];
}
// 执行操作
[self.model doSomething];
// 告诉委托我们完成执行了
if ([self.delegate
respondsToSelector:
@selector(someObjectDidDoSomething:)]) {
[self.delegate someObjectDidDoSomething:self];
}
}
委托非常容易实现。只要创建一个新的类并且让它采用指定的协议。然后实现所有必需的方法(如果有的话),以及所有你感兴趣的可选方法。接着在你的代码中创建一个委托类的实例并且将它赋值给主对象的delegate属性。UIAppKit的很多视图子类可以接受委托。这意味着你将经常通过Interface Builder连接视图和委托(生成@property声明中的IBOutlet标记)。
注意:
Objective-C 2.0中引入了可选协议方法。在这之前,大部分委托都是使用非正式的协议实现的。这个约定涉及到在NSObject类上声明类别,然而,方法并没有被定义。当它运行时,IDE和编译器不能提供很多支持。随着时间的推移,苹果公司慢慢用真正的协议代替了非正式的协议,然而,你仍然会偶然遇见它们。
一些视图子类同样也需要数据源。数据源和委托是紧密相关的。然而,委托被用来监控和改变对象行为,而数据源是专门用来为对象提供数据的。其他的区别都是由这个区别产生的。例如,委托通常是可选的,委托对象应该在没有委托的时候也功能完整。而另一方面呢,数据源经常被主类所需要以便实现正确的功能。因此,数据源通常在协议中声明了一个或者更多的必需方法。
其他方面,数据源的行为和委托很相似。命名约定是相似的,并且和委托一样,主类应该持有它的数据源的一个弱引用。
2.4.3 通知
通知让对象不需要紧密耦合就能通信。在iOS系统中,使用通知中心来管理通知。想要收到通知的对象必须在通知中心注册。同时,想要广播通知的对象只要把通知发送给通知将中心就可以了。通知中心就会通过发送对象和通知名称来处理通知,将每个通知发送给正确的接收者。
通知非常容易实现。首先,你通常会在通用的头文件中创建一个NSString常量作为通知的名称。发送和接收对象都需要访问这个常量。
static NSString* const MyNotification = @"My Notification";
接着,对象可以注册以便获得通知。你可以指定你所感兴趣的发送者和通知名称。另外,发送者和通知名称都可以指定为nil。如果传递nil给名称,那么将会收到指定的对象发送的所有通知。如果传递nil给发送者,那么将会收到所有和名称所匹配的通知。如果传递nil给两者,那么将会收到所有发送到这个通知中心的通知。
NSNotificationCenter* center = [NSNotificationCenter defaultCenter];
[center addObserver:self
selector:@selector(receiveNotification:)
name:MyNotification
object:nil];
此处我们获得了一个默认通知中心的引用,接着将我们添加自己为观察者。我们注册接收所有对象发送的通知,只要通知的名称是MyNotification常量。我们还指定当匹配的通知到达时,通知中心会调用的选择器。
接着,我们需要为选择器实现这个方法。这个方法应该接受一个NSNotification对象作为参数:
- (void)receiveNotification:(NSNotification*)notification {
NSLog(@"Notification Received");
// 现在做一些有用的事情作为响应
}
最后,在对象释放之前移除观察者是非常重要的(还有任何在addObserver:selector:name:object方法中提到的对象),否则,通知中心会包含野指针。通常这应该在观察者对象的dealloc方法中完成。
- (void)dealloc {
NSNotificationCenter* center =
[NSNotificationCenter defaultCenter];
// 移除指定观察者的所有条目
[center removeObserver:self];
}
发送通知则更加简单。我们的发送对象只需要使用一个指向默认消息中心的指针,然后投递所要发送的消息即可。
NSNotificationCenter* center = [NSNotificationCenter defaultCenter];
[center postNotificationName:MyNotification object:self];
发送通知是同步操作。这意味着postNotificationName调用要等到通知中心完成调用所有匹配的观察者的指定选择器后才会返回。这会花费相当可观的时间,尤其是在观察者数量很大或者响应方法很慢时。
另外,我们可以使用NSNotification队列来发送异步通知。通知队列实际上会将通知推迟到当前的事件循环结束以后才发送(或者有可能直到事件循环完全空闲了)。队列还能够将重复的消息合并为单个通知。
下列的示例代码将通知延迟到运行循环空闲后才发送:
NSNotification* notification =
[NSNotification notificationWithName:MyNotification
object:self];
NSNotificationQueue* queue = [NSNotificationQueue defaultQueue];
[queue enqueueNotification:notification
postingStyle:NSPostWhenIdle];
2.4.4 键-值编码
键-值编码是使用字符串间接获取和设置对象的实例变量的技术。NSKeyValueCoding协议定义了获取或者设置这些值的一些方法。最简单的例子就是valueForKey:以及setValue:forKey``:。
NSString* oldName = [emp valueForKey:@"firstName"];
[emp setValue:@"Bob" forKey:@"firstName"];
为了能这样做,你的对象必须兼容KVC(键-值编码)。实际上,valueForKey:方法会查找一个名为<key>或者is<key>的祖先。如果它没能找到一个有效的祖先,它就会查找一个名为<key>或_<key>的实例变量。另一方面,setValue:forKey
:方法查找一个set<key>``:方法,然后寻找实例变量。
幸运的是,你为实例变量定义的任何属性都是兼容KVC(键-值编码)的。
KVC(键-值编码)方法还可以使用关键字路径(key paths)。关键字路径就是用点分隔的关键字列表。实际上,获取器或者访问器将会在整个关键字路径中依次进行下去。第一个关键字用于接收对象。随后每个关键字都用于之前的关键字所返回的值上。这让你可以沿着整个对象层级图访问到你想要的值。
// 将会返回公司名称,假设中间的所有值兼容KVC(键-值编码)
NSString* companyName =
[emp valueforKey:@"department.company.name"];
虽然KVC(键-值编码)可以被用来产生高度动态耦合度非常低的代码,但是它是有点特殊化的技术,你可能从来不会直接使用KVC。然而,在它之上发展了一些有意思的技术(例如键-值观察)。
2.4.5 键-值观察
键-值观察让一个对象能够观察另外一个对象实例变量的任何变化。虽然这在表面上和通知很相似,它们还是有一些重要的区别。首先,KVO(键-值观察)没有中心控制层。一个对象直接观察另外一个对象。其次,被观察的对象一般不需要做任何操作来发送这些通知。只要它们的实例变量是兼容KVC(键-值编码)的,不论你的应用程序是使用实例变量的设置器还是KVC(键-值编码)来改变实例变量的值时,都会自动发送通知。
不妙的是,如果你直接修改实例变量的值,被观测的对象必须在变化之前手动调用willChangeValueForKey:方法,在变化之后调用didChangeValueForKey
:方法。这又是一个总是应该通过属性访问实例变量值的理由。
为了注册成为观察者,调用addObserver:forKeyPath:options:
context:``。观察者就是将会接收KVO(键-值观察)通知的对象。forKeyPath就是由圆点分隔的关键字列表,用来指定将会被观察的值。options参数决定了通知返回什么信息,而context参数让你可以传递添加到通知中的任意数据。
// 无论何时这个员工的姓氏改变,你都会收到通知
//
[emp addObserver:self
forKeyPath:@"lastName"
options:NSKeyValueObservingOptionNew |
NSKeyValueObservingOptionOld
context:nil];
在使用通知中心时,在观察者释放之前删除它是非常重要的。虽然NSN``otificationCenter有一个删除所有指定观察者通知的便利方法,但是在KVO(键-值观察)中,你必须为每一次addObserver调用单独释放。
[emp removeObserver:self forKeyPath:@"lastName"];
接着,要接收到通知,你必须重载observeValueForKeyPath:
ofObject:``change:context:方法。object参数标识你在观察的对象。keyPath参数表示发生改变的属性。change参数是一个包含所请求的属性值的映射表。最后,context参数包含注册成为观察者时所提供的上下文数据。
- (void)observeValueForKeyPath:(NSString *)keyPath
ofObject:(id)object
change:(NSDictionary *)change
context:(void *)context {
if ([keyPath isEqualToString:@"lastName"]) {
NSString* oldName =
[change objectForKey:NSKeyValueChangeOldKey];
NSString* newName =
[change objectForKey:NSKeyValueChangeNewKey];
NSLog(@"%@'s last name changed from %@ to %@",
object, oldName, newName];
}
}
2.4.6 单例
单例的最基本的概念是,单例是只能有一个实例对象的类。无论何时从该类请求一个新的对象,你只会获得一个指向唯一对象的指针。
单例一般用来表示只能存在一个实例的对象。UIApplication类就是一个很好的例子。每个应用程序只能有且仅有一个应用程序对象。此外,你可以在任何地方通过[UIApplication sharedApplication
]方法来访问应用程序对象。
当然,单例是网络争论的一个永恒的话题。有些人认为它们是邪恶之源,应该像躲避瘟疫一样躲避单例。如果你使用了单例,那么*就赢了。或者,稍微理性一点的人则认为,单例只不过就是一个过度设计的性能不错的全局变量。
这些抱怨是有一定道理的。当开发者开始接触到单例模式的时候,他们经常会过度使用它。使用过多的单例会让你的代码很难跟踪。单例也很容易混淆并且很难写正确(关于如何才算“正确”的理解也很多)。然而,如果能正确使用它们,它们将会难以置信地有用。最重要的是,Cocoa Touch使用了大量的单例类,因此你至少要懂得基本原理,即使你从来不编写自己的单例。
下面是一个典型的、相对安全的实现。在类的头文件中,声明一个类方法来访问你的共享实例:
+ (SampleSingleton*)sharedSampleSingleton;
然后打开实现文件并给你的类添加一个扩展。这个扩展将会声明一个私有的指定初始化方法。
@interface SampleSingleton()
- (id)initSharedInstance;
@end
请记住,我们可以在任何对象上调用任何方法,将初始方法声明为私有的只是表达我们的意图。开发者应该只调用在头文件中声明的方法,在这种情况下,开发者应该只能通过shared``Sam``p``leSingleton方法来访问共享的对象。
最后,在@implementation程序块中,实现下面的方法:
+ (SampleSingleton*)sharedSampleSingleton {
static SampleSingleton* sharedSingleton;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
sharedSingleton = [[SampleSingleton alloc]
initSharedInstance];
});
return sharedSingleton;
}
// 私有的指定初始化方法
- (id)initSharedInstance {
self = [super init];
if (self) {
// 在此处进行初始化
}
return self;
}
//重载父类的指定初始化方法来防止使用它
// 调用这个方法将会抛出一个异常
- (id)init {
[self doesNotRecognizeSelector:_cmd];
return nil;
}
上面的代码使用延迟初始化方式创建共享实例(实际上在shared SampleSingleton被调用之前我们不会创建实例)。在sharedSample
Singleton中,我们使用了dispatch_
once程序块来保护我们的共享实例。dispatch_
once程序块以多线程安全的方式确保代码块在应用程序的生命周期中只执行一次。
注意:
在ARC之前,很多单例的实现会重载许多额外的方法:allocWithZone:,copyWithZone:,mutableCopy
WithZone:都是比较常见的需要被重载的方法。锁住这些方法有助于防止开发者不小心创建单例的副本。然而,当在ARC下编译时,这些方法不能被重载,而且也没有必要这么做。苹果公司目前建议使用一个简单的单例,依赖惯用法和沟通来防止复制。
我们还会重载父类的指定初始化方法,使得它在被调用时抛出一个异常。隐藏我们的指定初始化方法,并禁用父类的指定初始化方法,从而阻止开发者不小心创建副本(例如,调用[[SampleSingleton alloc
]init
])。
注意copy和mutableCopy默认都是禁用的。由于我们没有实现copy
WithZone:或者mutableCopyWithZone:,这些方法将会自动抛出异常。
这个实现没有处理从硬盘中加载和保存单例的微妙问题。如何实现存档代码很大程度取决于加载和存储单例在你的应用程序中意味着什么。当单例首次创建时你是否从硬盘中加载过?或者加载单例只是改变单例中所存储的值吗?例如,你的应用可能有一个GameState单例。你只会有一个GameState对象,但是状态值会随着用户加载和保存游戏而改变。
更高级的窍门,一些Cocoa Touch的单例会让你在应用程序的info. plist文件中指定单例的类。这让你能够创建单例类的子类,但仍确保在运行时加载正确的版本。如果你需要这些帮助,如下所示修改你的代码:
+ (SampleSingleton*)sharedSampleSingleton {
static SampleSingleton* sharedSingleton;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
NSBundle* main = [NSBundle mainBundle];
NSDictionary* info = [main infoDictionary];
NSString* className =
[info objectForKey:@"SampleSingleton"];
Class singletonClass = NSClassFromString(className);
if (!singletonClass) {
singletonClass = self;
}
sharedSingleton =
[[singletonClass****alloc] initSharedInstance];
});
return sharedSingleton;
}
以上代码读取了info.plist文件并且查找一个名为SampleSingleton的关键字。如果找到了一个,它就会将相应的值解释为类名称,并且尝试查找相应的类对象。如果找到了,它就会使用那个类来创建单例对象,否则,它就会使用默认的单例类。
2.4.7 程序块
唾手可得的程序块是Objective-C 2.0所增加的功能中我最喜欢的。它们只在iOS 4.0和之后的版本才可用。不过,除非你的目标设备很老,不然程序块可以极大地简化很多算法。
例如,UIView类有大量方法使用程序块来动画视图。这些方法比旧的动画API提供了更加简洁、更加优雅的解决方案。
程序块和方法、函数有点相似,它们都是创建可供之后执行的表达式。但是,程序块可以作为变量存储,并且可以作为参数传递。我们可以像下面这样创建程序块变量:
returnType (^blockName)(argument, types);
例如,让我们声明一个名为sum的程序块变量,它返回一个整数类型并且有两个整数类型的参数:
int (^sum)(int, int);
你可以使用一个插入符号(^)打头定义一个常量程序块,然后在圆括号中声明参数,在花括号中声明实际的代码。
sum = ^(int a, int b){return a + b;};
一旦程序块变量被赋值后,你就可以像调用其他函数一样地调用它了。
NSLog(@"The sum of %d and %d is %d", 5, 6, sum(5, 6));
请注意,当程序块定义以后,它可以捕获范围内的任何数据,这是非常重要的。例如,在这个例子中,addToOffset接受一个参数,并且将它和offset变量相加。
int offset = 5;
int (^addToOffset)(int) = ^(int value){return offset + value;};
注意:
当程序块捕获一个栈上存储的局部变量时,它将这个变量看做一个常量值。你可以读取这个值但是不能修改它。如果你需要改变一个局部变量,你应该用_``block存储类型修改器来声明它。总的来说,它仅仅应用于局部C类型数据和结构体。你也可以改变对象、实例变量以及在堆上分配的其他任何东西。
然而,通常来说,我们不会创建或者调用程序块变量,而是将常量程序块作为参数传递给方法和函数。例如,NSArray有一个方法enumerateObjectsUsingBlock:,它就接受一个程序块参数。这个方法将会迭代访问数组中的所有对象。对每个对象,它都会调用程序块,传入对象、对象的索引以及终止值的引用三个参数。
终止值只会被用来作为程序块的输出。将它设置为YES就中止了enumerateObjectsUsingBlock:方法。这里有一个使用这个方法的简单例子:
NSArray* array = [NSArray arrayWithObjects:@"Bill", @"Anne",
@"Jim", nil];
[array enumerateObjectsUsingBlock:
^(id obj, NSUInteger idx, BOOL *stop) {
NSLog(@"Person %d: %@", idx, obj);
}];
控制台的输出如下:
Person 0: Bill
Person 1: Anne
Person 2: Jim
注意到我们可以通过传入选择器,并且让我们的枚举方法为数组中的每个元素调用选择器,从而复制了enumerateObjectsUsingBlock: 的功能。我们首先在NSArray中创建一个类别:
@implementation NSArray (EnumerateWithSelector)
- (void)enumerateObjectsUsingTarget:(id)target
selector:(SEL)selector {
for (int i = 0; i < [self count]; i++) {
[target performSelector:selector
withObject:[self objectAtIndex:i]
withObject:[NSNumber numberWithInt:i]];
}
}
然后,为了使用这个枚举器,我们需要实现回调方法:
- (void)printName:(NSString*)name index:(NSNumber*)index {
NSLog(@"Person %d: %@", [index intValue], name);
}
然后我们传递选择器,调用我们的方法:
[array enumerateObjectsUsingTarget:self
selector:@selector(printName:index:)];
这毫无疑问要比我们的程序块例子笨重一些,但也还不是太坏。然而,那不是重点。如果我们想要改变枚举器的行为会发生什么呢?比如我们想要添加一个终止值,当达到这个值时,我们就停止枚举。
好吧,我们可以将终止参数添加到enumerateObjectsUsingTarget:
Selector:方法中,也可添加到printName:index:方法中。当然,这样做工作量不会很大,但是改变会影响到整个项目中。更糟糕的是,如果我们这样操作不止一次,好了,复杂性就会很快地增加。我们会立刻发现自己深陷复杂的枚举方法丛中,每个方法只处理轻微不同的情况。
另一个方法,我们可以创建一个实例变量来保存终止名称,并在printName:index:方法中访问它。那样的话就避免了改变枚举方法,但有些草率了。终止名称不应该是我们类的一部分,我们添加它只是为了避免额外的参数的权宜之计。那么如果我们需要几个不同的行为会发生什么事呢?我们愿意添加多少实例变量?
幸运的是,程序块不会有这些问题。我们能局部地修改enumerate
ObjectsUsingBlock:方法的行为。
NSString* stopName = @"Anne";
[array enumerateObjectsUsingBlock:
^(id obj, NSUInteger idx, BOOL *stop) {
NSLog(@"Person %d: %@", idx, obj);
*stop = [obj isEqualToString:stopName];
}];
请注意,我们完全不需要改变enumerateObjectsUsingBlock:的实现。我们也不需要任何实例变量。更重要的是,所有的一切都维持得很好。
最大的优势是,解决方案是可扩展的。如果我们想要在别的地方使用不同的行为,没问题!我们写一个新的程序块,捕获所有我们需要的局部变量,然后调用我们通用的枚举方法。单个通用的方法就能满足我们所有的需要。
注意:
在ARC产生之前,我们都必须小心地使用程序块。默认情况下,程序块是在栈上创建的,并且一旦超出作用范围它们就被销毁了。为了把程序块保存在实例变量中以便以后使用,我们必须将程序块复制到堆上。一旦使用完毕后就要释放它。幸运的是,ARC又一次简化了这些操作。我们可以创建程序块、存储它们、使用它们甚至返回它们。我们不需要担心它们是否在堆上或者在栈上。ARC为我们处理了所有的复制和管理细节。