南峰子的技术博客

攀登,一步一个脚印,方能知其乐


  • 首页

  • 知识小集

  • Swift

  • Objective-C

  • Cocoa

  • 翻译

  • 源码分析

  • 杂项

  • 归档

iOS知识小集 第1期(2015.05.10)

发表于 2015-05-10   |   分类于 知识小集

一直想做这样一个小册子,来记录自己平时开发、阅读博客、看书、代码分析和与人交流中遇到的各种问题。之前有过这样的尝试,但都是无疾而终。不过,每天接触的东西多,有些东西不记下来,忘得也是很快,第二次遇到同样的问题时,还得再查一遍。好记性不如烂笔头,所以又决定重拾此事,时不时回头看看,温故而知新。

这里面的每个问题,不会太长。或是读书笔记,或是摘抄,亦或是验证,每个问题的篇幅争取在六七百字的样子。笔记和摘抄的出处会详细标明。问题的个数不限,凑齐3000字左右就发一篇。争取每月至少发两篇吧,权当是对自己学习的一个整理。

本期主要记录了以下几个问题:

  1. NSString属性什么时候用copy,什么时候用strong?
  2. Foundation中的断言处理
  3. IBOutletCollection
  4. NSRecursiveLock递归锁的使用
  5. NSHashTable

NSString属性什么时候用copy,什么时候用strong?

我们在声明一个NSString属性时,对于其内存相关特性,通常有两种选择(基于ARC环境):strong与copy。那这两者有什么区别呢?什么时候该用strong,什么时候该用copy呢?让我们先来看个例子。

示例

我们定义一个类,并为其声明两个字符串属性,如下所示:

1
2
3
4
@interface TestStringClass ()
@property (nonatomic, strong) NSString *strongString;
@property (nonatomic, copy) NSString *copyedString;
@end

上面的代码声明了两个字符串属性,其中一个内存特性是strong,一个是copy。下面我们来看看它们的区别。

首先,我们用一个不可变字符串来为这两个属性赋值,

1
2
3
4
5
6
7
8
9
- (void)test {
NSString *string = [NSString stringWithFormat:@"abc"];
self.strongString = string;
self.copyedString = string;
NSLog(@"origin string: %p, %p", string, &string);
NSLog(@"strong string: %p, %p", strongString, &strongString);
NSLog(@"copy string: %p, %p", copyedString, &copyedString);
}

其输出结果是:

1
2
3
origin string: 0x7fe441592e20, 0x7fff57519a48
strong string: 0x7fe441592e20, 0x7fe44159e1f8
copy string: 0x7fe441592e20, 0x7fe44159e200

我们要以看到,这种情况下,不管是strong还是copy属性的对象,其指向的地址都是同一个,即为string指向的地址。如果我们换作MRC环境,打印string的引用计数的话,会看到其引用计数值是3,即strong操作和copy操作都使原字符串对象的引用计数值加了1。

接下来,我们把string由不可变改为可变对象,看看会是什么结果。即将下面这一句

1
NSString *string = [NSString stringWithFormat:@"abc"];

改成:

1
NSMutableString *string = [NSMutableString stringWithFormat:@"abc"];

其输出结果是:

1
2
3
origin string: 0x7ff5f2e33c90, 0x7fff59937a48
strong string: 0x7ff5f2e33c90, 0x7ff5f2e2aec8
copy string: 0x7ff5f2e2aee0, 0x7ff5f2e2aed0

可以发现,此时copy属性字符串已不再指向string字符串对象,而是深拷贝了string字符串,并让_copyedString对象指向这个字符串。在MRC环境下,打印两者的引用计数,可以看到string对象的引用计数是2,而_copyedString对象的引用计数是1。

此时,我们如果去修改string字符串的话,可以看到:因为_strongString与string是指向同一对象,所以_strongString的值也会跟随着改变(需要注意的是,此时_strongString的类型实际上是NSMutableString,而不是NSString);而_copyedString是指向另一个对象的,所以并不会改变。

结论

由于NSMutableString是NSString的子类,所以一个NSString指针可以指向NSMutableString对象,让我们的strongString指针指向一个可变字符串是OK的。

而上面的例子可以看出,当源字符串是NSString时,由于字符串是不可变的,所以,不管是strong还是copy属性的对象,都是指向源对象,copy操作只是做了次浅拷贝。

当源字符串是NSMutableString时,strong属性只是增加了源字符串的引用计数,而copy属性则是对源字符串做了次深拷贝,产生一个新的对象,且copy属性对象指向这个新的对象。另外需要注意的是,这个copy属性对象的类型始终是NSString,而不是NSMutableString,因此其是不可变的。

这里还有一个性能问题,即在源字符串是NSMutableString,strong是单纯的增加对象的引用计数,而copy操作是执行了一次深拷贝,所以性能上会有所差异。而如果源字符串是NSString时,则没有这个问题。

所以,在声明NSString属性时,到底是选择strong还是copy,可以根据实际情况来定。不过,一般我们将对象声明为NSString时,都不希望它改变,所以大多数情况下,我们建议用copy,以免因可变字符串的修改导致的一些非预期问题。

关于字符串的内存管理,还有些有意思的东西,可以参考NSString特性分析学习。

参考

  1. NSString copy not copying?
  2. NSString特性分析学习
  3. NSString什么时候用copy,什么时候用strong

Foundation中的断言处理

经常在看一些第三方库的代码时,或者自己在写一些基础类时,都会用到断言。所以在此总结一下Objective-C中关于断言的一些问题。

Foundation中定义了两组断言相关的宏,分别是:

1
2
NSAssert / NSCAssert
NSParameterAssert / NSCParameterAssert

这两组宏主要在功能和语义上有所差别,这些区别主要有以下两点:

  1. 如果我们需要确保方法或函数的输入参数的正确性,则应该在方法(函数)的顶部使用NSParameterAssert / NSCParameterAssert;而在其它情况下,使用NSAssert / NSCAssert。
  2. 另一个不同是介于C和Objective-C之间。NSAssert / NSParameterAssert应该用于Objective-C的上下文(方法)中,而NSCAssert / NSCParameterAssert应该用于C的上下文(函数)中。

当断言失败时,通常是会抛出一个如下所示的异常:

1
*** Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'true is not equal to false'

Foundation为了处理断言,专门定义了一个NSAssertionHandler来处理断言的失败情况。NSAssertionHandler对象是自动创建的,用于处理失败的断言。当断言失败时,会传递一个字符串给NSAssertionHandler对象来描述失败的原因。每个线程都有自己的NSAssertionHandler对象。当调用时,一个断言处理器会打印包含方法和类(或函数)的错误消息,并引发一个NSInternalInconsistencyException异常。就像上面所看到的一样。

我们很少直接去调用NSAssertionHandler的断言处理方法,通常都是自动调用的。

NSAssertionHandler提供的方法并不多,就三个,如下所示:

1
2
3
4
5
6
7
8
9
// 返回与当前线程的NSAssertionHandler对象。
// 如果当前线程没有相关的断言处理器,则该方法会创建一个并指定给当前线程
+ (NSAssertionHandler *)currentHandler
// 当NSCAssert或NSCParameterAssert断言失败时,会调用这个方法
- (void)handleFailureInFunction:(NSString *)functionName file:(NSString *)object lineNumber:(NSInteger)fileName description:(NSString *)line, format,...
// 当NSAssert或NSParameterAssert断言失败时,会调用这个方法
- (void)handleFailureInMethod:(SEL)selector object:(id)object file:(NSString *)fileName lineNumber:(NSInteger)line description:(NSString *)format, ...

另外,还定义了一个常量字符串,

1
NSString * const NSAssertionHandlerKey;

主要是用于在线程的threadDictionary字典中获取或设置断言处理器。

关于断言,还需要注意的一点是在Xcode 4.2以后,在release版本中断言是默认关闭的,这是由宏NS_BLOCK_ASSERTIONS来处理的。也就是说,当编译release版本时,所有的断言调用都是无效的。

我们可以自定义一个继承自NSAssertionHandler的断言处理类,来实现一些我们自己的需求。如Mattt Thompson的NSAssertionHandler实例一样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@interface LoggingAssertionHandler : NSAssertionHandler
@end
@implementation LoggingAssertionHandler
- (void)handleFailureInMethod:(SEL)selector
object:(id)object
file:(NSString *)fileName
lineNumber:(NSInteger)line
description:(NSString *)format, ...
{
NSLog(@"NSAssert Failure: Method %@ for object %@ in %@#%i", NSStringFromSelector(selector), object, fileName, line);
}
- (void)handleFailureInFunction:(NSString *)functionName
file:(NSString *)fileName
lineNumber:(NSInteger)line
description:(NSString *)format, ...
{
NSLog(@"NSCAssert Failure: Function (%@) in %@#%i", functionName, fileName, line);
}
@end

上面说过,每个线程都有自己的断言处理器。我们可以通过为线程的threadDictionary字典中的NSAssertionHandlerKey指定一个新值,来改变线程的断言处理器。

如下代码所示:

1
2
3
4
5
6
7
8
9
- (BOOL)application:(UIApplication *)application
didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
NSAssertionHandler *assertionHandler = [[LoggingAssertionHandler alloc] init];
[[[NSThread currentThread] threadDictionary] setValue:assertionHandler
forKey:NSAssertionHandlerKey];
// ...
return YES;
}

而什么时候应该使用断言呢?通常我们期望程序按照我们的预期去运行时,如调用的参数为空时流程就无法继续下去时,可以使用断言。但另一方面,我们也需要考虑,在这加断言确实是需要的么?我们是否可以通过更多的容错处理来使程序正常运行呢?

Matt Thompson在NSAssertionHandler中的倒数第二段说得挺有意思,在此摘抄一下:

But if we look deeper into NSAssertionHandler—and indeed, into our own hearts, there are lessons to be learned about our capacity for kindness and compassion; about our ability to forgive others, and to recover from our own missteps. We can’t be right all of the time. We all make mistakes. By accepting limitations in ourselves and others, only then are we able to grow as individuals.

参考

  1. NSAssertion​Handler
  2. NSAssertionHandler Class Reference

IBOutletCollection

在IB与相关文件做连接时,我们经常会用到两个关键字:IBOutlet和IBAction。经常用xib或storyboard的童鞋应该用这两上关键字非常熟悉了。不过UIKit还提供了另一个伪关键字IBOutletCollection,我们使用这个关键字,可以将界面上一组相同的控件连接到同一个数组中。

我们先来看看这个伪关键字的定义,可以从UIKit.framework的头文件UINibDeclarations.h找到如下定义:

1
2
3
#ifndef IBOutletCollection
#define IBOutletCollection(ClassName)
#endif

另外,在Clang源码中,有更安全的定义方式,如下所示:

1
#define IBOutletCollection(ClassName) attribute((iboutletcollection(ClassName)))

从上面的定义可以看到,与IBOutlet不同的是,IBOutletCollection带有一个参数,该参数是一个类名。

通常情况下,我们使用一个IBOutletCollection属性时,属性必须是strong的,且类型是NSArray,如下所示:

1
@property (strong, nonatomic) IBOutletCollection(UIScrollView) NSArray *scrollViews;

假定我们的xib文件中有三个横向的scrollView,我们便可以将这三个scrollView都连接至scrollViews属性,然后在我们的代码中便可以做一些统一处理,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
- (void)setupScrollViewImages
{
for (UIScrollView *scrollView in self.scrollViews) {
[self.imagesData enumerateObjectsUsingBlock:^(NSString *imageName, NSUInteger idx, BOOL *stop) {
UIImageView *imageView = [[UIImageView alloc] initWithFrame:CGRectMake(CGRectGetWidth(scrollView.frame) * idx, 0, CGRectGetWidth(scrollView.frame), CGRectGetHeight(scrollView.frame))];
imageView.contentMode = UIViewContentModeScaleAspectFill;
imageView.image = [UIImage imageNamed:imageName];
[scrollView addSubview:imageView];
}];
}
}

这段代码会影响到三个scrollView。这样做的好处是我们不需要手动通过addObject:方法将scrollView添加到scrollViews中。

不过在使用IBOutletCollection时,需要注意两点:

  1. IBOutletCollection集合中对象的顺序是不确定的。我们通过调试方法可以看到集合中对象的顺序跟我们连接的顺序是一样的。但是这个顺序可能会因为不同版本的Xcode而有所不同。所以我们不应该试图在代码中去假定这种顺序。
  2. 不管IBOutletCollection(ClassName)中的控件是什么,属性的类型始终是NSArray。实际上,我们可以声明是任何类型,如NSSet,NSMutableArray,甚至可以是UIColor,但不管我们在此设置的是什么类,IBOutletCollection属性总是指向一个NSArray数组。

关于第二点,我们以上面的scrollViews为例,作如下修改:

1
@property (strong, nonatomic) IBOutletCollection(UIScrollView) NSSet *scrollViews;

实际上我们在控制台打印这个scrollViews时,结果如下所示:

1
2
3
4
5
6
(lldb) po self.scrollViews
<__NSArrayI 0x1740573d0>(
<UIScrollView: 0x12d60d770; frame = (0 0; 320 162); clipsToBounds = YES; autoresize = W+H; gestureRecognizers = <NSArray: 0x1740574f0>; layer = <CALayer: 0x174229480>; contentOffset: {0, 0}; contentSize: {0, 0}>,
<UIScrollView: 0x12d60dee0; frame = (0 0; 320 161); clipsToBounds = YES; autoresize = W+H; gestureRecognizers = <NSArray: 0x174057790>; layer = <CALayer: 0x1742297c0>; contentOffset: {0, 0}; contentSize: {0, 0}>,
<UIScrollView: 0x12d60e650; frame = (0 0; 320 163); clipsToBounds = YES; autoresize = W+H; gestureRecognizers = <NSArray: 0x1740579a0>; layer = <CALayer: 0x1742298e0>; contentOffset: {0, 0}; contentSize: {0, 0}>
)

可以看到,它指向的是一个NSArray数组。

另外,IBOutletCollection实际上在iOS 4版本中就有了。不过,现在的Objective-C已经支持object literals了,所以定义数组可以直接用@[],方便了许多。而且object literals方式可以添加不在xib中的用代码定义的视图,所以显得更加灵活。当然,两种方式选择哪一种,就看我们自己的实际需要和喜好了。

参考

  1. IBAction / IBOutlet / IBOutlet​Collection
  2. IBOutletCollection.m

NSRecursiveLock递归锁的使用

NSRecursiveLock实际上定义的是一个递归锁,这个锁可以被同一线程多次请求,而不会引起死锁。这主要是用在循环或递归操作中。我们先来看一个示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
NSLock *lock = [[NSLock alloc] init];
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
static void (^RecursiveMethod)(int);
RecursiveMethod = ^(int value) {
[lock lock];
if (value > 0) {
NSLog(@"value = %d", value);
sleep(2);
RecursiveMethod(value - 1);
}
[lock unlock];
};
RecursiveMethod(5);
});

这段代码是一个典型的死锁情况。在我们的线程中,RecursiveMethod是递归调用的。所以每次进入这个block时,都会去加一次锁,而从第二次开始,由于锁已经被使用了且没有解锁,所以它需要等待锁被解除,这样就导致了死锁,线程被阻塞住了。调试器中会输出如下信息:

1
2
value = 5
* -[NSLock lock]: deadlock (<NSLock: 0x1700ceee0> '(null)') * Break on _NSLockError() to debug.

在这种情况下,我们就可以使用NSRecursiveLock。它可以允许同一线程多次加锁,而不会造成死锁。递归锁会跟踪它被lock的次数。每次成功的lock都必须平衡调用unlock操作。只有所有达到这种平衡,锁最后才能被释放,以供其它线程使用。

所以,对上面的代码进行一下改造,

1
NSRecursiveLock *lock = [[NSRecursiveLock alloc] init];

这样,程序就能正常运行了,其输出如下所示:

1
2
3
4
5
value = 5
value = 4
value = 3
value = 2
value = 1

NSRecursiveLock除了实现NSLocking协议的方法外,还提供了两个方法,分别如下:

1
2
3
4
5
// 在给定的时间之前去尝试请求一个锁
- (BOOL)lockBeforeDate:(NSDate *)limit
// 尝试去请求一个锁,并会立即返回一个布尔值,表示尝试是否成功
- (BOOL)tryLock

这两个方法都可以用于在多线程的情况下,去尝试请求一个递归锁,然后根据返回的布尔值,来做相应的处理。如下代码所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
NSRecursiveLock *lock = [[NSRecursiveLock alloc] init];
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
static void (^RecursiveMethod)(int);
RecursiveMethod = ^(int value) {
[lock lock];
if (value > 0) {
NSLog(@"value = %d", value);
sleep(2);
RecursiveMethod(value - 1);
}
[lock unlock];
};
RecursiveMethod(5);
});
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
sleep(2);
BOOL flag = [lock lockBeforeDate:[NSDate dateWithTimeIntervalSinceNow:1]];
if (flag) {
NSLog(@"lock before date");
[lock unlock];
} else {
NSLog(@"fail to lock before date");
}
});

在前面的代码中,我们又添加了一段代码,增加一个线程来获取递归锁。我们在第二个线程中尝试去获取递归锁,当然这种情况下是会失败的,输出结果如下:

1
2
3
4
5
6
value = 5
value = 4
fail to lock before date
value = 3
value = 2
value = 1

另外,NSRecursiveLock还声明了一个name属性,如下:

1
@property(copy) NSString *name

我们可以使用这个字符串来标识一个锁。Cocoa也会使用这个name作为错误描述信息的一部分。

参考

  1. NSRecursiveLock Class Reference
  2. Objective-C中不同方式实现锁(二)

NSHashTable

在看KVOController的代码时,又看到了NSHashTable这个类,所以就此整理一下。

NSHashTable效仿了NSSet(NSMutableSet),但提供了比NSSet更多的操作选项,尤其是在对弱引用关系的支持上,NSHashTable在对象/内存处理时更加的灵活。相较于NSSet,NSHashTable具有以下特性:

  1. NSSet(NSMutableSet)持有其元素的强引用,同时这些元素是使用hash值及isEqual:方法来做hash检测及判断是否相等的。
  2. NSHashTable是可变的,它没有不可变版本。
  3. 它可以持有元素的弱引用,而且在对象被销毁后能正确地将其移除。而这一点在NSSet是做不到的。
  4. 它的成员可以在添加时被拷贝。
  5. 它的成员可以使用指针来标识是否相等及做hash检测。
  6. 它可以包含任意指针,其成员没有限制为对象。我们可以配置一个NSHashTable实例来操作任意的指针,而不仅仅是对象。

初始化NSHashTable时,我们可以设置一个初始选项,这个选项确定了这个NSHashTable对象后面所有的行为。这个选项是由NSHashTableOptions枚举来定义的,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
enum {
// 默认行为,强引用集合中的对象,等同于NSSet
NSHashTableStrongMemory = 0,
// 在将对象添加到集合之前,会拷贝对象
NSHashTableCopyIn = NSPointerFunctionsCopyIn,
// 使用移位指针(shifted pointer)来做hash检测及确定两个对象是否相等;
// 同时使用description方法来做描述字符串
NSHashTableObjectPointerPersonality = NSPointerFunctionsObjectPointerPersonality,
// 弱引用集合中的对象,且在对象被释放后,会被正确的移除。
NSHashTableWeakMemory = NSPointerFunctionsWeakMemory
};
typedef NSUInteger NSHashTableOptions;

当然,我们还可以使用NSPointerFunctions来初始化,但只有使用NSHashTableOptions定义的这些值,才能确保NSHashTable的各个API可以正确的工作–包括拷贝、归档及快速枚举。

个人认为NSHashTable吸引人的地方在于可以持有元素的弱引用,而且在对象被销毁后能正确地将其移除。我们来写个示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 具体调用如下
@implementation TestHashAndMapTableClass {
NSMutableDictionary *_dic;
NSSet *_set;
NSHashTable *_hashTable;
}
- (instancetype)init {
self = [super init];
if (self) {
[self testWeakMemory];
NSLog(@"hash table [init]: %@", _hashTable);
}
return self;
}
- (void)testWeakMemory {
if (!_hashTable) {
_hashTable = [NSHashTable weakObjectsHashTable];
}
NSObject *obj = [[NSObject alloc] init];
[_hashTable addObject:obj];
NSLog(@"hash table [testWeakMemory] : %@", _hashTable);
}

这段代码的输出结果如下:

1
2
3
4
5
hash table [testWeakMemory] : NSHashTable {
[6] <NSObject: 0x7fa2b1562670>
}
hash table [init]: NSHashTable {
}

可以看到,在离开testWeakMemory方法,obj对象被释放,同时对象在集合中的引用也被安全的删除。

这样看来,NSHashTable似乎比NSSet(NSMutableSet)要好啊。那是不是我们就应用都使用NSHashTable呢?Peter Steinberger在The Foundation Collection Classes给了我们一组数据,显示在添加对象的操作中,NSHashTable所有的时间差不多是NSMutableSet的2倍,而在其它操作中,性能大体相近。所以,如果我们只需要NSSet的特性,就尽量用NSSet。

另外,Mattt Thompson在NSHashTable & NSMapTable的结尾也写了段挺有意思的话,在此直接摘抄过来:

As always, it’s important to remember that programming is not about being clever: always approach a problem from the highest viable level of abstraction. NSSet and NSDictionary are great classes. For 99% of problems, they are undoubtedly the correct tool for the job. If, however, your problem has any of the particular memory management constraints described above, then NSHashTable & NSMapTable may be worth a look.

参考

  1. NSHashTable Class Reference
  2. NSHash​Table & NSMap​Table
  3. NSHashTable & NSMapTable
  4. The Foundation Collection Classes

零碎

(一) “Unknown class XXViewController in Interface Builder file.”” 问题处理

最近在静态库中写了一个XXViewController类,然后在主工程的xib中,将xib的类指定为XXViewController,程序运行时,报了如下错误:

1
Unknown class XXViewController in Interface Builder file.

之前也遇到这个问题,但已记得不太清楚,所以又开始在stackoverflow上找答案。

其实这个问题与Interface Builder无关,最直接的原因还是相关的symbol没有从静态库中加载进来。这种问题的处理就是在Target的"Build Setting"->"Other Link Flags"中加上"-all_load -ObjC"这两个标识位,这样就OK了。

(二)关于Unbalanced calls to begin/end appearance transitions for …问题的处理

我们的某个业务有这么一个需求,进入一个列表后需要立马又push一个web页面,做一些活动的推广。在iOS 8上,我们的实现是一切OK的;但到了iOS 7上,就发现这个web页面push不出来了,同时控制台给了一条警告消息,即如下:

1
Unbalanced calls to begin/end appearance transitions for ...

在这种情况下,点击导航栏中的返回按钮时,直接显示一个黑屏。

我们到stackoverflow上查了一下,有这么一段提示:

1
occurs when you try and display a new viewcontroller before the current view controller is finished displaying.

意思是说在当前视图控制器完成显示之前,又试图去显示一个新的视图控制器。

于是我们去排查代码,果然发现,在viewDidLoad里面去做了次网络请求操作,且请求返回后就去push这个web活动推广页。此时,当前的视图控制器可能并未显示完成(即未完成push操作)。

1
Basically you are trying to push two view controllers onto the stack at almost the same time.

当几乎同时将两个视图控制器push到当前的导航控制器栈中时,或者同时pop两个不同的视图控制器,就会出现不确定的结果。所以我们应该确保同一时间,对同一个导航控制器栈只有一个操作,即便当前的视图控制器正在动画过程中,也不应该再去push或pop一个新的视图控制器。

所以最后我们把web活动的数据请求放到了viewDidAppear里面,并做了些处理,这样问题就解决了。

参考

  1. “Unbalanced calls to begin/end appearance transitions for DetailViewController” when pushing more than one detail view controller
  2. Unbalanced calls to begin/end appearance transitions for UITabBarController

Foundation: NSKeyValueObserving(KVO)

发表于 2015-04-23   |   分类于 Cocoa

NSKeyValueObserving非正式协议定义了一种机制,它允许对象去监听其它对象的某个属性的修改。

我们可以监听一个对象的属性,包括简单属性,一对一的关系,和一对多的关系。一对多关系的监听者会被告知集合变更的类型,以及哪些对象参与了变化。

NSObject提供了一个NSKeyValueObserving协议的默认实现,它为所有对象提供了一种自动发送修改通知的能力。我们可以通过禁用自动发送通知并使用这个协议提供的方法来手动实现通知的发送,以便更精确地去处理通知。

在这里,我们将通过具体的实例来看看NSKeyValueObserving提供了哪些方法。我们的基础代码如代码清单1所示:

代码清单1:示例基础代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#pragma mark - PersonObject
@interface PersonObject : NSObject
@end
@implementation PersonObject
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context {
NSLog(@"keyPath = %@, change = %@, context = %s", keyPath, change, (char *)context);
}
@end
#pragma mark - BankObject
@interface BankObject : NSObject
@property (nonatomic, assign) int accountBalance;
@property (nonatomic, copy) NSString *bankCodeEn;
@property (nonatomic, strong) NSMutableArray *departments;
@end

在这段代码中,我们定义一两个类,一个是PersonObject类,这个类的对象在下面将充当观察者的角色。另一个是BankObject类,我们在这个类中定义了三个属性,作为被监听的属性。由于NSObject类已经实现了NSKeyValueObserving协议,所以我们不需要再显式地去让我们的类实现这个协议。

接下来,我们便来看看NSKeyValueObserving协议有哪些功能。

注册/移除观察者

要让一个对象监听另一个对象的属性的变化,首先需要将这个对象注册为相关属性的观察者,我们可以使用以下方法来实现:

1
2
3
4
- (void)addObserver:(NSObject *)anObserver
forKeyPath:(NSString *)keyPath
options:(NSKeyValueObservingOptions)options
context:(void *)context

