学计算机的那个

不是我觉到、悟到,你给不了我,给了也拿不住;只有我觉到、悟到,才有可能做到,能做到的才是我的.

0%

【读书笔记】《Effective Objective-C 2.0》

第一章:熟悉 Objective-C

了解 Objective-C 语言的起源

OC 的动态性都是由 “运行期组件” ,也就是 Runtime 库(ObjC4)来实现的,使用 OC 的面向对象特性所需的全部数据结构以及函数都在 ObjC4 里面。

运行期组件本质上是一种与开发者所编写的代码相链接的动态库(dynamic library),其代码能把开发者所编写的所有程序粘合起来,所以只要更新运行期组件,就可以提升应用程序性能。

在类的头文件中尽量少引用其他头文件

有时,类A需要将类B的实例变量作为它公共API的属性。这个时候,我们不应该引入类B的头文件,而应该使用向前声明(forward declaring)使用class关键字,并且在A的实现文件引用B的头文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// EOCPerson.h
#import <Foundation/Foundation.h>

@class EOCEmployer;

@interface EOCPerson : NSObject

@property (nonatomic, copy) NSString *firstName;
@property (nonatomic, copy) NSString *lastName;
@property (nonatomic, strong) EOCEmployer *employer;//将EOCEmployer作为属性

@end

// EOCPerson.m
#import "EOCEmployer.h"

优点:
不在A的头文件中引入B的头文件,就不会一并引入B的全部内容,这样就减少了编译时间。
可以避免循环引用:因为如果两个类在自己的头文件中都引入了对方的头文件,那么就会导致其中一个类无法被正确编译。

个别的时候,必须在头文件中引入其他类的头文件:

  1. 该类继承于某个类,则应该引入父类的头文件。
  2. 该类遵从某个协议,则应该引入该协议的头文件。而且最好将协议单独放在一个头文件中。

多用类型常量,少用 #define 预处理指令

  • 不要用预处理指令定义常量。这样定义出来的常量不含类型信息,编译器只是会在编译前据此执行查找与替换操作。即使有人重新定义了常量值,编译器也不会产生警告信息,这将导致应用程序中的常量值不一致。

  • 在实现文件中使用 static const 来定义 “只在编译单元内可见的常量”(translation-unit-specific constant)。由于此类常量不在全局符号表中,所以无须为其名称加前缀。

  • 在头文件中使用 extern 来声明全局常量,并在相关实现文件中定义其值。这种常量要出现在全局符号表中,所以其名称应加以区隔,通常用与之相关的类名做前缀

类型常量

1
static const NSTimeInterval kAnimationDuration = 0.3;

static 修饰符意味着该常量只在定义它的 .m 中可见(设置了其使用范围)

const 修饰符意味着该常量不可修改(不可修改)

类型常量命名法

  1. 如果常量局限于某 “编译单元”(也就是 .m 中),则命名前面加字母 k,比如 kAnimationDuration
  2. 如果常量在类之外可见,定义成了全局常量,则通常以 类名 作为前缀,比如 EOCViewClassAnimationDuration

局部类型常量

1
static const NSTimeInterval kAnimationDuration = 0.3;
  1. 一定要同时使用 static 和 const 来声明。这样编译器就不会创建符号,而是像预处理指令一样,进行值替换。
  2. 如果试图修改由 const 修饰的变量,编译器就会报错。
  3. 如果不加 static,则编译器就会它创建一个 “外部符号 symbol”。此时如果另一个编译单元中也声明了同名变量,那么编译器就会抛出 “重复定义符号” 的错误:
1
2
3
duplicate symbol _kAnimationDuration in:
EOCAnimatedView.o
EOCOtherView.o
  1. 局部类型常量不放在 “全局符号表” 中,所以无须用类名作为前缀。

全局类型常量

1
2
3
4
5
// In the header file
extern NSString *const EOCStringConstant;

