block可以说是OC一项非常好用的功能。block的本质,实际上是『带有自动变量值的匿名函数』。但是在block的使用上,有各种江湖传说,说在某某情况下,block的使用是不安全的,会造成崩溃。于是也有很多面试题喜欢考察block。但是,实际的block的不安全使用,貌似除了循环引用,也没遇到过什么情况啊?我敢说,block在现如今的iOS开发中,99%的崩溃都是因为你没有给block判空。而其他问题,都是因为循环引用。那么block到底啥时候不安全呢?

其实关于block,我们不用那么害怕。

block 的数据结构

首先,block的数据结构其实可以通过查看源码来获得。关于block的数据结构和runtime是开源的,可以在llvm项目看到,或者下载苹果的libclosure库的源码来看。苹果也提供了在线的代码查看方式,其中包含了很多示例和文档说明。

所以,block真正的结构,就是这个样子:

struct Block_descriptor_1 {
uintptr_t reserved;
uintptr_t size;
};
struct Block_layout {
void *isa;
volatile int32_t flags; // contains ref count
int32_t reserved;
void (*invoke)(void *, ...);
struct Block_descriptor_1 *descriptor;
// imported variables
};

在objc中,根据对象的定义,凡是首地址是*isa的结构体指针,都可以认为是对象(id)。这样在objc中,block实际上就算是对象。

那么既然block是个对象,那么block就应该有Class,那么block的Class是什么呢?

在block runtime中,定义了6种类:

_NSConcreteStackBlock 栈上创建的block

_NSConcreteMallocBlock 堆上创建的block

_NSConcreteGlobalBlock 作为全局变量的block

_NSConcreteWeakBlockVariable

_NSConcreteAutoBlock

_NSConcreteFinalizingBlock

其中我们能接触到的主要是前3种,后三种用于GC不做讨论。

全局block

其实,这三种block类型的情况非常好理解。

首先我们要明确,在编译完成后,block内部的代码将会提取出来,成为一个单独的C函数。创建block时,实际就是在方法中声明一个struct,并且初始化该struct的成员。而执行block时,就是调用那个单独的C函数,并把该struct指针传递过去。block的的实际作用效果,相当于C语言中的匿名函数

于是,就可以理解_NSConcreteGlobalBlock的使用了。因为全局block是当一个block内部没有捕获任何外部变量时,就会是一个全局block类型。此时,这个block与一个函数无异。所以,那么它就应该有和函数一样的静态特性。而且,我们在调用block的时候,其实和普通C函数的调用很相似,都是名称加括号:block()

那么有函数一样静态特性的block,显然不需要再取考虑他的生命周期。

栈block

这个类型的block,是在编译器发现block内部引用了外部变量后,会生成的block类型。

在block内部有引用外部变量时,当struct第一次被创建时,它是存在于该函数的栈帧上的,其Class是固定的_NSConcreteStackBlock。其捕获的变量是会赋值到结构体的成员上,所以当block初始化完成后,捕获到的变量不能更改。

当函数返回时,函数的栈帧被销毁,这个block的内存也会被清除。所以在函数结束后仍然需要这个block时,就必须用Block_copy()方法将它拷贝到堆上。这个方法的核心动作很简单:申请内存,将栈数据复制过去,将Class改一下,最后向捕获到的对象发送retain,增加block的引用计数。详细代码可以直接点这里查看。

之所以这样设计,实际上,可以认为成,当block有了外部变量的捕获,那么它就需要持有这个外部变量,就是赋值到结构体成员上。这种捕获,造成了block对应struct结构体大小的动态变化,所以,在设计上适合放在栈上更合理。

堆block

在栈block中,说到过,当函数的栈帧的销毁,那么栈block也会被随之清楚。但是我们一般都需要在函数结束后仍然能使用这个block,所以,需要把栈block拷贝到堆上。在copy时,就把栈block的类型转换成了堆block。

所以在MRC时代,block属性的关键字必须是copy。这样就可以保证在给block属性赋值的时候,能把在栈上的block给copy到堆区。

而讲得再细一点,为什么非要把block放到堆区才安全。

因为你可以这么理解,block就是个匿名函数,只不过我们给了一个变量来引用这个匿名函数,在需要的时候调用。但是,栈block会随着函数栈帧的销毁而销毁,这样一来,我们用之前做引用的变量再去调用这么一块被销毁的内存,就会出现内存崩溃

所以,只有把block放到由我们来控制生命周期的堆区中,才能安全地使用block。

我们知道,在OC中,对象都会在堆区存储。实际上,此时的堆block,它的确就是一个对象。而且,你还需要对它手动release。

当然,当ARC时代来临,这就又有所不同了。

ARC下的 block

首先先看我写的一段测试代码:

这是在ARC下的Command line tools工程。

这段代码中的str是做常量区地址参考的,最后的obj是做堆区地址参考的。

可以看到str的地址是0x1000021f0obj的地址是0x100406b40。的确符合预期,常量区地址非常小,堆区地址稍微大一些。

全局block

先看gBlock。这个block没有捕获任何外部变量,仅仅是打印一句文字。所以,理所当然,它是一个全局block。通过地址的观察,的确如此,比str常量的地址还小。

堆block和栈block

再看mBlock。这个block是捕获了一个外部变量,打印一个外部声明的字符串。这种情况下,在MRC小中应该是属于栈block。但是这里的执行结果显示,它实际是一个堆block。

实际上,这是因为在ARC下,对block做了大量处理。现在的情况是,只要一个block被赋值给一个strong变量,会自动copy。所以,我们看到mBlock这个地址,和参考对象obj的地址非常接近。

这样一来,实际上在ARC下,很难在写出一个栈block的情况,因为一旦有赋值给strong变量,那么就得到的是堆block。所以,为了写出一个栈block,我使用一个函数,入参是block类型,但是这个block参数没有经过任何一次赋值操作,直接放在了函数参数里。所以,这样就可以再函数体内得到这个栈block类型的参数。

通过地址,可以看到,这个栈block的确地址很大。在给这个栈block进行一次手动copy后,block也变成了堆block。

不过,这里虽然倒弄出来一个栈block,不过这种情况是不会有栈block被提前释放的问题。因为这个栈block作为入参,他的生命周期本身也是跟随这个函数的函数体。函数体栈帧释放,block也被释放,由于函数外并没有对block有引用,所以,这个block也可以被安全的释放。

结论

网上很多关于block的资料,说使用block会造成崩溃,实际都是因为文章太老。现阶段的block是非常安全的。而且LLVM编译器的检查也十分完善,可以提前发现仅有的一些block被提前释放的情况。

所以,在ARC下,你可以大胆地使用block,而不太需要在意block本身的生命周期。因为他实际和我们平常用的其他NSObject对象的表现,并无二致。

Reference

objc 中的 block