这个方法带有四个参数,描述如下:

  1. anObserver:观察者对象,这个对象必须实现observeValueForKeyPath:ofObject:change:context:方法,以响应属性的修改通知。
  2. keyPath:被监听的属性。这个值不能为nil。
  3. options:监听选项,这个值可以是NSKeyValueObservingOptions选项的组合。关于监听选项,我们会在下面介绍。
  4. context:任意的额外数据,我们可以将这些数据作为上下文数据,它会传递给观察者对象的observeValueForKeyPath:ofObject:change:context:方法。这个参数的意义在于用于区分同一对象监听同一属性(从属于同一对象)的多个不同的监听。我们将在下面看到。

监听选项是由枚举NSKeyValueObservingOptions定义的,是传入-addObserver:forKeyPath:options:context:方法中以确定哪些值将被传到-observeValueForKeyPath:ofObject:change:context:方法中。这个枚举的定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
enum {
// 提供属性的新值
NSKeyValueObservingOptionNew = 0x01,
// 提供属性的旧值
NSKeyValueObservingOptionOld = 0x02,
// 如果指定,则在添加观察者的时候立即发送一个通知给观察者,
// 并且是在注册观察者方法返回之前
NSKeyValueObservingOptionInitial = 0x04,
// 如果指定,则在每次修改属性时,会在修改通知被发送之前预先发送一条通知给观察者,
// 这与-willChangeValueForKey:被触发的时间是相对应的。
// 这样,在每次修改属性时,实际上是会发送两条通知。
NSKeyValueObservingOptionPrior = 0x08
};
typedef NSUInteger NSKeyValueObservingOptions;

需要注意的是,当设定了NSKeyValueObservingOptionPrior选项时,第一条通知不会包含NSKeyValueChangeNewKey。当观察者自身的KVO需要为自己的某个属性调用-willChange...方法,而这个属性的值又依赖于被观察对象的属性时,我们可以使用这个选项。

另外,在添加观察者时还有两点需要注意的是:

  1. 调用这个方法时,两个对象(即观察者对象及属性所属的对象)都不会被retain。
  2. 可以多次调用这个方法,将同一个对象注册为同一属性的观察者(所有参数可以完全相同)。这时,即便在所有参数一致的情况下,新注册的观察者并不会替换原来观察者,而是会并存。这样,当属性被修改时,两次监听都会响应。

对于第2点,我们在代码清单2中来验证一下:

代码清单2:验证多次使用相同参数来添加观察者的实际效果

1
2
3
4
5
6
7
8
9
10
PersonObject *personInstance = [[PersonObject alloc] init];
BankObject *bankInstance = [[BankObject alloc] init];
[bankInstance addObserver:personInstance forKeyPath:@"accountBalance" options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld context:NULL];
[bankInstance addObserver:personInstance forKeyPath:@"accountBalance" options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld context:NULL];
[bankInstance addObserver:personInstance forKeyPath:@"accountBalance" options:NSKeyValueObservingOptionNew context:"person instance"];
[bankInstance addObserver:personInstance forKeyPath:@"accountBalance" options:NSKeyValueObservingOptionNew context:"person instance 2"];
bankInstance.accountBalance = 10;

(注,以上代码为在MRC环境下调用,确保personInstance和bankInstance不会被释放。)

这段代码的输出如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
keyPath = accountBalance, change = {
kind = 1;
new = 10;
}, context = person instance 2
keyPath = accountBalance, change = {
kind = 1;
new = 10;
}, context = person instance
keyPath = accountBalance, change = {
kind = 1;
new = 10;
old = 0;
}, context = (null)
keyPath = accountBalance, change = {
kind = 1;
new = 10;
old = 0;
}, context = (null)

可以看到KVO为每次注册都调用了一次监听处理操作。所以多次调用同样的注册操作会产生多个观察者。另外,多个观察者之间的observeValueForKeyPath:ofObject:change:context:方法调用顺序是按照先进后出的顺序来的(所有的监听信息都是放在一个数组中的,我们将在下面了解到)。

一个良好的实践是在观察者不再需要监听属性变化时,必须调用removeObserver:forKeyPath:或removeObserver:forKeyPath:context:方法来移除观察者,这两个方法的声明如下:

1
2
3
4
5
6
- (void)removeObserver:(NSObject *)anObserver
forKeyPath:(NSString *)keyPath
- (void)removeObserver:(NSObject *)observer
forKeyPath:(NSString *)keyPath
context:(void *)context

这两个方法会根据传入的参数(主要是keyPath和context)来移除观察者。如果observer没有监听keyPath属性,则调用这两个方法会抛出异常。大家可以试一下,程序会果断的崩溃。并报类似于以下的错误:

1
*** Terminating app due to uncaught exception 'NSRangeException', reason: 'Cannot remove an observer <PersonObject 0x7ff541534e20> for the key path "accountBalance" from <BankObject 0x7ff541528430> because it is not registered as an observer.'

所以,我们必须确保先注册了观察者,才能调用移除方法。

那如果我们忘记调用移除观察者方法,会怎么样呢?我们来制造一个场景,看看会是什么结果。还是使用上面的代码,只不过这次我们在ARC下来测试:

代码清单3:未移除观察者的影响

1
2
3
4
5
6
7
8
9
- (void)testKVO {
PersonObject *personInstance = [[PersonObject alloc] init];
BankObject *bankInstance = [[BankObject alloc] init];
[bankInstance addObserver:personInstance forKeyPath:@"accountBalance" options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld context:NULL];
bankInstance.accountBalance = 20;
}

其输出结果如下所示:

1
2
3
4
5
6
7
8
9
keyPath = accountBalance, change = {
kind = 1;
new = 20;
old = 0;
}, context = (null)
*** Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'An instance 0x7fc88047e7e0 of class BankObject was deallocated while key value observers were still registered with it. Current observation info: <NSKeyValueObservationInfo 0x7fc880770fa0> (
<NSKeyValueObservance 0x7fc880771850: Observer: 0x7fc8804737a0, Key path: accountBalance, Options: <New: YES, Old: YES, Prior: NO> Context: 0x0, Property: 0x7fc88076edd0>
)'
......

程序在调用一次KVO后,很爽快地崩溃了。给我们的解释是bankInstance被释放了,但KVO中仍然还有关于它的注册信息。实际上,我们上面说过,在添加观察者的时候,观察者对象与被观察属性所属的对象都不会被retain,然而在这些对象被释放后,相关的监听信息却还存在,KVO做的处理是直接让程序崩溃。

处理属性修改通知

当被监听的属性修改时,KVO会发出一个通知以告知所有监听这个属性的观察者对象。而观察者对象必须实现

-observeValueForKeyPath:ofObject:change:context:方法,来对属性修改通知做相应的处理。这个方法的声明如下:

1
2
3
4
- (void)observeValueForKeyPath:(NSString *)keyPath
ofObject:(id)object
change:(NSDictionary *)change
context:(void *)context

这个方法有四个参数,描述如下:

  1. keyPath:即被观察的属性,与参数object相关。
  2. object:keyPath所属的对象。
  3. change:这是一个字典,它包含了属性被修改的一些信息。这个字典中包含的值会根据我们在添加观察者时设置的options参数的不同而有所不同。
  4. context:这个值即是添加观察者时提供的上下文信息。

在我们的示例中,这个方法的实现是打印一些基本的信息。如代码清单1所示。

对于第三个参数,我们通常称之为变化字典(Change Dictionary),它记录了被监听属性的变化情况。我们可以通过以下key来获取我们想要的信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 属性变化的类型,是一个NSNumber对象,包含NSKeyValueChange枚举相关的值
NSString *const NSKeyValueChangeKindKey;
// 属性的新值。当NSKeyValueChangeKindKey是 NSKeyValueChangeSetting,
// 且添加观察的方法设置了NSKeyValueObservingOptionNew时,我们能获取到属性的新值。
// 如果NSKeyValueChangeKindKey是NSKeyValueChangeInsertion或者NSKeyValueChangeReplacement,
// 且指定了NSKeyValueObservingOptionNew时,则我们能获取到一个NSArray对象,包含被插入的对象或
// 用于替换其它对象的对象。
NSString *const NSKeyValueChangeNewKey;
// 属性的旧值。当NSKeyValueChangeKindKey是 NSKeyValueChangeSetting,
// 且添加观察的方法设置了NSKeyValueObservingOptionOld时,我们能获取到属性的旧值。
// 如果NSKeyValueChangeKindKey是NSKeyValueChangeRemoval或者NSKeyValueChangeReplacement,
// 且指定了NSKeyValueObservingOptionOld时,则我们能获取到一个NSArray对象,包含被移除的对象或
// 被替换的对象。
NSString *const NSKeyValueChangeOldKey;
// 如果NSKeyValueChangeKindKey的值是NSKeyValueChangeInsertion、NSKeyValueChangeRemoval
// 或者NSKeyValueChangeReplacement,则这个key对应的值是一个NSIndexSet对象,
// 包含了被插入、移除或替换的对象的索引
NSString *const NSKeyValueChangeIndexesKey;
// 当指定了NSKeyValueObservingOptionPrior选项时,在属性被修改的通知发送前,
// 会先发送一条通知给观察者。我们可以使用NSKeyValueChangeNotificationIsPriorKey
// 来获取到通知是否是预先发送的,如果是,获取到的值总是@(YES)
NSString *const NSKeyValueChangeNotificationIsPriorKey;

其中,NSKeyValueChangeKindKey的值取自于NSKeyValueChange,它的值是由以下枚举定义的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
enum {
// 设置一个新值。被监听的属性可以是一个对象,也可以是一对一关系的属性或一对多关系的属性。
NSKeyValueChangeSetting = 1,
// 表示一个对象被插入到一对多关系的属性。
NSKeyValueChangeInsertion = 2,
// 表示一个对象被从一对多关系的属性中移除。
NSKeyValueChangeRemoval = 3,
// 表示一个对象在一对多的关系的属性中被替换
NSKeyValueChangeReplacement = 4
};
typedef NSUInteger NSKeyValueChange;

通知观察者属性的变化

通知观察者的方式有自动与手动两种方式。

默认情况下是自动发送通知,在这种模式下,当我们修改属性的值时,KVO会自动调用以下两个方法:

1
2
- (void)willChangeValueForKey:(NSString *)key
- (void)didChangeValueForKey:(NSString *)key

这两个方法的任务是告诉接收者给定的属性将要或已经被修改。需要注意的是不应该在子类中去重写这两个方法。

但如果我们希望自己控制通知发送的一些细节,则可以启用手动控制模式。手动控制通知提供了对KVO更精确控制,它可以控制通知如何以及何时被发送给观察者。采用这种方式可以减少不必要的通知,或者可以将多个修改组合到一个修改中。

实现手动通知的类必须重写NSObject中对automaticallyNotifiesObserversForKey:方法的实现。这个方法是在NSKeyValueObserving协议中声明的,其声明如下:

1
+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key

这个方法返回一个布尔值(默认是返回YES),以标识参数key指定的属性是否支持自动KVO。如果我们希望手动去发送通知,则针对指定的属性返回NO。

假设我们希望PersonObject对象去监听BankObject对象的bankCodeEn属性,并希望执行手动通知,则可以如下处理:

代码清单4:关闭属性的自动通知发送

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@implementation BankObject
+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key {
BOOL automatic = YES;
if ([key isEqualToString:@"bankCodeEn"]) {
automatic = NO;
} else {
automatic = [super automaticallyNotifiesObserversForKey:key];
}
return automatic;
}
@end

这样我们便可以手动去发送属性修改通知了。需要注意的是,对于对象中其它没有处理的属性,我们需要调用[super automaticallyNotifiesObserversForKey:key],以避免无意中修改了父类的属性的处理方式。

现在我们已经通过+automaticallyNotifiesObserversForKey:方法设置了对象中哪些属性需要手动处理。接下来就是实际操作了。为了实现手动发送通知,我们需要在修改属性值前调用willChangeValueForKey:,然后在修改属性值之后调用didChangeValueForKey:方法。继续上面的示例,我们需要对bankCodeEn属性做如下处理:

代码清单5:手动控制通知发送

1
2
3
4
5
6
7
8
9
10
@implementation BankObject
- (void)setBankCodeEn:(NSString *)bankCodeEn {
[self willChangeValueForKey:@"bankCodeEn"];
_bankCodeEn = bankCodeEn;
[self didChangeValueForKey:@"bankCodeEn"];
}
@end

如果我们希望只有当bankCodeEn实际被修改时发送通知,以尽量减少不必要的通知,则可以如下实现:

代码清单6:在发送通知前测试值是否修改

1
2
3
4
5
6
7
8
- (void)setBankCodeEn:(NSString *)bankCodeEn {
if (bankCodeEn != _bankCodeEn) {
[self willChangeValueForKey:@"bankCodeEn"];
_bankCodeEn = bankCodeEn;
[self didChangeValueForKey:@"bankCodeEn"];
}
}

我们来测试一下上面这段代码的实际效果:

代码清单7:测试避免属性未实际修改下不发送通知

1
2
3
4
5
6
7
8
PersonObject *personInstance = [[PersonObject alloc] init];
BankObject *bankInstance = [[BankObject alloc] init];
[bankInstance addObserver:personInstance forKeyPath:@"bankCodeEn" options:NSKeyValueObservingOptionNew context:NULL];
NSString *bankCodeEn = @"CCB";
bankInstance.bankCodeEn = bankCodeEn;
bankInstance.bankCodeEn = bankCodeEn;

这段代码的输出结果如下所示:

1
2
3
4
keyPath = bankCodeEn, change = {
kind = 1;
new = CCB;
}, context = (null)

我们可以看到只输出了一次,而不是两次。

如果我们在setter方法之外改变了实例变量(如_bankCodeEn),且希望这种修改被观察者监听到,则需要像在setter方法里面做一样的处理。这也涉及到我们通常会遇到的一个问题,在类的内部,对于一个属性值,何时用属性(self.bankCodeEn)访问而何时用实例变量(_bankCodeEn)访问。一般的建议是,在获取属性值时,可以用实例变量,在设置属性值时,尽量用setter方法,以保证属性的KVO特性。当然,性能也是一个考量,在设置值时,使用实例变量比使用属性设置值的性能高不少。

另外,对于一对多关系的属性,如果想手动处理通知,则可以使用以下几个方法:

1
2
3
4
5
6
7
// 有序的一对多关系
- (void)willChange:(NSKeyValueChange)change valuesAtIndexes:(NSIndexSet *)indexes forKey:(NSString *)key
- (void)didChange:(NSKeyValueChange)change valuesAtIndexes:(NSIndexSet *)indexes forKey:(NSString *)key
// 无序的一对多关系
- (void)willChangeValueForKey:(NSString *)key withSetMutation:(NSKeyValueSetMutationKind)mutationKind usingObjects:(NSSet *)objects
- (void)didChangeValueForKey:(NSString *)key withSetMutation:(NSKeyValueSetMutationKind)mutationKind usingObjects:(NSSet *)objects

同样,在子类中也不应该去重写这几个方法。

计算属性(注册依赖键)

有时候,我们的监听的某个属性可能会依赖于其它多个属性的变化(类似于swift,可以称之为计算属性),不管所依赖的哪个属性发生了变化,都会导致计算属性的变化。对于这种一对一(To-one)的关系,我们需要做两步操作,首先是确定计算属性与所依赖属性的关系。如我们在BankObject类中定义一个accountForBank属性,其get方法定义如下:

代码清单8:计算属性

1
2
3
4
5
6
7
8
@implementation BankObject
- (NSString *)accountForBank {
return [NSString stringWithFormat:@"account for %@ is %d", self.bankCodeEn, self.accountBalance];
}
@end

定义了这种依赖关系后,我们就需要以某种方式告诉KVO,当我们的被依赖属性修改时,会发送accountForBank属性被修改的通知。此时,我们需要重写NSKeyValueObserving协议的keyPathsForValuesAffectingValueForKey:方法,该方法声明如下:

1
+ (NSSet *)keyPathsForValuesAffectingValueForKey:(NSString *)key

这个方法返回的是一个集合对象,包含了那些影响key指定的属性依赖的属性所对应的字符串。所以对于accountForBank属性,该方法的实现如下:

代码清单9:accountForBank属性的keyPathsForValuesAffectingValueForKey方法的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@implementation BankObject
+ (NSSet *)keyPathsForValuesAffectingValueForKey:(NSString *)key {
NSSet *keyPaths = [super keyPathsForValuesAffectingValueForKey:key];
if ([key isEqualToString:@"accountForBank"]) {
keyPaths = [keyPaths setByAddingObjectsFromArray:@[@"accountBalance", @"bankCodeEn"]];
}
return keyPaths;
}
@end

我们来再来看看监听accountForBank属性是什么效果:

代码清单10:监听accountForBank属性

1
2
3
4
5
6
7
PersonObject *personInstance = [[PersonObject alloc] init];
BankObject *bankInstance = [[BankObject alloc] init];
[bankInstance addObserver:personInstance forKeyPath:@"accountForBank" options:NSKeyValueObservingOptionNew context:NULL];
bankInstance.accountBalance = 10;
bankInstance.bankCodeEn = @"CCB";

其输出结果为:

1
2
3
4
5
6
7
8
keyPath = accountForBank, change = {
kind = 1;
new = "account for (null) is 10";
}, context = (null)
keyPath = accountForBank, change = {
kind = 1;
new = "account for CCB is 10";
}, context = (null)

可以看到,不管是accountBalance还是bankCodeEn被修改了,都会发送accountForBank属性被修改的通知。

需要注意的就是当我们重写+keyPathsForValuesAffectingValueForKey:时,需要去调用super的对应方法,并返回一个包含父类中可能会对key指定属性产生影响的属性集合。

另外,我们还可以实现一个命名为keyPathsForValuesAffecting\<Key\>的类方法来达到同样的目的,其中<Key>是我们计算属性的名称。所以对于accountForBank属性,还可以如下实现:

1
2
3
4
+ (NSSet *)keyPathsForValuesAffectingAccountForBank {
return [NSSet setWithObjects:@"accountBalance", @"bankCodeEn", nil];
}

两种方法的实现效果是一样的。不过更建议使用后面一种方法,这种方法让依赖关系更加清晰明了。

集合属性的监听

keyPathsForValuesAffectingValueForKey:只支持一对一的关系,而不支持一对多的关系,即不支持对集合的处理。

对于集合的KVO,我们需要了解的一点是,KVO旨在观察关系(relationship)而不是集合。对于不可变集合属性,我们更多的是把它当成一个整体来监听,而无法去监听集合中的某个元素的变化;对于可变集合属性,实际上也是当成一个整体,去监听它整体的变化,如添加、删除和替换元素。

在KVC中,我们可以使用集合代理对象(collection proxy object)来处理集合相关的操作。我们以数组为例,在我们的BankObject类中有一个departments数组属性,如果我们希望通过集合代理对象来负责响应departments所有的方法,则需要实现以下方法:

1
2
3
4
5
6
7
8
-countOf<Key>
// 以下两者二选一
-objectIn<Key>AtIndex:
-<key>AtIndexes:
// 可选(增强性能)
-get<Key>:range:

因此,我们的实现以下几个方法:

代码清单11:集合代码对象的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@implementation BankObject
#pragma mark - 集合代理对象
- (NSUInteger)countOfDepartments {
return [_departments count];
}
- (id)objectInDepartmentsAtIndex:(NSUInteger)index {
return [_departments objectAtIndex:index];
}
@end

实现以上方法之后,对于不可变数组,当我们调用[bankInstance valueForKey:@"departments"]的时候,便会返回一个由以上方法来代理所有调用方法的~对象。这个代理数组对象支持所有正常的NSArray调用。换句话说,调用者并不知道返回的是一个真正的NSArray,还是一个代理的数组。

另外,对于可变数组的代理对象,我们需要实现以下几个方法:

1
2
3
4
5
6
7
8
9
// 至少实现一个插入方法和一个删除方法
-insertObject:in<Key>AtIndex:
-removeObjectFrom<Key>AtIndex:
-insert<Key>:atIndexes:
-remove<Key>AtIndexes:
// 可选(增强性能)以下方法二选一
-replaceObjectIn<Key>AtIndex:withObject:
-replace<Key>AtIndexes:with<Key>:

这些方法分别对应插入、删除和替换,有批量操作的,也有只改变一个对象的方法。可以根据实际需要来实现。

另外,对于可变集合,我们通常不使用valueForKey:来获取代理对象,而是使用以下方法:

1
- (NSMutableArray *)mutableArrayValueForKey:(NSString *)key;

通过这个方法,我们便可以将可变数组与强大的KVO结合在一起。KVO机制能在集合改变的时候把详细的变化放进change字典中。

我们先来看看下面的代码:

代码清单12:使用真正的数组对象监听可变数组属性

1
2
3
4
5
6
7
8
BankObject *bankInstance = [[BankObject alloc] init];
PersonObject *personInstance = [[PersonObject alloc] init];
[bankInstance addObserver:personInstance forKeyPath:@"departments" options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld context:NULL];
bankInstance.departments = [[NSMutableArray alloc] init];
[bankInstance.departments addObject:@"departments"];

其输出为:

1
2
3
4
5
6
7
keyPath = departments, change = {
kind = 1;
new = (
);
old = (
);
}, context = (null)

可以看到通过这种方法,我们获取的是真正的数组,只在departments属性整体被修改时,才会触发KVO,而在添加元素时,并没有触发KVO。

现在我们通过代理集合对象来看看:

代码清单13:使用代理集合对象监听可变数组属性

1
2
3
4
5
6
7
8
9
BankObject *bankInstance = [[BankObject alloc] init];
PersonObject *personInstance = [[PersonObject alloc] init];
[bankInstance addObserver:personInstance forKeyPath:@"departments" options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld context:NULL];
bankInstance.departments = [[NSMutableArray alloc] init];
NSMutableArray *departments = [bankInstance mutableArrayValueForKey:@"departments"];
[departments insertObject:@"departments 0" atIndex:0];

其输出是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
keyPath = departments, change = {
kind = 1;
new = (
);
old = (
);
}, context = (null)
keyPath = departments, change = {
indexes = "<NSIndexSet: 0x7fcd18673150>[number of indexes: 1 (in 1 ranges), indexes: (0)]";
kind = 2;
new = (
"departments 0"
);
}, context = (null)

可以看到,在往数组中添加对象时,也触发了KVO,并将改变的详细信息也写进了change字典。在第二个消息中,kind的值为2,即表示这是一次插入操作。同样,可变数组的删除,替换操作也是一样的。

集合(Set)也有一套对应的方法来实现集合代理对象,包括无序集合与有序集合;而字典则没有,对于字典属性的监听,还是只能作为一个整理来处理。

如果我们想到手动控制集合属性消息的发送,则可以使用上面提到的几个方法,即:

1
2
3
4
5
6
7
-willChange:valuesAtIndexes:forKey:
-didChange:valuesAtIndexes:forKey:
或
-willChangeValueForKey:withSetMutation:usingObjects:
-didChangeValueForKey:withSetMutation:usingObjects:

不过得先保证把自动通知关闭,否则每次改变KVO都会被发送两次。

监听信息

如果我们想获取一个对象上有哪些观察者正在监听其属性的修改,则可以查看对象的observationInfo属性,其声明如下:

1
@property void *observationInfo

可以看到它是一个void指针,指向一个包含所有观察者的一个标识信息对象,这些信息包含了每个监听的观察者,注册时设定的选项等等。我们还是用示例来看看。

代码清单14:observationInfo的使用

1
2
3
4
5
6
7
8
9
10
11
PersonObject *personInstance = [[PersonObject alloc] init];
BankObject *bankInstance = [[BankObject alloc] init];
[bankInstance addObserver:personInstance forKeyPath:@"bankCodeEn" options:NSKeyValueObservingOptionNew context:NULL];
[bankInstance addObserver:personInstance forKeyPath:@"accountBalance" options:NSKeyValueObservingOptionOld context:NULL];
NSLog(@"%p", personInstance);
NSLog(@"%p", bankInstance);
id info = bankInstance.observationInfo;
NSLog(@"%@", [info description]);

其输出结果如下:

1
2
3
4
5
6
personInstance = 0x7fdc2369e5e0
bankInstance = 0x7fdc2369d8f0
<NSKeyValueObservationInfo 0x7fdc236a19d0> (
<NSKeyValueObservance 0x7fdc236a17a0: Observer: 0x7fdc2369e5e0, Key path: bankCodeEn, Options: <New: YES, Old: NO, Prior: NO> Context: 0x0, Property: 0x7fdc236a15c0>
<NSKeyValueObservance 0x7fdc236a1960: Observer: 0x7fdc2369e5e0, Key path: accountBalance, Options: <New: NO, Old: YES, Prior: NO> Context: 0x0, Property: 0x7fdc236a1880>
)

我们可以看到observationInfo指针实际上是指向一个NSKeyValueObservationInfo对象,它包含了指定对象上的所有的监听信息。而每条监听信息而是封装在一个NSKeyValueObservance对象中,从上面可以看到,这个对象中包含消息的观察者、被监听的属性、添加观察者时所设置的一些选项、上下文信息等。

NSKeyValueObservationInfo类及NSKeyValueObservance类都是私有类,我们无法在官方文档中找到这两个类的实现。不过从一些对系统库dump出来的头文件,我们可以对这两个类有一些初步的了解。这里有一个对iOS SKD 4.3的Foundation.framework的dump头文件,可以找到这两个类的头文件,其中NSKeyValueObservationInfo的头文件信息如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#import <XXUnknownSuperclass.h> // Unknown library
@class NSArray, NSHashTable;
__attribute__((visibility("hidden")))
@interface NSKeyValueObservationInfo : XXUnknownSuperclass {
@private
int _retainCountMinusOne;
NSArray* _observances;
unsigned _cachedHash;
BOOL _cachedIsShareable;
NSHashTable* _observables;
}
-(id)_initWithObservances:(id*)observances count:(unsigned)count;
-(id)retain;
-(oneway void)release;
-(unsigned)retainCount;
-(void)dealloc;
-(unsigned)hash;
-(BOOL)isEqual:(id)equal;
-(id)description;
@end

可以看到其中有一个数组来存储NSKeyValueObservance对象。