// In the implementation file
NSString *const EOCStringConstant = @"VALUE";
  1. 此类常量会被放在 “全局符号表” 中,这样才可以在定义该常量的编译单元之外使用。
  2. const 位置不同则常量类型不同,以上为,定义一个指针常量 EOCStringConstant,指向 NSString 对象。也就是说,EOCStringConstant 不会再指向另一个 NSString 对象。
  3. extern 是告诉编译器,在 “全局符号表” 中将会有一个名叫 EOCStringConstant 的符号。这样编译器就允许代码使用该常量。因为它知道,当链接成二进制文件后,肯定能找到这个常量。
  4. 必须要定义,而且只能定义一次,通常定义在声明该常量的 .h 的对应的 .m 中。
  5. 在实现文件生成目标文件时(编译器每收到一个 “编译单元” .m,就会输出一份 “目标文件” ),编译器会在 “数据段” 为字符串分配存储空间。链接器会把此目标文件与其他目标文件相链接,以生成最终的二进制文件。凡是用到 EOCStringConstant 这个全局符号的地方,链接器都能将其解析。
  6. 因为符号要放在全局符号表里,所以常量命名需谨慎,为避免名称冲突,一般以类名作为前缀。

第二章:对象、消息、运行期

理解 “属性” 这一概念

@synthesize@dynamic

  1. 可以通过@synthesize来指定实例变量名字,如果你不喜欢默认的以下划线开头来命名实例变量的话。但最好还是用默认的,否则别人可能看不懂。

如果不想令编译器合成存取方法,则可以自己实现。如果你只实现了其中一个存取方法 setter or getter,那么另一个还是会由编译器来合成。但是需要注意的是,如果你实现了属性所需的全部方法(如果属性是 readwrite 则需实现 setter and getter,如果是 readonly 则只需实现 getter 方法),那么编译器就不会自动进行 @synthesize,这时候就不会生成该属性的实例变量,需要根据实际情况自己手动 @synthesize 一下。

@synthesize name = _myName;

  1. @dynamic 会告诉编译器:不要自动创建实现属性所用的实例变量,也不要为其创建存取方法,即告诉编译器你要自己做这些事。当使用了 @dynamic,即使你没有为其实现存取方法,编译器也不会报错,因为你已经告诉它你要自己来做。
  • 注意:遵循属性定义
    如果属性定义为copy,那么在非设置方法里设定属性的时候,也要遵循copy的语义
1
2
3
4
5
6
7
8
- (id)initWithFirstName:(NSString*)firstName lastName:(NSString*)lastName
{
if ((self = [super init])) {
_firstName = [firstName copy];
_lastName = [lastName copy];
}
return self;
}

理解 “对象等同性” 这一概念

== 操作符比较的是两个指针本身,而不是其所指的对象,应该使用 NSObject 协议中声明的 isEqual: 方法来判断两个对象的等同性。某些对象提供了特殊的“等同性判定方法”,比如 NSStringisEqualToString:、NSArrayisEqualToArray:、NSDictionaryisEqualToDictionary:。

理解Objective-C错误类型

用NSError描述错误。 使用NSError可以封装三种信息:

  1. Error domain:错误范围,类型是字符串
  2. Error code :错误码,类型是整数
  3. User info:用户信息,类型是字典

接口与API设计

理解 NSCopying 协议

  • 对 mutable 对象与 immutable 对象 进行 copy 与 mutableCopy 的结果:

注:这里的 mutable 对象与 immutable 对象指的是系统类 NSArray、NSDictionary、NSSet、NSString、NSData 与它们的可变版本如 NSMutableArray 等。

  • 以上对 collection 容器对象进行的深浅拷贝是指对容器对象本身的,对 collection 中的对象执行的默认都是浅拷贝。也就是说只拷贝容器对象本身,而不复制其中的数据。主要原因是,容器内的对象未必都能拷贝,而且调用者也未必想在拷贝容器时一并拷贝其中的每个对象。
  • 如果想要实现对自定义对象的拷贝,需要遵守 NSCopying 协议,并实现 copyWithZone: 方法

如果要浅拷贝,copyWithZone: 方法就返回同一个对象:return self;
如果要深拷贝,copyWithZone: 方法中就创建新对象,并给希望拷贝的属性赋值。

  • 如果自定义对象支持可变拷贝和不可变拷贝,那么还需要遵守 NSMutableCopying 协议,并实现 mutableCopyWithZone: 方法,返回可变副本。而 copyWithZone: 方法返回不可变副本。使用方可根据需要调用该对象的 copymutableCopy 方法来进行不可变拷贝或可变拷贝。

尽量使用不可变对象

把对外公布出来的属性设置为只读,在实现文件内部设为读写。具体做法是:

在头文件中,设置对象属性为readonly,在实现文件中设置为readwrite。这样一来,在外部就只能读取该数据,而不能修改它,使得这个类的实例所持有的数据更加安全。

