我所理解的 Block
关于 block 的文章,网上已经有很多了。我这里只是将这个知识点再梳理一下,做一下记录。毕竟年纪大了,容易忘事。
抛砖引玉
围绕 block 所产生的问题,太多太多。这里我将这些问题罗列出来,如果你对某些问题感到懵逼,可以在下文中找到答案。找不到,私信我。
- 为什么要用 block?毕竟它的语法难记,还容易产生内存泄漏。
- block 的各种书写格式,你是否了解?
- 按内存区这一维度划分,block 可以分为哪几种类型,如何定义的?
- block 是 Objective-C 对象吗?
- block 内部实现原理是怎样的?
- 怎样写会造成循环引用,又是如何避免循环引用?
- 如果以上问题你都了解,可以不用往下看了。
为什么使用 Block
block 的唯一好处就是:使代码变得更简洁。
我们可以向一个方法以参数的形式传递一个 block,作为方法的 callback 函数。类似于向方法传递一个函数指针。这样就不必再声明一个新的方法,并调用,在一定程度上简化了代码。下面有一个例子:
使用 notification 时,常规方式是注册一个 selector 并实现对应的方法,像这样:
1 | - (void)viewDidLoad { |
如果使用 block,可以写成这样:
1 | - (void)viewDidLoad { |
另外一个简化代码的特性就是,block 可以捕获外部变量。这样就不必再以参数的形式传递,简化的方法的定义和调用。
Block 长什么样
在最初接触 block 时,我经常写不对,它的语法太另类。fucking block syntax 提供了各种 block 的写法,我这里就直接照搬过来了。
- 作为局部变量
1 | returnType (^blockName)(parameterTypes) = ^returnType(parameters) {...}; |
- 作为属性(property)
1 | @property (nonatomic, copy, nullability) returnType (^blockName)(parameterTypes); |
- 定义方法时,作为方法参数
1 | - (void)someMethodThatTakesABlock:(returnType (^nullability)(parameterTypes))blockName; |
- 调用方法时,作为参数传递
1 | [someObject someMethodThatTakesABlock:^returnType (parameters) {...}]; |
- 作为类型别名 (typedef),增加代码可读性
1 | typedef returnType (^TypeName)(parameterTypes); |
Block 内部原理是怎样的
在编译时,编译器会将 block 语法转化成 C 的源代码,再将这部分 C 的源代码编译为编译器处理的代码。我们可以使用 clange (LLVM 编译器) 来完成 “将 block 语法转化为 C++ 源代码 (本质还是 C)” 这一阶段。具体命令如下:
1 | clang -rewrite-objc 源代码文件名 |
1.一个简单 Block 的结构
下面我们转化一段 OC 代码来分析 block。
使用 clang -rewrite-objc main.m
转化如下代码:
1 | int main(int argc, char * argv[]) { |
转化接入后是下面这个样子(主要代码)。因为语法和命名的关系,代码看着很乱,但是逻辑很清晰。为了方便理解,我加了部分注释。
1 | // block 结构体。可以理解为 'block' 这种类型的基本结构 |
上述代码中,定义了三个结构体:block 基本结构 __block_impl
、Desc 指针 __main_block_desc_0
、整个 block 的结构 __main_block_impl_0
。其中 __main_block_impl_0
包含两个成员变量,分别为 __block_impl
结构体实例和 __main_block_desc_0
指针。
上述还定义了两个方法:block 实际执行方法 __main_block_func_0
和 main()
方法。
__main_block_func_0
方法为输出对应的字符串(”test block”)。
main
方法主要分为两步:
定义 block。将 block 实际执行方法,也就是 __main_block_func_0
的函数指针和 __main_block_desc_0_DATA
的地址传入 __main_block_desc_0
的构造方法,构造成一个完整的 block。根据定义可以看出 __main_block_desc_0
初始化时所有的大小为 __main_block_impl_0
结构体大小。
执行 block。实际可以简化为 *myBlock->impl.FuncPtr
,就是调用对应的方法。
了解了这个基本结构,后面的都是在这基础上追加部分代码,很容易理解。
2.Block 结构与 isa 指针
在上述代码中,我们可以看出 block 结构体,也就是 __block_impl
中有一个 isa
指针。我们先来看看这个 isa
指针。
“id” 这一变量类型用于存储 OC 对象。在 runtime.h
中,它的定义如下:
1 | typedef struct objc_objct { |
Class
类型属于一个结构体指针类型,定义为:
1 | typedef struct objc_class *Class |
objc_class
结构体定义如下:
1 | struct objc_class { |
综上可知,OC 中每个类的结构体就是基于 objc_class
结构体。
在上面可以看到这样一段代码:
1 | impl.isa = &_NSConcreteStackBlock; |
isa
被赋值为 _NSConcreteStackBlock
类型的指针。那么 _NSConcreteStackBlock
又是什么?通过 debug 界面我们可以看到如下情况 :
block 一供有三种类型,分别为
__NSGlobalBlock__
、__NSStackBlock__
、__NSMallocBlock__
,这三种类型后面会详细解释。这里转化的代码和 debug 界面显示的类型不一样,但是基本类型以信仰,都是Class
类型,不必纠结。
可以看出 _NSConcreteStackBlock
实际是 Class
类型。那么,block 本质就是 Objective-c 对象。
3.捕获自动变量
我们将源代码改为如下情况:
1 | int main(int argc, char * argv[]) { |
使用 clang 进行转化。我们只看转化后的关键部分。即整个 block 结构:
1 | struct __main_block_impl_0 { |
可以看到局部变量 val
被自动追加到了 __main_block_impl_0
结构体中,并在构造函数中添加了参数。通过构造函数初始化 block 时,会将外部变量捕获进来。这里捕获的是引用,所以在 block 内部改变局部变量的值之后,并不会传出去。
4.关于 __block
正常情况下,block 捕获的变量是不可以修改的。但是有两种方式可以让其修改:
- 使用静态变量、静态全局变量、全局变量。因为前两个生成在静态数据区,最后一个生成在堆区。它们都不会随着 block 栈的消失而被释放。出了 block 作用域依然有效。但是平时使用这种变量诸多不变。
- 使用
__block
关键字修饰。它类似于static
、auto
和register
这些关键字,主要来指定变量存储在哪个区域。
为什么使用 __block
关键字修饰之后就可以修改。我们使用 clang 转化如下一段代码:
1 | int main(int argc, char * argv[]) { |
转换后如下,可以看出加了一句 __block
多了很多代码,依然是代码很乱,但是逻辑很清晰,我们只看主要部分 :
1 | struct __Block_byref_val_0 { |
我们可以看出局部变量转化为一个结构体:
1 | struct __Block_byref_val_0 { |
在 __main_block_impl_0
中追加了一个 __Block_byref_val_0
结构体指针,后续的初始化和修改 val 的值也是通过指针来操作。所以修改后的值就可以传出去了。
5.block 的存储类型
前面有提到过,block 按照存储类型划分,可以分为三种:
- _NSConcreteGlobalBlock
- _NSConcreteStackBlock
- _NSConcreteMallocBlock
他们在内存中的存储结构如下图所示,对号入座:
我们分别来解释一下。
_NSConcreteGlobalBlock,也叫全局 block。有两种生成方式:
一种是在全局的地方生成,不存在捕获局部变量的情况。例如:
1 | void(^globalBlock)(void) = ^{printf("this is global block");}; |
另一种是,block 中不截获局部变量。例如:
1 | typedef int (^TestBlock) (int); |
_NSConcreteStackBlock,也叫栈 block。除了上述的初始化方式,通过其他方式初始化为的 block 都是栈 block。
_NSConcreteMallocBlock,也叫堆 block。
堆 block 不是由代码初始化来的,而是由栈 block 调用 copy 方法时从栈内存拷贝到堆内存而得来的。
至于什么时候会发生 copy 操作,可以总结为一下几点 (ARC 环境):
- Cocoa 框架的方法且方法名中含有 usingBlock。
- GCD 中的方法。
- block 赋值给强引用对象时。
- 作为返回值时。
- 显示调用 copy 方法。
下面是一些例子:
1 | typedef BOOL (^TestBlock)(NSString *); |
6. block 变量结构中的 forwarding
在前面的代码中,我们发现 __block
代码中有一个 __forwarding
,如下面的代码:
1 | struct __Block_byref_val_0 { |
长话短说。当一个栈 block 捕获了一个在栈上生成的 __block
变量,那么随着 block 从栈上 copy 到堆上,这个 __block
变量也从栈上 copy 到堆上。因为有一个 __forwarding
指针,使得无论从从栈上还是堆上,访问的都是一个变量。如果没有明白看下面的图和代码。
1 | __block int val = 10; |
无论是操作栈上的 val 变量还是堆上的 val 变量,最终修改的是同一个值。
7.block 与循环引用
发生循环引用说明出现了互相持有的现象,例如下面这样:
上图中 self 持有 blk 属性,blk 持有 block,block 持有 self,这就形成了一个环。现如今的 Xcode 已经很智能,这种简单的循环引用,会出现警告。
为避免循环引用,可以使用 __weak
关键字。例如下面这样:
1 | __weak typeof(self) weakSelf = self; |
为了避免在 block 内使用 self 期间,self 被释放。可以在 block 内部对 self 进行强引用。因为这个强引用生成在 block 栈内,会随着 block 的作用域消失而消失。不会产生循环引用。
1 | __weak typeof(self) weakSelf = self; |
如何使用 Block
前面讲了很多原理,过程中也讲了很多使用。这里只总结几点,使用 block 一定要注意:
- block 的命名方式,牢记。
- 对于要再 block 内修改的变量,加
__block
修饰符。对于 OC 中的一些对象,例如 NSMutableArray,如果只修改数组内的元素,不需要加__block
;如果要修改数组的指针,需要加__block
。 - 使用自定义 block 时,注意循环引用的问题。尤其是各种间接关系产生的循环引用。
对于捕获到 block 中的弱引用,如果怕使用期间被释放,需要再 block 内部再次强引用一下。
综上,block 总结完毕,祝好运。
参考文献
1.A Short Practical Guide to Blocks
2.How Do I Declare A Block in Objective-C?