NSKeyValueObservance类的头文件信息如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#import "Foundation-Structs.h"
#import <XXUnknownSuperclass.h> // Unknown library
@class NSPointerArray, NSKeyValueProperty, NSObject;
__attribute__((visibility("hidden")))
@interface NSKeyValueObservance : XXUnknownSuperclass {
@private
int _retainCountMinusOne;
NSObject* _observer;
NSKeyValueProperty* _property;
unsigned _options;
void* _context;
NSObject* _originalObservable;
unsigned _cachedUnrotatedHashComponent;
BOOL _cachedIsShareable;
NSPointerArray* _observationInfos;
auto_weak_callback_block _observerWentAwayCallback;
}
-(id)_initWithObserver:(id)observer property:(id)property options:(unsigned)options context:(void*)context originalObservable:(id)observable;
-(id)retain;
-(oneway void)release;
-(unsigned)retainCount;
-(void)dealloc;
-(unsigned)hash;
-(BOOL)isEqual:(id)equal;
-(id)description;
-(void)observeValueForKeyPath:(id)keyPath ofObject:(id)object change:(id)change context:(void*)context;
@end

可以看到其中包含了一个监听的基本要素。在此不再做深入分析(没有源代码,深入不下去了啊)。

我们再回到observationInfo属性本身来。在文档中,对这个属性的描述有这样一段话:

1
2
The default implementation of this method retrieves the information from a global
dictionary keyed by the receiver’s pointers.

即这个方法的默认实现是以对象的指针作为key,从一个全局的字典中获取信息。由此,我们可以理解为,KVO的信息是存储在一个全局字典中,而不是存储在对象本身。这类似于Notification,所有关于通知的信息都是放在NSNotificationCenter中。

不过,为了提高效率,我们可以重写observationInfo属性的set和get方法,以将这个不透明的数据指针存储到一个实例变量中。但是,在重写时,我们不应该尝试去向这些数据发送一个Objective-C消息,包括retain和release。

KVO的实现机制

【本来这一小节是想放在另一篇总结中来写的,但后来觉得还是放在这里比较合适,所以就此添加上】

了解了NSKeyValueObserving所提供的功能后,我们再来看看KVO的实现机制,以便更深入地的理解KVO。

KVO据我所查还没有开源(若哪位大大有查到源代码,还烦请告知),所以我们无法从源代码的层面来分析它的实现。不过Mike Ash的博文(译文见参考文献4)为我们解开了一些谜团。

基本的思路是:Objective-C依托于强大的runtime机制来实现KVO。当我们第一次观察某个对象的属性时,runtime会创建一个新的继承自这个对象的class的subclass。在这个新的subclass中,它会重写所有被观察的key的setter,然后将object的isa指针指向新创建的class(这个指针告诉Objective-C运行时某个object到底是什么类型的)。所以object神奇地变成了新的子类的实例。

嗯,让我们通过代码来看看实际的实现:

代码清单15:探究KVO的实现机制

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// 辅助方法
static NSArray *ClassMethodNames(Class c) {
NSMutableArray *array = [NSMutableArray array];
unsigned int methodCount = 0;
Method *methodList = class_copyMethodList(c, &methodCount);
unsigned int i;
for (i = 0; i < methodCount; i++) {
[array addObject:NSStringFromSelector(method_getName(methodList[i]))];
}
free(methodList);
return array;
}
static void PrintDescription(NSString *name, id obj) {
struct objc_object *objcet = (__bridge struct objc_object *)obj;
Class cls = objcet->isa;
NSString *str = [NSString stringWithFormat:@"%@: %@\n\tNSObject class %s\n\tlibobjc class %s : super class %s\n\timplements methods <%@>",
name,
obj,
class_getName([obj class]),
class_getName(cls),
class_getName(class_getSuperclass(cls)),
[ClassMethodNames(cls) componentsJoinedByString:@", "]];
printf("%s\n", [str UTF8String]);
}
// 测试代码
BankObject *bankInstance1 = [[BankObject alloc] init];
BankObject *bankInstance2 = [[BankObject alloc] init];
PersonObject *personInstance = [[PersonObject alloc] init];
[bankInstance2 addObserver:personInstance forKeyPath:@"accountBalance" options:NSKeyValueObservingOptionNew context:NULL];
PrintDescription(@"bankInstance1", bankInstance1);
PrintDescription(@"bankInstance2", bankInstance2);
printf("Using libobjc functions, normal setAccountBalance: is %p, overridden setAccountBalance: is %p", method_getImplementation(class_getInstanceMethod(object_getClass(bankInstance2), @selector(setAccountBalance:))),
method_getImplementation(class_getInstanceMethod(object_getClass(bankInstance1), @selector(setAccountBalance:))));

这段代码的输出如下:

1
2
3
4
5
6
7
8
9
10
11
bankInstance1: <BankObject: 0x7f9e8ae3cf60>
NSObject class BankObject
libobjc class BankObject : super class NSObject
implements methods <accountBalance, setAccountBalance:, bankCodeEn, setBankCodeEn:, departments, setDepartments:>
bankInstance2: <BankObject: 0x7f9e8ae3cfc0>
NSObject class BankObject
libobjc class NSKVONotifying_BankObject : super class BankObject
implements methods <setAccountBalance:, class, dealloc, _isKVOA>
Using libobjc functions, normal setAccountBalance: is 0x1013cec17, overridden setAccountBalance: is 0x10129fe50

从输出中可以看到,bankInstance2监听accountBalance属性后,其实际上所属的类已经不是BankObject了,而是继承自BankObject的NSKVONotifying_BankObject类。同时,NSKVONotifying_BankObject类重写了setAccountBalance方法,这个方法将实现如何通知观察者们的操作。当改变accountBalance属性时,就会调用被重写的setAccountBalance方法,并通过这个方法来发送通知。

另外我们也可以看到bankInstance2对象的打印[bankInstance2 class]时,返回的仍然是BankObject。这是苹果故意而为之,他们不希望这个机制暴露在外面。所以除了重写相应的setter,所以动态生成的NSKVONotifying_BankObject类还重写了class方法,让它返回原先的类。

小结

KVO作为Objective-C中两个对象间通信机制中的一种,提供了一种非常强大的机制。在经典的MVC架构中,控制器需要确保视图与模型的同步,当model对象改变时,视图应该随之改变以反映模型的变化;当用户和控制器交互的时候,模型也应该做出相应的改变。而KVO便为我们提供了这样一种同步机制:我们让控制器去监听一个model对象属性的改变,并根据这种改变来更新我们的视图。所有,有效地使用KVO,对我们应用的开发意义重大。

别话:对KVO的总结感觉还是意犹未尽,总感觉缺少点什么,特别是在对集合这一块的处理。还请大家多多提供指点。

参考

  1. NSKeyValueObserving Protocol Reference
  2. Key-Value Observing Programming Guide
  3. iOS-SDK-4.3-Framework-Header-Dump
  4. KVC 和 KVO
  5. Understanding Key-Value Observing and Coding
  6. KVO的内部实现

MBProgressHUD实现分析

发表于 2015-03-24   |   分类于 源码分析

源码来源:https://github.com/jdg/MBProgressHUD

版本:0.9.1

MBProgressHUD是一个显示HUD窗口的第三方类库,用于在执行一些后台任务时,在程序中显示一个表示进度的loading视图和两个可选的文本提示的HUD窗口。我想最多是应用在加载网络数据的时候。其实苹果官方自己有一个带有此功能的类UIProgressHUD,只不过它是私有的,现在不让用。至于实际的效果,可以看看github上工程给出的几张图例(貌似我这经常无法单独打开图片,所以就不在这贴图片了),也可以运行一下Demo。

具体用法我们就不多说了,参考github上的说明就能用得很顺的。本文主要还是从源码的角度来分析一下它的具体实现。

模式

在分析实现代码之前,我们先来看看MBProgressHUD中定义的MBProgressHUDMode枚举。它用来表示HUD窗口的模式,即我们从效果图中看到的几种显示样式。其具体定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
typedef enum {
// 使用UIActivityIndicatorView来显示进度,这是默认值
MBProgressHUDModeIndeterminate,
// 使用一个圆形饼图来作为进度视图
MBProgressHUDModeDeterminate,
// 使用一个水平进度条
MBProgressHUDModeDeterminateHorizontalBar,
// 使用圆环作为进度条
MBProgressHUDModeAnnularDeterminate,
// 显示一个自定义视图,通过这种方式,可以显示一个正确或错误的提示图
MBProgressHUDModeCustomView,
// 只显示文本
MBProgressHUDModeText
} MBProgressHUDMode;

通过设置MBProgressHUD的模式,我们可以使用MBProgressHUD自定义的表示进度的视图来满足我们的需求,也可以自定义这个进度视图,当然还可以只显示文本。在下面我们会讨论源码中是如何使用这几个值的。

外观

我们先来了解一下MBProgressHUD的基本组成。一个MBProgressHUD视图主要由四个部分组成:

  1. loading动画视图(在此做个统称,当然这个区域可以是自定义的一个UIImageView视图)。这个视图由我们设定的模式值决定,可以是菊花、进度条,也可以是我们自定义的视图;
  2. 标题文本框(label):主要用于显示提示的主题信息。这个文本框是可选的,通常位于loading动画视图的下面,且它是单行显示。它会根据labelText属性来自适应文本的大小(有一个长度上限),如果过长,则超出的部分会显示为”…”;
  3. 详情文本框(detailsLabel)。如果觉得标题不够详细,或者有附属信息,就可以将详细信息放在这里面显示。该文本框对应的是显示detailsLabelText属性的值,它是可以多行显示的。另外,详情的显示还依赖于labelText属性的设置,只有labelText属性被设置了,且不为空串,才会显示detailsLabel;
  4. HUD背景框。主要是作为上面三个部分的一个背景,用来突出上面三部分。

为了让我们更好地自定义这几个部分,MBProgressHUD还提供了一些属性,我们简单了解一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 背景框的透明度,默认值是0.8
@property (assign) float opacity;
// 背景框的颜色
// 需要注意的是如果设置了这个属性,则opacity属性会失效,即不会有半透明效果
@property (MB_STRONG) UIColor *color;
// 背景框的圆角半径。默认值是10.0
@property (assign) float cornerRadius;
// 标题文本的字体及颜色
@property (MB_STRONG) UIFont* labelFont;
@property (MB_STRONG) UIColor* labelColor;
// 详情文本的字体及颜色
@property (MB_STRONG) UIFont* detailsLabelFont;
@property (MB_STRONG) UIColor* detailsLabelColor;
// 菊花的颜色,默认是白色
@property (MB_STRONG) UIColor *activityIndicatorColor;

通过以上属性,我们可以根据自己的需要来设置这几个部分的外观。

另外还有一个比较有意思的属性是dimBackground,用于为HUD窗口的视图区域覆盖上一层径向渐变(radial gradient)层,其定义如下:

1
@property (assign) BOOL dimBackground;

让我们来看看通过它,MBProgressHUD都做了些什么。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
- (void)drawRect:(CGRect)rect {
...
if (self.dimBackground) {
//Gradient colours
size_t gradLocationsNum = 2;
CGFloat gradLocations[2] = {0.0f, 1.0f};
CGFloat gradColors[8] = {0.0f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0f,0.75f};
CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB();
CGGradientRef gradient = CGGradientCreateWithColorComponents(colorSpace, gradColors, gradLocations, gradLocationsNum);
CGColorSpaceRelease(colorSpace);
//Gradient center
CGPoint gradCenter= CGPointMake(self.bounds.size.width/2, self.bounds.size.height/2);
//Gradient radius
float gradRadius = MIN(self.bounds.size.width , self.bounds.size.height) ;
// 由中心向四周绘制渐变
CGContextDrawRadialGradient (context, gradient, gradCenter,
0, gradCenter, gradRadius,
kCGGradientDrawsAfterEndLocation);
CGGradientRelease(gradient);
}
...
}

这段代码由中心向MBProgressHUD视图的四周绘制了一个渐变层。当然,这里的颜色值是写死的,我们无法自行定义。有兴趣的话,大家可以将这个属性设置为YES,看看实际的效果。

创建、布局与绘制

除了继承自UIView的-initWithFrame:初始化方法,MBProgressHUD还为我们提供了两个初始化方法,如下所示:

1
2
- (id)initWithWindow:(UIWindow *)window;
- (id)initWithView:(UIView *)view;

这两个方法分别传入一个UIWindow对象和一个UIView对象。传入的视图对象仅仅是做为MBProgressHUD视图定义其frame属性的参照,而不会直接将MBProgressHUD视图添加到传入的视图对象上。这个添加操作还得我们自行处理(当然,MBProgressHUD还提供了几个便捷的类方法,我们下面会说明)。

MBProgressHUD提供了几个属性,可以让我们控制HUD的布局,这些属性主要有以下几个:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// HUD相对于父视图中心点的x轴偏移量和y轴偏移量
@property (assign) float xOffset;
@property (assign) float yOffset;
// HUD各元素与HUD边缘的间距
@property (assign) float margin;
// HUD背景框的最小大小
@property (assign) CGSize minSize;
// HUD的实际大小
@property (atomic, assign, readonly) CGSize size;
// 是否强制HUD背景框宽高相等
@property (assign, getter = isSquare) BOOL square;

需要注意的是,MBProgressHUD视图会充满其父视图的frame内,为此,在MBProgressHUD的layoutSubviews方法中,还专门做了处理,如下代码所示:

1
2
3
4
5
6
7
8
9
10
11
- (void)layoutSubviews {
[super layoutSubviews];
// Entirely cover the parent view
UIView *parent = self.superview;
if (parent) {
self.frame = parent.bounds;
}
...
}

也因此,当MBProgressHUD显示时,它也会屏蔽父视图的各种交互操作。

在布局的过程中,会先根据我们要显示的视图计算出容纳这些视图所需要的总的宽度和高度。当然,会设置一个最大值。我们截取其中一段来看看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
CGRect bounds = self.bounds;
...
CGFloat remainingHeight = bounds.size.height - totalSize.height - kPadding - 4 * margin;
CGSize maxSize = CGSizeMake(maxWidth, remainingHeight);
CGSize detailsLabelSize = MB_MULTILINE_TEXTSIZE(detailsLabel.text, detailsLabel.font, maxSize, detailsLabel.lineBreakMode);
totalSize.width = MAX(totalSize.width, detailsLabelSize.width);
totalSize.height += detailsLabelSize.height;
if (detailsLabelSize.height > 0.f && (indicatorF.size.height > 0.f || labelSize.height > 0.f)) {
totalSize.height += kPadding;
}
totalSize.width += 2 * margin;
totalSize.height += 2 * margin;

之后,就开始从上到下放置各个视图。在布局代码的最后,计算了一个size值,这是为后面绘制背景框做准备的。

在上面的布局代码中,主要是处理了loading动画视图、标题文本框和详情文本框,而HUD背景框主要是在drawRect:中来绘制的。背景框的绘制代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Center HUD
CGRect allRect = self.bounds;
// Draw rounded HUD backgroud rect
CGRect boxRect = CGRectMake(round((allRect.size.width - size.width) / 2) + self.xOffset,
round((allRect.size.height - size.height) / 2) + self.yOffset, size.width, size.height);
float radius = self.cornerRadius;
CGContextBeginPath(context);
CGContextMoveToPoint(context, CGRectGetMinX(boxRect) + radius, CGRectGetMinY(boxRect));
CGContextAddArc(context, CGRectGetMaxX(boxRect) - radius, CGRectGetMinY(boxRect) + radius, radius, 3 * (float)M_PI / 2, 0, 0);
CGContextAddArc(context, CGRectGetMaxX(boxRect) - radius, CGRectGetMaxY(boxRect) - radius, radius, 0, (float)M_PI / 2, 0);
CGContextAddArc(context, CGRectGetMinX(boxRect) + radius, CGRectGetMaxY(boxRect) - radius, radius, (float)M_PI / 2, (float)M_PI, 0);
CGContextAddArc(context, CGRectGetMinX(boxRect) + radius, CGRectGetMinY(boxRect) + radius, radius, (float)M_PI, 3 * (float)M_PI / 2, 0);
CGContextClosePath(context);
CGContextFillPath(context);

这是最平常的绘制操作,在此不多做解释。

我们上面讲过MBProgressHUD提供了几种窗口模式,这几种模式的主要区别在于loading动画视图的展示。默认情况下,使用的是菊花(MBProgressHUDModeIndeterminate)。我们可以通过设置以下属性,来改变loading动画视图:

1
@property (assign) MBProgressHUDMode mode;

对于其它几种模式,MBProgressHUD专门我们提供了几个视图类。如果是进度条模式(MBProgressHUDModeDeterminateHorizontalBar),则使用的是MBBarProgressView类;如果是饼图模式(MBProgressHUDModeDeterminate)或环形模式(MBProgressHUDModeAnnularDeterminate),则使用的是MBRoundProgressView类。上面这两个类的主要操作就是在drawRect:中根据一些进度参数来绘制形状,大家可以自己详细看一下。

当然,我们还可以自定义loading动画视图,此时选择的模式是MBProgressHUDModeCustomView。或者不显示loading动画视图,而只显示文本框(MBProgressHUDModeText)。

具体显示哪一种loading动画视图,是在-updateIndicators方法中来处理的,其实现如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
- (void)updateIndicators {
BOOL isActivityIndicator = [indicator isKindOfClass:[UIActivityIndicatorView class]];
BOOL isRoundIndicator = [indicator isKindOfClass:[MBRoundProgressView class]];
if (mode == MBProgressHUDModeIndeterminate) {
...
}
else if (mode == MBProgressHUDModeDeterminateHorizontalBar) {
// Update to bar determinate indicator
[indicator removeFromSuperview];
self.indicator = MB_AUTORELEASE([[MBBarProgressView alloc] init]);
[self addSubview:indicator];
}
else if (mode == MBProgressHUDModeDeterminate || mode == MBProgressHUDModeAnnularDeterminate) {
if (!isRoundIndicator) {
...
}
if (mode == MBProgressHUDModeAnnularDeterminate) {
[(MBRoundProgressView *)indicator setAnnular:YES];
}
}
else if (mode == MBProgressHUDModeCustomView && customView != indicator) {
...
} else if (mode == MBProgressHUDModeText) {
...
}
}

显示与隐藏

MBRoundProgressView为我们提供了丰富的显示与隐藏HUD窗口的。在分析这些方法之前,我们先来看看MBProgressHUD为显示与隐藏提供的一些属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// HUD显示和隐藏的动画类型
@property (assign) MBProgressHUDAnimation animationType;
// HUD显示的最短时间。设置这个值是为了避免HUD显示后立即被隐藏。默认值为0
@property (assign) float minShowTime;
// 这个属性设置了一个宽限期,它是在没有显示HUD窗口前被调用方法可能运行的时间。
// 如果被调用方法在宽限期内执行完,则HUD不会被显示。
// 这主要是为了避免在执行很短的任务时,去显示一个HUD窗口。
// 默认值是0。只有当任务状态是已知时,才支持宽限期。具体我们看实现代码。
@property (assign) float graceTime;
// 这是一个标识位,标明执行的操作正在处理中。这个属性是配合graceTime使用的。
// 如果没有设置graceTime,则这个标识是没有太大意义的。在使用showWhileExecuting:onTarget:withObject:animated:方法时,
// 会自动去设置这个属性为YES,其它情况下都需要我们自己手动设置。
@property (assign) BOOL taskInProgress;
// 隐藏时是否将HUD从父视图中移除,默认是NO。
@property (assign) BOOL removeFromSuperViewOnHide;
// 进度指示器,从0.0到1.0,默认值为0.0
@property (assign) float progress;
// 在HUD被隐藏后的回调
@property (copy) MBProgressHUDCompletionBlock completionBlock;

以上这些属性都还好理解,可能需要注意的就是graceTime和taskInProgress的配合使用。在下面我们将会看看这两个属性的用法。

对于显示操作,最基本的就是-show:方法(其它几个显示方法都会调用该方法来显示HUD窗口),我们先来看看它的实现,

1
2
3
4
5
6
7
8
9
10
11
12
- (void)show:(BOOL)animated {
useAnimation = animated;
// If the grace time is set postpone the HUD display
if (self.graceTime > 0.0) {
self.graceTimer = [NSTimer scheduledTimerWithTimeInterval:self.graceTime target:self
selector:@selector(handleGraceTimer:) userInfo:nil repeats:NO];
}
// ... otherwise show the HUD imediately
else {
[self showUsingAnimation:useAnimation];
}
}

可以看到,如果我们没有设置graceTime属性,则会立即显示HUD;而如果设置了graceTime,则会创建一个定时器,并让显示操作延迟到graceTime所设定的时间再执行,而-handleGraceTimer:实现如下:

1
2
3
4
5
6
- (void)handleGraceTimer:(NSTimer *)theTimer {
// Show the HUD only if the task is still running
if (taskInProgress) {
[self showUsingAnimation:useAnimation];
}
}

可以看到,只有在设置了taskInProgress标识位为YES的情况下,才会去显示HUD窗口。所以,如果我们要自己调用-show:方法的话,需要酌情考虑设置taskInProgress标识位。

除了-show:方法以外,MBProgressHUD还为我们提供了一组显示方法,可以让我们在显示HUD的同时,执行一些后台任务,我们在此主要介绍两个。其中一个是-showWhileExecuting:onTarget:withObject:animated:,它是基于target-action方式的调用,在执行一个后台任务时显示HUD,等后台任务执行完成后再隐藏HUD,具体实现如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
- (void)showWhileExecuting:(SEL)method onTarget:(id)target withObject:(id)object animated:(BOOL)animated {
methodForExecution = method;
targetForExecution = MB_RETAIN(target);
objectForExecution = MB_RETAIN(object);
// Launch execution in new thread
self.taskInProgress = YES;
[NSThread detachNewThreadSelector:@selector(launchExecution) toTarget:self withObject:nil];
// Show HUD view
[self show:animated];
}
- (void)launchExecution {
@autoreleasepool {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
// Start executing the requested task
[targetForExecution performSelector:methodForExecution withObject:objectForExecution];
#pragma clang diagnostic pop
// Task completed, update view in main thread (note: view operations should
// be done only in the main thread)
[self performSelectorOnMainThread:@selector(cleanUp) withObject:nil waitUntilDone:NO];
}
}

可以看到,-showWhileExecuting:onTarget:withObject:animated:首先将taskInProgress属性设置为YES,这样在调用-show:方法时,即使设置了graceTime,也确保能在任务完成之前显示HUD。然后开启一个新线程,来异步执行我们的后台任务,最后去显示HUD。

而在异步调用方法-launchExecution中,线程首先是维护了自己的一个@autoreleasepool,所以在我们自己的方法中,就不需要再去维护一个@autoreleasepool了。之后是去执行我们的任务,在任务完成之后,再回去主线程去执行清理操作,并隐藏HUD窗口。

另一个显示方法是-showAnimated:whileExecutingBlock:onQueue:completionBlock:,它是基于GCD的调用,当block中的任务在指定的队列中执行时,显示HUD窗口,任务完成之后执行completionBlock操作,最后隐藏HUD窗口。我们来看看它的具体实现:

1
2
3
4
5
6
7
8
9
10
11
12
- (void)showAnimated:(BOOL)animated whileExecutingBlock:(dispatch_block_t)block onQueue:(dispatch_queue_t)queue
completionBlock:(MBProgressHUDCompletionBlock)completion {
self.taskInProgress = YES;
self.completionBlock = completion;
dispatch_async(queue, ^(void) {
block();
dispatch_async(dispatch_get_main_queue(), ^(void) {
[self cleanUp];
});
});
[self show:animated];
}

这个方法也是首先将taskInProgress属性设置为YES,然后开启一个线程去执行block任务,最后主线程去执行清理操作,并隐藏HUD窗口。

对于HUD的隐藏,MBProgressHUD提供了两个方法,一个是-hide:,另一个是-hide:afterDelay:,后者基于前者,所以我们主要来看看-hide:的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
- (void)hide:(BOOL)animated {
useAnimation = animated;
// If the minShow time is set, calculate how long the hud was shown,
// and pospone the hiding operation if necessary
if (self.minShowTime > 0.0 && showStarted) {
NSTimeInterval interv = [[NSDate date] timeIntervalSinceDate:showStarted];
if (interv < self.minShowTime) {
self.minShowTimer = [NSTimer scheduledTimerWithTimeInterval:(self.minShowTime - interv) target:self
selector:@selector(handleMinShowTimer:) userInfo:nil repeats:NO];
return;
}
}
// ... otherwise hide the HUD immediately
[self hideUsingAnimation:useAnimation];
}

我们可以看到,在设置了minShowTime属性并且已经显示了HUD窗口的情况下,会去判断显示的时间是否小于minShowTime指定的时间,如果是,则会开启一个定时器,等到显示的时间到了minShowTime所指定的时间,才会去隐藏HUD窗口;否则会直接去隐藏HUD窗口。

隐藏的实际操作主要是去做了些清理操作,包括根据设定的removeFromSuperViewOnHide值来执行是否从父视图移除HUD窗口,以及执行completionBlock操作,还有就是执行代理的hudWasHidden:方法。这些操作是在私有方法-done里面执行的,实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
- (void)done {
[NSObject cancelPreviousPerformRequestsWithTarget:self];
isFinished = YES;
self.alpha = 0.0f;
if (removeFromSuperViewOnHide) {
[self removeFromSuperview];
}
#if NS_BLOCKS_AVAILABLE
if (self.completionBlock) {
self.completionBlock();
self.completionBlock = NULL;
}
#endif
if ([delegate respondsToSelector:@selector(hudWasHidden:)]) {
[delegate performSelector:@selector(hudWasHidden:) withObject:self];
}
}

其它

MBProgressHUD的一些主要的代码差不多已经分析完了,最后还有些边边角角的地方,一起来看看。

显示和隐藏的便捷方法

除了上面描述的实例方法之外,MBProgressHUD还为我们提供了几个便捷显示和隐藏HUD窗口的方法,如下所示:

1
2
3
+ (MB_INSTANCETYPE)showHUDAddedTo:(UIView *)view animated:(BOOL)animated
+ (BOOL)hideHUDForView:(UIView *)view animated:(BOOL)animated
+ (NSUInteger)hideAllHUDsForView:(UIView *)view animated:(BOOL)animated

方法的签名已经很能说明问题了,在此不多描述。

部分属性值的设置

