什么是 Block
Block 是带有自动变量(局部变量)的匿名函数 ——《Objective-C 高级编程》
Block 是 Objective-C 对于闭包的实现,本质是一个封装了函数以及函数上下文的对象
- 可以定义在函数内或函数外
- 本质是对象
Block 的写法
Block 的定义
无参数无返回值
1
2
3
4void (^MyBlockOne)(void) = ^(void){
NSLog(@"无参数,无返回值");
};
MyBlockOne(); // Block的调用有参数无返回值
1
2
3
4void (^MyblockTwo)(int a) = ^(int a){
NSLog(@"@ = %d我就是Block,有参数,无返回值",a);
};
MyblockTwo(100);有参数有返回值
1
2
3
4
5
6// 声明时可以省略参数的名字
int (^MyBlockThree)(int,int) = ^(int a,int b){
NSLog(@"%d我就是Block,有参数,有返回值",a + b);
return a + b;
};
int ret = MyBlockThree(12,56);无参数有返回值(很少用到)
1
2
3
4
5int (^MyblockFour)(void) = ^{
NSLog(@"无参数,有返回值");
return 45;
};
int ret = MyblockFour();
Block 的省略写法
以上等式的右半部分是 Block 的写法,以下是 Block 的语法,其中表达式就是 Block 的函数内容
1 | ^ 返回值类型 参数列表 表达式 |
其中返回值类型可以被省略。省略返回值类型时,如果有 return 语句就使用该返回值的类型,如果有多条 return 语句则它们的类型必须相同,如果没有 return 则为 void
1 | ^ 参数列表 表达式 |
参数列表也可以被省略,前提是这个 Block 没有参数(而有返回值的时候依然可以省略返回值)
1 | ^ 表达式 |
typedef
实际开发中常用 typedef 定义 Block
1 | // 最好不要省略参数的名字 |
- 使用 typedef 定义的时候,最好不要省略参数的名字
- Block 虽然是对象,但是作为属性一般不是指针类型
截获外界变量
截获自动变量(局部变量)值
Block 只捕获在 Block 内部使用的自动变量(局部变量),是值复制而非引用,特别要注意的是默认情况下 Block 只能访问不能修改局部变量的值
1 | int age = 10; |
全局变量、静态全局变量和静态局部变量
如果是这些类型,则 Block 直接可以访问到该变量自身,不需要进行拷贝,因此修改生效
1 | int global_val = 10; |
__block
修饰的外部变量
对于用 __block
修饰的外部变量(称为 __block 变量
),Block 是复制其引用地址来实现访问的。Block 可以修改 __block
修饰的外部变量的值
1 | __block int age = 10; |
注意:
__block
变量就是用__block
修饰的局部变量
Block 的实现
1 | int main() |
通过 clang -rewrite-objc MyBlock.c
可以转化为 C 语言的源码
1 | struct __main_block_impl_0 |
__main_block_impl_0
的指针,其命名规则是由函数名(main)和 Block 出现的位置(第 0 个)决定的- Block 其实是一个
__main_block_impl_0
的指针,其主要包含一个__block_impl
,__block_impl
主要存储了一个函数指针,指向 Block 的内容 - Block 的调用就是利用函数指针进行函数调用
为什么说 Block 是一个 Objective-C 对象?
1 | // Objective-C 对象的结构体 |
__main_block_impl_0
相当于基于objc_object
的 Objective-C 类对象的结构体,所以说 Block 是一个 Objective-C 对象- 其中,Block 的 isa 的取值可能为
_NSConcreteGlobalBlock
/_NSConcreteStackBlock
/_NSConcreteMallocBlock
截获自动变量(局部变量)的实质
1 | int main() |
转换后的代码与之前的差别是
1 | struct __main_block_impl_0 |
最主要的差别就是 __main_block_impl_0
自动增加了一个 val 的成员变量,构造函数也发生了相应的变化。注意这里是值复制,这样就可以解释为什么 Block 截获局部变量,在执行 Block 内容时修改其值并不会影响原来的值。同时,因为这种实现无法改变被截获的局部变量的值,所以当在一个 Block 内对一个局部变量进行赋值的时候,编译器就会报错
截获全局变量、静态全局变量、静态局部变量的实质
1 | int global_val = 10; |
转换后的代码与之前的差别是
1 | int global_val = 10; |
对于全局变量和静态全局变量,转换后的代码依然可以访问到,因此在
__main_block_impl_0
内部并不会新增多余的成员变量对于静态变量,Block 存储了其地址,从而达到可以修改其值的目的。其实普通局部变量也可以通过传地址的方式来达到在 Block 执行时可以修改其值的目的,但为什么没这么做呢?因为即使保存了普通局部变量的地址,当该变量的作用域失效的时候,那么这个地址也是非法的。而静态局部变量的作用域是一直有效,因此采用存储的地址的方法
截获 __block
变量
1 | int main() |
转换后的代码与之前的差别是
1 | struct __Block_byref_val_0 |
- main 函数中,
__block
变量已经被转化为__Block_byref_val_0
,注意转换后的代码没有一个叫做 val 的基本类型的值 __Block_byref_val_0
最后一个参数表示__block
变量的值,这意味着该结构体持有着与原局部变量值相同的成员变量- main 函数中,
__Block_byref_val_0
的构造函数中,第二个参数传的是它自己(原本是__block
变量)的地址 - 修改 val 的值是通过
__forwording
来实现的,这个例子中__forwording
指向它自己 - 为什么
__Block_byref_val_0
不是在 Block 内部创建呢,而是定义在 Block 的外部?这是为了如果有两个 Block 同时截获同一个局部变量,这两个 Block 需要同时引用这个值,如此才能实现多个 Block 能够修改同一个__block
变量的值 - 新增了
__main_block_copy_0
和__main_block_dispose_0
函数,而这两个函数没有被显式调用,只是作为参数传给了__main_block_desc_0
构造函数,这两个是为了实现正确的引用计数 - 因为
__block
变量被转化为一个带原值的对象,这个对象以指针的形式传到了 Block 内部,因此在 Block 内部修改其值就得以实现
注意:
__block
变量与 Block 的区别:__block
变量是栈上的结构体实例,而 Block 是栈上块的结构体实例
截获对象
__strong
类型的对象
1 | blk_t blk; |
array 是局部变量,看起来应该会被释放,但是实际上却是被 Block 截获了
转换后的关键代码如下
1 | struct __main_block_impl_0 { |
- 截获对象时,Block 内部的成员变量是用 strong 修饰,因此才能使 array 不被释放
__main_block_copy_0
相当于 retain,会在栈 Block 被复制到堆时被系统调用,使对象的引用计数+1__main_block_dispose_0
相当于 release,堆上的 Block 被释放,使对象的引用计数-1- 如果以上代码不对 Block 进行 copy,那么虽然 Block 可以捕获 array 并强持有,但是由于还是在栈上,超出其作用域之后,Block 被释放,array 也跟着被释放,后续的 Block 调用会 Crash
__block
+ __strong
类型的对象
注意,如果是被 __block
和 __strong
同时修饰的对象,那么区别在于 __main_block_impl_0
持有的对象不再是 id __strong array
而是 __Block_byref_obj_0
1 | struct __Block_byref_obj_0 { |
除此之外,其结果基本与 __strong
类型的对象一致
__block
使得对象可以在 Block 内被赋值,否则会编译失败
__weak
类型的对象
无论是有没有被 __block
修饰,__weak
类型的对象并不会增加对象的引用计数,所以对象依然会作用域结束时被释放,nil 被赋值给被截获的对象
Block 的类型
我们先来看看一个由 C/C++/OBJC 编译的程序占用内存分布的结构:
block有三种类型:
- 全局块(
_NSConcreteGlobalBlock
) - 栈块(
_NSConcreteStackBlock
) - 堆块(
_NSConcreteMallocBlock
)
它们的内存分配如上图
- 全局块存在于全局内存中,相当于单例
- 栈块存在于栈内存中,超出其作用域则马上被销毁
- 堆块存在于堆内存中,是一个带引用计数的对象,需要自行管理其内存
简而言之,存储在栈中的 Block 就是栈块、存储在堆中的就是堆块、既不在栈中也不在堆中的块就是全局块
_NSConcreteGlobalBlock
以下情况均为全局块:
- 定义在函数之外(即写全局变量的地方)
- 没有截获任何局部变量(即使定义在函数内部)
_NSConcreteStackBlock
栈上的 Block,如果其作用域结束,该 Block 就被废弃,如同一般的局部变量。同时,因为 __block
变量是被 Block 持有,所以它也会跟着 Block 一起被废弃
_NSConcreteMallocBlock
为了解决栈块在其变量作用域结束之后被废弃(释放)的问题,我们可以把 Block 复制到堆中延长其生命周期
开启 ARC 时,大多数情况下编译器会恰当地进行判断,自动生成将 Block 从栈上复制到堆上的代码
Block 的复制操作执行的是 copy 方法。只要调用了 copy 方法,栈块就会变成堆块
举个例子,编译器会自动 copy 返回的 Block
1 | typedef int (^blk_t)(int); |
将 Block 从栈上复制到堆上相当消耗 CPU,所以当 Block 设置在栈上也能够使用时,就不要复制了,因为此时的复制只是在浪费 CPU 资源
栈 Block 复制到堆之后,它的 __forwarding
指针会指向堆 Block
通过 __forwarding
, 无论是在 Block 中还是 Block 外访问 __block
变量, 也不管该变量在栈上或堆上, 都能顺利地访问同一个 __block
变量
1 | __block int val = 0; |
其中 ^{++val;} 和 ++val 转换后的代码如下
1 | // val 是 __block 变量变成的结构体,它含有 __forwarding 指针 |
关于 Block 类型的进一步阐释
ARC 下,对于堆 Block 和栈 Block 的判断会更复杂,因为大多数情况下 ARC 会帮忙做 Copy
比如
- Block 作为函数返回值返回的时候
- Cocoa 框架的方法,方法中含有 usingBlock 等(如 NSArray 的 enumerateObjectUsingBlock)
- GCD 的 API
- 被 strong 变量引用到的 block
- 捕获到局部变量的时候,会自动 Copy 一下(所以大部分情况都是堆 Block 了)
- block 方法是参数的时候,依然是会触发 copy。(Xcode 13.4.1 验证如此,网上的答案都过时了)
1 | - (void)testMethod:(void (^)(int num))block { |
ARC 下,反而要创建一个栈 Block 更困难了,比如专门使用 weak 修饰的 block,但是没什么实际用途
因而随着 ARC 的完善,考察 Block 的类型判断跟考察 MRC 一样,越来越没有意义了
对各种类型的 Block 执行 copy 操作
栈 Block 何时会从栈复制到堆
- 对 Block 调用 copy
- Block 作为函数返回值返回的时候(编译器自动复制)
- Cocoa 框架的方法,方法中含有 usingBlock 等(如 NSArray 的 enumerateObjectUsingBlock)(编译器自动复制)
- GCD 的 API(编译器自动复制)
- 将 Block 赋值给类的 strong 成员变量
多次 copy Block
无论是什么类型的 Block,对 Block 进行多次 copy 都不会有问题。在不确定时调用 copy 方法即可
1 | blk = [[blk copy] copy]; |
改代码等价为
1 | // 翻译后的代码 |
1 | // 翻译后的代码+注释 |
Block 循环引用
使用 Block 成员变量引起的循环引用
1 | typedeft void (^blk_t)(void); |
以上代码引起循环引用,self 强引用 Block,Block 强引用 self
Block 截获了类的成员变量时,即使没有使用 self,也会同样截获 self
1 | @interface MyObject : NSObject |
对编译器来说,等价于
1 | blk_ = ^{NSLog(@"obj_ = %@", self->obj_);}; |
破解方法:使用 weak 修饰符
1 | - (id)init |
使用 __block
变量引起的循环引用
1 | - (instancetype)init |
该源代码没有引起循环引用。但是,如果不调用 execBlock 实例方法(即不执行赋值给成员变量 blk_
的 Block),便会循环引用并引起内存泄露
破解方法:执行 Block 将 __block
变量置空
两种方法的比较
相比使用 Block 成员变量,使用 __block
变量的优点如下:
- 通过
__block
变量可控制对象的持有期间(即不会在执行 Block 之前被释放,不过使用 Block 成员变量可以通过 Strong-Weak Dance 来解决被截获对象被提早释放的问题)
缺点如下:
- 为避免循环引用必须执行 Block