介绍
Block(块)是一种可在C、C++以及Objective-C代码中使用的语法闭包,借由此机制,开发者可将代码像对象一样传递,令其在不同上下文下运行。另外,在定义Block的范围内,他可以访问到其中的全部变量。
块可以实现闭包。这项语言特性是作为扩展加入到GCC便以其中的,在近期版本的Clang中都可以使用。10.4版及之后的Mac OS X系统,以及iOS 4.0及之后的系统,都含有正常执行块所需要的运行期组件。从技术上讲,这是个位于C语言层面的特性,只要有支持此特性的编译器,以及能执行块的运行期组件,就可以在C、C++、Objective-C、Objective-C++代码中使用它。
基础知识
块与函数类似,只不过是直接定义另一个函数里的,和定义它那个函数共享同一个范围内的东西。块用“^”符号来表示,后面跟一对花括号,括号里面是块的实现代码,示例:
^ {
// code
}
块其实是个值,可以把块赋给变量。块类型的语法与函数指针类似,比如:
void (^someBlock)() = ^(){
// code
};
这段代码看起来有点怪,变量名写在中间,不过这正是块类型的语法结构:
return_type (^block_name)(parameters)
示例代码:
int (^addBlock)(int a, int b) = ^(int a, int b){
return a + b;
};
int add = addBlock(1, 2); //< add = 3
块的强大之处是:在声明它的范围里,所有变量都可以为其所捕获。也就是说在这个范围内,所有的变量都可以在块中使用。比如:
int additional = 5;
int (^addBlock)(int a, int b) = ^(int a, int b) {
return a + b + additional;
};
int add = addBlock(2, 5); //< add = 12
默认情况下,为块所捕获的变量,是不可以在块里修改,在上面的代码中如果改动additional变量,编译器会报错。不过声明变量的时候可以加上 __block 修饰符,这样就可以在块内修改变量了。例如:
NSArray *array = @[@0, @1, @2, @3, @4, @5];
__block NSInteger count = 0;
[array enumerateObjectsUsingBlock:^(NSNumber *number, NSUInteger idx, BOOL *stop) {
if ([number compare:@2] == NSOrderedAscending) {
count ++;
}
}];
// count = 2
上面的代码使用了内联块,直接使用内联块可以吧所有业务逻辑放在一处。
如果块所捕获的变量是对象类型,那么就会自动保留它。系统在释放这个块的时候,也会将其一并释放,块本身可视为对象,也就和其他对象一样有引用计数。
如果将块定义在Objective-C类的实例方法中,那么除了可以访问类的所有实例变量之外,还可以使用self变量。块总能修改实例变量,所以在声明时无须加 __block。如果通过读取或写入操作获取了实例变量,那么也会自动把self变量也一并捕获了,因为实例变量是与self所指代的实例关联在一起的。例如:
@interface EOCClass
- (void)anInstanceMethod {
// ...
void (^someBlock)() = ^() {
_anInstanceVariable = @"Something";
NSLog(@"_anInstanceVariable = %@", _anInstanceVariable);
}
// ...
}
@end
在这个块里没有明确使用self变量,但是使用了实例变量,这和通过self来访问是等效的
self-_anInstanceVariable = @"Something";
self是一个对象,块在捕获它时会将其保留,如果self所指代的那个对象同事也保留了块,通常就会导致“保留环”。
块的内部结构
块本身是个对象,在存放块对象的内存区域中,首个变量是指向Class对象的指针isa。其余内存里含有块对象正常运转所需的各种信息,详情见下图:
在内存布局中,最重要的就是invoke变量,这是个函数指针,指向快的实现代码。函数原型至少接受一个void*型的参数,此参数代表块对象。
descriptor变量指向结构体的指针,每个块里都包含此结构体,其中生命了块对象的总体大小,还声明了copy与dispose这两个副主函数所对应的函数指针。副主函数在拷贝及丢弃块对象时运行,其中会执行一些操作,比方说,前者要保留捕获的对象,而后者则将之释放。
块还会把它所捕获的所有变量都拷贝一份。这些拷贝放在descriptor的后面,捕获多少个变量,就要占据多少内存空间。这里拷贝的并不是对象本身,而是指向这些对象的指针变量。invoke函数之所以需要把块对象作为参数传递进来,就是因为在执行块时,要从内存中把这些捕获到的变量读出来。
全局块、栈块及堆块
定义块的时候,其所占的内存区域是分配在栈中的。这就是说,块只在定义它的那个范围内有效。例如下面一段危险代码:
void (^someBlock)();
if (/* condition */) {
someBlock = ^{
NSLog(@"Block A");
};
} else {
someBlock = ^{
NSLog(@"Block B");
};
}
someBlock();
定义在if和else语句中的两个块都分配在栈内存中。编译器给每个块分配好栈内存,然而等离开相应的范围后,编译器有可能把分配给块的内存覆写掉。所以,这两个块只能保证在对应的范围有效。这段代码运行起来,若编译器未覆写待执行的块,则程序正常,若覆写,则程序崩溃。
如何解决?可以copy块对象,这样的话就可以吧块对象从栈复制到堆。拷贝后的块,可以在定义它的那个范围之外使用。而且,一旦复制到堆上,块就成了带引用计数的对象。后续堆上的块会当引用计数为0后被系统回收。分配在栈上的块无须明确释放,因为栈内存本来就会自动回收。所以上面的代码加上两个copy就可以了。
void (^someBlock)();
if (/* condition */) {
someBlock = [^{
NSLog(@"Block A");
} copy];
} else {
someBlock = [^{
NSLog(@"Block B");
} copy];
}
someBlock();
除了“栈块”和“堆块”之外,还有一类块叫做“全局块”。这种块不会捕捉任何状态,运行时也无须有状态来参与。块所使用的整个内存区域,在编译期已经确定了,因此,全局块可以声明在全局内存里,而不需要在每次用到的时候在栈中创建。这种块相当于单例,它的拷贝操作是个空操作,因为全局块不可能被系统回收。下面就是个全局块:
void (^someBlock)() = ^{
NSLog(@"This is a block");
};
由于运行该块所需的全部信息都能在编译期确定,所以可以把它做成全局块。这是一种优化技术:若把这么简单的块当做复杂块来处理,在复制和丢弃的时候会执行一些无谓的操作。
为常用的块类型创建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) {
if (flag) {
return value * 5;
} else {
return value * 10;
}
};
可以看出上面的块类型定义的变量比较难读,鉴于此,常用的块类型应该起个别名,尤其是打算把代码发不成API供其他人使用。可以起个更容易读的名字来表示块的拥堵,而把块类型隐藏在后面。使用typedef来实现别名。
typedef int(^EOCSomeBlock)(BOOL flag, int value);
EOCSomeBlock block = ^(BOOL flag, int value){
// code
};
上面的代码读起来顺畅了许多。
通过typedef可以把API做的更易用。比如有些方法需要用块来做参数,比如异步任务是用的“completion handler”。比如下面的函数:
- (void)startWithCompletionHandler:(void(^)(NSData *data, NSError *error))completion;
可以发现定义方法参数所用的块类型语法,更难读,如果使用别名:
typedef void(^EOCCompletionHandler)(NSData *data, NSError *error);
- (void)startWithCompletionHandler:(EOCCompletionHandler)completion;
上面的代码参数看上去更容易理解。下面总结下使用typedef定义块类型。
- 以typedef重新定义块类型,可令块类型看起来更简单
- 定义新类型时应遵从现有命名习惯,比如如果类ZDLHttp中使用的块类型,最好把类名放在定义新类型的前面(ZDLHttpCompletionHandler)
- 同一个块签名,可以按用途定义多个类型别名。如果重构的代码用到块类型的某个别名,只需要修改对应的typedef的快签名即可
用块引用其所属对象时不要出现保留环
使用块时,若不仔细思量,很容易导致“保留环”。比如下面这个类就提供一套接口,调用者可以从某个URL中下载数据。可以设置completion handler,这个块会在下载结束后以回调的方式执行。为了能在下载完成后通过p_requestCompleted方法执行调用者所指定的块,需要将这个块保存到实例变量中。
// EOCNetworkFetcher.h
#import <Foundation/Foundation.h>
typedef void(^EOCNetworkFetcherCompletionHandler)(NSData *data);
@interface EOCNetworkFetcher : NSObject
@property (nonatomic, strong, readonly) NSURL *url;
- (id)initWithURL:(NSURL*)url;
- (void)startWithCompletionHandler:(EOCNetworkFetcherCompletionHandler)completion;
@end
// EOCNetworkFetcher.m
#import "EOCNetworkFetcher.h"
@interface EOCNetworkFetcher ()
@property (nonatomic, strong, readwrite) NSURL *url;
@property (nonatomic, copy) EOCNetworkFetcherCompletionHandler completionHandler;
@property (nonatomic, strong) NSData *downloadedData;
@end
@implementation EOCNetworkFetcher
- (id)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
题外话,在对象内部读取数据是,应该通过实例变量来读;写入数据时,则应通过属性来写。在初始化方法及dealloc方法中,总是应该直接通过访问实例变量来读写数据。
某个类会用上面的类来从URL中下载数据:
@implementation EOCClass {
EOCNetworkFetcher *_networkFetcher;
NSData *_fetchedData;
}
- (void)downloadData {
NSURL *url = [NSURL URLWithString:@"https//www.example.com/something.dat"];
_networkFetcher = [[EOCNetworkFetcher alloc] initWithURL:url];
[_networkFetcher startWithCompletionHandler:^(NSData *data){
NSLog(@"Request URL %@ finished", _networkFetcher.url);
_fetchedData = data;
}];
}
@end
虽然这段代码看起来没有问题,但是它确实有个保留环。因为completionHandler块要设置_fetchedData实例变量,所以它会捕获self变量(前文有描述)。这就是说,handler块保留了EOCClass实例,而EOCClass实例通过strong实例变量(_networkFetcher)保留了EOCNetworkFetcher的对象,最后EOCNetworkFetcher的对象又保留了handler块。一个保留环就此产生。如下图:
要打破保留环,也很容易:要么令_networkFetcher实例变量不再引用获取器,要么令获取器的completionHandler不再持有handler块。在上面的例子中,应该等completion handler执行完毕后,再去打破保留环,以便使获取器对象在handler块执行期间保持存活状态。比如可以作如下修改:
[_networkFetcher startWithCompletionHandler:^(NSData *data){
NSLog(@"Request for URL %@ finished", _networkFetcher.url);
_fetchedData = data;
_networkFetcher = nil;
}];
completion handler这种回调块,很容易形成保留环,所以一定要重视。除了像上面情况,还有另一种形式的保留环。如果completion handler块所引用的对象,最终又引用了这个块本身,那么就出现了保留环。比方说,我们修改下上面的例子,使调用API的那段代码无须在执行期间保留指向网络获取器的引用,而是令获取器对象自己设法保持存活。想要保持存活,获取器对象可以在启动任务时把自己加到全局collection中,带任务完成后,再移除。修改调用方法代码如下:
- (void)downloadData {
NSURL *url = [NSURL URLWithString:@"https//www.example.com/something.dat"];
EOCNetworkFetcher *networkFetcher = [[EOCNetworkFetcher alloc] initWithURL:url];
[networkFetcher startWithCompletionHandler:^(NSData *data){
NSLog(@"Request URL %@ finished", networkFetcher.url);
_fetchedData = data;
}];
}
大部分网络通讯库都采用这种办法,因为假如令调用者自己来将获取器对象保持存活的话,他们会觉得麻烦,也容易出现问题。然而就现在的EOCNetworkFetcher代码来看,此调用代码引入了保留环。completion handler块通过获取器对象访问url,所以块保留了获取器对象,而获取器对象通过completionHandler属相保留了块。那么如何打破这个保留环呢?可以看到获取器对象之所以需要保留completion handler块,唯一目的是稍后使用这个块。那么运行完块之后,就没有必要再保留了。所以可以修改p_requestCompleted方法如下:
- (void)p_requestCompleted {
if (_completionHandler) {
_completionHandler(_downloadedData);
}
self.completionHandler = nil;
}
要点:
- 如果块所捕获的对象直接或间接地保留了块本身,那么就得当心保留环问题
- 一定要找个适当的时机解除保留环,而不能把责任推给API的调用者
摘自《Effective Objective-C》,手敲一遍为的是加深印象,没看过此书的同学建议看看。