对于部分属性(主要是”外观”一节中针对菊花、标题文本框和详情文本框的几个属性值),为了在设置将这些属性时修改对应视图的属性,并没有直接为每个属性生成一个setter,而是通过KVO来监听这些属性值的变化,再将这些值赋值给视图的对应属性,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 监听的属性数组
- (NSArray *)observableKeypaths {
return [NSArray arrayWithObjects:@"mode", @"customView", @"labelText", @"labelFont", @"labelColor", ..., nil];
}
// 注册KVO
- (void)registerForKVO {
for (NSString *keyPath in [self observableKeypaths]) {
[self addObserver:self forKeyPath:keyPath options:NSKeyValueObservingOptionNew context:NULL];
}
}
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context {
if (![NSThread isMainThread]) {
[self performSelectorOnMainThread:@selector(updateUIForKeypath:) withObject:keyPath waitUntilDone:NO];
} else {
[self updateUIForKeypath:keyPath];
}
}
- (void)updateUIForKeypath:(NSString *)keyPath {
if ([keyPath isEqualToString:@"mode"] || [keyPath isEqualToString:@"customView"] ||
[keyPath isEqualToString:@"activityIndicatorColor"]) {
[self updateIndicators];
} else if ([keyPath isEqualToString:@"labelText"]) {
label.text = self.labelText;
}
...
[self setNeedsLayout];
[self setNeedsDisplay];
}

代理

MBProgressHUD还为我们提供了一个代理MBProgressHUDDelegate,这个代理中只提供了一个方法,即:

1
- (void)hudWasHidden:(MBProgressHUD *)hud;

这个代理方法是在隐藏HUD窗口后调用,如果此时我们需要在我们自己的实现中执行某些操作,则可以实现这个方法。

问题

MBProgressHUD为我们提供了一个HUD窗口的很好的实现,不过个人在使用过程中,觉得它给我们提供的交互功能太少。其代理只提供了一个-hudWasHidden:方法,而且我们也无法通过点击HUD来执行一些操作。在现实的需求中,可能存在这种情况:比如一个网络操作,在发送请求等待响应的过程中,我们会显示一个HUD窗口以显示一个loading框。但如果我们想在等待响应的过程中,在当前视图中取消这个网络请求,就没有相应的处理方式,MBProgressHUD没有为我们提供这样的交互操作。当然这时候,我们可以根据自己的需求来修改源码。

与SVProgressHUD的对比

与MBProgressHUD类似,SVProgressHUD类库也为我们提供了在视图中显示一个HUD窗口的功能。两者的基本思路是差不多的,差别更多的是在实现细节上。相对于MBProgressHUD来说,SVProgressHUD的实现有以下几点不同:

  1. SVProgressHUD类对外提供的都是类方法,包括显示、隐藏、和视图属性设置都是使用类方法来操作。其内部实现为一个单例对象,类方法实际是针对这个单例对象来操作的。
  2. SVProgressHUD主要包含三部分:loading视图、提示文本框和背景框,没有详情文本框。
  3. SVProgressHUD默认提供了正确、错误和信息三种状态视图(与loading视图同一位置,根据需要来设置)。当然MBProgressHUD中,也可以自定义视图(customView)来显示相应的状态视图。
  4. SVProgressHUD为我们提供了更多的交互操作,包括点击事件、显示事件及隐藏事件。不过这些都是通过通知的形式向外发送,所以我们需要自己去监听这些事件。
  5. SVProgressHUD中一些loading动画是以Layer动画的形式来实现的。

SVProgressHUD的实现细节还未详细去看,有兴趣的读者可以去研究一下。这两个HUD类库各有优点,大家在使用时,可根据自己的需要和喜好来选择。

小结

总体来说,MBProgressHUD的代码相对朴实,简单易懂,没有什么花哨难懂的东西。就技术点而言,也没有太多复杂的技术,都是我们常用的一些东西。就使用而言,也是挺方便的,参考一下github上的使用指南就能很快上手。

Foundation: NSNotificationCenter

发表于 2015-03-20   |   分类于 Cocoa

一个NSNotificationCenter对象(通知中心)提供了在程序中广播消息的机制,它实质上就是一个通知分发表。这个分发表负责维护为各个通知注册的观察者,并在通知到达时,去查找相应的观察者,将通知转发给他们进行处理。

本文主要了整理了一下NSNotificationCenter的使用及需要注意的一些问题,并提出了一些未解决的问题,希望能在此得到解答。

获取通知中心

每个程序都会有一个默认的通知中心。为此,NSNotificationCenter提供了一个类方法来获取这个通知中心:

1
+ (NSNotificationCenter *)defaultCenter

获取了这个默认的通知中心对象后,我们就可以使用它来处理通知相关的操作了,包括注册观察者,移除观察者、发送通知等。

通常如果不是出于必要,我们一般都使用这个默认的通知中心,而不自己创建维护一个通知中心。

添加观察者

如果想让对象监听某个通知,则需要在通知中心中将这个对象注册为通知的观察者。早先,NSNotificationCenter提供了以下方法来添加观察者:

1
2
3
4
- (void)addObserver:(id)notificationObserver
selector:(SEL)notificationSelector
name:(NSString *)notificationName
object:(id)notificationSender

这个方法带有4个参数,分别指定了通知的观察者、处理通知的回调、通知名及通知的发送对象。这里需要注意几个问题:

  1. notificationObserver不能为nil。
  2. notificationSelector回调方法有且只有一个参数(NSNotification对象)。
  3. 如果notificationName为nil,则会接收所有的通知(如果notificationSender不为空,则接收所有来自于notificationSender的所有通知)。如代码清单1所示。
  4. 如果notificationSender为nil,则会接收所有notificationName定义的通知;否则,接收由notificationSender发送的通知。
  5. 监听同一条通知的多个观察者,在通知到达时,它们执行回调的顺序是不确定的,所以我们不能去假设操作的执行会按照添加观察者的顺序来执行。

对于以上几点,我们来重点关注一下第3条。以下代码演示了当我们的notificationName设置为nil时,通知的监听情况。

代码清单1:添加一个Observer,其中notificationName为nil

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleNotification:) name:nil object:nil];
[[NSNotificationCenter defaultCenter] postNotificationName:TEST_NOTIFICATION object:nil];
}
- (void)handleNotification:(NSNotification *)notification
{
NSLog(@"notification = %@", notification.name);
}
@end

运行后的输出结果如下:

1
2
3
4
5
6
7
8
9
10
notification = TestNotification
notification = UIWindowDidBecomeVisibleNotification
notification = UIWindowDidBecomeKeyNotification
notification = UIApplicationDidFinishLaunchingNotification
notification = _UIWindowContentWillRotateNotification
notification = _UIApplicationWillAddDeactivationReasonNotification
notification = _UIApplicationDidRemoveDeactivationReasonNotification
notification = UIDeviceOrientationDidChangeNotification
notification = _UIApplicationDidRemoveDeactivationReasonNotification
notification = UIApplicationDidBecomeActiveNotification

可以看出,我们的对象基本上监听了测试程序启动后的所示消息。当然,我们很少会去这么做。

而对于第4条,使用得比较多的场景是监听UITextField的修改事件,通常我们在一个ViewController中,只希望去监听当前视图中的UITextField修改事件,而不希望监听所有UITextField的修改事件,这时我们就可以将当前页面的UITextField对象指定为notificationSender。

在iOS 4.0之后,NSNotificationCenter为了跟上时代,又提供了一个以block方式实现的添加观察者的方法,如下所示:

1
2
3
4
- (id<NSObject>)addObserverForName:(NSString *)name
object:(id)obj
queue:(NSOperationQueue *)queue
usingBlock:(void (^)(NSNotification *note))block

大家第一次看到这个方法时是否会有这样的疑问:观察者呢?参数中并没有指定具体的观察者,那谁是观察者呢?实际上,与前一个方法不同的是,前者使用一个现存的对象作为观察者,而这个方法会创建一个匿名的对象作为观察者(即方法返回的id<NSObject>对象),这个匿名对象会在指定的队列(queue)上去执行我们的block。

这个方法的优点在于添加观察者的操作与回调处理操作的代码更加紧凑,不需要拼命滚动鼠标就能直接找到处理代码,简单直观。这个方法也有几个地方需要注意:

  1. name和obj为nil时的情形与前面一个方法是相同的。
  2. 如果queue为nil,则消息是默认在post线程中同步处理,即通知的post与转发是在同一线程中;但如果我们指定了操作队列,情况就变得有点意思了,我们一会再讲。
  3. block块会被通知中心拷贝一份(执行copy操作),以在堆中维护一个block对象,直到观察者被从通知中心中移除。所以,应该特别注意在block中使用外部对象,避免出现对象的循环引用,这个我们在下面将举例说明。
  4. 如果一个给定的通知触发了多个观察者的block操作,则这些操作会在各自的Operation Queue中被并发执行。所以我们不能去假设操作的执行会按照添加观察者的顺序来执行。
  5. 该方法会返回一个表示观察者的对象,记得在不用时释放这个对象。

下面我们重点说明一下第2点和第3点。

关于第2点,当我们指定一个Operation Queue时,不管通知是在哪个线程中post的,都会在Operation Queue所属的线程中进行转发,如代码清单2所示:

代码清单2:在指定队列中接收通知

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
[[NSNotificationCenter defaultCenter] addObserverForName:TEST_NOTIFICATION object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *note) {
NSLog(@"receive thread = %@", [NSThread currentThread]);
}];
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
NSLog(@"post thread = %@", [NSThread currentThread]);
[[NSNotificationCenter defaultCenter] postNotificationName:TEST_NOTIFICATION object:nil];
});
}
@end

在这里,我们在主线程里添加了一个观察者,并指定在主线程队列中去接收处理这个通知。然后我们在一个全局队列中post了一个通知。我们来看下输出结果:

1
2
post thread = <NSThread: 0x7ffe0351f5f0>{number = 2, name = (null)}
receive thread = <NSThread: 0x7ffe03508b30>{number = 1, name = main}

可以看到,消息的post与接收处理并不是在同一个线程中。如上面所提到的,如果queue为nil,则消息是默认在post线程中同步处理,大家可以试一下。

对于第3点,由于使用的是block,所以需要注意的就是避免引起循环引用的问题,如代码清单3所示:

代码清单3:block引发的循环引用问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
@interface Observer : NSObject
@property (nonatomic, assign) NSInteger i;
@property (nonatomic, weak) id<NSObject> observer;
@end
@implementation Observer
- (instancetype)init
{
self = [super init];
if (self)
{
NSLog(@"Init Observer");
// 添加观察者
_observer = [[NSNotificationCenter defaultCenter] addObserverForName:TEST_NOTIFICATION object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *note) {
NSLog(@"handle notification");
// 使用self
self.i = 10;
}];
}
return self;
}
@end
#pragma mark - ViewController
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
[self createObserver];
// 发送消息
[[NSNotificationCenter defaultCenter] postNotificationName:TEST_NOTIFICATION object:nil];
}
- (void)createObserver {
Observer *observer = [[Observer alloc] init];
}
@end

运行后的输出如下:

1
2
Init Observer
handle notification

我们可以看到createObserver中创建的observer并没有被释放。所以,使用addObserverForName:object:queue:usingBlock:一定要注意这个问题。

移除观察者

与注册观察者相对应的,NSNotificationCenter为我们提供了两个移除观察者的方法。它们的定义如下:

1
2
3
- (void)removeObserver:(id)notificationObserver
- (void)removeObserver:(id)notificationObserver name:(NSString *)notificationName object:(id)notificationSender

前一个方法会将notificationObserver从通知中心中移除,这样notificationObserver就无法再监听任何消息。而后一个会根据三个参数来移除相应的观察者。

这两个方法也有几点需要注意:

  1. 由于注册观察者时(不管是哪个方法),通知中心会维护一个观察者的弱引用,所以在释放对象时,要确保移除对象所有监听的通知。否则,可能会导致程序崩溃或一些莫名其妙的问题。

  2. 对于第二个方法,如果notificationName为nil,则会移除所有匹配notificationObserver和notificationSender的通知,同理notificationSender也是一样的。而如果notificationName和notificationSender都为nil,则其效果就与第一个方法是一样的了。大家可以试一下。

  3. 最有趣的应该是这两个方法的使用时机。–removeObserver:适合于在类的dealloc方法中调用,这样可以确保将对象从通知中心中清除;而在viewWillDisappear:这样的方法中,则适合于使用-removeObserver:name:object:方法,以避免不知情的情况下移除了不应该移除的通知观察者。例如,假设我们的ViewController继承自一个类库的某个ViewController类(假设为SKViewController吧),可能SKViewController自身也监听了某些通知以执行特定的操作,但我们使用时并不知道。如果直接在viewWillDisappear:中调用–removeObserver:,则也会把父类监听的通知也给移除。

    ​

    关于注册监听者,还有一个需要注意的问题是,每次调用addObserver时,都会在通知中心重新注册一次,即使是同一对象监听同一个消息,而不是去覆盖原来的监听。这样,当通知中心转发某一消息时,如果同一对象多次注册了这个通知的观察者,则会收到多个通知,如代码清单4所示:

代码清单4:同一对象多次注册同一消息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleNotification:) name:TEST_NOTIFICATION object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleNotification:) name:TEST_NOTIFICATION object:nil];
[[NSNotificationCenter defaultCenter] postNotificationName:TEST_NOTIFICATION object:nil];
}
- (void)handleNotification:(NSNotification *)notification
{
NSLog(@"notification = %@", notification.name);
}
@end

其输出结果如下所示:

1
2
notification = TestNotification
notification = TestNotification

可以看到对象处理了两次通知。所以,如果我们需要在viewWillAppear监听一个通知时,一定要记得在对应的viewWillDisappear里面将观察者移除,否则就可能会出现上面的情况。

最后,再特别重点强调的非常重要的一点是,在释放对象前,一定要记住如果它监听了通知,一定要将它从通知中心移除。如果是用addObserverForName:object:queue:usingBlock:,也记得一定得移除这个匿名观察者。说白了就一句话,添加和移除要配对出现。

post消息

注册了通知观察者,我们便可以随时随地的去post一个通知了(当然,如果闲着没事,也可以不注册观察者,post通知随便玩,只是没人理睬罢了)。NSNotificationCenter提供了三个方法来post一个通知,如下所示:

1
2
3
- postNotification:
– postNotificationName:object:
– postNotificationName:object:userInfo:

我们可以根据需要指定通知的发送者(object)并附带一些与通知相关的信息(userInfo),当然这些发送者和userInfo可以封装在一个NSNotification对象中,由- postNotification:来发送。注意,- postNotification:的参数不能为空,否则会引发一个异常,如下所示:

1
*** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: '*** -[NSNotificationCenter postNotification:]: notification is nil'

每次post一个通知时,通知中心都会去遍历一下它的分发表,然后将通知转发给相应的观察者。

另外,通知的发送与处理是同步的,在某个地方post一个消息时,会等到所有观察者对象执行完处理操作后,才回到post的地方,继续执行后面的代码。如代码清单5所示:

代码清单5:通知的同步处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleNotification:) name:TEST_NOTIFICATION object:nil];
[[NSNotificationCenter defaultCenter] postNotificationName:TEST_NOTIFICATION object:nil];
NSLog(@"continue");
}
- (void)handleNotification:(NSNotification *)notification
{
NSLog(@"handle notification");
}
@end

运行后输出结果是:

1
2
handle notification
continue

一些思考

翻了好些资料,还有两个问题始终没有明确的答案。

首先就是通知中心是如何维护观察者对象的。可以明确的是,添加观察者时,通知中心没有对观察者做retain操作,即不会使观察者的引用计数加1。那通知中心维护的是观察者的weak引用呢还是unsafe_unretained引用呢?

个人认为可能是unsafe_unretained的引用,因为我们知道如果是weak引用,其所指的对象被释放后,这个引用会被置成nil。而实际情况是通知中心还会给这个对象发送消息,并引发一个异常。而如果向nil发送一个消息是不会导致异常的。

【非常感谢 @lv-pw,上面这个问题在《斯坦福大学公开课:iOS 7应用开发》的第5集的第57分50秒中得到了解答:确实使用的是unsafe_unretained,老师的解释是,之所以使用unsafe_unretained,而不使用weak,是为了兼容老版本的系统。】

另外,我们知道NSNotificationCenter实现的是观察者模式,而且通常情况下消息在哪个线程被post,就在哪个线程被转发。而从上面的描述可以发现,

-addObserverForName:object:queue:usingBlock:添加的匿名观察者可以在指定的队列中处理通知,那它的实现机制是什么呢?

小结

在我们的应用程序中,一个大的话题就是两个对象之间如何通信。我们需要根据对象之间的关系来确定采用哪一种通信方式。对象之间的通信方式主要有以下几种:

  1. 直接方法调用
  2. Target-Action
  3. Delegate
  4. 回调(block)
  5. KVO
  6. 通知

一般情况下,我们可以根据以下两点来确定使用哪种方式:

  1. 通信对象是一对一的还是一对多的
  2. 对象之间的耦合度,是强耦合还是松耦合

Objective-C中的通知由于其广播性及松耦合性,非常适合于大的范围内对象之间的通信(模块与模块,或一些框架层级)。通知使用起来非常方便,也正因为如此,所以容易导致滥用。所以在使用前还是需要多想想,是否有更好的方法来实现我们所需要的对象间通信。毕竟,通知机制会在一定程度上会影响到程序的性能。

对于使用NSNotificationCenter,最后总结一些小建议:

  1. 在需要的地方使用通知。
  2. 注册的观察者在不使用时一定要记得移除,即添加和移除要配对出现。
  3. 尽可能迟地去注册一个观察者,并尽可能早将其移除,这样可以改善程序的性能。因为,每post一个通知,都会是遍历通知中心的分发表,确保通知发给每一个观察者。
  4. 记住通知的发送和处理是在同一个线程中。
  5. 使用-addObserverForName:object:queue:usingBlock:务必处理好内存问题,避免出现循环引用。
  6. NSNotificationCenter是线程安全的,但并不意味着在多线程环境中不需要关注线程安全问题。不恰当的使用仍然会引发线程问题。

最后,“@叶孤城___”叶大大在微博中推荐了几篇文章,即参考中的4-7,值得细读一下。

参考

  1. NSNotificationCenter Class Reference
  2. Notification Programming Topics
  3. NSNotification & NSNotification​Center
  4. NSNotificationCenter part 1: Receiving and sending notifications
  5. NSNotificationCenter part 2: Implementing the observer pattern with notifications
  6. NSNotificationCenter part 3: Unit testing notifications with OCMock
  7. NSNotificationCenter part 4: Asynchronous notifications with NSNotificationQueue
  8. View controller dealloc not called when using NSNotificationCenter code block method with ARC
  9. 《斯坦福大学公开课:iOS 7应用开发》第5集

Notification与多线程

发表于 2015-03-14   |   分类于 Cocoa

前几天与同事讨论到Notification在多线程下的转发问题,所以就此整理一下。

先来看看官方的文档,是这样写的:

In a multithreaded application, notifications are always delivered in the thread in which the notification was posted, which may not be the same thread in which an observer registered itself.

翻译过来是:

在多线程应用中,Notification在哪个线程中post,就在哪个线程中被转发,而不一定是在注册观察者的那个线程中。

也就是说,Notification的发送与接收处理都是在同一个线程中。为了说明这一点,我们先来看一个示例:

代码清单1:Notification的发送与处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
NSLog(@"current thread = %@", [NSThread currentThread]);
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleNotification:) name:TEST_NOTIFICATION object:nil];
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
[[NSNotificationCenter defaultCenter] postNotificationName:TEST_NOTIFICATION object:nil userInfo:nil];
});
}
- (void)handleNotification:(NSNotification *)notification
{
NSLog(@"current thread = %@", [NSThread currentThread]);
NSLog(@"test notification");
}
@end

其输出结果如下:

1
2
3
2015-03-11 22:05:12.856 test[865:45102] current thread = <NSThread: 0x7fbb23412f30>{number = 1, name = main}
2015-03-11 22:05:12.857 test[865:45174] current thread = <NSThread: 0x7fbb23552370>{number = 2, name = (null)}
2015-03-11 22:05:12.857 test[865:45174] test notification

可以看到,虽然我们在主线程中注册了通知的观察者,但在全局队列中post的Notification,并不是在主线程处理的。所以,这时候就需要注意,如果我们想在回调中处理与UI相关的操作,需要确保是在主线程中执行回调。

这时,就有一个问题了,如果我们的Notification是在二级线程中post的,如何能在主线程中对这个Notification进行处理呢?或者换个提法,如果我们希望一个Notification的post线程与转发线程不是同一个线程,应该怎么办呢?我们看看官方文档是怎么说的:

For example, if an object running in a background thread is listening for notifications from the user interface, such as a window closing, you would like to receive the notifications in the background thread instead of the main thread. In these cases, you must capture the notifications as they are delivered on the default thread and redirect them to the appropriate thread.

这里讲到了“重定向”,就是我们在Notification所在的默认线程中捕获这些分发的通知,然后将其重定向到指定的线程中。

一种重定向的实现思路是自定义一个通知队列(注意,不是NSNotificationQueue对象,而是一个数组),让这个队列去维护那些我们需要重定向的Notification。我们仍然是像平常一样去注册一个通知的观察者,当Notification来了时,先看看post这个Notification的线程是不是我们所期望的线程,如果不是,则将这个Notification存储到我们的队列中,并发送一个信号(signal)到期望的线程中,来告诉这个线程需要处理一个Notification。指定的线程在收到信号后,将Notification从队列中移除,并进行处理。

官方文档已经给出了示例代码,在此借用一下,以测试实际结果:

代码清单2:在不同线程中post和转发一个Notification

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
@interface ViewController () <NSMachPortDelegate>
@property (nonatomic) NSMutableArray *notifications; // 通知队列
@property (nonatomic) NSThread *notificationThread; // 期望线程
@property (nonatomic) NSLock *notificationLock; // 用于对通知队列加锁的锁对象,避免线程冲突
@property (nonatomic) NSMachPort *notificationPort; // 用于向期望线程发送信号的通信端口
@end
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
NSLog(@"current thread = %@", [NSThread currentThread]);
// 初始化
self.notifications = [[NSMutableArray alloc] init];
self.notificationLock = [[NSLock alloc] init];
self.notificationThread = [NSThread currentThread];
self.notificationPort = [[NSMachPort alloc] init];
self.notificationPort.delegate = self;
// 往当前线程的run loop添加端口源
// 当Mach消息到达而接收线程的run loop没有运行时,则内核会保存这条消息,直到下一次进入run loop
[[NSRunLoop currentRunLoop] addPort:self.notificationPort
forMode:(__bridge NSString *)kCFRunLoopCommonModes];
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(processNotification:) name:@"TestNotification" object:nil];
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
[[NSNotificationCenter defaultCenter] postNotificationName:TEST_NOTIFICATION object:nil userInfo:nil];
});
}
- (void)handleMachMessage:(void *)msg {
[self.notificationLock lock];
while ([self.notifications count]) {
NSNotification *notification = [self.notifications objectAtIndex:0];
[self.notifications removeObjectAtIndex:0];
[self.notificationLock unlock];
[self processNotification:notification];
[self.notificationLock lock];
};
[self.notificationLock unlock];
}
- (void)processNotification:(NSNotification *)notification {
if ([NSThread currentThread] != _notificationThread) {
// Forward the notification to the correct thread.
[self.notificationLock lock];
[self.notifications addObject:notification];
[self.notificationLock unlock];
[self.notificationPort sendBeforeDate:[NSDate date]
components:nil
from:nil
reserved:0];
}
else {
// Process the notification here;
NSLog(@"current thread = %@", [NSThread currentThread]);
NSLog(@"process notification");
}
}
@end

运行后,其输出如下:

1
2
3
2015-03-11 23:38:31.637 test[1474:92483] current thread = <NSThread: 0x7ffa4070ed50>{number = 1, name = main}
2015-03-11 23:38:31.663 test[1474:92483] current thread = <NSThread: 0x7ffa4070ed50>{number = 1, name = main}
2015-03-11 23:38:31.663 test[1474:92483] process notification

可以看到,我们在全局dispatch队列中抛出的Notification,如愿地在主线程中接收到了。

这种实现方式的具体解析及其局限性大家可以参考官方文档Delivering Notifications To Particular Threads,在此不多做解释。当然,更好的方法可能是我们自己去子类化一个NSNotificationCenter,或者单独写一个类来处理这种转发。

NSNotificationCenter的线程安全性

苹果之所以采取通知中心在同一个线程中post和转发同一消息这一策略,应该是出于线程安全的角度来考量的。官方文档告诉我们,NSNotificationCenter是一个线程安全类,我们可以在多线程环境下使用同一个NSNotificationCenter对象而不需要加锁。原文在Threading Programming Guide中,具体如下:

1
2
3
4
5
6
7
The following classes and functions are generally considered to be thread-safe. You can use the same instance from multiple threads without first acquiring a lock.
NSArray
...
NSNotification
NSNotificationCenter
...

我们可以在任何线程中添加/删除通知的观察者,也可以在任何线程中post一个通知。

NSNotificationCenter在线程安全性方面已经做了不少工作了,那是否意味着我们可以高枕无忧了呢?再回过头来看看第一个例子,我们稍微改造一下,一点一点来:

代码清单3:NSNotificationCenter的通用模式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
@interface Observer : NSObject
@end
@implementation Observer
- (instancetype)init
{
self = [super init];
if (self)
{
_poster = [[Poster alloc] init];
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleNotification:) name:TEST_NOTIFICATION object:nil]
}
return self;
}
- (void)handleNotification:(NSNotification *)notification
{
NSLog(@"handle notification ");
}
- (void)dealloc
{
[[NSNotificationCenter defaultCenter] removeObserver:self];
}
@end
// 其它地方
[[NSNotificationCenter defaultCenter] postNotificationName:TEST_NOTIFICATION object:nil];
```
上面的代码就是我们通常所做的事情:添加一个通知监听者,定义一个回调,并在所属对象释放时移除监听者;然后在程序的某个地方post一个通知。简单明了,如果这一切都是发生在一个线程里面,或者至少dealloc方法是在-postNotificationName:的线程中运行的(注意:NSNotification的post和转发是同步的),那么都OK,没有线程安全问题。但如果dealloc方法和-postNotificationName:方法不在同一个线程中运行时,会出现什么问题呢?
我们再改造一下上面的代码:
**代码清单4:NSNotificationCenter引发的线程安全问题**
```objc
#pragma mark - Poster
@interface Poster : NSObject
@end
@implementation Poster
- (instancetype)init
{
self = [super init];
if (self)
{
[self performSelectorInBackground:@selector(postNotification) withObject:nil];
}
return self;
}
- (void)postNotification
{
[[NSNotificationCenter defaultCenter] postNotificationName:TEST_NOTIFICATION object:nil];
}
@end
#pragma mark - Observer
@interface Observer : NSObject
{
Poster *_poster;
}
@property (nonatomic, assign) NSInteger i;
@end
@implementation Observer
- (instancetype)init
{
self = [super init];
if (self)
{
_poster = [[Poster alloc] init];
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleNotification:) name:TEST_NOTIFICATION object:nil];
}
return self;
}
- (void)handleNotification:(NSNotification *)notification
{
NSLog(@"handle notification begin");
sleep(1);
NSLog(@"handle notification end");
self.i = 10;
}
- (void)dealloc
{
[[NSNotificationCenter defaultCenter] removeObserver:self];
NSLog(@"Observer dealloc");
}
@end
#pragma mark - ViewController
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
__autoreleasing Observer *observer = [[Observer alloc] init];
}
@end

这段代码是在主线程添加了一个TEST_NOTIFICATION通知的监听者,并在主线程中将其移除,而我们的NSNotification是在后台线程中post的。在通知处理函数中,我们让回调所在的线程睡眠1秒钟,然后再去设置属性i值。这时会发生什么呢?我们先来看看输出结果:

1
2
3
4
5
6
2015-03-14 00:31:41.286 SKTest[932:88791] handle notification begin
2015-03-14 00:31:41.291 SKTest[932:88713] Observer dealloc
2015-03-14 00:31:42.361 SKTest[932:88791] handle notification end
(lldb)
// 程序在self.i = 10处抛出了"Thread 6: EXC_BAD_ACCESS(code=EXC_I386_GPFLT)"

经典的内存错误,程序崩溃了。其实从输出结果中,我们就可以看到到底是发生了什么事。我们简要描述一下:

  1. 当我们注册一个观察者是,通知中心会持有观察者的一个弱引用,来确保观察者是可用的。
  2. 主线程调用dealloc操作会让Observer对象的引用计数减为0,这时对象会被释放掉。
  3. 后台线程发送一个通知,如果此时Observer还未被释放,则会向其转发消息,并执行回调方法。而如果在回调执行的过程中对象被释放了,就会出现上面的问题。

当然,上面这个例子是故意而为之,但不排除在实际编码中会遇到类似的问题。虽然NSNotificationCenter是线程安全的,但并不意味着我们在使用时就可以保证线程安全的,如果稍不注意,还是会出现线程问题。

那我们该怎么做呢?这里有一些好的建议:

  1. 尽量在一个线程中处理通知相关的操作,大部分情况下,这样做都能确保通知的正常工作。不过,我们无法确定到底会在哪个线程中调用dealloc方法,所以这一点还是比较困难。
  2. 注册监听都时,使用基于block的API。这样我们在block还要继续调用self的属性或方法,就可以通过weak-strong的方式来处理。具体大家可以改造下上面的代码试试是什么效果。
  3. 使用带有安全生命周期的对象,这一点对象单例对象来说再合适不过了,在应用的整个生命周期都不会被释放。
  4. 使用代理。

小结

NSNotificationCenter虽然是线程安全的,但不要被这个事实所误导。在涉及到多线程时,我们还是需要多加小心,避免出现上面的线程问题。想进一步了解的话,可以查看Observers and Thread Safety。

参考

  1. Notification Programming Topics
  2. Threading Programming Guide
  3. NSNotification的几点说明
  4. NSNotificationCenter is thread-safe NOT
  5. Observers and Thread Safety

UIKit: UIResponder

发表于 2015-03-07   |   分类于 Cocoa

我们的App与用户进行交互,基本上是依赖于各种各样的事件。例如,用户点击界面上的按钮,我们需要触发一个按钮点击事件,并进行相应的处理,以给用户一个响应。UIView的三大职责之一就是处理事件,一个视图是一个事件响应者,可以处理点击等事件,而这些事件就是在UIResponder类中定义的。

一个UIResponder类为那些需要响应并处理事件的对象定义了一组接口。这些事件主要分为两类:触摸事件(touch events)和运动事件(motion events)。UIResponder类为每两类事件都定义了一组接口,这个我们将在下面详细描述。

在UIKit中,UIApplication、UIView、UIViewController这几个类都是直接继承自UIResponder类。另外SpriteKit中的SKNode也是继承自UIResponder类。因此UIKit中的视图、控件、视图控制器,以及我们自定义的视图及视图控制器都有响应事件的能力。这些对象通常被称为响应对象,或者是响应者(以下我们统一使用响应者)。

本文将详细介绍一个UIResponder类提供的基本功能。不过在此之前,我们先来了解一下事件响应链机制。

响应链

大多数事件的分发都是依赖响应链的。响应链是由一系列链接在一起的响应者组成的。一般情况下,一条响应链开始于第一响应者,结束于application对象。如果一个响应者不能处理事件,则会将事件沿着响应链传到下一响应者。

那这里就会有三个问题:

  1. 响应链是何时构建的
  2. 系统是如何确定第一响应者的
  3. 确定第一响应者后,系统又是按照什么样的顺序来传递事件的

构建响应链

我们都知道在一个App中,所有视图是按一定的结构组织起来的,即树状层次结构。除了根视图外,每个视图都有一个父视图;而每个视图都可以有0个或多个子视图。而在这个树状结构构建的同时,也构建了一条条的事件响应链。

确定第一响应者

当用户触发某一事件(触摸事件或运动事件)后,UIKit会创建一个事件对象(UIEvent),该对象包含一些处理事件所需要的信息。然后事件对象被放到一个事件队列中。这些事件按照先进先出的顺序来处理。当处理事件时,程序的UIApplication对象会从队列头部取出一个事件对象,将其分发出去。通常首先是将事件分发给程序的主window对象,对于触摸事件来讲,window对象会首先尝试将事件分发给触摸事件发生的那个视图上。这一视图通常被称为hit-test视图,而查找这一视图的过程就叫做hit-testing。

系统使用hit-testing来找到触摸下的视图,它检测一个触摸事件是否发生在相应视图对象的边界之内(即视图的frame属性,这也是为什么子视图如果在父视图的frame之外时,是无法响应事件的)。如果在,则会递归检测其所有的子视图。包含触摸点的视图层次架构中最底层的视图就是hit-test视图。在检测出hit-test视图后,系统就将事件发送给这个视图来进行处理。

我们通过一个示例来演示hit-testing的过程。图1是一个视图层次结构,

image

假设用户点击了视图E,系统按照以下顺序来查找hit-test视图:

  1. 点击事件发生在视图A的边界内,所以检测子视图B和C;
  2. 点击事件不在视图B的边界内,但在视图C的边界范围内,所以检测子图片D和E;
  3. 点击事件不在视图D的边界内,但在视图E的边界范围内;

视图E是包含触摸点的视图层次架构中最底层的视图(倒树结构),所以它就是hit-test视图。

hit-test视图可以最先去处理触摸事件,如果hit-test视图不能处理事件,则事件会沿着响应链往上传递,直到找到能处理它的视图。

事件传递

最有机会处理事件的对象是hit-test视图或第一响应者。如果这两者都不能处理事件,UIKit就会将事件传递到响应链中的下一个响应者。每一个响应者确定其是否要处理事件或者是通过nextResponder方法将其传递给下一个响应者。这一过程一直持续到找到能处理事件的响应者对象或者最终没有找到响应者。

图2演示了这样一个事件传递的流程,

image

当系统检测到一个事件时,将其传递给初始对象,这个对象通常是一个视图。然后,会按以下路径来处理事件(我们以左图为例):

  1. 初始视图(initial view)尝试处理事件。如果它不能处理事件,则将事件传递给其父视图。
  2. 初始视图的父视图(superview)尝试处理事件。如果这个父视图还不能处理事件,则继续将视图传递给上层视图。
  3. 上层视图(topmost view)会尝试处理事件。如果这个上层视图还是不能处理事件,则将事件传递给视图所在的视图控制器。
  4. 视图控制器会尝试处理事件。如果这个视图控制器不能处理事件,则将事件传递给窗口(window)对象。
  5. 窗口(window)对象尝试处理事件。如果不能处理,则将事件传递给单例app对象。
  6. 如果app对象不能处理事件,则丢弃这个事件。

从上面可以看到,视图、视图控制器、窗口对象和app对象都能处理事件。另外需要注意的是,手势也会影响到事件的传递。

以上便是响应链的一些基本知识。有了这些知识,我们便可以来看看UIResponder提供给我们的一些方法了。

管理响应链

UIResponder提供了几个方法来管理响应链,包括让响应对象成为第一响应者、放弃第一响应者、检测是否是第一响应者以及传递事件到下一响应者的方法,我们分别来介绍一下。

上面提到在响应链中负责传递事件的方法是nextResponder,其声明如下:

1
- (UIResponder *)nextResponder

UIResponder类并不自动保存或设置下一个响应者,该方法的默认实现是返回nil。子类的实现必须重写这个方法来设置下一响应者。UIView的实现是返回管理它的UIViewController对象(如果它有)或者其父视图。而UIViewController的实现是返回它的视图的父视图;UIWindow的实现是返回app对象;而UIApplication的实现是返回nil。所以,响应链是在构建视图层次结构时生成的。

一个响应对象可以成为第一响应者,也可以放弃第一响应者。为此,UIResponder提供了一系列方法,我们分别来介绍一下。

如果想判定一个响应对象是否是第一响应者,则可以使用以下方法:

1
- (BOOL)isFirstResponder

如果我们希望将一个响应对象作为第一响应者,则可以使用以下方法:

1
- (BOOL)becomeFirstResponder

如果对象成为第一响应者,则返回YES;否则返回NO。默认实现是返回YES。子类可以重写这个方法来更新状态,或者来执行一些其它的行为。

一个响应对象只有在当前响应者能放弃第一响应者状态(canResignFirstResponder)且自身能成为第一响应者(canBecomeFirstResponder)时才会成为第一响应者。

这个方法相信大家用得比较多,特别是在希望UITextField获取焦点时。另外需要注意的是只有当视图是视图层次结构的一部分时才调用这个方法。如果视图的window属性不为空时,视图才在一个视图层次结构中;如果该属性为nil,则视图不在任何层次结构中。

上面提到一个响应对象成为第一响应者的一个前提是它可以成为第一响应者,我们可以使用canBecomeFirstResponder方法来检测,

1
- (BOOL)canBecomeFirstResponder

需要注意的是我们不能向一个不在视图层次结构中的视图发送这个消息,其结果是未定义的。

与上面两个方法相对应的是响应者放弃第一响应者的方法,其定义如下:

1
2
- (BOOL)resignFirstResponder
- (BOOL)canResignFirstResponder

resignFirstResponder默认也是返回YES。需要注意的是,如果子类要重写这个方法,则在我们的代码中必须调用super的实现。

canResignFirstResponder默认也是返回YES。不过有些情况下可能需要返回NO,如一个输入框在输入过程中可能需要让这个方法返回NO,以确保在编辑过程中能始终保证是第一响应者。

管理输入视图

所谓的输入视图,是指当对象为第一响应者时,显示另外一个视图用来处理当前对象的信息输入,如UITextView和UITextField两个对象,在其成为第一响应者是,会显示一个系统键盘,用来输入信息。这个系统键盘就是输入视图。输入视图有两种,一个是inputView,另一个是inputAccessoryView。这两者如图3所示:

image

与inputView相关的属性有如下两个,

1
2
@property(nonatomic, readonly, retain) UIView *inputView
@property(nonatomic, readonly, retain) UIInputViewController *inputViewController

这两个属性提供一个视图(或视图控制器)用于替代为UITextField和UITextView弹出的系统键盘。我们可以在子类中将这两个属性重新定义为读写属性来设置这个属性。如果我们需要自己写一个键盘的,如为输入框定义一个用于输入身份证的键盘(只包含0-9和X),则可以使用这两个属性来获取这个键盘。

与inputView类似,inputAccessoryView也有两个相关的属性:

1
2
@property(nonatomic, readonly, retain) UIView *inputAccessoryView
@property(nonatomic, readonly, retain) UIInputViewController *inputAccessoryViewController

设置方法与前面相同,都是在子类中重新定义为可读写属性,以设置这个属性。

另外,UIResponder还提供了以下方法,在对象是第一响应者时更新输入和访问视图,

1
- (void)reloadInputViews

调用这个方法时,视图会立即被替换,即不会有动画之类的过渡。如果当前对象不是第一响应者,则该方法是无效的。

响应触摸事件

UIResponder提供了如下四个大家都非常熟悉的方法来响应触摸事件:

1
2
3
4
5
6
7
8
// 当一个或多个手指触摸到一个视图或窗口
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
// 当与事件相关的一个或多个手指在视图或窗口上移动时
- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event
// 当一个或多个手指从视图或窗口上抬起时
- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event
// 当一个系统事件取消一个触摸事件时
- (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event

这四个方法默认都是什么都不做。不过,UIKit中UIResponder的子类,尤其是UIView,这几个方法的实现都会把消息传递到响应链上。因此,为了不阻断响应链,我们的子类在重写时需要调用父类的相应方法;而不要将消息直接发送给下一响应者。

默认情况下,多点触摸是被禁用的。为了接受多点触摸事件,我们需要设置响应视图的multipleTouchEnabled属性为YES。

响应移动事件

与触摸事件类似,UIResponder也提供了几个方法来响应移动事件:

1
2
3
4
5
6
// 移动事件开始
- (void)motionBegan:(UIEventSubtype)motion withEvent:(UIEvent *)event
// 移动事件结束
- (void)motionEnded:(UIEventSubtype)motion withEvent:(UIEvent *)event
// 取消移动事件
- (void)motionCancelled:(UIEventSubtype)motion withEvent:(UIEvent *)event

与触摸事件不同的是,运动事件只有开始与结束操作;它不会报告类似于晃动这样的事件。这几个方法的默认操作也是什么都不做。不过,UIKit中UIResponder的子类,尤其是UIView,这几个方法的实现都会把消息传递到响应链上。

响应远程控制事件

远程控制事件来源于一些外部的配件,如耳机等。用户可以通过耳机来控制视频或音频的播放。接收响应者对象需要检查事件的子类型来确定命令(如播放,子类型为UIEventSubtypeRemoteControlPlay),然后进行相应处理。

为了响应远程控制事件,UIResponder提供了以下方法,

1
- (void)remoteControlReceivedWithEvent:(UIEvent *)event

我们可以在子类中实现该方法,来处理远程控制事件。不过,为了允许分发远程控制事件,我们必须调用UIApplication的beginReceivingRemoteControlEvents方法;而如果要关闭远程控制事件的分发,则调用endReceivingRemoteControlEvents方法。

获取Undo管理器

默认情况下,程序的每一个window都有一个undo管理器,它是一个用于管理undo和redo操作的共享对象。然而,响应链上的任何对象的类都可以有自定义undo管理器。例如,UITextField的实例的自定义管理器在文件输入框放弃第一响应者状态时会被清理掉。当需要一个undo管理器时,请求会沿着响应链传递,然后UIWindow对象会返回一个可用的实例。

UIResponder提供了一个只读方法来获取响应链中共享的undo管理器,

1
@property(nonatomic, readonly) NSUndoManager *undoManager

我们可以在自己的视图控制器中添加undo管理器来执行其对应的视图的undo和redo操作。

验证命令

在我们的应用中,经常会处理各种菜单命令,如文本输入框的”复制”、”粘贴”等。UIResponder为此提供了两个方法来支持此类操作。首先使用以下方法可以启动或禁用指定的命令:

1
- (BOOL)canPerformAction:(SEL)action withSender:(id)sender

该方法默认返回YES,我们的类可以通过某种途径处理这个命令,包括类本身或者其下一个响应者。子类可以重写这个方法来开启菜单命令。例如,如果我们希望菜单支持”Copy“而不支持”Paser“,则在我们的子类中实现该方法。需要注意的是,即使在子类中禁用某个命令,在响应链上的其它响应者也可能会处理这些命令。

另外,我们可以使用以下方法来获取可以响应某一行为的接收者:

1
- (id)targetForAction:(SEL)action withSender:(id)sender

在对象需要调用一个action操作时调用该方法。默认的实现是调用canPerformAction:withSender:方法来确定对象是否可以调用action操作。如果可以,则返回对象本身,否则将请求传递到响应链上。如果我们想要重写目标的选择方式,则应该重写这个方法。下面这段代码演示了一个文本输入域禁用拷贝/粘贴操作:

1
2
3
4
5
6
7
8
9
10
11
- (id)targetForAction:(SEL)action withSender:(id)sender
{
UIMenuController *menuController = [UIMenuController sharedMenuController];
if (action == @selector(selectAll:) || action == @selector(paste:) ||action == @selector(copy:) || action == @selector(cut:)) {
if (menuController) {
[UIMenuController sharedMenuController].menuVisible = NO;
}
return nil;
}
return [super targetForAction:action withSender:sender];
}

访问快捷键命令

我们的应用可以支持外部设备,包括外部键盘。在使用外部键盘时,使用快捷键可以大大提高我们的输入效率。因此从iOS7后,UIResponder类新增了一个只读属性keyCommands,来定义一个响应者支持的快捷键,其声明如下:

1
@property(nonatomic, readonly) NSArray *keyCommands

一个支持硬件键盘命令的响应者对象可以重新定义这个方法并使用它来返回一个其所支持快捷键对象(UIKeyCommand)的数组。每一个快捷键命令表示识别的键盘序列及响应者的操作方法。

我们用这个方法返回的快捷键命令数组被用于整个响应链。当与快捷键命令对象匹配的快捷键被按下时,UIKit会沿着响应链查找实现了响应行为方法的对象。它调用找到的第一个对象的方法并停止事件的处理。

管理文本输入模式

文本输入模式标识当响应者激活时的语言及显示的键盘。UIResponder为此定义了一个属性来返回响应者对象的文本输入模式:

1
@property(nonatomic, readonly, retain) UITextInputMode *textInputMode

对于响应者而言,系统通常显示一个基于用户语言设置的键盘。我们可以重新定义这个属性,并让它返回一个不同的文本输入模式,以让我们的响应者使用一个特定的键盘。用户在响应者被激活时仍然可以改变键盘,在切换到另一个响应者时,可以再恢复到指定的键盘。

如果我们想让UIKit来跟踪这个响应者的文本输入模式,我们可以通过textInputContextIdentifier属性来设置一个标识,该属性的声明如下:

1
@property(nonatomic, readonly, retain) NSString *textInputContextIdentifier

该标识指明响应者应保留文本输入模式的信息。在跟踪模式下,任何对文本输入模式的修改都会记录下来,当响应者激活时再用于恢复处理。

为了从程序的user default中清理输入模式信息,UIResponder定义了一个类方法,其声明如下:

1
+ (void)clearTextInputContextIdentifier:(NSString *)identifier

调用这个方法可以从程序的user default中移除与指定标识相关的所有文本输入模式。移除这些信息会让响应者重新使用默认的文本输入模式。

支持User Activities

从iOS 8起,苹果为我们提供了一个非常棒的功能,即Handoff。使用这一功能,我们可以在一部iOS设备的某个应用上开始做一件事,然后在另一台iOS设备上继续做这件事。Handoff的基本思想是用户在一个应用里所做的任何操作都可以看作是一个Activity,一个Activity可以和一个特定iCloud用户的多台设备关联起来。在编写一个支持Handoff的应用时,会有以下三个交互事件:

  1. 为将在另一台设备上继续做的事创建一个新的User Activity;
  2. 当需要时,用新的数据更新已有的User Activity;
  3. 把一个User Activity传递到另一台设备上。

为了支持这些交互事件,在iOS 8后,UIResponder类新增了几个方法,我们在此不讨论这几个方法的实际使用,想了解更多的话,可以参考iOS 8 Handoff 开发指南。我们在此只是简单描述一下这几个方法。

在UIResponder中,已经为我们提供了一个userActivity属性,它是一个NSUserActivity对象。因此我们在UIResponder的子类中不需要再去声明一个userActivity属性,直接使用它就行。其声明如下:

1
@property(nonatomic, retain) NSUserActivity *userActivity

由UIKit管理的User Activities会在适当的时间自动保存。一般情况下,我们可以重写UIResponder类的updateUserActivityState:方法来延迟添加表示User Activity的状态数据。当我们不再需要一个User Activity时,我们可以设置userActivity属性为nil。任何由UIKit管理的NSUserActivity对象,如果它没有相关的响应者,则会自动失效。

另外,多个响应者可以共享一个NSUserActivity实例。

上面提到的updateUserActivityState:是用于更新给定的User Activity的状态。其定义如下:

1
- (void)updateUserActivityState:(NSUserActivity *)activity

子类可以重写这个方法来按照我们的需要更新给定的User Activity。我们需要使用NSUserActivity对象的addUserInfoEntriesFromDictionary:方法来添加表示用户Activity的状态。

在我们修改了User Activity的状态后,如果想将其恢复到某个状态,则可以使用以下方法:

1
- (void)restoreUserActivityState:(NSUserActivity *)activity

子类可以重写这个方法来使用给定User Activity的恢复响应者的状态。系统会在接收到数据时,将数据传递给application:continueUserActivity:restorationHandler:以做处理。我们重写时应该使用存储在user activity的userInfo字典中的状态数据来恢复对象。当然,我们也可以直接调用这个方法。

参考

  1. UIResponder Class Reference
  2. Event Handling Guide for iOS
  3. iOS UIResponder 学习笔记
  4. 如何让你的iOS7应用支持键盘快捷键
  5. iOS 8 Handoff 开发指南
  6. iOS 8 Handoff Tutorial

iOS 8 Handoff Tutorial

发表于 2015-03-01   |   分类于 翻译

原文由Soheil Azarpour发表于raywenderlich,地址是iOS 8 Handoff Tutorial

Handoff是iOS 8和OS X Yosemite中的一个新特性。它让我们在不同的设备间切换时,可以不间断地继续一个Activity,而不需要重新配置任何设备。

我们可以为在iOS 8和Yosemite上的应用添加Handoff特性。在这篇指南中,我们将学习Handoff的基本功能和如何在非基于文档的app中使用Handoff。

Handoff概览

在开始写代码前,我们需要先来了解一下handoff的一些基本概念。

起步

Handoff不仅可以将当前的activity从一个iOS设备传递到OS X设备,还可以将activity在不同的iOS设备传递。目前在模拟器上还不能使用Handoff功能,所以需要在iOS设备上运行我们的实例。

设备兼容性:iOS

为了查看我们的iOS设备是否支持handoff功能,我们可以查看“设置”->“通用”列表。如果在列表中看到“Handoff与建议的应用程序”,则设备具备Handoff功能。以下截图显示了iPhone 5s(具备Handoff功能)和iPad3(不具备Handoff功能)的对比:

image

Handoff功能依赖于以下几点:

  1. 一个iCloud账户:我们必须在希望使用Handoff功能的多台设备上登录同一个iCloud账户。
  2. 低功耗蓝牙(Bluetooth LE 4.0):Handoff是通过低功耗蓝牙来广播activities的,所以广播设备和接收设备都必须支持Bluetooth LE 4.0。
  3. iCloud配对:设备必须已经通过iCloud配对。当在支持Handoff的设备上登录iCloud账户后,每台设备都会与其它支持Handoff的设备进行配对。

此时,我们需要确保已经使用同一iCloud账号在两台支持Handoff功能且运行iOS 8+系统的设备上登录了。(译者注:具体配置可以参考在 Chrome(iOS 版)中使用 Handoff)

User Activities

Handoff是基于User Activity的。User Activity是一个独立的信息集合单位,可以不依赖于任何其它信息而进行传输(be handed off)。

NSUserActivity对象表示一个User Activity实例。它封装了程序的一些状态,这些状态可以在其它设备相关的程序中继续使用。

有三种方法和一个NSUserActivity对象交互:

1) 创建一个user activity:原始应用程序创建一个NSUserActivity实例并调用becomeCurrent()以开启一个广播进程。下面是一个实例:

1
2
3
4
5
let activity = NSUserActivity(activityType: "com.razeware.shopsnap.view")
activity.title = "Viewing"
activity.userInfo = ["shopsnap.item.key": ["Apple", "Orange", "Banana"]]
self.userActivity = activity;
self.userActivity?.becomeCurrent()

我们可以使用NSUserActivity的userInfo字典来传递本地数据类型对象或可编码的自定义对象以将其传输到接收设备。本地数据类型包括NSArray, NSData, NSDate, NSDictionary, NSNull, NSNumber, NSSet, NSString, NSUUID和NSURL。通过NSURL可能会有点棘手。在使用NSURL前可以先参考一下下面的“最佳实践”一节。

2) 更新user activity:一旦一个NSUserActivity成为当前的activity,则iOS会在最上层的视图控制器中调用updateUserActivityState(activity:)方法,以让我们有机会来更新user activity。下面是一个实例:

1
2
3
4
5
override func updateUserActivityState(activity: NSUserActivity) {
let activityListItems = // ... get updated list of items
activity.addUserInfoEntriesFromDictionary(["shopsnap.item.key": activityListItems])
super.updateUserActivityState(activity)
}

注意我们不要将userInfo设置为一个新的字典或直接更新它,而是应该使用便捷方法addUserInfoEntriesFromDictionary()。

在下文中,我们将学习如何按需求强制刷新user activity,或者是在程序的app delegate级别来获取一个相似功能的回调。

3) 接收user activity:当我们的接收程序以Handoff的方式启动时,程序代理会调用application(:willContinueUserActivityWithType:)方法。注意这个方法的参数不是NSUserActivity对象,因为接收程序在下载并传递NSUserActivity数据需要花费一定的时间。在user activity已经被下载完成后,会调用以下的回调函数:

1
2
3
4
5
6
7
8
9
10
11
func application(application: UIApplication!,
continueUserActivity userActivity: NSUserActivity!,
restorationHandler: (([AnyObject]!) -> Void)!)
-> Bool {
// Do some checks to make sure you can proceed
if let window = self.window {
window.rootViewController?.restoreUserActivityState(userActivity)
}
return true
}

然后我们可以使用存储在NSUserActivity对象中的数据来重新创建用户的activity。在这里,我们更新我们的应用以继续相关的activity。

Activity类型

当创建一个user activity后,我们必须为其指定一个activity类型。一个activity类型是一个简单的唯一字符串,通常使用反转DNS语义,如com.razeware.shopsnap.view。

每一个可以接收user activity的程序都必须声明其可接收的activity类型。这类似于在程序中声明支持的URL方案(URL schemes)。对于非基于文本的程序,activity类型需要在Info.plist文件中定义,其键值为NSUserActivityTypes,如下所示:

image

对于支持一个给定activity的程序来说,需要满足三个要求:

  1. 相同的组:两个程序都必须源于使用同一开发者组ID(developer Team ID)的开发者。
  2. 相同的activity类型:发送程序创建某一activity类型的user activity,接收程序必须有相应类型的NSUserActivityTypes入口。
  3. 签约:两个程序必须通过App store来发布或使用同一开发者账号来签约。

现在我们已经学习了user activities和activity类型的基础知识,接下来让我们来看一个实例。

启动工程

本指南的启动工程可以在“启动工程”中下载。下载后,使用Xcode打开工程并在iPhone模拟器中运行。

image

工程名是ShopSnap,我们可以在这个程序中构建一个简单的购物清单。一个购物项由一个字符串表示,然后我们将购物项存储在一个字符串的数组中。点击+按钮添加一个新的项目到清单中,而轻扫可以移除项目。

我们将在程序中定义两个独立的user activity:

  1. 查看清单。如果用户当前正在查看清单,我们将传输整个数组。
  2. 添加或编译项目。如果用户当前正在添加新的项目,我们将传递一个单一项目的“编辑”activity。

设置开发组

为了让Handoff工作,发送和接收app都必须使用相同的开发组来签约。由于这个示例程序即是发送者也是接收者,所以这很简单!

选择ShopSnap工程,在“通用”选项卡中,在”Team“中选择自己的开发组:

image

在支持Handoff的设备中编译并运行程序,以确保运行正常,然后继续。

配置activity类型

接下来是配置程序所支持的activity类型。打开"Supporting Files\Info.plist",点击"Information Property List"旁边的”+”按钮,在"Information Property List"中添加一个新的选项:

image

键名为"NSUserActivityTypes",类型设备为数组类型,如下所示:

image

在NSUserActivityTypes下添加两项并设置类型为字符串。Item 0的值为com.razeware.shopsnap.view,Item 1的值为com.razeware.shopsnap.edit。

image

这些任意的activity类型对于我们的程序来说是特定和唯一的。因为我们将在程序的不同地方引用它们,所以在独立的文件中将其添加为常量是一种好的实践。

在工程导航中右键点击ShopSnap组,选择"New File \ iOS \ Source \ Swift File"。将文件命名为Constants.swift并确保新类被添加到ShopSnap target中。

在类中添加以下代码:

1
2
3
4
5
let ActivityTypeView = "com.razeware.shopsnap.view"
let ActivityTypeEdit = "com.razeware.shopsnap.edit"
let ActivityItemsKey = "shopsnap.items.key"
let ActivityItemKey = "shopsnap.item.key"

然后我们就可以使用这两个activity类型的常量。同时我们定义一些用于user activity的userInfo字典的键名字符串。

快速端到端测试

让我们来运行一个快速端到端测试以确保设备可以正确地通信。

打开ListViewController.swift并添加以下两个函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 1.
func startUserActivity() {
let activity = NSUserActivity(activityType: ActivityTypeView)
activity.title = "Viewing Shopping List"
activity.userInfo = [ActivityItemsKey: ["Ice cream", "Apple", "Nuts"]]
userActivity = activity
userActivity?.becomeCurrent()
}
// 2.
override func updateUserActivityState(activity: NSUserActivity) {
activity.addUserInfoEntriesFromDictionary([ActivityItemsKey: ["Ice cream", "Apple", "Nuts"]])
super.updateUserActivityState(activity)
}

我们通过硬编码一个user activity来快速测试,以确保我们可以在另一端正常接收。

上面的代码做了以下两件事:

  1. startUserActivity()是一个辅助函数,它使用一个硬编码的购物清单来创建了一个NSUserActivity实例。然后调用becomeCurrent()来广播这个activity。
  2. 在调用becomeCurrent()后,系统将定期调用updateUserActivityState()。UIViewController从UIResponder类中继承了这个方法,我们应该重写它来更新我们的userActivity的状态。在这里,我们像前面一样使用硬编码来更新购物清单。注意,我们应该使用addUserInfoEntriesFromDictionary方法来修改NSUserActivity的userInfo字典。我们应该总是在方法的结尾调用super.updateUserActivityState()。

注意,我们只需要调用上面的起始方法。在viewDidLoad()起始行下面添加以下代码

1
startUserActivity()

开始广播至少需要以上步骤。现在来看看接收者。打开AppDelegate.swift并添加以下代码:

1
2
3
4
5
6
7
8
9
func application(application: UIApplication!,
continueUserActivity userActivity: NSUserActivity!,
restorationHandler: (([AnyObject]!) -> Void)!)
-> Bool {
let userInfo = userActivity.userInfo as NSDictionary
println("Received a payload via handoff: \(userInfo)")
return true
}

AppDelegate中的这个方法在所有事情都准备好,且一个userActivity被成功传送后调用。在这里我们简单打印userActivity中的userInfo字典。我们返回true来标识我们处理了user activity。

让我们来试试!要想在两台设备中正常工作,还需要做一些协调工作,所以还得仔细跟着。

  1. 在第一台设备上安装并运行程序。
  2. 在第二台设备上安装并运行程序。确保在Xcode中调用程序以便我们能看到打印输出。
  3. 按下电源按钮让第二台设备休眠。在同一台设备上,按下Home键。如果所有事件都正常运行,我们应该可以看到ShopSnap程序的icon显示在屏幕的左下角上。从这里我们可以启动程序,然后在Xcode控制台可以看到以下的日志信息:
1
2
3
4
5
6
7
Received a payload via handoff: {
"shopsnap.items.key" = (
"Ice cream",
Apple,
Nuts
);
}

如果在锁屏下没有看到程序的icon,则在源设备上关闭并重新打开程序。这将强制系统重新广播信息。同时确认一下设备的控制台以查看是否有来自于Handoff的错误消息。

image

创建一个新的Activity

现在我们有一个基本上可以工作的Handoff程序,是时候来扩展它了。打开ListViewController.swift,更新startUserActivity()方法,这次我们传入实际的购物清单以代码硬编码。使用以下代码来更新方法:

1
2
3
4
5
6
7
func startUserActivity() {
let activity = NSUserActivity(activityType: ActivityTypeView)
activity.title = "Viewing Shopping List"
activity.userInfo = [ActivityItemsKey: items]
userActivity = activity
userActivity?.becomeCurrent()
}

同样,更新ListViewController.swift的updateUserActivityState(activity:)方法,传递购物清单数组:

1
2
3
4
override func updateUserActivityState(activity: NSUserActivity) {
activity.addUserInfoEntriesFromDictionary([ActivityItemsKey: items])
super.updateUserActivityState(activity)
}

现在,更新ListViewController.swift中的viewDidLoad(),在从前面的代码中成功获取到清单后开启userActivity,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
override func viewDidLoad() {
title = "Shopping List"
weak var weakSelf = self
PersistentStore.defaultStore().fetchItems({ (items:[String]) in
if let unwrapped = weakSelf {
unwrapped.items = items
unwrapped.tableView.reloadData()
if items.isEmpty == false {
unwrapped.startUserActivity()
}
}
})
super.viewDidLoad()
}

当然,如果程序开始时,清单是空的,则程序不会去广播user activity。我们需要解决这个问题:在用户第一次添加一个购物项到列表时开启user activity。

为了做到这一点,更新ListViewController.swift中代理回调detailViewController(controller:didFinishWithUpdatedItem:)的实现,如下所示:

1
2
3
4
5
6
7
func detailViewController(#controller: DetailViewController,
didFinishWithUpdatedItem item: String) {
// ... some code
if !items.isEmpty {
startUserActivity()
}
}

在此有三种可能:

  1. 用于更新一个已存在的购物项
  2. 用户删除一个存在的购物项
  3. 用户添加一个新的购物项

现存的代码处理了所有的可能性;我们只需要添加一些检测代码,以在有一个非空的清单时开始一个activity。

在两台设备上编译并运行。此时,我们应该可以在一台设备上添加新的项目,然后将其发送给另外一台设备。

收尾

当用户开始添加一个新的项目或编辑一个已存在的项目时,用户可能不是在查看购物清单。所以我们需要停止广播当前activity。同样,当清单中的所有项目被删除时,没有理由去继续广播当前activiry。在ListViewController.swift中添加以下辅助方法:

1
2
3
func stopUserActivity() {
userActivity?.invalidate()
}

在stopUserActivity()中,我们废止已存在的NSUserActivity。这让handoff停止广播。

现在有了stopUserActivity(),是时候在适当的地方调用它了。

在ListViewController.swift中,更新prepareForSegue(segue:, sender:)方法的实现,如下所示:

1
2
3
4
override func prepareForSegue(segue: UIStoryboardSegue, sender: AnyObject!) {
// ... some code
stopUserActivity()
}

当用户选择一行或者点击添加按钮时,ListViewController准备导航到详情视图。我们废弃当前的清单查看activity。

在同一文件中,更新tableView(_:commitEditingStyle:forRowAtIndexPath:)的实现,如下所示:

1
2
3
4
5
6
7
8
9
10
override func tableView(tableView: UITableView,
commitEditingStyle editingStyle: UITableViewCellEditingStyle,
forRowAtIndexPath indexPath: NSIndexPath) {
// ... some code
if items.isEmpty {
stopUserActivity()
} else {
userActivity?.needsSave = true
}
}

当用户从清单中删除一项时,我们需要相应地更新user activity。如果移除清单中的所有项目,我们停止广播。否则,我们设置userActivity的needsSave属性为true。当我们这样做时,系统会立即回调updateUserActivityState(activity:),在这里我们会更新userActivity。

结束这一节之前,还有一种情况需要考虑,用户点击取消按钮,然后从DetailViewController中返回。这触发了一个已存在的场景。我们需要重新开始userActivity。更新unwindDetailViewController(unwindSegue:)的实现,如下所示:

1
2
3
4
@IBAction func unwindDetailViewController(unwindSegue: UIStoryboardSegue) {
// ... some code
startUserActivity()
}

编译并运行,确保所有事情运行正常。尝试添加一些项目到清单中,确保它们在设备间传输。

创建一个编辑Activity

接下来,我们以类似的方式来处理DetailViewController。这一次,我们广播另一个activity类型。

打开DetailViewController.swift并修改textFieldDidBeginEditing(textField:),如下所示:

1
2
3
4
5
6
7
8
9
func textFieldDidBeginEditing(textField: UITextField!) {
// Broadcast what we have, if there is anything!
let activity = NSUserActivity(activityType: ActivityTypeEdit)
activity.title = "Editing Shopping List Item"
let activityItem = (countElements(textField.text!) > 0) ? textField.text : ""
activity.userInfo = [ActivityItemKey: activityItem]
userActivity = activity
userActivity?.becomeCurrent()
}

​

上面的方法使用项目的字符串的当前内容创建一个“编辑”activity。

当用户继续编辑项目时,我们需要更新user activity。仍然是在DetailViewController.swift中,更新textFieldTextDidChange(notification:)的实现,如下所示:

1
2
3
4
5
6
7
func textFieldTextDidChange(notification: NSNotification) {
if let text = textField!.text {
item = text
}
userActivity?.needsSave = true
}

现在我们已经标记了activity需要更新,接下来实现updateUserActivityState(activity:),以备系统的更新需求:

1
2
3
4
5
override func updateUserActivityState(activity: NSUserActivity) {
let activityListItem = (countElements(textField!.text!) > 0) ? textField!.text : ""
activity.addUserInfoEntriesFromDictionary([ActivityItemKey: activityListItem])
super.updateUserActivityState(activity)
}

这里我们简单地更新了当前项为文本输入框中的文本。

编译并运行。此时,如果我们在一个设备中开始添加一个新项或编辑已存在的项目,我们可以将编辑进程同步给另一个设备。

收尾

因为needsSave是一个轻量级的操作,在上面的代码中,你可以根据需要来设置它,然后在每次按键时更新userInfo。

这里有一个小细节你可能已经注意到了。视图控制器在iPad和iPhone的景观模式下中是一个分离视图。这样可以在清单的项目间切换而不需要收起键盘。这种情况发生时,textFieldDidBeginEditing(textField:)方法不会被调用,导致我们的user activity不会更新为新的文本。

为了解决这个问题,更新DetailViewController.swift中item属性的didSet观察者,如下所示:

1
2
3
4
5
6
7
8
9
10
var item: String? {
didSet {
if let textField = self.textField {
textField.text = item
}
if let activity = userActivity {
activity.needsSave = true
}
}
}

当用户点击ListViewController中的一个项目时,DetailViewController的item属性被设置。一个简单解决方案是让视图控制器知道,在项目更新时它必须更新activity。

最后,当用户离开DetailViewController时,我们需要废止userActivity,以让编辑activity不再被广播。

在DetailViewController.swift的textFieldShouldReturn(_:)方法的起始位置添加以下代码:

1
userActivity?.invalidate()

编译并运行程序,确保程序工作正常。接下来,我们将处理接收的activity。

接收Activity

当用户通过Handoff启动程序时,处理接收的NSUserActivity的任务大部分是由程序的delegate来完成的。

假设所有事情运行正常,数据成功传输,iOS会调用application(_:continueUserActivity:restorationHandler:)方法。这是我们与NSUserActivity实例交互的第一次机会。

我们在前面的章节中已经有一个该方法的实现了。现在,我们做如下修改:

​

1
2
3
4
5
6
7
8
9
10
func application(application: UIApplication!,
continueUserActivity userActivity: NSUserActivity!,
restorationHandler: (([AnyObject]!) -> Void)!)
-> Bool {
if let window = self.window {
window.rootViewController?.restoreUserActivityState(userActivity)
}
return true
}

我们将userActivity传递给程序的window对象的rootViewController,然后返回true。这告诉系统成功处理了Handoff行为。从这里开始,我们将自己转发调用并恢复activity。

我们在rootViewController中调用的方法是restoreUserActivityState(activity:)。这是在UIResponder中声明的一个标准方法。系统使用这个方法来告诉接收者恢复一个NSUserActivivty实例。

我们现在的任务是沿着视图控制器架构往下,将activity从父视图控制器传递到子视图控制器,直到到达需要使用activity的地方:

image

根视图控制器是一个TraitOverrideViewController对象,它的任务是管理程序的size classes;它对我们的user activity不感兴趣。打开TraitOverrideViewController.swift并添加以下代码:

1
2
3
4
5
override func restoreUserActivityState(activity: NSUserActivity) {
let nextViewController = childViewControllers.first as UIViewController
nextViewController.restoreUserActivityState(activity)
super.restoreUserActivityState(activity)
}

在这里,我们获取TraitOverrideViewController的第一个子视图控制器,然后将activity往下传递。这样做是安全的,因为我们知道程序的视图控制器只包含一个子视图控制器。

层级架构中的下一个视图控制器是SplitViewController,在这里事情会变得更有趣一些。

打开SplitViewController.swift并添加以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
override func restoreUserActivityState(activity: NSUserActivity) {
// What type of activity is it?
let activityType = activity.activityType
// This is an activity for ListViewController.
if activityType == ActivityTypeView {
let controller = viewControllerForViewing()
controller.restoreUserActivityState(activity)
} else if activityType == ActivityTypeEdit {
// This is an activity for DetailViewController.
let controller = viewControllerForEditing()
controller.restoreUserActivityState(activity)
}
super.restoreUserActivityState(activity)
}

SplitViewController知道ListViewController和DetailViewController。如果NSUserActivity是一个列表查看activity类型,则将其传递给ListViewController;否则,如果是一个编辑activity类型,则传递给DetailViewController。

我们将所有的activity传递给正确的对象,现在是时候从这些activity中获取数据了。

打开ListViewController.swift并实现restoreUserActivityState(activity:),如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
override func restoreUserActivityState(activity: NSUserActivity) {
// Get the list of items.
if let userInfo = activity.userInfo {
if let importedItems = userInfo[ActivityItemsKey] as? NSArray {
// Merge it with what we have locally and update UI.
for anItem in importedItems {
addItemToItemsIfUnique(anItem as String)
}
PersistentStore.defaultStore().updateStoreWithItems(items)
PersistentStore.defaultStore().commit()
tableView.reloadData()
}
}
super.restoreUserActivityState(activity)
}

在上面的方法中,我们终于可以继续一个查看activity了。因为我们需要维护一个唯一的购物清单时,我们只需要将这些唯一的项目添加到本地列表中,然后保存并更新UI。

编译并运行。现在我们可以看到通过Handoff从另一台设备上同步过来的清单数据了。

编辑activity以类似的方法来处理。打开DetailViewController.swift并实现restoreUserActivityState(activity:),如下所示:

1
2
3
4
5
6
7
8
9
10
override func restoreUserActivityState(activity: NSUserActivity) {
if let userInfo = activity.userInfo {
var activityItem: AnyObject? = userInfo[ActivityItemKey]
if let itemToRestore = activityItem as? String {
item = itemToRestore
textField?.text = item
}
}
super.restoreUserActivityState(activity)
}

这里获取编辑activity的信息并更新文本域的内容。

编译并运行,查看运行结果!

收尾

当用户在另一台设备上点击程序的icon以表明他们想要继续一个user activity时,系统启动相应的程序。一旦程序启动后,系统调用application(_, willContinueUserActivityWithType:)方法。打开AppDelegate.swift并添加以下方法:

1
2
3
4
5
func application(application: UIApplication,
willContinueUserActivityWithType userActivityType: String!)
-> Bool {
return true
}

到这里,我们的程序已经下载了NSUserActivity实例及其userInfo有效载荷。现在我们只是简单返回true。这强制程序在每次用户初始Handoff进程时接收activity。如果想要通知用户activity正在处理,则这是个好地方。

到这里,系统开始将数据从一台设备同步到另一台设备上。我们已经覆盖了任务正常运行的所有情况。但是可以想象Handoff的activity在某些情况下会失败。

将以下方法添加到AppDelegate.swift中来处理这种情况:

1
2
3
4
5
6
7
8
9
10
func application(application: UIApplication!,
didFailToContinueUserActivityWithType userActivityType: String!,
error: NSError!) {
if error.code != NSUserCancelledError {
let message = "The connection to your other device may have been interrupted. Please try again. \(error.localizedDescription)"
let alertView = UIAlertView(title: "Handoff Error", message: message, delegate: nil, cancelButtonTitle: "Dismiss")
alertView.show()
}
}

如果我们接收到除了NSUserCancelledError之外的任何信息,则发生了某些错误,且我们不能恢复activity。在这种情况下,我们显示一个适当的消息给用户。然而,如果用户显示取消Handoff行为,则在这里我们不需要做任何事情,只需要放弃操作。

版本支持

使用Handoff的最佳实践之一是版本化。处理这的一个策略是为每个发送的Handoff添加一个版本号,并且只接收来自这个版本号(或者更早的)handoff。让我们来试试。

打开Constants.swift并添加以下常量:

1
2
let ActivityVersionKey = "shopsnap.version.key"
let ActivityVersionValue = "1.0"

上面的版本键名和值是我们为这个版本的程序随意挑选的键值对。

如果我们回顾一下上面的章节,系统会定期并自动调用restoreUserActivityState(activity:)方法。这个方法的实现聚集于并限定于实现它的对象的范围内。例如,ListViewController重写了这个方法来更新带有购物清单的userActivity,而DetailViewController的实现是更新当前正在被编辑的项目。

如果涉及到的东西对于userActivity来说是通用的,可用于所有的user activity,如版本号,则处理它的最好的地方就是在AppDelegate中了。

任何时候调用restoreUserActivityState(activity:),系统都会紧接着调用程序delegate的application(application:, didUpdateUserActivity userActivity:)方法。我们使用这个方法来为我们的Handoff添加版本支持。

打开AppDelegate.swift并添加以下代码:

1
2
3
4
func application(application: UIApplication,
didUpdateUserActivity userActivity: NSUserActivity) {
userActivity.addUserInfoEntriesFromDictionary([ActivityVersionKey: ActivityVersionValue])
}

在这里我们简单地使用了程序的版本号来更新了userInfo字典。

仍然是在AppDelegate.swift中,更新application(_:, continueUserActivity: restorationHandler:)的实现,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func application(application: UIApplication!,
continueUserActivity userActivity: NSUserActivity!,
restorationHandler: (([AnyObject]!) -> Void)!)
-> Bool {
if let userInfo: NSDictionary = userActivity.userInfo {
if let version = userInfo[ActivityVersionKey] as? String {
// Pass it on.
if let window = self.window {
window.rootViewController?.restoreUserActivityState(userActivity)
}
return true
}
}
return false
}

在这里我们检查userAcitivty的版本,只有当版本号与我们知道的相匹配时才传递。编译并运行,确保程序运行正常。

Handoff最佳实践

在结束之前,我们来看看Handoff的最佳实践:

  1. NSURL:在NSUserActivity的userInfo字典中使用NSURL有点棘手。唯一可以安全地在Handoff中传输的NSURLs是使用HTTP/HTTPS和iCloud文档的web网址。我们不能传递本地文件的URL,因为在接收者端,接收者不能正确地转换并映射这些URL。传输文件链接的最好的方式是传递相对路径,然后在接收者端重新构建我们的URL。
  2. 平台特定值:避免使用平台特定值,如滑动视图的内容偏移量;更好的方法是使用相对位置。例如,如果用户查看table view中的一些项目时,在我们的user activity中传递table view最上面的可视项的index path,而不是传递table view可视区域的内容偏移量。
  3. 版本:想想在程序中使用版本和将来的更新。我们可以在程序的未来版本中添加一些新数据格式或者从userInfo字典中移除值。版本让我们可以理好地控制我们的user activity在当前和将来版本的程序中的行为。

下一步是哪

这里是示例工程的最终版本。

如果想了解更多的关于Handoff,流和基于文档的Handoff,则可以查看Handoff的开发文档Apple’s Handoff Programming Guide以获取更多的信息。

如果喜欢这篇文章,则可以下载我们的书iOS 8 by Tutorials,这里塞满了这样的教程。

如果有更多的总量或关于这篇文章的评论,那么可以加入下面的讨论。

Foundation: NSCache

发表于 2015-02-11   |   分类于 Cocoa

NSCache是一个类似于集合的容器,即缓存。它存储key-value对,这一点类似于NSDictionary类。我们通常用使用缓存来临时存储短时间使用但创建昂贵的对象。重用这些对象可以优化性能,因为它们的值不需要重新计算。另外一方面,这些对象对于程序来说不是紧要的,在内存紧张时会被丢弃。如果对象被丢弃了,则下次使用时需要重新计算。

当一个key-value对在缓存中时,缓存维护它的一个强引用。存储在NSCache中的通用数据类型通常是实现了NSDiscardableContent协议的对象。在缓存中存储这类对象是有好处的,因为当不再需要它时,可以丢弃这些内容,以节省内存。默认情况下,缓存中的NSDiscardableContent对象在其内容被丢弃时,会被移除出缓存,尽管我们可以改变这种缓存策略。如果一个NSDiscardableContent被放进缓存,则在对象被移除时,缓存会调用discardContentIfPossible方法。

NSCache与可变集合有几点不同:

  1. NSCache类结合了各种自动删除策略,以确保不会占用过多的系统内存。如果其它应用需要内存时,系统自动执行这些策略。当调用这些策略时,会从缓存中删除一些对象,以最大限度减少内存的占用。
  2. NSCache是线程安全的,我们可以在不同的线程中添加、删除和查询缓存中的对象,而不需要锁定缓存区域。
  3. 不像NSMutableDictionary对象,一个缓存对象不会拷贝key对象。

这些特性对于NSCache类来说是必须的,因为在需要释放内存时,缓存必须异步地在幕后决定去自动修改自身。

缓存限制

NSCache提供了几个属性来限制缓存的大小,如属性countLimit限定了缓存最多维护的对象的个数。声明如下:

1
@property NSUInteger countLimit

默认值为0,表示不限制数量。但需要注意的是,这不是一个严格的限制。如果缓存的数量超过这个数量,缓存中的一个对象可能会被立即丢弃、或者稍后、也可能永远不会,具体依赖于缓存的实现细节。

另外,NSCache提供了totalCostLimit属性来限定缓存能维持的最大内存。其声明如下:

1
@property NSUInteger totalCostLimit

默认值也是0,表示没有限制。当我们添加一个对象到缓存中时,我们可以为其指定一个消耗(cost),如对象的字节大小。如果添加这个对象到缓存导致缓存总的消耗超过totalCostLimit的值,则缓存会自动丢弃一些对象,直到总消耗低于totalCostLimit值。不过被丢弃的对象的顺序无法保证。

需要注意的是totalCostLimit也不是一个严格限制,其策略是与countLimit一样的。

存取方法

NSCache提供了一组方法来存取key-value对,类似于NSMutableDictionary类。如下所示:

1
2
3
4
- (id)objectForKey:(id)key
- (void)setObject:(id)obj forKey:(id)key
- (void)removeObjectForKey:(id)key
- (void)removeAllObjects

如上所述,与NSMutableDictionary不同的就是它不会拷贝key对象。

此外,我们在存储对象时,可以为对象指定一个消耗值,如下所示:

1
- (void)setObject:(id)obj forKey:(id)key cost:(NSUInteger)num

这个消耗值用于计算缓存中所有对象的一个消耗总和。当内存受限或者总消耗超过了限定的最大总消耗,则缓存应该开启一个丢弃过程以移除一些对象。不过,这个过程不能保证被丢弃对象的顺序。其结果是,如果我们试图操作这个消耗值来实现一些特殊的行为,则后果可能会损害我们的程序。通常情况下,这个消耗值是对象的字节大小。如果这些信息不是现成的,则我们不应该去计算它,因为这样会使增加使用缓存的成本。如果我们没有可用的值传递,则直接传递0,或者是使用-setObject:forKey:方法,这个方法不需要传入一个消耗值。

NSDiscardableContent协议

NSDiscardableContent是一个协议,实现这个协议的目的是为了让我们的对象在不被使用时,可以将其丢弃,以让程序占用更少的内存。

一个NSDiscardableContent对象的生命周期依赖于一个“counter”变量。一个NSDiscardableContent对象实际是一个可清理内存块,这个内存记录了对象当前是否被其它对象使用。如果这块内存正在被读取,或者仍然被需要,则它的counter变量是大于或等于1的;当它不再被使用时,就可以丢弃,此时counter变量将等于0。当counter变量等于0时,如果当前时间点内存比较紧张的话,内存块就可能被丢弃。

为了丢弃这些内容,可以调用对象的discardContentIfPossible方法,该方法的声明如下:

1
- (void)discardContentIfPossible

这样当counter变量等于0时将会释放相关的内存。而如果counter变量不为0,则该方法什么也不做。

默认情况下,NSDiscardableContent对象的counter变量初始值为1,以确保对象不会被内存管理系统立即释放。从这个点开始,我们就需要去跟踪counter变量的状态。为此。协议声明了两个方法:beginContentAccess和endContentAccess。

其中调用beginContentAccess方法会增加对象的counter变量(+1),这样就可以确保对象不会被丢弃。该方法声明如下:

1
- (BOOL)beginContentAccess

通常我们在对象被需要或者将要使用时调用这个方法。具体的实现类可以决定在对象已经被丢弃的情况下是否重新创建这些内存,且重新创建成功后返回YES。协议的实现者在NSDiscardableContent对象被使用,而又没有调用它的beginContentAccess方法时,应该抛出一个异常。

函数的返回值如果是YES,则表明可丢弃内存仍然可用且已被成功访问;否则返回NO。另外需要注意的是,该方法是在实现类中必须实现(required)。

与beginContentAccess相对应的是endContentAccess。如果可丢弃内存不再被访问时调用。其声明如下:

1
- (void)endContentAccess

该方法会减少对象的counter变量,通常是让对象的counter值变回为0,这样在对象的内容不再被需要时,就要以将其丢弃。

NSCache类提供了一个属性,来标识缓存是否自动舍弃那些内存已经被丢弃的对象(discardable-content object),其声明如下:

1
@property BOOL evictsObjectsWithDiscardedContent

如果设置为YES,则在对象的内存被丢弃时舍弃对象。默认值为YES。

NSCacheDelegate代理

NSCache对象还有一个代理属性,其声明如下:

1
@property(assign) id< NSCacheDelegate > delegate

实现NSCacheDelegate代理的对象会在对象即将从缓存中移除时执行一些特定的操作,因此代理对象可以实现以下方法:

1
- (void)cache:(NSCache *)cache willEvictObject:(id)obj

需要注意的是在这个代理方法中不能修改cache对象。

小结

实际上,我们常用的SDWebImage图片下载库的缓存机制就是通过NSCache来实现的。《Effectiveobjc 2.0》中也专门用一小篇的内容来介绍NSCache的使用(第50条:构建缓存时选用NSCache而非NSDictionary),里面有更精彩的内容。如果我们需要构建缓存机制,则应该使用NSCache,而不是NSDictionary,这样可以减少我们应用对内存的占用,从而达到优化内存的目标。

参考

  1. NSCache Class Reference
  2. NSDiscardableContent Protocol Reference
  3. NSCacheDelegate Protocol Reference
  4. Objective-C中的缓存: NSCache介绍
  5. NSCache

SDWebImage实现分析

发表于 2015-02-07   |   分类于 源码分析

源码来源:https://github.com/rs/SDWebImage

版本: 3.7

SDWebImage是一个开源的第三方库,它提供了UIImageView的一个分类,以支持从远程服务器下载并缓存图片的功能。它具有以下功能:

  1. 提供UIImageView的一个分类,以支持网络图片的加载与缓存管理
  2. 一个异步的图片加载器
  3. 一个异步的内存+磁盘图片缓存
  4. 支持GIF图片
  5. 支持WebP图片
  6. 后台图片解压缩处理
  7. 确保同一个URL的图片不被下载多次
  8. 确保虚假的URL不会被反复加载
  9. 确保下载及缓存时,主线程不被阻塞

从github上对SDWebImage使用情况就可以看出,SDWebImage在图片下载及缓存的处理方面还是很被认可的。在本文中,我们主要从源码的角度来分析一下SDWebImage的实现机制。讨论的内容将主要集中在图片的下载及缓存,而不包含对GIF图片及WebP图片的支持操作。

下载

在SDWebImage中,图片的下载是由SDWebImageDownloader类来完成的。它是一个异步下载器,并对图像加载做了优化处理。下面我们就来看看它的具体实现。

下载选项

在下载的过程中,程序会根据设置的不同的下载选项,而执行不同的操作。下载选项由枚举SDWebImageDownloaderOptions定义,具体如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
typedef NS_OPTIONS(NSUInteger, SDWebImageDownloaderOptions) {
SDWebImageDownloaderLowPriority = 1 << 0,
SDWebImageDownloaderProgressiveDownload = 1 << 1,
// 默认情况下请求不使用NSURLCache,如果设置该选项,则以默认的缓存策略来使用NSURLCache
SDWebImageDownloaderUseNSURLCache = 1 << 2,
// 如果从NSURLCache缓存中读取图片,则使用nil作为参数来调用完成block
SDWebImageDownloaderIgnoreCachedResponse = 1 << 3,
// 在iOS 4+系统上,允许程序进入后台后继续下载图片。该操作通过向系统申请额外的时间来完成后台下载。如果后台任务终止,则操作会被取消
SDWebImageDownloaderContinueInBackground = 1 << 4,
// 通过设置NSMutableURLRequest.HTTPShouldHandleCookies = YES来处理存储在NSHTTPCookieStore中的cookie
SDWebImageDownloaderHandleCookies = 1 << 5,
// 允许不受信任的SSL证书。主要用于测试目的。
SDWebImageDownloaderAllowInvalidSSLCertificates = 1 << 6,
// 将图片下载放到高优先级队列中
SDWebImageDownloaderHighPriority = 1 << 7,
};

可以看出,这些选项主要涉及到下载的优先级、缓存、后台任务执行、cookie处理以认证几个方面。

下载顺序

SDWebImage的下载操作是按一定顺序来处理的,它定义了两种下载顺序,如下所示

1
2
3
4
5
6
7
8
typedef NS_ENUM(NSInteger, SDWebImageDownloaderExecutionOrder) {
// 以队列的方式,按照先进先出的顺序下载。这是默认的下载顺序
SDWebImageDownloaderFIFOExecutionOrder,
// 以栈的方式,按照后进先出的顺序下载。
SDWebImageDownloaderLIFOExecutionOrder
};

下载管理器

SDWebImageDownloader下载管理器是一个单例类,它主要负责图片的下载操作的管理。图片的下载是放在一个NSOperationQueue操作队列中来完成的,其声明如下:

1
@property (strong, nonatomic) NSOperationQueue *downloadQueue;

默认情况下,队列最大并发数是6。如果需要的话,我们可以通过SDWebImageDownloader类的maxConcurrentDownloads属性来修改。

所有下载操作的网络响应序列化处理是放在一个自定义的并行调度队列中来处理的,其声明及定义如下:

1
2
3
4
5
6
7
8
9
10
@property (SDDispatchQueueSetterSementics, nonatomic) dispatch_queue_t barrierQueue;
- (id)init {
if ((self = [super init])) {
...
_barrierQueue = dispatch_queue_create("com.hackemist.SDWebImageDownloaderBarrierQueue", DISPATCH_QUEUE_CONCURRENT);
...
}
return self;
}

每一个图片的下载都会对应一些回调操作,如下载进度回调,下载完成回调等,这些回调操作是以block形式来呈现,为此在SDWebImageDownloader.h中定义了几个block,如下所示:

1
2
3
4
5
6
// 下载进度
typedef void(^SDWebImageDownloaderProgressBlock)(NSInteger receivedSize, NSInteger expectedSize);
// 下载完成
typedef void(^SDWebImageDownloaderCompletedBlock)(UIImage *image, NSData *data, NSError *error, BOOL finished);
// Header过滤
typedef NSDictionary *(^SDWebImageDownloaderHeadersFilterBlock)(NSURL *url, NSDictionary *headers);

图片下载的这些回调信息存储在SDWebImageDownloader类的URLCallbacks属性中,该属性是一个字典,key是图片的URL地址,value则是一个数组,包含每个图片的多组回调信息。由于我们允许多个图片同时下载,因此可能会有多个线程同时操作URLCallbacks属性。为了保证URLCallbacks操作(添加、删除)的线程安全性,SDWebImageDownloader将这些操作作为一个个任务放到barrierQueue队列中,并设置屏障来确保同一时间只有一个线程操作URLCallbacks属性,我们以添加操作为例,如下代码所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
- (void)addProgressCallback:(SDWebImageDownloaderProgressBlock)progressBlock andCompletedBlock:(SDWebImageDownloaderCompletedBlock)completedBlock forURL:(NSURL *)url createCallback:(SDWebImageNoParamsBlock)createCallback {
...
// 1. 以dispatch_barrier_sync操作来保证同一时间只有一个线程能对URLCallbacks进行操作
dispatch_barrier_sync(self.barrierQueue, ^{
...
// 2. 处理同一URL的同步下载请求的单个下载
NSMutableArray *callbacksForURL = self.URLCallbacks[url];
NSMutableDictionary *callbacks = [NSMutableDictionary new];
if (progressBlock) callbacks[kProgressCallbackKey] = [progressBlock copy];
if (completedBlock) callbacks[kCompletedCallbackKey] = [completedBlock copy];
[callbacksForURL addObject:callbacks];
self.URLCallbacks[url] = callbacksForURL;
...
});
}

整个下载管理器对于下载请求的管理都是放在downloadImageWithURL:options:progress:completed:方法里面来处理的,该方法调用了上面所提到的addProgressCallback:andCompletedBlock:forURL:createCallback:方法来将请求的信息存入管理器中,同时在创建回调的block中创建新的操作,配置之后将其放入downloadQueue操作队列中,最后方法返回新创建的操作。其具体实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
- (id <SDWebImageOperation>)downloadImageWithURL:(NSURL *)url options:(SDWebImageDownloaderOptions)options progress:(SDWebImageDownloaderProgressBlock)progressBlock completed:(SDWebImageDownloaderCompletedBlock)completedBlock {
...
[self addProgressCallback:progressBlock andCompletedBlock:completedBlock forURL:url createCallback:^{
...
// 1. 创建请求对象,并根据options参数设置其属性
// 为了避免潜在的重复缓存(NSURLCache + SDImageCache),如果没有明确告知需要缓存,则禁用图片请求的缓存操作
NSMutableURLRequest *request = [[NSMutableURLRequest alloc] initWithURL:url cachePolicy:(options & SDWebImageDownloaderUseNSURLCache ? NSURLRequestUseProtocolCachePolicy : NSURLRequestReloadIgnoringLocalCacheData) timeoutInterval:timeoutInterval];
...
// 2. 创建SDWebImageDownloaderOperation操作对象,并进行配置
// 配置信息包括是否需要认证、优先级
operation = [[wself.operationClass alloc] initWithRequest:request
options:options
progress:^(NSInteger receivedSize, NSInteger expectedSize) {
// 3. 从管理器的callbacksForURL中找出该URL所有的进度处理回调并调用
...
for (NSDictionary *callbacks in callbacksForURL) {
SDWebImageDownloaderProgressBlock callback = callbacks[kProgressCallbackKey];
if (callback) callback(receivedSize, expectedSize);
}
}
completed:^(UIImage *image, NSData *data, NSError *error, BOOL finished) {
// 4. 从管理器的callbacksForURL中找出该URL所有的完成处理回调并调用,
// 如果finished为YES,则将该url对应的回调信息从URLCallbacks中删除
...
if (finished) {
[sself removeCallbacksForURL:url];
}
for (NSDictionary *callbacks in callbacksForURL) {
SDWebImageDownloaderCompletedBlock callback = callbacks[kCompletedCallbackKey];
if (callback) callback(image, data, error, finished);
}
}
cancelled:^{
// 5. 取消操作将该url对应的回调信息从URLCallbacks中删除
SDWebImageDownloader *sself = wself;
if (!sself) return;
[sself removeCallbacksForURL:url];
}];
...
// 6. 将操作加入到操作队列downloadQueue中
// 如果是LIFO顺序,则将新的操作作为原队列中最后一个操作的依赖,然后将新操作设置为最后一个操作
[wself.downloadQueue addOperation:operation];
if (wself.executionOrder == SDWebImageDownloaderLIFOExecutionOrder) {
[wself.lastAddedOperation addDependency:operation];
wself.lastAddedOperation = operation;
}
}];
return operation;
}

另外,每个下载操作的超时时间可以通过downloadTimeout属性来设置,默认值为15秒。

下载操作

每个图片的下载都是一个Operation操作。我们在上面分析过这个操作的创建及加入操作队列的过程。现在我们来看看单个操作的具体实现。

SDWebImage定义了一个协议,即SDWebImageOperation作为图片下载操作的基础协议。它只声明了一个cancel方法,用于取消操作。协议的具体声明如下:

1
2
3
4
5
@protocol SDWebImageOperation <NSObject>
- (void)cancel;
@end

SDWebImage自定义了一个Operation类,即SDWebImageDownloaderOperation,它继承自NSOperation,并采用了SDWebImageOperation协议。除了继承而来的方法,该类只向外暴露了一个方法,即上面所用到的初始化方法initWithRequest:options:progress:completed:cancelled:。

对于图片的下载,SDWebImageDownloaderOperation完全依赖于URL加载系统中的NSURLConnection类(并未使用7.0以后的NSURLSession类)。我们先来分析一下SDWebImageDownloaderOperation类中对于图片实际数据的下载处理,即NSURLConnection各代理方法的实现。

首先,SDWebImageDownloaderOperation在分类中采用了NSURLConnectionDataDelegate协议,并实现了该协议的以下几个方法:

1
2
3
4
5
6
7
- connection:didReceiveResponse:
- connection:didReceiveData:
- connectionDidFinishLoading:
- connection:didFailWithError:
- connection:willCacheResponse:
- connectionShouldUseCredentialStorage:
- connection:willSendRequestForAuthenticationChallenge:

我们在此不逐一分析每个方法的实现,就重点分析一下-connection:didReceiveData:方法。该方法的主要任务是接收数据。每次接收到数据时,都会用现有的数据创建一个CGImageSourceRef对象以做处理。在首次获取到数据时(width+height==0)会从这些包含图像信息的数据中取出图像的长、宽、方向等信息以备使用。而后在图片下载完成之前,会使用CGImageSourceRef对象创建一个图片对象,经过缩放、解压缩操作后生成一个UIImage对象供完成回调使用。当然,在这个方法中还需要处理的就是进度信息。如果我们有设置进度回调的话,就调用这个进度回调以处理当前图片的下载进度。

注:缩放操作可以查看SDWebImageCompat文件中的SDScaledImageForKey函数;解压缩操作可以查看SDWebImageDecoder文件+decodedImageWithImage方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
- (void)connection:(NSURLConnection *)connection didReceiveData:(NSData *)data {
// 1. 附加数据
[self.imageData appendData:data];
if ((self.options & SDWebImageDownloaderProgressiveDownload) && self.expectedSize > 0 && self.completedBlock) {
// 2. 获取已下载数据总大小
const NSInteger totalSize = self.imageData.length;
// 3. 更新数据源,我们需要传入所有数据,而不仅仅是新数据
CGImageSourceRef imageSource = CGImageSourceCreateWithData((__bridge CFDataRef)self.imageData, NULL);
// 4. 首次获取到数据时,从这些数据中获取图片的长、宽、方向属性值
if (width + height == 0) {
CFDictionaryRef properties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, NULL);
if (properties) {
NSInteger orientationValue = -1;
CFTypeRef val = CFDictionaryGetValue(properties, kCGImagePropertyPixelHeight);
if (val) CFNumberGetValue(val, kCFNumberLongType, &height);
...
CFRelease(properties);
// 5. 当绘制到Core Graphics时,我们会丢失方向信息,这意味着有时候由initWithCGIImage创建的图片
// 的方向会不对,所以在这边我们先保存这个信息并在后面使用。
orientation = [[self class] orientationFromPropertyValue:(orientationValue == -1 ? 1 : orientationValue)];
}
}
// 6. 图片还未下载完成
if (width + height > 0 && totalSize < self.expectedSize) {
// 7. 使用现有的数据创建图片对象,如果数据中存有多张图片,则取第一张
CGImageRef partialImageRef = CGImageSourceCreateImageAtIndex(imageSource, 0, NULL);
#ifdef TARGET_OS_IPHONE
// 8. 适用于iOS变形图像的解决方案。我的理解是由于iOS只支持RGB颜色空间,所以在此对下载下来的图片做个颜色空间转换处理。
if (partialImageRef) {
const size_t partialHeight = CGImageGetHeight(partialImageRef);
CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB();
CGContextRef bmContext = CGBitmapContextCreate(NULL, width, height, 8, width * 4, colorSpace, kCGBitmapByteOrderDefault | kCGImageAlphaPremultipliedFirst);
CGColorSpaceRelease(colorSpace);
if (bmContext) {
CGContextDrawImage(bmContext, (CGRect){.origin.x = 0.0f, .origin.y = 0.0f, .size.width = width, .size.height = partialHeight}, partialImageRef);
CGImageRelease(partialImageRef);
partialImageRef = CGBitmapContextCreateImage(bmContext);
CGContextRelease(bmContext);
}
else {
CGImageRelease(partialImageRef);
partialImageRef = nil;
}
}
#endif
// 9. 对图片进行缩放、解码操作
if (partialImageRef) {
UIImage *image = [UIImage imageWithCGImage:partialImageRef scale:1 orientation:orientation];
NSString *key = [[SDWebImageManager sharedManager] cacheKeyForURL:self.request.URL];
UIImage *scaledImage = [self scaledImageForKey:key image:image];
image = [UIImage decodedImageWithImage:scaledImage];
CGImageRelease(partialImageRef);
dispatch_main_sync_safe(^{
if (self.completedBlock) {
self.completedBlock(image, nil, nil, NO);
}
});
}
}
CFRelease(imageSource);
}
if (self.progressBlock) {
self.progressBlock(self.imageData.length, self.expectedSize);
}
}

我们前面说过SDWebImageDownloaderOperation类是继承自NSOperation类。它没有简单的实现main方法,而是采用更加灵活的start方法,以便自己管理下载的状态。

在start方法中,创建了我们下载所使用的NSURLConnection对象,开启了图片的下载,同时抛出一个下载开始的通知。当然,如果我们期望下载在后台处理,则只需要配置我们的下载选项,使其包含SDWebImageDownloaderContinueInBackground选项。start方法的具体实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
- (void)start {
@synchronized (self) {
// 管理下载状态,如果已取消,则重置当前下载并设置完成状态为YES
if (self.isCancelled) {
self.finished = YES;
[self reset];
return;
}
#if TARGET_OS_IPHONE && __IPHONE_OS_VERSION_MAX_ALLOWED >= __IPHONE_4_0
// 1. 如果设置了在后台执行,则进行后台执行
if ([self shouldContinueWhenAppEntersBackground]) {
__weak __typeof__ (self) wself = self;
self.backgroundTaskId = [[UIApplication sharedApplication] beginBackgroundTaskWithExpirationHandler:^{
...
}
}];
}
#endif
self.executing = YES;
self.connection = [[NSURLConnection alloc] initWithRequest:self.request delegate:self startImmediately:NO];
self.thread = [NSThread currentThread];
}
[self.connection start];
if (self.connection) {
if (self.progressBlock) {
self.progressBlock(0, NSURLResponseUnknownLength);
}
// 2. 在主线程抛出下载开始通知
dispatch_async(dispatch_get_main_queue(), ^{
[[NSNotificationCenter defaultCenter] postNotificationName:SDWebImageDownloadStartNotification object:self];
});
// 3. 启动run loop
if (floor(NSFoundationVersionNumber) <= NSFoundationVersionNumber_iOS_5_1) {
CFRunLoopRunInMode(kCFRunLoopDefaultMode, 10, false);
}
else {
CFRunLoopRun();
}
// 4. 如果未完成,则取消连接
if (!self.isFinished) {
[self.connection cancel];
[self connection:self.connection didFailWithError:[NSError errorWithDomain:NSURLErrorDomain code:NSURLErrorTimedOut userInfo:@{NSURLErrorFailingURLErrorKey : self.request.URL}]];
}
}
else {
...
}
#if TARGET_OS_IPHONE && __IPHONE_OS_VERSION_MAX_ALLOWED >= __IPHONE_4_0
if (self.backgroundTaskId != UIBackgroundTaskInvalid) {
[[UIApplication sharedApplication] endBackgroundTask:self.backgroundTaskId];
self.backgroundTaskId = UIBackgroundTaskInvalid;
}
#endif
}

当然,在下载完成或下载失败后,需要停止当前线程的run loop,清除连接,并抛出下载停止的通知。如果下载成功,则会处理完整的图片数据,对其进行适当的缩放与解压缩操作,以提供给完成回调使用。具体可参考-connectionDidFinishLoading:与-connection:didFailWithError:的实现。

小结

下载的核心其实就是利用NSURLConnection对象来加载数据。每个图片的下载都由一个Operation操作来完成,并将这些操作放到一个操作队列中。这样可以实现图片的并发下载。

缓存

为了减少网络流量的消耗,我们都希望下载下来的图片缓存到本地,下次再去获取同一张图片时,可以直接从本地获取,而不再从远程服务器获取。这样做的另一个好处是提升了用户体验,用户第二次查看同一幅图片时,能快速从本地获取图片直接呈现给用户。

SDWebImage提供了对图片缓存的支持,而该功能是由SDImageCache类来完成的。该类负责处理内存缓存及一个可选的磁盘缓存。其中磁盘缓存的写操作是异步的,这样就不会对UI操作造成影响。

内存缓存及磁盘缓存

内存缓存的处理是使用NSCache对象来实现的。NSCache是一个类似于集合的容器。它存储key-value对,这一点类似于NSDictionary类。我们通常用使用缓存来临时存储短时间使用但创建昂贵的对象。重用这些对象可以优化性能,因为它们的值不需要重新计算。另外一方面,这些对象对于程序来说不是紧要的,在内存紧张时会被丢弃。

磁盘缓存的处理则是使用NSFileManager对象来实现的。图片存储的位置是位于Cache文件夹。另外,SDImageCache还定义了一个串行队列,来异步存储图片。

内存缓存与磁盘缓存相关变量的声明及定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@interface SDImageCache ()
@property (strong, nonatomic) NSCache *memCache;
@property (strong, nonatomic) NSString *diskCachePath;
@property (strong, nonatomic) NSMutableArray *customPaths;
@property (SDDispatchQueueSetterSementics, nonatomic) dispatch_queue_t ioQueue;
@end
- (id)initWithNamespace:(NSString *)ns {
if ((self = [super init])) {
NSString *fullNamespace = [@"com.hackemist.SDWebImageCache." stringByAppendingString:ns];
...
_ioQueue = dispatch_queue_create("com.hackemist.SDWebImageCache", DISPATCH_QUEUE_SERIAL);
...
// Init the memory cache
_memCache = [[NSCache alloc] init];
_memCache.name = fullNamespace;
// Init the disk cache
NSArray *paths = NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES);
_diskCachePath = [paths[0] stringByAppendingPathComponent:fullNamespace];
dispatch_sync(_ioQueue, ^{
_fileManager = [NSFileManager new];
});
...
}
return self;
}

SDImageCache提供了大量方法来缓存、获取、移除及清空图片。而对于每个图片,为了方便地在内存或磁盘中对它进行这些操作,我们需要一个key值来索引它。在内存中,我们将其作为NSCache的key值,而在磁盘中,我们用这个key作为图片的文件名。对于一个远程服务器下载的图片,其url是作为这个key的最佳选择了。我们在后面会看到这个key值的重要性。

存储图片

我们先来看看图片的缓存操作,该操作会在内存中放置一份缓存,而如果确定需要缓存到磁盘,则将磁盘缓存操作作为一个task放到串行队列中处理。在iOS中,会先检测图片是PNG还是JPEG,并将其转换为相应的图片数据,最后将数据写入到磁盘中(文件名是对key值做MD5摘要后的串)。缓存操作的基础方法是-storeImage:recalculateFromImage:imageData:forKey:toDisk,它的具体实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
- (void)storeImage:(UIImage *)image recalculateFromImage:(BOOL)recalculate imageData:(NSData *)imageData forKey:(NSString *)key toDisk:(BOOL)toDisk {
...
// 1. 内存缓存,将其存入NSCache中,同时传入图片的消耗值
[self.memCache setObject:image forKey:key cost:image.size.height * image.size.width * image.scale * image.scale];
if (toDisk) {
// 2. 如果确定需要磁盘缓存,则将缓存操作作为一个任务放入ioQueue中
dispatch_async(self.ioQueue, ^{
NSData *data = imageData;
if (image && (recalculate || !data)) {
#if TARGET_OS_IPHONE
// 3. 需要确定图片是PNG还是JPEG。PNG图片容易检测,因为有一个唯一签名。PNG图像的前8个字节总是包含以下值:137 80 78 71 13 10 26 10
// 在imageData为nil的情况下假定图像为PNG。我们将其当作PNG以避免丢失透明度。而当有图片数据时,我们检测其前缀,确定图片的类型
BOOL imageIsPng = YES;
if ([imageData length] >= [kPNGSignatureData length]) {
imageIsPng = ImageDataHasPNGPreffix(imageData);
}
if (imageIsPng) {
data = UIImagePNGRepresentation(image);
}
else {
data = UIImageJPEGRepresentation(image, (CGFloat)1.0);
}
#else
data = [NSBitmapImageRep representationOfImageRepsInArray:image.representations usingType: NSJPEGFileType properties:nil];
#endif
}
// 4. 创建缓存文件并存储图片
if (data) {
if (![_fileManager fileExistsAtPath:_diskCachePath]) {
[_fileManager createDirectoryAtPath:_diskCachePath withIntermediateDirectories:YES attributes:nil error:NULL];
}
[_fileManager createFileAtPath:[self defaultCachePathForKey:key] contents:data attributes:nil];
}
});
}
}