1
2
3
4
5
6
7
8
9
10
11
@interface EOCPerson : NSObject

@property (nonatomic, copy, readonly) NSString *firstName;
@property (nonatomic, copy, readonly) NSString *lastName;
@property (nonatomic, strong, readonly) NSSet *friends //向外公开的不可变集合

- (id)initWithFirstName:(NSString*)firstName lastName:(NSString*)lastName;
- (void)addFriend:(EOCPerson*)person;
- (void)removeFriend:(EOCPerson*)person;

@end

在这里,我们将friends属性设置为不可变的set。然后,提供了来增加和删除这个set里的元素的公共接口。

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
@interface EOCPerson ()

@property (nonatomic, copy, readwrite) NSString *firstName;
@property (nonatomic, copy, readwrite) NSString *lastName;

@end

@implementation EOCPerson {
NSMutableSet *_internalFriends; //实现文件里的可变集合
}

- (NSSet*)friends {
return [_internalFriends copy]; //get方法返回的永远是可变set的不可变型
}

- (void)addFriend:(EOCPerson*)person {
[_internalFriends addObject:person]; //在外部增加集合元素的操作
//do something when add element
}

- (void)removeFriend:(EOCPerson*)person {
[_internalFriends removeObject:person]; //在外部移除元素的操作
//do something when remove element
}

- (id)initWithFirstName:(NSString*)firstName lastName:(NSString*)lastName {

if ((self = [super init])) {
_firstName = firstName;
_lastName = lastName;
_internalFriends = [NSMutableSet new];
}
return self;
}
  • 在公共接口设置不可变set 和 将增删的代码放在公共接口中是否矛盾的?

因为如果将friends属性设置为可变的,那么外部就可以随便更改set集合里的数据,这里的更改,仅仅是底层数据的更改,并不伴随其他任何操作。
然而有时,我们需要在更改set数据的同时要执行隐秘在实现文件里的其他工作,那么如果在外部随意更改这个属性的话,显然是达不到这种需求的。
因此,我们需要提供给外界我们定制的增删的方法,并不让外部”自行“增删。

协议与分类

通过委托与数据源协议进行对象间通信

可以通过委托 (也就是我们平常所说的 “代理 delegate” )与数据源(data source)协议进行对象间通信。
协议中可以定义什么?方法和属性。

委托模式(代理模式)

  • 代理模式的主旨:
    定义一个委托协议,若对象想接受另一个对象(委托方)的委托,则需遵守该协议,以成为 “代理方”。而委托方则可以通过协议方法给代理方回传一些信息,也可以在发生相关事件时通知代理方。这样委托方就可以把应对某个行为的责任委托给代理方去处理了。

  • 代理的工作流程:

“委托方” 要求 “代理方” 需要实现的接口,全都定义在 “委托协议” 当中;
“代理方” 遵守 “协议” 并实现 “协议” 方法;
“代理方” 所实现的 “协议” 方法可能会有返回值,将返回值返回给 “委托方” ;
“委托方” 调用 “代理方” 遵从的 “协议” 方法。

  • 数据源模式
    旨在向类提供数据,所以也称 “数据源模式”。
    数据源模式是用协议定义一套接口,令某类经由该接口获取其所需的数据。

  • 数据源模式与常规委托模式的区别在于:
    数据源模式中,信息从数据源(Data Source)流向类(委托方);
    常规委托模式中,信息从类(委托方)流向受委托者(代理方)。

  • 通过 UITableView 就可以很好的理解 Data Source 和 Delegate 这两种模式:
    通过 UITableViewDataSource 协议获取要在列表中显示的数据;
    通过 UITableViewDelegate 协议来处理用户与列表的交互操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@protocol UITableViewDataSource<NSObject>
@required
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section;
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath;
@optional
- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView;
...
@end

