第六章 block与GCD
“块”(block)是一种可在C、C++及Objective-C代码中使用的“词法闭包”(lexical closure),它极为有用,这主要是因为借由此机制,开发者可将代码像对象一样传递,令其在不同环境下运行。还有个关键的地方是,在定义“块”的范围内,它可以访问到其中的全部变量。
GCD是一种与“块”有关的技术,它提供了对线程的抽象,而这种抽象则基于“派发队列”(dispatch queue)。开发者可将块排入队列中,由GCD负责处理所有调度事宜。
37.理解“块”这一概念
块可以实现闭包。
1.块的基础知识
块与函数类似,只不过是直接定义在另一个函数里的,和定义它的那个函数共享同一个范围内的东西。块用“^”符号来表示,后面跟着一对花括号,括号里面是块的实现代码。例如:
^{
//Block implementation here
}
块其实就是个值,而且自有其相关类型。与int、float或Objective-C对象一样,也可以把块赋给变量,然后像使用其他变量那样使用它。块类型的语法与函数指针近似。下面列出的这个块很简单,没有参数,也不返回值:
void (^someBlock)() = ^{
//Block implementation here
};
这段代码定义了一个名为someBlock的变量。由于变量名写在正中间,所以看上去也许有点怪,不过一旦理解了语法,很容易就能读懂。
块类型的语法结构如下:
return_type (^block_name)(parameters)
下面这种写法所定义的块,返回int值,并且接受两个int做参数:
int(^addBlock)(int a,int b) = ^(int a,int b){
return a + b;
};
定义好之后,就可以像函数那样使用了。比方说,addBlock块可以这样用:
int add = addBlock(2,5);///< add = 7
块的强大之处是:在声明它的范围里,所有变量都可以为其所捕获。这也就是说,那个范围里的全部变量,在块里依然可用。比如,下面这段代码所定义的块,就使用了块以外的变量:
int addtional = 5;
int(^addBlock)(int a,int b) = ^(int a,int b){
return a + b + addtional;
};
int add = addBlock(2,5);///< add = 12
默认情况下,为块所捕获的变量,是不可以在块里修改的。在本例中,假如块内的代码改动了additional变量的值,那么编译器就会报错。不过,声明变量的时候可以加上__block修饰符,这样就可以在块内修改了。例如,可以用下面这个块来枚举数组中的元素(参见第48条),以判断其中有多少个小于2的数:
NSArray *array = @[@0,@1,@2,@3,@4,@5];
__block NSInteger count = 0;
[array enumerateObjectsUsingBlock:
^(NSNumber *number, NSUInteger idx, BOOL * _Nonnull stop) {
if([number compare:@2]==NSOrderedAscending){
count++;
}
}];
//count = 2
这段范例代码也演示了”内联块“(inline block)的用法。传给”enumerateObjectsUsingBlock:”方法的块并未先赋给局部变量,而是直接内联在函数调用里了。由这种常见的编码习惯也可以看出块为何如此有用。在Objective-C语言引入块的这一特性之前,想要编出与刚才那段代码相同的功能,就必须传入函数指针或选择子的名称,以供枚举方法调用。状态必须手工传入传出,这一版通过“不透明的void指针“实现,如此一来,就得再写几行代码了,而且还会令方法变得有些松散。与之相反,若声明内联形式的块,则可把所有业务逻辑都放在一处。
如果块所捕获的变量是对象类型,那么就会自动保留它。系统在释放这个块的时候,也会将其一并释放。这就引出了一个与块有关的重要问题。块本身可视为对象。实际上,在其他Objective-C对象所能响应的选择子中,有很多是块也可以响应的。而最重要之处则在于,块本身也和其他对象一样,有引用计数。当最后一个指向块的引用移走之后,块就回收了。回收时也会释放块所捕获的变量,以便平衡捕获时所执行的保留操作。
如果将块定义在Objective-C类的实例方法中,那么除了可以访问类的所有实例变量之外,还可以使用self变量。块总能修改实例变量,所以在声明时无须加block。不过,如果通过读取或写入操作捕获了实例变量,那么也会自动把self变量一并捕获了,因为实例变量是与self所指代的实例关联在一起的。例如,下面这个块声明在EOCClass类的方法中:
#import "EOCClass.h"
@implementation EOCClass
-(void)anInstanceMethod{
void (^someBlock)() = ^{
_anInstanceVariable = @"Something";
NSLog(@"_anInstanceVariable = %@",_anInstanceVariable);
};
}
@end
如果某个EOCClass实例正在执行anInstanceMethod方法,那么self变量就指向此实例。由于块里没有明确使用self变量,所以很容易就会忘记self变量其实也是为块所捕获了。直接访问实例变量和通过self来访问时等效的:
self-> _anInstanceVariable = @"Something";
之所以要捕获self变量,原因正在于此。我们经常通过属性访问实例变量,在这种情况下,就要指明self了:
self.aProperty = @“Something”;
然而,一定要记住:self也是个对象,因而块在捕获它时也会将其保留。如果self所指代的那个对象同时也保留了块,那么这种情况通常就会导致”循环引用“。
2.块的内部结构
每个Objective-C对象都占据着某个内存区域。因为实例变量的个数及对象所包含的关联数据互不相同,所以每个对象所占的内存区域也有大有小。块本身也是对象,在存放块对象的内存区域中,首个变量是指向Class对象的指针,该指针叫做isa。其余内存里含有块对象正常运转所需的各种信息。下图详细描述了块对象的内存布局:
在内存布局中,最重要的就是invoke变量,这是个函数指针,指向块的实现代码。函数原型至少要接受一个void*型的参数,此参数代表块。刚才说过,块其实就是一种代替函数指针的语法结构,原来使用函数指针时,需要用”不透明的void指针“来传递状态。而改用块之后,则可以把原来用标准C语言特性所编写的代码封装成简明且易用的接口。
descriptor变量是指向结构体的指针,每个块里都包含此结构体,其中声明了块对象的总体大小,还声明了copy与dispose这两个辅助函数所对应的函数指针。辅助函数在拷贝及丢弃块对象时运行,其中会执行一些操作,比方说,前者要保留捕获的对象,而后者则将之释放。
块还会把它所捕获的所有变量都拷贝一份。这些拷贝放在descriptor变量后面,捕获了多少个变量,就要占据多少内存空间。请注意,拷贝的并不是对象本身,而是指向这些对象的指针变量。invoke函数需要把块对象作为参数传进来是因为在执行块时,要从内存中把这些捕获到的变量读出来。
3.全局块、栈块及堆块
定义块的时候,其所占的内存区域是分配在栈中的。这就是说,块只在定义它的那个范围内有效。例如,下面这段代码就有危险:
void (^block)();
if(/*some condition*/){
block = ^{
NSLog(@"Block A");
};
}else{
block = ^{
NSLog(@"Block B");
};
}
block();
定义在if及else语句中的两个块都分配在栈内存中。编译器会给每个块分配好栈内存,然而等离开了相应的范围之后,编译器有可能把分配给块的内存覆写掉。于是,这两个块只能保证在对应的if或else语句范围内有效。这样写出来的代码可以编译,但是运行起来时而正确,时而错误。若编译器未覆写待执行的块,则程序照常运行,若覆写,则程序崩溃。
为解决此问题,可给块对象发送copy消息以拷贝之。这样的话,就可以把块从栈复制到堆了。拷贝后的块,可以在定义它的那个范围之外使用。而且,一旦复制到堆中,块就成了带引用计数的对象了。后续的复制操作都不会真的执行复制,只是递增块对象的引用计数。如果不再使用这个块,那就应该将其释放,在ARC下会自动释放,而手动管理引用计数时则需要自己来调用release方法。当引用计数降为0时,”分配在堆上的块“会像其他对象一样,为系统所回收。而”分配在栈上的块“则无须明确释放,因为栈内存本来就会自动回收。
我们只需给代码加上两个copy方法调用,就可令其变得安全了:
void (^block)();
if(/*some condition*/){
block = [^{
NSLog(@"Block A");
}copy];
}else{
block = [^{
NSLog(@"Block B");
}copy];
}
block();
现在代码就安全了。如果手动管理引用计数,那么在用完块之后还需将其释放。
除了”栈块“和”堆块“之外,还有一类块叫做”全局块“。这种块不会捕获任何状态(比如外围的变量等),运行时也无须有状态来参与。块所使用的整个内存区域,在编译期已经完全确定了,因此,全局块可以声明在全局内存里,而不需要在每次用到的时候于栈中创建。另外,全局块的拷贝操作是个空操作,因为全局块决不可能为系统所回收。这种块实际上相当于单例。下面就是个全局块:
void (^block)() = ^{
NSLog(@"This is a block");
};
由于运行该块所需的部信息都能在编译期确定,所以可把它做成全局块。这完全是种优化技术:若把如此简单的块当成复杂的块来处理,那就会在复制及丢弃该块时执行一些无谓的操作。
要点:
- 块是C、C++、Objective-C中的词法闭包。
- 块可接受参数,也可返回值。
- 块可以分配在栈或堆上,也可以是全局的。分配在栈上的块可拷贝到堆里,这样的话,就和标准的Objective-C对象一样,具备引用计数了。
38.为常用的块类型创建typedef
每个块都具备其”固有类型“,因而可将其赋值给适当类型的变量。这个类型由块所接受的参数及其返回值组成。例如有如下这个块:
^(BOOL flag,int value){
if(flag){
return value * 5;
}else{
return value * 10;
}
}
此块接受的两个类型分别为BOOL类型及int的参数,并返回类型为int的值。如果想把它赋值给变量,则需注意其类型。变量类型及相关赋值语句如下:
int (^variableName)(BOOL flag,int value) =
^(BOOL flag,int value){
//Implementation
return someInt;
}
这个类型似乎和普通的类型大不相同,然而如果习惯函数指针的话,那么看上去就会觉得眼熟了。块类型的语法结构如下:
return_type (^block_name)(parameters)
与其他类型的变量不同,在定义块变量时,要把变量名放在类型之中,而不要放在右侧。这种语法非常难记,也非常难读。鉴于此,我们应该为常用的块类型起个别名,尤其是打算把代码发不成API供他人使用时,更应这样做。开发者可以起个更为易读的名字来表示块的用途,而把块的类型隐藏在其后面。
为了隐藏复杂的块类型,需要用到C语言中名为“类型定义”的特性。typedef关键字用于给类型起个易读的别名。比方说,想定义新类型,用以表示接受BOOL及int参数并返回int值的块,可通过下列语句来做:
typedef int(^EOCSomeBlock)(BOOL flag,int value);
声明变量时,要把名称放在类型中间,并在前面加上“^”符号,而定义新类型时也得这么做。上面这条语句向系统中新增了一个名为EOCSomeBlock的类型。此后,不用再以复杂的块类型来创建变量了,直接使用新类型即可:
EOCSomeBlock block = ^(BOOL flag,int value){
//Implementation
};
这次代码读起来就顺畅多了:与定义其他变量时一样,变量类型在左边,变量名在右边。
通过这项特性,可以把使用块的API做得更为易用些。类里面有些方法可能需要用块来做参数,比如执行异步任务时所用的“completion handler”参数就是块,凡遇到这种情况,都可以通过定义别名使代码变得更为易读。比方说,类里有个方法可以启动任务,它接受一个块作为处理程序,在完成任务之后执行这个块。若不定义别名,则方法签名会像下面这样:
-(void)startWithCompletionHandler:
(void(^)(NSData *data,NSError *error))completion;
注意,定义方法参数所用的块类型语法,又和定义变量时不同。若能把方法签名中的参数类型写成一个词,那读起来就顺口多了。于是,可以给参数类型起个别名,然后使用此名称来定义:
typedef void(^EOCCompletionHandler)(NSData *data,NSError *error);
-(void)startWithCompletionHandler:(EOCCompletionHandler)completion;
现在参数看上去就简单多了,而且易于理解。
使用类型定义还有个好处,就是当你打算重构块的类型签名时会很方便。比方说,要给原来的completion handler块再加一个参数,用以表示完成任务所花的时间,那么只需修改类型定义语句即可:
typedef void(^EOCCompletionHandler)(NSData *data,NSTimeInterval duration,NSError *error);
修改之后,凡是使用了这个类型定义的地方,比如方法签名等处,都会无法编译,而且报的是同一种错误,于是开发者可据此逐个修复。若不用类型定义,而直接写块类型,那么代码中要修改的地方就更多了。开发者很容易忘掉其中一两处,从而引发难于排查的bug。
最好在使用块类型的类中定义这些typedef,而且还应该把这个类的名字加在由typedef所定义的新类型名前面,这样可以阐明块的用途。还可以用typedef给同一个块签名类型创建数个别名。在这件事上,多多益善。
Mac OS X与iOS的Accounts框架就是个例子。在该框架中可以找到下面这两个类型定义语句:
typedef void(^ACAccountStoreSaveCompletionHandler)(BOOL success, NSError *error);
typedef void(^ACAccountStoreRequestAccessCompletionHandler)(BOOL granted, NSError *error);
这两个类型定义的签名相同,但用在不同的地方。开发者看到类型别名及签名中的参数之后,很容易就能理解此类型的用途。它们本来也可以合并成一个typedef,比如叫做ACAccountStorBooleanCompletionHandler,使用那两个别名的地方,都可以统一使用此名称。然后,这么做之后,块与参数的用途看上去就不那么明显了。
与此相似,如果有好几个类都要执行相似但各有区别的异步任务,而这几个类又不能放入同一个继承体系,那么,每个类就应该有自己的completion handler类型。这几个completion handler的前面也许完全相同,但最好还是在每个类里都各自定义一个别名,而不要共用同一个名称。反之,若这些类能纳入同一个继承中,则应该将类型定义语句放在超类中,以供各子类使用。
要点:
- 以typedef重新定义块类型,可令块变量用起来更加简单。
- 定义新类型时应遵从现有的命名规范,勿使其名称与别的类型相冲突。
- 不妨为同一个块签名定义多个类型别名。如果要重构的代码使用了块类型的某个别名,那么只需修改相应的typedef中的块签名即可,无须改动其他typedef。
39.用handler块降低代码分散程度
与使用委托模式的代码相比,用块写出来的代码显得更为简洁。异步任务执行完毕后所需运行的业务逻辑,和启动异步任务所用的代码放在了一起。而且,由于块声明在创建获取器的范围内,所以它可以访问此范围内的全部变量。
委托模式有个缺点:如果类要分别使用多个获取器下载不同数据,那么就得在delegate回调方法里根据传入的获取器参数来切换。
把成功情况和失败情况放在同一个块中,缺点是:由于全部逻辑都写在一起,所以会令块变得比较长,切比较复杂。然而只用一个块的写法也有好处,那就是更为灵活。比方说,在传入错误信息时,可以把数据也传进来。有时数据正下载到一半,突然网络故障了。在这种情况下,可以把数据及相关的错误都传给块。这样的话,completion handler就能根据此判断问题并适当处理了,而且还可利用已下载好的这部分数据做些事情。还有个优点是:调用API的代码可能会在处理成功响应的过程中发现错误。这种情况需要和网络数据获取器所认定的失败情况按同一方式处理。此时,如果采用单一块的写法,那么就能把这种情况和获取器认定的失败情况统一处理了。要是把成功情况和失败情况交给两个不同的处理程序来负责,那么就没办法共享同一份错误处理代码了,除非把这段代码单独放在一个方法里,而这又违背了我们想把全部逻辑代码都放在一处的初衷。
建议使用同一个块来处理成功与失败情况。
基于handler来设计API还有个原因,就是某些代码必须运行在特定的线程上。比方说,Cocoa与Cocoa Touch中的UI操作必须在主线程上执行。这就相当于GCD中的“主队列”。因此,最好能由调用API的人来决定handler应该运行在哪个线程上。NSNotificationCenter就属于这种API,它提供了一个方法,调用者可以经由此方法来注册想要接收的通知,等到相关事件发生时,通知中心就会执行注册好的那个块。调用者可以指定某个块应该安排在哪个执行队列里,然而这不是必需的。若没有指定队列,则按默认方式执行,也就是说,将由投递通知的那个线程来执行。下列方法可用来新增观察者:
- (id )addObserverForName:(nullable NSString *)name
object:(nullable id)obj
queue:(nullable NSOperationQueue *)queue
usingBlock:(void (^)(NSNotification *note))block
此处传入的NSOperationQueue参数就表示触发通知时用来执行块代码的那个队列。这是个“操作队列”,而非“底层GCD队列”,不过两者语义相同。
要点:
- 要创建对象时,可以使用内联的handler块将相关业务逻辑一并声明。
- 在有多个实例需要监控时,如果采用委托模式,那么经常需要根据传入的对象来切换,而若改用handler块来实现,则可直接将块与相关对象放在一起。
- 设计API时如果用到了handler块,那么可以增加一个参数,使调用者可通过此参数来决定应该把块安排在哪个队列上执行。
40.用Block引用其所属对象时不要出现循环引用
使用块时,若不仔细思量,则很容易导致循环引用。比方说,下面这个类就提供了一套接口,调用者可由此从某个URL中下载数据。在启动获取器时,可设置completion handler,这个块会在下载结束之后以回调方式执行。为了能在p_requestCompleted方法执行调用者所指定的块,这段代码需要把completion handler保存到实例变量里面。
//EOCNetworkFetcher.h
typedef void(^EOCNetworkFetcherCompletionHandler)(NSData *data);
@interface EOCNetworkFetcher : NSObject
@property(nonatomic,strong,readonly)NSURL *url;
-(instancetype)initWithURL:(NSURL *)url;
-(void)startWithCompletionHandler:
(EOCNetworkFetcherCompletionHandler)completion;
@end
//EOCNetworkFetcher.m
@interface EOCNetworkFetcher ()
@property(nonatomic,strong,readwrite)NSURL *url;
@property(nonatomic,copy)EOCNetworkFetcherCompletionHandler completionHandler;
@property(nonatomic,strong)NSData *downloadedData;
@end
@implementation EOCNetworkFetcher
-(instancetype)initWithURL:(NSURL *)url{
if(self = [super init]){
_url = url;
}
return self;
}
-(void)startWithCompletionHandler:(EOCNetworkFetcherCompletionHandler)completion{
self.completionHandler = completion;
//Start the request
//Request sets downloadedData property
//When request is finished,p_requestCompleted is called
}
-(void)p_requestCompleted{
if(_completionHandler){
_completionHandler(_downloadedData);
}
}
@end
某个类可能会创建这种网络数据获取器对象,并用其从URL中下载数据:
#import "EOCClass.h"
#import "EOCNetworkFetcher.h"
@interface EOCClass ()
{
EOCNetworkFetcher *_networkFetcher;
NSData *_fetchedData;
}
@end
@implementation EOCClass
-(void)downloadData{
NSURL *url = [[NSURL alloc]initWithString:@"http://www.example.com/something.dat"];
_networkFetcher = [[EOCNetworkFetcher alloc]initWithURL:url];
[_networkFetcher startWithCompletionHandler:^(NSData *data) {
NSLog(@"Request URL %@ finished",_networkFetcher.url);
_fetchedData = data;
}];
}
@end
这里就造成了一个循环引用。因为completion handler块要设置_fetchedData实例变量,所以它必须捕获self变量(第37条)。这就是说,handler块保留了创建网络数据获取器的那个EOCClass实例。而EOCClass实例则通过strong实例变量保留了获取器,最后,获取器对象又保留了handler块。下图描述了这个环:
要打破循环引用也很容易:要么令_networkFetcher实例变量不要引用获取器,要么令获取器的completionHandler属性不再持有handler块。在网络数据获取器这个例子中,应该等completion handler块执行完毕后,再去打破引用环,以便使获取器对象在handler块执行期间保持存活状态。比方说,completion handler块的代码可以这么修改:
[_networkFetcher startWithCompletionHandler:^(NSData *data) {
NSLog(@"Request URL %@ finished",_networkFetcher.url);
_fetchedData = data;
_networkFetcher = nil;
}];
如果设计API时用到了completion handler这样的回调块,那么很容易形成循环引用,所以必须意识到这个重要问题。一般来说,只要适时清理掉环中的某个引用,即可解决此问题,然而,未必总有这种机会。在本例中,唯有completion handler运行过后,方能解除引用环。若是completion handler一直不运行,那么引用环就无法打破,于是内存就会泄露。
像completion handler块这种写法,还可能引入另外一种形式的引用环。如果completion handler块所引用的对象最终又引用了这个块本身,那么就会出现引用环。比方说,我们修改下这个例子,使调用API的那段代码无须在执行期间保留指向网络数据获取器的引用,而是设定一套机制,令获取器对象自己设法保持存活。要想保持存活,获取器对象可以在启动任务时把自己加到全局的collection中(比如用set来实现这个collection),待任务完成后,再移除。而调用方则需将其代码修改如下:
-(void)downloadData{
NSURL *url = [[NSURL alloc]initWithString:@"http://www.example.com/something.dat"];
EOCNetworkFetcher *networkFetcher = [[EOCNetworkFetcher alloc]initWithURL:url];
[networkFetcher startWithCompletionHandler:^(NSData *data) {
NSLog(@"Request URL %@ finished",_networkFetcher.url);
_fetchedData = data;
}];
}
大部分网络通信库都采用这种方法,因为假如令调用者自己来将获取器对象保持存活的话,他们会觉得麻烦。Twitter框架的TWRequest对象也用的这个办法。然后,本例这样做会引入引用环。completion handler块其实要通过获取器对象来引用其中的URL(引用了EOCNetworkFetcher的url属性)。于是,块就要保留获取器,而获取器反过来又经由其completion handler属性保留了这个块。所幸要修复这个问题也不难。回想一下,获取器对象之所以要把completion handler块保存在属性里面,其唯一目的就是想稍后使用这个块。于是,获取器一旦运行过completion handler之后,就没必要再保留它了。所以,只需将p_requestCompleted方法按照如下方式修改即可:
-(void)p_requestCompleted{
if(_completionHandler){
_completionHandler(_downloadedData);
}
self.completionHandler = nil;
}
这样一来,只要下载请求执行完毕,引用环就解除了,而获取器对象也将会在必要时为系统所回收。请注意,之所以要在start方法中把completion handler作为参数传进去,这也是一条重要原因。假如把completion handler暴露为获取器对象的公共属性,那么就不便在执行完下载请求之后直接将其清理掉了。因为既然已经把handler作为属性公布了,那就意味着调用者可以自由使用它,若是此时又在内部将其清理掉的话,则会破坏“封装语义”。在这种情况下要想打破引用环,只有一个办法可用,那就是强迫调用者在handler代码里自己把completionHandler属性清理干净。可这并不是十分合理,因为你无法假定调用者一定会这么做。
这两种引用环都很容易发生。使用块来编程时,一不小心就会出现这种bug,反过来说,只要小心谨慎,这种问题也很容易解决。关键在于,要想清楚块可能会捕获并保留哪些对象。如果这些对象又直接或间接保留了块,那么就要考虑怎样在适当的时机解除引用环。
要点:
- 如果块所捕获的对象直接或间接地保留了块本身,那么就得当心循环引用问题。
- 一定要找个适当的时机解除循环引用,而不能把责任推给API的调用者。
转载请注明出处:第六章 block与GCD(上)
参考:《Effective Objective-C 2.0》