查询图片

如果我们想在内存或磁盘中查询是否有key指定的图片,则可以分别使用以下方法:

1
2
- (UIImage *)imageFromMemoryCacheForKey:(NSString *)key;
- (UIImage *)imageFromDiskCacheForKey:(NSString *)key;

而如果只是想查看本地是否在key指定的图片,则不管是在内存还是在磁盘上,则可以使用以下方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
- (NSOperation *)queryDiskCacheForKey:(NSString *)key done:(SDWebImageQueryCompletedBlock)doneBlock {
...
// 1. 首先查看内存缓存,如果查找到,则直接回调doneBlock并返回
UIImage *image = [self imageFromDiskCacheForKey:key];
if (image) {
doneBlock(image, SDImageCacheTypeMemory);
return nil;
}
// 2. 如果内存中没有,则在磁盘中查找。如果找到,则将其放到内存缓存,并调用doneBlock回调
NSOperation *operation = [NSOperation new];
dispatch_async(self.ioQueue, ^{
if (operation.isCancelled) {
return;
}
@autoreleasepool {
UIImage *diskImage = [self diskImageForKey:key];
if (diskImage) {
CGFloat cost = diskImage.size.height * diskImage.size.width * diskImage.scale * diskImage.scale;
[self.memCache setObject:diskImage forKey:key cost:cost];
}
dispatch_async(dispatch_get_main_queue(), ^{
doneBlock(diskImage, SDImageCacheTypeDisk);
});
}
});
return operation;
}

移除图片

图片的移除操作则可以使用以下方法:

1
2
3
4
- (void)removeImageForKey:(NSString *)key;
- (void)removeImageForKey:(NSString *)key withCompletion:(SDWebImageNoParamsBlock)completion;
- (void)removeImageForKey:(NSString *)key fromDisk:(BOOL)fromDisk;
- (void)removeImageForKey:(NSString *)key fromDisk:(BOOL)fromDisk withCompletion:(SDWebImageNoParamsBlock)completion;

我们可以选择同时移除内存及磁盘上的图片。

清理图片

磁盘缓存图片的清理操作可以分为完全清空和部分清理。完全清空操作是直接把缓存的文件夹移除,清空操作有以下两个方法:

1
2
- (void)clearDisk;
- (void)clearDiskOnCompletion:(SDWebImageNoParamsBlock)completion;

而部分清理则是根据我们设定的一些参数值来移除一些文件,这里主要有两个指标:文件的缓存有效期及最大缓存空间大小。文件的缓存有效期可以通过maxCacheAge属性来设置,默认是1周的时间。如果文件的缓存时间超过这个时间值,则将其移除。而最大缓存空间大小是通过maxCacheSize属性来设置的,如果所有缓存文件的总大小超过这一大小,则会按照文件最后修改时间的逆序,以每次一半的递归来移除那些过早的文件,直到缓存的实际大小小于我们设置的最大使用空间。清理的操作在-cleanDiskWithCompletionBlock:方法中,其实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
- (void)cleanDiskWithCompletionBlock:(SDWebImageNoParamsBlock)completionBlock {
dispatch_async(self.ioQueue, ^{
NSURL *diskCacheURL = [NSURL fileURLWithPath:self.diskCachePath isDirectory:YES];
NSArray *resourceKeys = @[NSURLIsDirectoryKey, NSURLContentModificationDateKey, NSURLTotalFileAllocatedSizeKey];
// 1. 该枚举器预先获取缓存文件的有用的属性
NSDirectoryEnumerator *fileEnumerator = [_fileManager enumeratorAtURL:diskCacheURL
includingPropertiesForKeys:resourceKeys
options:NSDirectoryEnumerationSkipsHiddenFiles
errorHandler:NULL];
NSDate *expirationDate = [NSDate dateWithTimeIntervalSinceNow:-self.maxCacheAge];
NSMutableDictionary *cacheFiles = [NSMutableDictionary dictionary];
NSUInteger currentCacheSize = 0;
// 2. 枚举缓存文件夹中所有文件,该迭代有两个目的:移除比过期日期更老的文件;存储文件属性以备后面执行基于缓存大小的清理操作
NSMutableArray *urlsToDelete = [[NSMutableArray alloc] init];
for (NSURL *fileURL in fileEnumerator) {
NSDictionary *resourceValues = [fileURL resourceValuesForKeys:resourceKeys error:NULL];
// 3. 跳过文件夹
if ([resourceValues[NSURLIsDirectoryKey] boolValue]) {
continue;
}
// 4. 移除早于有效期的老文件
NSDate *modificationDate = resourceValues[NSURLContentModificationDateKey];
if ([[modificationDate laterDate:expirationDate] isEqualToDate:expirationDate]) {
[urlsToDelete addObject:fileURL];
continue;
}
// 5. 存储文件的引用并计算所有文件的总大小,以备后用
NSNumber *totalAllocatedSize = resourceValues[NSURLTotalFileAllocatedSizeKey];
currentCacheSize += [totalAllocatedSize unsignedIntegerValue];
[cacheFiles setObject:resourceValues forKey:fileURL];
}
for (NSURL *fileURL in urlsToDelete) {
[_fileManager removeItemAtURL:fileURL error:nil];
}
// 6.如果磁盘缓存的大小大于我们配置的最大大小,则执行基于文件大小的清理,我们首先删除最老的文件
if (self.maxCacheSize > 0 && currentCacheSize > self.maxCacheSize) {
// 7. 以设置的最大缓存大小的一半作为清理目标
const NSUInteger desiredCacheSize = self.maxCacheSize / 2;
// 8. 按照最后修改时间来排序剩下的缓存文件
NSArray *sortedFiles = [cacheFiles keysSortedByValueWithOptions:NSSortConcurrent
usingComparator:^NSComparisonResult(id obj1, id obj2) {
return [obj1[NSURLContentModificationDateKey] compare:obj2[NSURLContentModificationDateKey]];
}];
// 9. 删除文件,直到缓存总大小降到我们期望的大小
for (NSURL *fileURL in sortedFiles) {
if ([_fileManager removeItemAtURL:fileURL error:nil]) {
NSDictionary *resourceValues = cacheFiles[fileURL];
NSNumber *totalAllocatedSize = resourceValues[NSURLTotalFileAllocatedSizeKey];
currentCacheSize -= [totalAllocatedSize unsignedIntegerValue];
if (currentCacheSize < desiredCacheSize) {
break;
}
}
}
}
if (completionBlock) {
dispatch_async(dispatch_get_main_queue(), ^{
completionBlock();
});
}
});
}

小结

以上分析了图片缓存操作,当然,除了上面讲的几个操作,SDImageCache类还提供了一些辅助方法。如获取缓存大小、缓存中图片的数量、判断缓存中是否存在某个key指定的图片。另外,SDImageCache类提供了一个单例方法的实现,所以我们可以将其当作单例对象来处理。

SDWebImageManager

在实际的运用中,我们并不直接使用SDWebImageDownloader类及SDImageCache类来执行图片的下载及缓存。为了方便用户的使用,SDWebImage提供了SDWebImageManager对象来管理图片的下载与缓存。而且我们经常用到的诸如UIImageView+WebCache等控件的分类都是基于SDWebImageManager对象的。该对象将一个下载器和一个图片缓存绑定在一起,并对外提供两个只读属性来获取它们,如下代码所示:

1
2
3
4
5
6
7
8
9
10
@interface SDWebImageManager : NSObject
@property (weak, nonatomic) id <SDWebImageManagerDelegate> delegate;
@property (strong, nonatomic, readonly) SDImageCache *imageCache;
@property (strong, nonatomic, readonly) SDWebImageDownloader *imageDownloader;
...
@end

从上面的代码中我们还可以看到有一个delegate属性,其是一个id\<SDWebImageManagerDelegate\>对象。SDWebImageManagerDelegate声明了两个可选实现的方法,如下所示:

1
2
3
4
5
// 控制当图片在缓存中没有找到时,应该下载哪个图片
- (BOOL)imageManager:(SDWebImageManager *)imageManager shouldDownloadImageForURL:(NSURL *)imageURL;
// 允许在图片已经被下载完成且被缓存到磁盘或内存前立即转换
- (UIImage *)imageManager:(SDWebImageManager *)imageManager transformDownloadedImage:(UIImage *)image withURL:(NSURL *)imageURL;

这两个代理方法会在SDWebImageManager的-downloadImageWithURL:options:progress:completed:方法中调用,而这个方法是SDWebImageManager类的核心所在。我们来看看它的具体实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
- (id <SDWebImageOperation>)downloadImageWithURL:(NSURL *)url
options:(SDWebImageOptions)options
progress:(SDWebImageDownloaderProgressBlock)progressBlock
completed:(SDWebImageCompletionWithFinishedBlock)completedBlock {
...
// 前面省略n行。主要作了如下处理:
// 1. 判断url的合法性
// 2. 创建SDWebImageCombinedOperation对象
// 3. 查看url是否是之前下载失败过的
// 4. 如果url为nil,或者在不可重试的情况下是一个下载失败过的url,则直接返回操作对象并调用完成回调
operation.cacheOperation = [self.imageCache queryDiskCacheForKey:key done:^(UIImage *image, SDImageCacheType cacheType) {
...
if ((!image || options & SDWebImageRefreshCached) && (![self.delegate respondsToSelector:@selector(imageManager:shouldDownloadImageForURL:)] || [self.delegate imageManager:self shouldDownloadImageForURL:url])) {
// 下载
id <SDWebImageOperation> subOperation = [self.imageDownloader downloadImageWithURL:url options:downloaderOptions progress:progressBlock completed:^(UIImage *downloadedImage, NSData *data, NSError *error, BOOL finished) {
if (weakOperation.isCancelled) {
// 操作被取消,则不做任务事情
}
else if (error) {
// 如果出错,则调用完成回调,并将url放入下载挫败url数组中
...
}
else {
BOOL cacheOnDisk = !(options & SDWebImageCacheMemoryOnly);
if (options & SDWebImageRefreshCached && image && !downloadedImage) {
// Image refresh hit the NSURLCache cache, do not call the completion block
}
else if (downloadedImage && (!downloadedImage.images || (options & SDWebImageTransformAnimatedImage)) && [self.delegate respondsToSelector:@selector(imageManager:transformDownloadedImage:withURL:)]) {
// 在全局队列中并行处理图片的缓存
// 首先对图片做个转换操作,该操作是代理对象实现的
// 然后对图片做缓存处理
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{
UIImage *transformedImage = [self.delegate imageManager:self transformDownloadedImage:downloadedImage withURL:url];
if (transformedImage && finished) {
BOOL imageWasTransformed = ![transformedImage isEqual:downloadedImage];
[self.imageCache storeImage:transformedImage recalculateFromImage:imageWasTransformed imageData:data forKey:key toDisk:cacheOnDisk];
}
...
});
}
else {
if (downloadedImage && finished) {
[self.imageCache storeImage:downloadedImage recalculateFromImage:NO imageData:data forKey:key toDisk:cacheOnDisk];
}
...
}
}
// 下载完成并缓存后,将操作从队列中移除
if (finished) {
@synchronized (self.runningOperations) {
[self.runningOperations removeObject:operation];
}
}
}];
// 设置取消回调
operation.cancelBlock = ^{
[subOperation cancel];
@synchronized (self.runningOperations) {
[self.runningOperations removeObject:weakOperation];
}
};
}
else if (image) {
...
}
else {
...
}
}];
return operation;
}

对于这个方法,我们没有做过多的解释。其主要就是下载图片并根据操作选项来缓存图片。上面这个下载方法中的操作选项参数是由枚举SDWebImageOptions来定义的,这个操作中的一些选项是与SDWebImageDownloaderOptions中的选项对应的。我们来看看这个SDWebImageOptions选项都有哪些:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
typedef NS_OPTIONS(NSUInteger, SDWebImageOptions) {
// 默认情况下,当URL下载失败时,URL会被列入黑名单,导致库不会再去重试,该标记用于禁用黑名单
SDWebImageRetryFailed = 1 << 0,
// 默认情况下,图片下载开始于UI交互,该标记禁用这一特性,这样下载延迟到UIScrollView减速时
SDWebImageLowPriority = 1 << 1,
// 该标记禁用磁盘缓存
SDWebImageCacheMemoryOnly = 1 << 2,
// 该标记启用渐进式下载,图片在下载过程中是渐渐显示的,如同浏览器一下。
// 默认情况下,图像在下载完成后一次性显示
SDWebImageProgressiveDownload = 1 << 3,
// 即使图片缓存了,也期望HTTP响应cache control,并在需要的情况下从远程刷新图片。
// 磁盘缓存将被NSURLCache处理而不是SDWebImage,因为SDWebImage会导致轻微的性能下载。
// 该标记帮助处理在相同请求URL后面改变的图片。如果缓存图片被刷新,则完成block会使用缓存图片调用一次
// 然后再用最终图片调用一次
SDWebImageRefreshCached = 1 << 4,
// 在iOS 4+系统中,当程序进入后台后继续下载图片。这将要求系统给予额外的时间让请求完成
// 如果后台任务超时,则操作被取消
SDWebImageContinueInBackground = 1 << 5,
// 通过设置NSMutableURLRequest.HTTPShouldHandleCookies = YES;来处理存储在NSHTTPCookieStore中的cookie
SDWebImageHandleCookies = 1 << 6,
// 允许不受信任的SSL认证
SDWebImageAllowInvalidSSLCertificates = 1 << 7,
// 默认情况下,图片下载按入队的顺序来执行。该标记将其移到队列的前面,
// 以便图片能立即下载而不是等到当前队列被加载
SDWebImageHighPriority = 1 << 8,
// 默认情况下,占位图片在加载图片的同时被加载。该标记延迟占位图片的加载直到图片已以被加载完成
SDWebImageDelayPlaceholder = 1 << 9,
// 通常我们不调用动画图片的transformDownloadedImage代理方法,因为大多数转换代码可以管理它。
// 使用这个票房则不任何情况下都进行转换。
SDWebImageTransformAnimatedImage = 1 << 10,
};

大家在看-downloadImageWithURL:options:progress:completed:,可以看到两个SDWebImageOptions与SDWebImageDownloaderOptions中的选项是如何对应起来的,在此不多做解释。

视图扩展

我在使用SDWebImage的时候,使用得最多的是UIImageView+WebCache中的针对UIImageView的扩展方法,这些扩展方法将UIImageView与WebCache集成在一起,来让UIImageView对象拥有异步下载和缓存远程图片的能力。其中最核心的方法是-sd_setImageWithURL:placeholderImage:options:progress:completed:,其使用SDWebImageManager单例对象下载并缓存图片,完成后将图片赋值给UIImageView对象的image属性,以使图片显示出来,其具体实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
- (void)sd_setImageWithURL:(NSURL *)url placeholderImage:(UIImage *)placeholder options:(SDWebImageOptions)options progress:(SDWebImageDownloaderProgressBlock)progressBlock completed:(SDWebImageCompletionBlock)completedBlock {
...
if (url) {
__weak UIImageView *wself = self;
// 使用SDWebImageManager单例对象来下载图片
id <SDWebImageOperation> operation = [SDWebImageManager.sharedManager downloadImageWithURL:url options:options progress:progressBlock completed:^(UIImage *image, NSError *error, SDImageCacheType cacheType, BOOL finished, NSURL *imageURL) {
if (!wself) return;
dispatch_main_sync_safe(^{
if (!wself) return;
// 图片下载完后显示图片
if (image) {
wself.image = image;
[wself setNeedsLayout];
} else {
if ((options & SDWebImageDelayPlaceholder)) {
wself.image = placeholder;
[wself setNeedsLayout];
}
}
if (completedBlock && finished) {
completedBlock(image, error, cacheType, url);
}
});
}];
[self sd_setImageLoadOperation:operation forKey:@"UIImageViewImageLoad"];
} else {
...
}
}

除了扩展UIImageView之外,SDWebImage还扩展了UIView、UIButton、MKAnnotationView等视图类,大家可以参考源码。

当然,如果不想使用这些扩展,则可以直接使用SDWebImageManager来下载图片,这也是很OK的。

技术点

SDWebImage的主要任务就是图片的下载和缓存。为了支持这些操作,它主要使用了以下知识点:

  1. dispatch_barrier_sync函数:该方法用于对操作设置屏幕,确保在执行完任务后才会执行后续操作。该方法常用于确保类的线程安全性操作。
  2. NSMutableURLRequest:用于创建一个网络请求对象,我们可以根据需要来配置请求报头等信息。
  3. NSOperation及NSOperationQueue:操作队列是Objective-C中一种高级的并发处理方法,现在它是基于GCD来实现的。相对于GCD来说,操作队列的优点是可以取消在任务处理队列中的任务,另外在管理操作间的依赖关系方面也容易一些。对SDWebImage中我们就看到了如何使用依赖将下载顺序设置成后进先出的顺序。
  4. NSURLConnection:用于网络请求及响应处理。在iOS7.0后,苹果推出了一套新的网络请求接口,即NSURLSession类。
  5. 开启一个后台任务。
  6. NSCache类:一个类似于集合的容器。它存储key-value对,这一点类似于NSDictionary类。我们通常用使用缓存来临时存储短时间使用但创建昂贵的对象。重用这些对象可以优化性能,因为它们的值不需要重新计算。另外一方面,这些对象对于程序来说不是紧要的,在内存紧张时会被丢弃。
  7. 清理缓存图片的策略:特别是最大缓存空间大小的设置。如果所有缓存文件的总大小超过这一大小,则会按照文件最后修改时间的逆序,以每次一半的递归来移除那些过早的文件,直到缓存的实际大小小于我们设置的最大使用空间。
  8. 对图片的解压缩操作:这一操作可以查看SDWebImageDecoder.m中+decodedImageWithImage方法的实现。
  9. 对GIF图片的处理
  10. 对WebP图片的处理

感兴趣的同学可以深入研究一下这些知识点。当然,这只是其中一部分,更多的知识还有待大家去发掘。

参考

  1. SDWebImage工程
  2. Grand Central Dispatch (GCD) Reference
  3. 常见的后台实践
  4. NSOperation Class Reference
  5. NSCache Class Reference

Foundation: NSObject Protocol

发表于 2015-01-31   |   分类于 Cocoa

前面一章我们整理了NSObject类,这一章我们来看看NSObject协议的内容。

NSObject协议提供了一组方法作为Objective-C对象的基础。其实我们对照一个NSObject类和NSObject协议,可以看到很多方法的方法名都是一样的,只不过NSObject类提供的是类方法,是基于类级别的操作;而NSObject协议提供的是实例方法,是基于实例对象级别的操作。

如果一个对象如果采用了这个协议,则可以被看作是一级对象。我们可以从这个对象获取以下信息:

  1. 类信息,以及类所在的继承体系。
  2. 协议信息
  3. 响应特定消息的能力

实际上,Cocoa的根类NSObject就采用了这个类,所以所有继承自NSObject类的对象都具备NSObject协议中描述的功能。接下来,我们参照NSObject类,整理一下这些功能。

识别对象

类似于NSObject类,NSObject协议提供了一些方法来识别类。

如果想获取对象的类对象,则可以使用如下方法:

1
- (Class)class

如果想获取对象父类的类对象,则可以使用以下只读属性:

1
@property(readonly) Class superclass

如果想查看某个对象是否是给定类的实例或者是给定类子类的实例,则可以使用以下方法:

1
- (BOOL)isKindOfClass:(Class)aClass

这个方法应该是大家常用的方法。需要注意的是在类簇中使用这个方法。在类簇中,我们获取到的对象类型可能并不是我们期望的类型。如果我们调用一个返回类簇的方法,则这个方法返回的实际类型会是最能标识这个类能做些什么的类型。例如,如果一个方法返回一个指向NSArray对象的指针,则不能使用isKindOfClass:方法查看经是否是一个可变数组,如以下代码:

1
2
3
4
if ([myArray isKindOfClass:[NSMutableArray class]])
{
// Modify the object
}

如果我们使用这样的代码,我们可能会认为修改一个实际上不应该被修改的对象是没问题的。这样做可能会对那些期望对象保持不要变的代码产生影响。

另外,查看对象是否是指定类的一个实例还可以使用以下方法:

1
- (BOOL)isMemberOfClass:(Class)aClass

注意,这个方法无法确定对象是否是指定类子类的实例。另外,类对象可能是编译器创建的对象,但它仍然支持这一概念。

测试对象

对于对象的测试,NSObject协议也定义了两个方法,其中respondsToSelector:方法用于测试对象是否能响应指定的消息,这个方法可以是类自定义的实例方法,也可以是继承而来的实例方法。其声明如下:

1
- (BOOL)respondsToSelector:(SEL)aSelector

不过我们不能使用super关键字来调用respondsToSelector:,以查看对象是否是从其父类继承了某个方法。因为我们可以从super的定义可知,消息的最终实际接收者还是self本身,因此测试的还是对象的整个体系(包括对象所在类本身),而不仅仅是父类。不过,我们可以使用父类来调用NSObject类的类方法instancesRespondToSelector:来达到这个目的,如下所示:

1
2
3
4
if( [MySuperclass instancesRespondToSelector:@selector(aMethod)] ) {
// invoke the inherited method
[super aMethod];
}

我们不能简单地使用[[self superclass] instancesRespondToSelector:@selector(aMethod)],因为如果由一个子类来调用,则可能导致方法的失败。

还需要注意的是,如果对象能够转发消息,则也可以响应这个消息,不过这个方法会返回NO。

如果想查看对象是否实现了某个类,则可以使用如下方法:

1
- (BOOL)conformsToProtocol:(Protocol *)aProtocol

这个方法与NSObject类的类方法conformsToProtocol:是一样的。它只是提供了一个便捷方法,我们不需要先去取对象的类,再调用类方法conformsToProtocol:。

标识和比较对象

如果我们想获取对象本身,则可以使用以下方法:

1
- (instancetype)self

比较两个对象是否相同,则可以使用以下方法:

1
- (BOOL)isEqual:(id)anObject

这个方法定义了对象相同的意义。例如,一个容器对象可能会按照特定规则来定义两个对象是否相等,如其所有元素的isEqual:请求都返回YES。我们在自定义子类时,可以重写这个方法,以使用我们自己的规则来评判两个对象相等。

如果两个对象相等,则它们必须拥有相同的hash值。在子类中定义isEqual:方法并打算把子类的实例放入集合中时,这一点非常重要。因此在子类中必须同时定义hash。

hash值是一个整数值,它可以用于在hash表结构中作为一个表地址。其声明如下:

1
@property(readonly) NSUInteger hash

如果一个可变对象被添加到一个以hash值来确定对象位置的集合中,则当对象还在集合中时,其由hash方法返回的值不能改变。因此,hash方法不能依赖于对象内部的任何状态信息,或许我们必须确保对象在集合中时,不能改变其内部状态信息。比如,一个可变字典可以放到一个hash表中,但当它还在表中时,不能改变它。

发送消息

在NSObject类中,定义了一系列的发送消息的方法,用于在目标线程中执行方法。NSObject协议也定义了如下几个方法,来执行发送消息的任务:

1
2
3
- (id)performSelector:(SEL)aSelector
- (id)performSelector:(SEL)aSelector withObject:(id)anObject
- (id)performSelector:(SEL)aSelector withObject:(id)anObject withObject:(id)anotherObject

这三个方法基本相同,只不过后面两个方法能为selector指定的方法携带参数。因此我们以performSelector:为例。

performSelector:方法的使用与直接将消息发送给对象的效果是一样的,如下面几个操作,做的事情是一样的:

1
2
3
id myClone = [anObject copy];
id myClone = [anObject performSelector:@selector(copy)];
id myClone = [anObject performSelector:sel_getUid("copy")];

区别在于,performSelector:允许在运行时再去确定对象是否能处理消息。而[anObject copy]中,如果anObject不能处理copy,编译器就直接会报错。

如果方法的参数过多,以至于上面几个方法都无法处理,则可以考虑使用NSInvocation对象。

描述对象

描述对象的方法与NSObject类中描述类的方法其方法名相同,都是description,其声明如下:

1
@property(readonly, copy) NSString *description

这个方法用于创建一个对象的文本表达方式,例如:

1
2
ClassName *anObject = <#An object#>;
NSString *string = [NSString stringWithFormat:@"anObject is %@", anObject];

为了便于调试,NSObject协议还定义debugDescription方法,该方法声明如下:

1
@property(readonly, copy) NSString *debugDescription

该方法返回一个在调试器中显示的用于描述对象内容的字符串。在调试器中打印一个对象时,会调用这个方法。NSObject类实现这个方法时只是调用了description方法,所以默认情况下,这两个方法的输出都是一样的。我们在子类中可以重写这个方法的实现。

总结

NSObject协议的定义的很多方法都是我们平常经常使用的。我们在创建NSObject类的子类时,默认都继承了NSObject类对于NSObject协议的实现。如果有特殊的需求,我们可以重写这些方法。

当然,NSObject协议还定义了一些方法,如我们非常熟悉的retain, release, autorelease, retainCount方法,不过这些方法在ARC时代已经过时了,我们在此不过多说明。

参考

  1. NSObject Protocol Reference
1234…9
南峰子

南峰子

86 日志
7 分类
© 2017 南峰子
由 Hexo 强力驱动
主题 - NexT.Pisces