@protocol UITableViewDelegate<NSObject, UIScrollViewDelegate>
@optional
-(void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath;
...
@end

性能优化

在实现委托模式和数据源模式时,如果协议方法时可选的,那么在调用协议方法时就需要判断其是否能响应。

1
2
3
if (_delegate && [_delegate respondsToSelector:@selector(networkFetcher:didReceiveData)]) {
[_delegate networkFetcher:self didReceiveData:data];
}

如果我们需要频繁调用该协议方法,那么仅需要第一次判断是否能响应即可。以上代码可做性能优化,将代理方是否能响应某个协议方法这一信息缓存起来:

  1. 在委托方中嵌入一个含有位域(bitfield,又称 “位段”、“位字段”)的结构体作为其实例变量,而结构体中的每个位域则表示 delegate 对象是否实现了协议中的相关方法。该结构体就是用来缓存代理方是否能响应特定的协议方法的。
1
2
3
4
5
6
7
@interface EOCNetworkFetcher () {
struct {
unsigned int didReceiveData : 1;
unsigned int didFailWithError : 1;
unsigned int didUpdateProgressTo : 1;
} _delegateFlags;
}
  1. 重写 delegate 属性的 setter 方法,对 _delegateFlags 结构体里的标志进行赋值,实现缓存功能。
1
2
3
4
5
6
- (void)setDelegate:(id<EOCNetworkFetcher>)delegate {
_delegate = delegate;
_delegateFlags.didReceiveData = [delegate respondsToSelector:@selector(networkFetcher:didReceiveData:)];
_delegateFlags.didFailWithError = [delegate respondsToSelector:@selector(networkFetcher:didFailWithError:)];
_delegateFlags.didUpdateProgressTo = [delegate respondsToSelector:@selector(networkFetcher:didUpdateProgressTo:)];
}
  1. 这样每次调用 delegate 的相关方法之前,就不用通过 respondsToSelector: 方法来检测代理方是否能响应特定协议方法了,而是直接查询结构体中的标志,提升了执行速度。
1
2
3
if (_delegate && _delegateFlags.didReceiveData) {
[_delegate networkFetcher:self didReceiveData:data];
}

内存管理

用 “僵尸对象” 调试内存管理问题

  • 向已回收的对象发送消息是不安全的,对象所占内存在 “解除分配(deallocated)” 之后,只是放回可用内存池。如果对象所占内存还没有分配给别人,这时候访问没有问题,如果已经分配给了别人,再次访问就会崩溃。

这在调试的时候可能不太方便,我们可以通过 “僵尸对象(Zombie Object)” 来更好地调试内存管理问题。

  • 僵尸对象的启用:
    通过环境变量 NSZombieEnabled 启用 “僵尸对象” 功能。

  • 僵尸对象的工作原理:
    它的实现代码深植与 Objective-C 的运行期库、Foundation 框架及 CoreFoundation 框架中。系统在即将回收对象时,如果发现 NSZombieEnabled == YES,那么就把对象转化为僵尸对象,而不是将其真的回收。接下来给该对象(此时已是僵尸对象)发送消息,就会在控制台打印一条包含消息内容及其接收者的信息(如下),然后终止应用程序。这样我们就能知道在何时何处向业已回收的对象发送消息了。

1
[EOCClass message] : message sent to deallocated instance 0x7fc821c02a00
  • 僵尸对象的实现原理:
  1. 在启用僵尸对象后,运行期系统就会 swizzle 交换 dealloc 方法实现,当每个对象即将被系统回收时,系统都会为其创建一个 _NSZombie_OriginalClass 类。(OriginalClass 为对象所属类类名,这些类直接由 _NSZombie_ 类拷贝而来而不是使用效率更低的继承,然后赋予类新的名字 _NSZombie_OriginalClass 来记住旧类名。记住旧类名是为了在给僵尸对象发送消息时,系统可由此知道该对象原来所属的类。)。
    然后将对象的 isa 指针指向僵尸类,从而待回收的对象变为僵尸对象。(由于是交换了 dealloc 方法,所有 free() 函数就不会执行,对象所占内存也就不会释放。虽然这样内存泄漏了,但也只是调试手段而已,所以泄漏问题无关紧要)。
  2. 由于 _NSZombie_ 类(以及所有从该类拷贝出来的类 _NSZombie_OriginalClass)没有实现任何方法,所以给僵尸对象发送任何消息,都会进入 “完整的消息转发阶段”。而在 “完整的消息转发阶段” 中,__forwarding__ 函数是核心。它首先要做的事情包括检查接收消息的对象所属的类名。若前缀为 _NSZombie_ 则表明消息接收者是僵尸对象,需要特殊处理:在控制台打印一条信息(信息中指明僵尸对象所接收到的消息、原来所属的类、内存地址等,[OriginalClass message] : message sent to deallocated instance 0x7fc821c02a00),然后终止应用程序。

块与大中枢派发

理解 “块” 这一概念

如果块中没有显式地使用 self 来访问实例变量,那么块就会隐式捕获 self,这很容易在我们不经意间造成循环引用。如下代码,编译器会给出警告,建议用 self->_anInstanceVariable self.anInstanceVariable 来访问。

1
2
3
self.block = ^{
_anInstanceVariable = @"Something"// ⚠️ Block implicitly retains ‘self’; explicitly mention ‘self’ to indicate this is intended behavior
}

块的内存布局

  • isa 指针指向 Class 对象
  • invoke 变量是个函数指针,指向块的实现代码
  • descriptor 变量是指向结构体的指针,其中声明了块对象的总体大小,还声明了保留和释放捕获的对象的 copy 和 dispose 这两个函数所对应的函数指针
  • 块还会把它所捕获的所有变量都拷贝一份,放在 descriptor 变量的后面

多用派发队列,少用同步锁

  • 用锁来实现同步,会有死锁的风险,而且效率也不是很高。而用 GCD 能以更简单、更高效的形式为代码加锁。

用锁实现同步

@synchronized

1
2
3
4
5
- (void)synchronizedMethod {
@synchronized(self) {
// Safe
}
}

原理:@synchronized 会根据给定的对象,自动创建一个锁,并等待块中的代码执行完毕,然后释放锁。
缺点:滥用 @synchronized(self) 会降低代码效率,因为共用同一个锁的那些同步块,都必须按顺序执行,也就是说所有的 @synchronized(self) 块中的代码之间都同步了。若是在 self 对象上频繁加锁,那么程序可能要等另一段与此无关的代码执行完毕,才能继续执行当前代码,这样做其实并没有必要。

NSLock等

1
2
3
4
5
6
7
_lock = [[NSLock alloc] init];

- (void)synchronizedMethod {
[_lock lock];
// Safe
[_lock unlock];
}

用GCD实现同步

GCD 串行同步队列
将读取操作和写入操作都安排在同一个队列里,即可保证数据同步。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
_syncQueue = dispatch_queue_create("com.effectiveobjectivec.syncQueue", NULL);
- (NSString *)someString {
__block NSString *localSomeString;
dispatch_sync(_syncQueue, ^{
localSomeString = _someString;
});
return localSomeString;
}

- (void)setSomeString:(NSString *)someString {
dispatch_sync(_syncQueue, ^{
_someString = _someString;
});
}

也可以将 setter 方法的代码异步执行。由于 getter 方法需要返回值,所以需要同步执行以阻塞线程来防止提前 return,而 setter 方法不需要返回值所以可以异步执行。
异步执行时需要拷贝 block,所以这里异步执行是否能提高执行速度取决于 block 任务的繁重程度。如果拷贝 block 的时间超过执行 block 的时间,那么异步执行反而降低效率,而如果 block 任务繁重,那么是可以提高执行速度的。

GCD 栅栏函数
以上虽保证了读写安全,但并不是最优方案,因为读取方法之间同步执行了。
保证读写安全,只需满足三个条件即可:

  1. 同一时间,只能有一个线程进行写操作;
  2. 同一时间,允许有多个线程进行读操作;
  3. 同一时间,不允许既有读操作,又有写操作。

我们可以针对第二点进行优化,让读取方法可以并发执行。使用 GCD 栅栏函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
_syncQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);

