Effective Objective-C 2.0 读书笔记六
块与大中枢派发
所谓的快和大中枢派发,就是我们常说的block和GCD
37. 理解“块”这一概念
快与函数类似,只不过快直接定义在函数中,和定义它的函数共享同一个范围的东西,下面就是一个简单的快:
^{
// 快中的代码
}
快可以看成是一个值,并且和其他值一样有自己的类型,可以把快赋值给其他的变量,然后像使用变量一样使用它:
void (^someBlock) = ^{
// 快中的代码
}
上面就是把一个快赋值给一个叫someBlock的变量。快类型的语法结构如下:
return_type (^block_name)(parameters)
下面是一个有两个int类型参数,返回值也是int类型,名字叫addBlock的快,作用是求两个值的和:
int (^addBlock)(int a, int b) = ^(int a, int b){
return a + b;
};
定义好之后就可以像调用函数一样调用快了:
int add = addBlock(2, 5);
快的一个强大特性就是他可以捕获他所被定义的范围内的所有变量,如下:
__block int additional = 5;
int (^addBlock)(int a, int b) = ^(int a, int b){
additional = a + b;
return additional;
};
int add = addBlock(2, 5);
我们注意到快以外的东西也被我们引用了,不过,如果要对引入的变量值进行修改就必须用__block
修饰,不修饰编译器会报错,后面会讲__block
的用处。
我们要注意一旦,如果快捕获的变量是对象类型,那么就会自动保留他,等快释放的时候一起释放。另外一点,快自己也可视为对象,并且也有引用计数。如果快定义在实力变量中,那么快也可以使用self变量,在一些我们没发现的地方,可能就间接使用过了self,例如对实例变量的属性执行设置方法的时候。注意,self也是一个对象,所以在block中使用self就要注意循环引用的问题,后面会单独介绍。
快的具体内部结构这里不做过多介绍,只说两点,快可以看做一种代替函数指针的语法结构,原来需要用指针来传递状态的地方,现在都可以用快实现。快会把捕获的变量拷贝一份,这个拷贝不是拷贝变量本身,拷贝的是指向这些对象的指针。
定义快的时候要注意定义快的范围,通过不同的范围可以把快分为全局快、栈快和堆快。我们通常定义快的时候内存分配是在栈中的,看如下的代码:
void (^block)();
if (/*判断条件*/) {
block = ^{
NSLog(@"block A");
};
} else {
block = ^{
NSLog(@"block B");
};
}
block();
定义在if和else中的两个快都是分配在栈中的,当离开相应的范围之后,内存会被回收,所以这样定义的快只有在对应的条件范围内有效,脱离范围就可能出现错误(这取决于这部分内存有没有被覆写),这样的定义是极不安全的,如果想让这样的定义变的安全,可以通过copy把快从栈复制到堆上,这样就可以在定义范围之外使用,如下:
void (^block)();
if (/*判断条件*/) {
block = [^{
NSLog(@"block A");
} copy];
} else {
block = [^{
NSLog(@"block B");
} copy];
}
block();
还可以定义一种全局快,这种快不会捕捉任何状态,运行时也无需其他状态参与,快使用的内存在编译期已经确定,这种快声明在全局内存中,不需要在每次调用的时候在栈中创建,这样的快相当于一个单利,例如下面的例子:
void (^block)() = ^{
NSLog(@"这是一个全局快");
};
38. 为常用的快类型创建typedef
前面已经介绍过每个块都有自己的固有类型,类型形式如下:
return_type (^block_name)(parameters)
其中return_type是返回值类型,block_name是快名,最后一个括号是参数,我们可以给所有这种类型的快起一个统一的名字,用typedef定义,看下面的例子,我们定义一个参数是bool和int类型,返回值是int类型的快类型:
typedef int (^someBlock)(BOOL flag, int valur);
这样就定义了一个名字是someBlock类型的快类型,这时候创建一个快如下:
someBlock block = ^(BOOL flag, int valur){
// 快的内部代码
};
这样定义一个block就和定义其他变量一样了。
在API中用这种方法会使代码更加简洁易懂,例如我们需要用一个快做参数,如果不定义快类型,可能方法名会像下面这样
- (void)emampleWithBlock:(void(^)(BOOL flag, int value))block;
如果通过typedef定义一个block类型,则如下:
typedef void (^someBlock)(BOOL flag, int valur);
- (void)emampleWithBlock:(someBlock)block;
这样参数就简单多了,并且易于理解,定义类型另外一个好处就是,假设我们想多添加一个参数,这时候只要在定义的地方改就可以了,不用每个方法名都更改,另外有时候我们也会在相同的类型的情况下定义不同的类型名,这样我们就可以区分不同类型的用处(虽然他们是相同的结构),总之用法很灵活,在不同的场景做不同的处理。
39. 用handler快降低代码分散程度
在开发中经常会涉及到一些异步执行的线程,最长见的就是网络请求,我们通常把这种耗时的工作放在子线程中,这就涉及到子线程中的任务完成后回调的过程,写委托协议是很常见的情况,我们可以通过不同的状态,在代理方法中做不同的操作。但是通过快,可以使代码更清晰,通过快的内联形式,可以在方法中直接处理接下来的工作,不在需要到别的地方写代码,使代码看起来更顺畅,最典型的可以看AFNetworking的API,看一下AFHTTPSessionManager.h中的一个方法:
- (nullable NSURLSessionDataTask *)POST:(NSString *)URLString
parameters:(nullable id)parameters
progress:(nullable void (^)(NSProgress *uploadProgress))uploadProgress
success:(nullable void (^)(NSURLSessionDataTask *task, id _Nullable responseObject))success
failure:(nullable void (^)(NSURLSessionDataTask * _Nullable task, NSError *error))failure;
通过这个方法可以很清楚的看明白整个方法的用处,通过快把整个方法不同阶段的状态表现出来,并且可以针对不同的状态做不同的处理,这种写法简单明了,令使用者简单易懂,也有的API把处理成功和失败的情况放在一起,不过各有各的好处,根据自己的情况自行设定。另外设计这种API还要注意一件事情,就是有一些操作必须在特定的线程,例如UI的操作要在主线程上,上面方法中成功和失败的快都是回调的主线程,API的设计者也是想让我们在网络请求之后通过主线程回调处理UI的事情,具体内部处理更复杂,这里不再深究,感兴趣同学自己研究。
不是所有的情况下都要主动控制线程,有时候应该让API的调用者自己选择在哪个线程上执行相关操作,例如NSNotificationCenter在设计的时候就提供方法,让API调用者自己选择线程,如下:
- (id <NSObject>)addObserverForName:(nullable NSString *)name
object:(nullable id)obj
queue:(nullable NSOperationQueue *)queue
usingBlock:(void (^)(NSNotification *note))block
40. 用快引用其所属对象时不要出现保留环
前面已经多次介绍过使用快的时候会出现循环引用的情况,并在某些情况下给出了一些解决方案,这一小节还是要提到这个问题,在使用快的时候要注意这个问题,尤其是在快中引用实例变量,间接的就在快中引入了self,这些都是要特别注意的地方。
在API中用到completion handler这样的回调快的时候,特别容易出现保留环,这个时候我们在设计API的时候就要考虑到这些问题,而不应该把消除保留环的工作留给调用者去处理,至于具体的保留环情况,在写代码的时候要谨慎排查,在适当的地方解决问题,这里不再过多举例。
41. 多用派发队列,少用同步锁
OC中多个线程在争夺一个资源的时候会出现问题,这种情况下就需要使用某种机制解决问题,在GCD之前,通常用两种方法,第一种是使用“同步块”,另外一种是使用NSLock对象加锁,下面介绍一下这两种方法。
第一种,同步块
- (void)synchronizedMethod{
@synchronized (self) {
// 安全区域
}
}
这就是给指定的对象添加一个同步锁,在代码结尾处同步锁就释放了,上面的例子是针对self对象,这样就可以保证每个对象实例都能不受干扰地运行synchronizedMethod方法,但是synchronized的滥用是很消耗内存的(所有的加锁都非常消耗内存),不要频繁的加锁。
第二种,NSLock
_lock = [[NSLock alloc] init];
- (void)synchronizedMethod{
[_lock lock];
// 安全区域
[_lock unlock];
}
和第一种方法类似,都是加锁,只不过这个控制的范围更加灵活,另外还用一种NSRecursiveLock(递归锁),这种锁线程可以多次持有,并且不会出现死锁现象。
GCD的出现能够更简单、更高效的给代码加锁,下面通过一个属性的例子介绍这种方法,通常我们可以通过atomic来修饰属性,做到线程安全,下面的例子不通过atomic修饰属性的线程安全,而是通过自定义属性的设置方法,做到属性的线程安全。首先看一下在同步块的情况下我们的写法:
- (void)setSomeString:(NSString *)someString{
@synchronized (self) {
_someString = someString;
}
}
- (NSString *)someString{
@synchronized (self) {
return _someString;
}
}
这样写做到了线程安全,但是如果有很多属性的时候都用synchronized会产生我们不想要的效果,因为每一个同步块都要等到上一个同步块执行完毕,另外这种写法不能保证绝对的线程安全,当同一个线程多次调用获取方法的时候,每次可能返回不同的结果,因为在两次访问期间,其他的线程可能会写入新的值。
下面看一下GCD的串行同步队列,将读取和写入操作安排在同一个队列里,保证数据的同步:
_syncQueue = dispatch_queue_create("com.eoc.syncQueue", NULL);
- (void)setSomeString:(NSString *)someString{
dispatch_sync(_syncQueue, ^{
_someString = someString;
});
}
- (NSString *)someString{
__block NSString *localSomeString;
dispatch_sync(_syncQueue, ^{
localSomeString = _someString;
});
return localSomeString;
}
这种写法是我们把设置和读取都安排在一个事先创建好的队列中,这样所有针对属性的操作都在一个队列中,就做到同步了,GCD是一套纯C的语言,他的加锁任务都是在底层来实现的,于是会有许多优化。另外设置操作不一定非要同步,异步操作也可以,这时候只要将设置方法改成下面这样:
- (void)setSomeString:(NSString *)someString{
dispatch_async(_syncQueue, ^{
_someString = someString;
});
}
注意dispatch_async和dispatch_sync两个方法的不同
异步操作可以提升设置方法的速度,并且可以保证读取和写入操作会按顺序执行,但是异步会让程序变慢,因为异步派发是,需要拷贝快,至于这个拷贝时间和设置时间那个更短则取决于快的大小。
如果想要更高效的代码,可以采用并发队列,不过这个并发是获取方法,获取方法和设置方法之间不能并发执行,结合GCD中的并发队列和栅栏快可以如下设计代码:
- (void)setSomeString:(NSString *)someString{
dispatch_barrier_async(_syncQueue, ^{
_someString = someString;
});
}
- (NSString *)someString{
__block NSString *localSomeString;
dispatch_sync(_syncQueue, ^{
localSomeString = _someString;
});
return localSomeString;
}
获取方法也可以采用同步的栅栏快,效率可能会更高,至于具体怎么组合,自己可以根据代码实际测一下。
42. 多用GCD,少用performSelector系列方法
GCD出现之前performSelector系列方法是相当重要的,这几个方法可以使得在任何时候调用任何方法都变的可能,这系列最大的特点就是可以动态的执行方法,在运行期根据需要执行合适的方法,而不是在编译期就固定了,如下:
SEL selector;
if (/* 条件 */) {
selector = @selector(foo);
} else if (/* 条件 */){
selector = @selector(bar);
} else {
selector = @selector(baz);
}
[object performSelector:selector];
但是在ARC下编译代码会出现内存泄漏的警告,因为编译器不知道要执行哪个方法,也就不知道方法签名和返回值(也可能没有返回值),而且由于不知道方法名,编译器不能按照常规的内存管理来判断返回值是不是应该释放,基于这些,在此种情况下ARC不执行释放操作,这样就会产生内存泄漏,看下面的例子
SEL selector;
if (/* 条件 */) {
selector = @selector(newObject);
} else if (/* 条件 */){
selector = @selector(copy);
} else {
selector = @selector(someProperty);
}
id ret = [object performSelector:selector];
前面小节介绍过copy、new开头的方法名的特殊内存处理机制,上面的例子中,如果调用的是前两个方法中的一个,那么ret对象应该由这段代码释放,如果是调用第三种方法,则无需释放(在ARC和非ARC下都是如此),这就产生了问题,并且这种问题很难发现。
另一个使用performSelector系列方法的局限就是performSelector系列方法返回的是id类型(指向OC对象的指针),如果方法返回的是整形或者浮点型就需要做转换处理,如果返回的C语言结构体则不能用performSelector系列方法。
列举几个performSelector系列方法:
- (id)performSelector:(SEL)aSelector;
- (id)performSelector:(SEL)aSelector withObject:(id)object;
- (id)performSelector:(SEL)aSelector withObject:(id)object1 withObject:(id)object2;
从这可以看出更大的局限性,传入的参数必须是对象,并且最多只能带两个参数,参数限制是performSelector系列方法的通病,虽然我们可以通过编写字典的方法,把参数打包,但是这样做很麻烦,performSelector系列方法提供的功能,在GCD中基本都可以查找到替代方法,并且执行起来不受参数等条件限制,下面是一个延后执行的例子:
// performSelector系列方法
[self performSelector:@selector(doSomething) withObject:nil afterDelay:5.0];
// GCD
dispatch_time_t time = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(5.0*NSEC_PER_SEC));
dispatch_after(time, dispatch_get_main_queue(), ^{
[self doSomething];
});
43. 掌握GCD及操作队列的使用时机
OC中,这种队列操作的不光GCD采用,还有一种技术叫做NSOperationQueue,两者有很多差别,其中一个特别明显的就是,NSOperationQueue针对OC对象,GCD则是纯C的API,下面介绍几点NSOperationQueue优势与GCD的地方
1.在运行任务前在NSOperation上调用cancel方法可以手动取消操作(运行后就无法执行取消方法),而GCD队列无法取消,GCD的原则是安排好任务后就不管了,如果想取消需要格外编写很多代码。
2.指定操作间的关系,NSOperation对象之间可以通过addDependency:方法使对象之间产生依赖关系,上一个操作不完成,下一个操作不会发生,并且可以依赖多个操作,开发者可以自定义之间的依赖关系。
3.通过键值观察机制监控NSOperation对象,NSOperation对象提供了许多属性对进度进行观测,这样相对于GCD可以做到更精细的控制。
4.指定操作优先级,指的是同一个队列中可以指定特定NSOperation对象的操作优先级,高的先执行,低的后执行,GCD中也有优先级,但是那是针对队列的,而不是针对具体的快。
5.重用NSOperation对象,系统提供了一些NSOperation对象的子类供我们调用,也可以自定义对象。
上面的几点基本就是操作队列(NSOperationQueue)和派发队列(GCD)的区别,不同的情况选择合适的代码很重要,有人遵循使用高层API的原则,其实不应该盲从,从自己的代码出发,选择最合适的。
44. 通过Dispatch Group机制,根绝系统资源状况类执行任务
dispatch group就是任务分组,每个组总有多个并发执行的任务,可以针对这一组任务进行操作,所有任务完成之后会有回调函数,这些特性可以很好的被利用,例如,把压缩一系列任务放到一个组来完成,简单介绍一下这个分组机制:
创建一个dispatch group:
dispatch_group_t dispatch_group = dispatch_group_create();
dispatch group是一个数据结构,彼此之间没有区别,想把任务编组,有两种办法:
第一种:
void dispatch_group_async(dispatch_group_t group, dispatch_queue_t queue, dispatch_block_t block);
dispatch_group_async方法可将block快添加到指点的组中。
第二种:
void dispatch_group_enter(dispatch_group_t group);
void dispatch_group_leave(dispatch_group_t group);
dispatch_group_enter方法使分组里正在执行的任务数递增,后面方法递减,类似引用计数机制,增加后就要减少,否则这一组的任务就永远执行不完。
下面的方法等待dispatch group执行完毕:
long dispatch_group_wait(dispatch_group_t group, dispatch_time_t timeout);
第一个参数是要等待的dispatch group,第二个参数是等待超时时间(应该阻塞线程多久),这个时间可以是无限DISPATCH_TIME_FOREVER(无限等待下去),若执行dispatch group所用时间小于设置时间,则返回0,否则返回非0值。
除了上面的方法外,等待执行完毕还有一个方法:
void dispatch_group_notify(dispatch_group_t group, dispatch_queue_t queue, dispatch_block_t block);
第一个参数是要等待的dispatch group,第二个参数是dispatch group执行完毕后将要执行的代码块所在的队列,第三个参数是dispatch group执行完毕后将要执行的代码块。
下面举个例子,令数组中的每个对象都执行某项任务,然后等全部执行完毕后再执行其他任务:
dispatch_queue_t lowQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_LOW, 0);
dispatch_queue_t highQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0);
dispatch_group_t dispatchGroup = dispatch_group_create();
for (id object in arrayA) {
dispatch_group_async(dispatchGroup, lowQueue, ^{
/* 数组对象要进行的操作 */
});
}
for (id object in arrayB) {
dispatch_group_async(dispatchGroup, highQueue, ^{
/* 数组对象要进行的操作 */
});
}
dispatch_group_wait(dispatchGroup, DISPATCH_TIME_FOREVER);
dispatch_queue_t notifyQueue = dispatch_get_main_queue();
dispatch_group_notify(dispatchGroup, notifyQueue, ^{
/* dispatch group执行完毕后执行的代码块 */
});
上面的例子中dispatch group添加了两个队列,这些队列可以有不同的优先级,从上面的例子我们可以发现,我们不用再为线程的事担忧,并且具体的资源怎么分配都是由GCD底层内部自动帮我们管理的,这样我们只要专注于业务逻辑代码就行了。
GCD中还有一个方法可以实现上面的效果
void dispatch_apply(size_t iterations, dispatch_queue_t queue, void (^block)(size_t));
该方法会将最后一个参数中的代码块反复执行一定次数,这个次数是自己制定的,每次传给快的参数值都会递增,从0开始到我们制定的个数减1。怎么实现这里不在举例。
未必总要使用dispatch group,有一些东西可以通过不同的道路去实现。
45. 使用dispatch_once来执行只需运行一次的线程安全代码
这一小节主要介绍一下通过dispatch_once这个方法编写“只需要执行一次的线程安全代码”,以最常用的单利的写法为例,通过这个方法编写单利代码,可以简化代码并保证线程绝对安全,并只执行一次,下面是单利的写法:
+ (id)sharedInstance{
static EOCClass *sharedInstance = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
sharedInstance = [[self alloc] init];
});
return sharedInstance;
}
标记声明成static是因为每次调用标记必须相同,变量声明成static是因为每次调用变量的时候都复用这个变量,而不是从新生成。
46. 不要使用dispatch_get_current_queue
dispatch_get_current_queue方法的作用是返回当前正在执行的队列,但是用的时候要小心,并且苹果也不推荐这个方法的使用,所以在iOS6.0之后,官方废弃了这个方法,书中举了一个例子,但是太极端,这里不做过多介绍了,dispatch_get_current_queue方法可以在调试的时候用,并且很方便,但是真正的发布代码中不建议用这个方法。