- (NSString *)someString {
__block NSString *localSomeString;
dispatch_sync(_syncQueue, ^{
localSomeString = _someString;
});
return localSomeString;
}
- (void)setSomeString:(NSString *)someString {
// 这里也可以根据 block 任务繁重程度选择 dispatch_barrier_async
dispatch_barrier_sync(_syncQueue, ^{
_someString = _someString;
});
}

多用GCD,少用performSelector系列方法

NSObject 定义了几个 performSelector 系列方法,可以让开发者随意调用任何方法,可以推迟执行方法调用,也可以指定执行方法的线程等等。

1
2
3
4
5
6
7
- (id)performSelector:(SEL)aSelector;
- (id)performSelector:(SEL)aSelector withObject:(id)object;
- (id)performSelector:(SEL)aSelector withObject:(id)object1 withObject:(id)object2;
- (void)performSelectorOnMainThread:(SEL)aSelector withObject:(nullable id)arg waitUntilDone:(BOOL)wait;
- (void)performSelector:(SEL)aSelector onThread:(NSThread *)thr withObject:(nullable id)arg waitUntilDone:(BOOL)wait;
- (void)performSelectorInBackground:(SEL)aSelector withObject:(nullable id)arg;
// ...

performSelector:方法有什么用处?

  1. 如果你只是用来调用一个方法的话,那么它确实有点多余
  2. 用法一:selector 是在运行期决定的
1
2
3
4
5
6
7
8
9
SEL selector;
if ( /* some condition */ ) {
selector = @selector(foo);
} else if ( /* some other condition */ ) {
selector = @selector(bar);
} else {
selector = @selector(baz);
}
[object performSelector:selector];
  1. 用法二:把 selector 保存起来等某个事件发生后再调用

performSelector:方法的缺点:

  1. 存在内存泄漏的隐患:
    由于 selector 在运行期才确定,所以编译器不知道所要执行的 selector 是什么。如果在 ARC 下,编译器会给出警告,提示可能会导致内存泄漏。

由于编译器不知道所要执行的 selector 是什么,也就不知道其方法名、方法签名及返回值等,所以就没办法运用 ARC 的内存管理规则来判定返回值是不是应该释放。鉴于此,ARC 采用了比较谨慎的做法,就是不添加释放操作,然而这样可能会导致内存泄漏,因为方法在返回对象时可能已经将其保留了。

如果是调用以 alloc/new/copy/mutableCopy 开头的方法,创建时就会持有对象,ARC 环境下编译器就会插入 release 方法来释放对象,而使用 performSelector 的话编译器就不添加释放操作,这就导致了内存泄漏。而其他名称开头的方法,返回的对象会被添加到自动释放池中,所以无须插入 release 方法,使用 performSelector 也就不会有问题。

  1. 返回值只能是 void 或对象类型
    如果想返回基本数据类型,就需要执行一些复杂的转换操作,且容易出错;如果返回值类型是 C struct,则不可使用 performSelector 方法。

  2. 参数类型和个数也有局限性
    类型:参数类型必须是 id 类型,不能是基本数据类型;
    个数:所执行的 selector 的参数最多只能有两个。而如果使用 performSelector 延后执行或是指定线程执行的方法,那么 selector 的参数最多只能有一个。

使用 GCD 替代 performSelector

  1. 如果要延后执行,可以使用 dispatch_after
  2. 如果要指定线程执行,那么 GCD 也完全可以做到

掌握 GCD 及操作队列的使用时机

根据实际情况使用GCD 或者 NSOperation,以下是它们的区别:

使用 NSOperation 和 NSOperationQueue 的优势:

  • 取消某个操作
    可以在执行操作之前调用 NSOperationcancel 方法来取消,不过正在执行的操作无法取消。iOS8 以后 GCD 可以用 dispatch_block_cancel 函数取消尚未执行的任务,正在执行的任务同样无法取消。
  • 指定操作间的依赖关系
    使特定的操作必须在另外一个操作顺利执行完以后才能执行。
  • 通过 KVO 监控 NSOperation 对象的属性
    在某个操作任务变更其状态时得到通知,比如 isCancelled、isFinished。而 GCD 不行。
  • 指定操作的优先级
    指定一个操作与队列中其他操作之间的优先级关系,优先级高的操作先执行,优先级低的则后执行。GCD 没有直接实现此功能的办法。
  • 重用 NSOperation 对象
    可以使用系统提供的 NSOperation 子类(比如 NSBlockOperation),也可以自定义子类。

GCD 任务用块来表示,块是轻量级数据结构,而 NSOperation 则是更为重量级的 Objective-C 对象。虽说如此,但 GCD 并不是最佳方案。有时候采用对象所带来的开销微乎其微,反而它所到来的好处大大反超其缺点。另外,“应该尽可能选用高层 API,只在确有必要时才求助于底层” 这个说法并不绝对。某些功能确实可以用高层的 API 来做,但这并不等于说它就一定比底层实现方案好。要想确定哪种方案更佳,最好还是测试一下性能。

系统框架

对自定义其内存管理语义的 collection 使用无缝桥接

__bridge__bridge_retained__bridge_transfer 三种桥接方案,它们的区别为:

  1. __bridge:不改变对象的内存管理权所有者。
  2. __bridge_retained:用在 Foundation 对象转换成 Core Foundation 对象时,进行 ARC 内存管理权的剥夺。
  3. __bridge_transfer:用在 Core Foundation 对象转换成 Foundation 对象时,进行内存管理权的移交。

在使用 Foundation 框架中的字典对象时会遇到一个大问题,其键的内存管理语义为 “拷贝”,而值的语义是 “保留”。也就是说,在向 NSMutableDictionary 中加入键和值时,字典会自动 “拷贝” 键并 “保留” 值。如果用做键的对象不支持拷贝操作(如果要支持,就必须遵守 NSCopying 协议,并实现 copyWithZone: 方法),那么编译器会给出警告并在运行期 Crash:

1
2
3
4
5
6
NSMutableDictionary *mDict = [NSMutableDictionary dictionary];
[mDict setObject:@"" forKey:[Person new]]; // ⚠️ warning : Sending 'Person *' to parameter of incompatible type 'id<NSCopying> _Nonnull'

Runtime:
*** Terminating app due to uncaught exception 'NSInvalidArgumentException',
reason: '-[Person copyWithZone:]: unrecognized selector sent to instance 0x60000230c210'

我们是无法直接修改 NSMutableDictionary 的键和值的内存管理语义的。这时候我们可以通过创建 CoreFoundation 框架的 CFMutableDictionary C 数据结构,修改内存管理语义,对键执行 “保留” 而非 “拷贝” 操作,然后再通过无缝桥接技术,将其转换 NSMutableDictionary 对象

也可以使用 NSMapTable,指定 key 和 value 的内存管理语义。

构建缓存时选用NSCache而非NSDictionary

NSCache 的优势在于:

  1. 当系统资源将要耗尽时,它可以优雅的自动删减缓存,且会先行删减最久未使用的缓存。使用 NSDictionary 虽也可以自己实现但很复杂。
  2. NSCache 不会拷贝键,而是保留它。使用 NSDictionary 虽也可以实现但比较复杂
  3. NSCache 是线程安全的。不编写加锁代码的前提下,多个线程可以同时访问 NSCache。而 NSDictionary 不是线程安全的。

可以操控 NSCache 删减缓存的时机

  1. totalCostLimit 限制缓存中所有对象的总开销
  2. countLimit 限制缓存中对象的总个数
  3. - (void)setObject:(ObjectType)obj forKey:(KeyType)key cost:(NSUInteger)g; 将对象添加进缓存时,可指定其开销值

可能会删减缓存对象的时机:

  1. 当对象总数或者总开销超过上限时
  2. 在可用的系统资源趋于紧张时

需要注意的是:

  1. 可能会删减某个对象,并不意味着一定会删减
  2. 删减对象的顺序,由具体实现决定的
  3. 想通过调整开销值来迫使缓存优先删减某对象是不建议的,绝对不要把这些尺度当成可靠的 “硬限制”,它们仅对 NSCache 起指导作用。

使用 - (void)setObject:(ObjectType)obj forKey:(KeyType)key cost:(NSUInteger)g; 可以在将对象添加进缓存时指定其开销值。但这种情况只适用于开销值能很快计算出来的情况,因为缓存的本意就是为了增加应用程序响应用户操作的速度。

比方说,计算开销值时必须访问磁盘或者数据库才能确定文件大小,那么就不适用这种方法。
如果要加入缓存的是 NSData 对象,其数据大小已知,直接访问属性即可 data.length

NSPurgeableData

  1. NSPurgeableData 继承自 NSMutableData,它与 NSCache 搭配使用,可实现自动清除数据的功能。它实现了 NSDiscardableContent 协议(如果某个对象所占内存能够根据数据需要随时丢弃,就可以实现该协议定义的接口),将其加入 NSCache 后当该对象被系统所丢弃时,也会自动从缓存中清除。可以通过 NSCache 的 evictsObjectWithDiscardedContent 属性来开启或关闭此功能。
  2. 使用 NSPurgeableData 的方式和 “引用计数” 很像,当需要访问某个 NSPurgeableData 对象时,可以调用 beginContentAccess 进行 “持有”,并在用完时调用 endContentAccess 进行 “释放”。NSPurgeableData 在创建的时候其 “引用计数” 就为 1,所以无须调用 beginContentAccess,只需要在使用完毕后调用 endContentAccess 就行。

beginContentAccess:告诉它现在还不应该丢弃自己所占据的内存
endContentAccess:告诉它必要时可以丢弃自己所占据的内存

NSPurgeableData 与 NSCache 一起实现缓存的代码示例:

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
// Network fetcher class
typedef void(^EOCNetworkFetcherCompletionHandler)(NSData *data);

@interface EOCNetworkFetcher : NSObject

- (id)initWithURL:(NSURL*)url;
- (void)startWithCompletionHandler:(EOCNetworkFetcherCompletionHandler)handler;

@end

// Class that uses the network fetcher and caches results
@interface EOCClass : NSObject
@end

@implementation EOCClass {
NSCache *_cache;
}

- (id)init {

if ((self = [super init])) {
_cache = [NSCache new];

// Cache a maximum of 100 URLs
_cache.countLimit = 100;

/**
* The size in bytes of data is used as the cost,
* so this sets a cost limit of 5MB.
*/
_cache.totalCostLimit = 5 * 1024 * 1024;
}
return self;
}

- (void)downloadDataForURL:(NSURL*)url {

NSPurgeableData *cachedData = [_cache objectForKey:url];

if (cachedData) {
[cachedData beginContentAccess];
// Cache hit:存在缓存,读取
[self useData:cachedData];
[cachedData endContentAccess];
} else {
// Cache miss:没有缓存,下载
EOCNetworkFetcher *fetcher = [[EOCNetworkFetcher alloc] initWithURL:url];

[fetcher startWithCompletionHandler:^(NSData *data){
NSPurgeableData *purgeableData = [NSPurgeableData dataWithData:data];
[_cache setObject:purgeableData forKey:url cost:purgeableData.length];
[self useData:data];
[purgeableData endContentAccess];
}];
}
}
@end

精简 initialize 与 load 的实现代码

使用 load 方法的问题和注意事项:

  1. load 方法中使用其他类是不安全的。比方说,类 A 和 B 没有继承关系,它们之间 load 方法的执行顺序是不确定的,而你在类 A 的 load 方法中去实例化 B,而类 B 可能会在其 load 方法中去完成实例化 B 前的一些重要操作,此时类 B 的 load 方法可能还未执行,所以不安全。
  2. load 方法务必实现得精简一些,尽量减少其所执行的操作,不要执行耗时太久或需要加锁的任务,因为整个应用程序在执行 load 方法时都会阻塞。
  3. 如果任务没必要在类加载进内存时就执行,而是可以在类初始化时执行,那么改用 initialize 替代 load 方法。

initialize

  • initialize 除了在调用时刻、调用方式、调用顺序方面与 load 有区别以外。initialize 方法还是安全的。
    运行期系统在执行 initialize 时,是处于正常状态的,因为这时候可以安全使用并调用任意类中的任意方法了。而且运行期系统也能确保 initialize 方法一定会在 “线程安全的环境” 中执行,只有执行 initialize 的那个线程可以操作类或类实例,其他线程都要先阻塞等着 initialize 执行完。
  • 如果子类没有实现 initialize 方法,那么就会调用父类的,所以通常会在 initialize 实现中对消息接收者做一下判断:
1
2
3
4
5
+ (void)initialize {
if (self == [EOCBaseClass class]) {
NSLog(@"%@ initialized", self);
}
}

initialize 的实现也要保持精简,其原因在于:

  1. 如果在主线程初始化一个类,那么初始化期间就会一直阻塞。
  2. 无法控制类的初始化时机。编写代码时不能令代码依赖特定的时间点执行,否则如果以后运行期系统更新改变了类的初始化方式,那么就会很危险。
  3. 如果在 initialize 中给其他类发送消息,那么会迫使这些类都进行初始化。如果其他类在执行 initialize 时又依赖该类的某些数据,而该类的这些数据又在 initialize 中完成,就会发生问题,产生 “环状依赖”。
    所以,initialize 方法只应该用来设置内部数据,例如无法在编译期设定的全局常量,可以放在 initialize 方法里初始化。不应该调用其他方法,即便是本类自己的方法,也最好别调用。

实现 loadinitialize 方法时,一定要注意以上问题,精简代码。除了初始化全局状态之外,如果还有其他事情要做,那么可以专门创建一个方法来执行这些操作,并要求该类的使用者必须在使用本类之前调用此方法。比如说,如果 “单例类” 在首次使用之前必须执行一些操作,那就可以采用这个办法。

参考

《Effective Objective-C 2.0》52 个知识点总结
《Effective Objective-C》干货三部曲