iOS开发中block是比较常用也是比较好用的语法,平时开发中我们都用的很溜,但它的底层是如何实现的呢?__block
原理是什么?__weak
是如何解决循环引用问题的?
block的本质
这些问题,我们都可以通过clang
命名分析代码得到答案;clang 命令可以将源码改写成C/C++的,通过C/C++ 源码可以很清楚的研究 block底层实现;
具体命令:
clang -rewrite-objc main.m
这个是最基本的命令,还可以增加参数生成具体平台,具体架构的代码,这样生成的代码量会少很多;具体的命令是:
xcrun -sdk iphoneos clang -arch arm64 -rewrite-objc main.m
执行命令后,目录下会生成一个同名的main.cpp文件;
OC代码:
int main(int argc, const char * argv[]) {
@autoreleasepool {
int i = 0;
void (^block)(int) = ^(int a) {
NSLog(@"HelloWorld-%d",i);
};
}
return 0;
}
生成的main.cpp文件最后面,能找到与之对应的C/C++代码:
int main(int argc, const char * argv[]) {
/* @autoreleasepool */ { __AtAutoreleasePool __autoreleasepool;
int i = 0;
void (*block)(int) = ((void (*)(int))&__main_block_impl_0((void *)__main_block_func_0, &__main_block_desc_0_DATA, i));
}
return 0;
}
以此为基础可以分析出与block底层的结构为:
__main_block_impl_0
结构体
struct __main_block_impl_0 {
struct __block_impl impl;
struct __main_block_desc_0* Desc;
int I;
__main_block_impl_0(void *fp, struct __main_block_desc_0 *desc, int _i, int flags=0) : i(_i) {
impl.isa = &_NSConcreteStackBlock;
impl.Flags = flags;
impl.FuncPtr = fp;
Desc = desc;
}
};
__main_block_impl_0有4个部分,3个成员,1个构造函数
-
__block_impl
结构体
struct __block_impl {
void *isa;
int Flags;
int Reserved;
void *FuncPtr;
};
这个结构体,第一个成员就是我们很熟悉的isa
指针,这就说明block本质就是OC对象;
最后一个成员FuncPtr
是函数指针,这个存储的函数就是block里的代码实现:
static void __main_block_func_0(struct __main_block_impl_0 *__cself, int a) {
int i = __cself->i; // bound by copy
NSLog((NSString *)&__NSConstantStringImpl__var_folders__p_gphdp0fd4yv2vlq4f0y1wm500000gn_T_main_a571b5_mi_0,i);
}
-
__main_block_desc_0
结构体
static struct __main_block_desc_0 {
size_t reserved;
size_t Block_size; // bolck的内存大小
}
- 捕获的外部变量i
-
__main_block_impl_0 ()
构造函数,初始化__block_impl,__main_block_desc_0结构体数据;
总的来说block本质就是:
- block内部也有isa指针,它是一个OC对象
- block是封装了函数调用以及函数调用环境的OC对象
- block是个结构体,具体结构:
block对变量的捕获
我们主要分析block对以下4种变量的捕获表现:
- 自动变量
- 静态变量
- 全局变量
- 静态全局变量
自动变量
我们平常声明的没有关键字修饰的局部变量默认就是自动变量,只是省略了auto
关键字:
上面代码中int i = 0
就是自动变量等价为auto int i = 0
;
从上面生成的代码可以看出,自动变量i被捕获到了__main_block_impl_0
结构体中,且是捕获的是值;
静态变量
现在将main函数的i变量改为静态变量:
int main(int argc, const char * argv[]) {
@autoreleasepool {
static int i = 0;
void (^block)(int) = ^(int a) {
NSLog(@"HelloWorld-%d",i);
};
}
return 0;
}
重新生成C/C++代码
struct __main_block_impl_0 {
struct __block_impl impl;
struct __main_block_desc_0* Desc;
int *i; // 指针 不同于自动变量的int i
....
}
静态变量i也被捕获了,只是这次捕获的是指针;
全局变量,静态全局变量
依次改为全局变量,静态全局变量,重新生成代码;最终会发现,block并未捕获该变量;这是因为全局变量,静态全局的作用域是全局的,任何地方都能访问该变量;block无需将该变量捕获到结构体找那个也能访问;
不同类型变量的捕获情况:
变量类型 | 是否捕获 | 访问方式 |
---|---|---|
自动变量 | 是 | 值传递 |
静态变量 | 是 | 指针传递 |
全局变量(静态全局) | 否 | 直接访问 |
由于捕获(访问)的方式不一样,外部变量修改后,block内部使用的变量结果不一样:
int global_i = 0;
int main(int argc, const char * argv[]) {
@autoreleasepool {
int auto_i = 0;
static int static_i = 0;
void (^block)(void) = ^() {
NSLog(@"%d,%d,%d",auto_i,static_i,global_i);
};
auto_i = 1;
static_i = 1;
global_i = 1;
block();
}
return 0;
}
以上代码,输出结果 0,1,1;
- 因为auto变量是以值的方式被捕获,block将外部变量值拷贝一份存储于结构体中;当外部变量修改后,block捕获的变量不受影响;
这和平常我们将一个变量传参给函数,函数内部更改了变量值,但外部变量不变的道理是一样的:
void change(int i) {
i = 1;
}
int main(int argc, const char * argv[]) {
@autoreleasepool {
int i = 0;
change(i);
NSLog(@"%d",i); // 0
}
return 0;
}
- 对于静态变量,block捕获的是变量的引用*i,也就是指针传递;当外部变量的值更改后,block通过*i访问的值也就是更改后的值;
对应函数参数的例子:
void change(int *i) {
*i = 1;
}
int main(int argc, const char * argv[]) {
@autoreleasepool {
int i = 0;
change(&i);
NSLog(@"%d",i); // 1
}
return 0;
}
- 对于全局变量,内存在数据区,全局访问的都是同一个值;只要一个地方改了,其他使用的都会变;
有一个疑问是,block对于自动变量为什么不和静态变量一样处理,将自动变量以指针传递的方式访问呢?归根结底还是因为自动变量作用域的问题,自动变量作用域是当前函数(方法)范围内;当出了作用域后,系统会自动释放;如果block对自动变量以指针方式捕获,block内部的变量指向的内容也释放了;所以对于自动变量,一定是要值传递;
block修改捕获的变量
以上是变量在外部进行了更改,如果在block内部进行更改又是什么情况呢?
block中分别对自动变量,静态变量,全局变量进行修改:
int global_i = 0;
int main(int argc, const char * argv[]) {
@autoreleasepool {
int auto_i = 0;
static int static_i = 0;
void (^block)(void) = ^() {
global_i = 1;
static_i = 1;
auto_i = 1;
};
}
return 0;
}
编译后,auto_i = 1;这行报错Variable is not assignable (missing __block type specifier)
;
而静态变量,全局变量均可以修改成功,具体原因同上面分析的一样:
- 全局变量作用域广,都可以修改;
- 静态变量通过指针的方式传递修改:
struct __main_block_impl_0 {
struct __block_impl impl;
struct __main_block_desc_0* Desc;
int *i; // 指针
....
}
static void __main_block_func_0(struct __main_block_impl_0 *__cself) {
int *i = __cself->i; // bound by copy
(*i) = 1;
}
为了更好的理解通过指针的方式传递修改变量,这里编写了类似的代码:
int main(int argc, const char * argv[]) {
@autoreleasepool {
// 外部变量
int i = 1;
// 模拟block结构体__main_block_impl_0
struct __main_block_impl_0 {
int *I;
}blockImp;
blockImp.i = &i; // 地址
// 模拟block调用函数__main_block_func_0 (不同作用域)
{
int *i = blockImp.i;
(*i) = 2; // 可以更改
}
}
return 0;
}
- 自动变量报错,语法方面是没问题的,只是编译器防止我们出错给的提示;这是因为自动变量是以值传递的形式被捕获到block内部的,block内部的变量和外部变量是独立的;而且block可能会被copy到堆上,__main_block_impl_0结构体及捕获到的变量都会copy到堆上(后面会讲这部分);那么在block的
auto_i = 1
变量存储在堆上,而外部的自动变量auto_i是存储在栈上的,如果此时在block里更改了变量值(堆),外部变量(栈)的值是不变的;这与编程意图有悖,这个编程意图很显然是要同时改变block内外变量的值;因此block修改auto变量会得到编译错误提示;而提示也给了我们建议:使用__block
声明变量;但这样更改值本身是没问题的,
类似的以下代码并不会报错:
int main(int argc, const char * argv[]) {
@autoreleasepool {
// 外部变量
int i = 1;
// 模拟存储block结构体__main_block_impl_0
struct __main_block_impl_0 {
int I;
}blockImp;
blockImp.i = I;
// 模拟block调用函数__main_block_func_0
{
int i = blockImp.i;
i = 2; // (不会报错) 虽然可以更改 但这个i和外部那个i不是一个东西
}
}
return 0;
}
一个很有意思的情况
如果外部的auto整型变量是一个指针,那block捕获到的也是指针,block内部能否修改呢?
int main(int argc, const char * argv[]) {
@autoreleasepool {
int *i = 0;
void (^block)(void) = ^{
*i = 1;
};
block();
}
return 0;
}
编译通过,但*i = 1这种赋值是错误的,运行会crash;需要通过地址的方式赋值(因为是auto变量,仍会报错提示加__block):
对象类型的auto变量
对于被捕获的对象类型的auto变量,容易让一些人误解或者说费解;
比如说以下代码:
int main(int argc, const char * argv[]) {
@autoreleasepool {
NSMutableArray *arr = [NSMutableArray array];
void (^block)(void) = ^{
[arr addObject:@(2)];
NSLog(@"%@",arr); // 1,2
};
[arr addObject:@(1)];
block();
}
return 0;
}
有些小伙伴就会认为block中的[arr addObject:@(2)];
这句代码,修改了auto变量,编译会报错提示添加__block
;
但事实上是正常的,且外部修改了,blokc内部的变量值也改了;
这是因为对象类型的变量有两部分,1.指针(栈),2.指针指向的对象(堆);block捕获的是指针,而[arr addObject:@(2)]更改的指针指向的堆上的值,指针本身未改变;外部变量和block捕获的变量是两个不同的指针,但指向的是同一值;
如果我们直接修改指针,这时才会报错:
类似的,在block内部只是修改自动变量对象(self)的属性(成员变量),也是没问题的,不需要__block;
block的类型
block有三种类型,继承自NSBlock
-
__NSStackBlock__
栈block 存储于栈区 -
__NSMallocBlock__
堆block 存储于堆区 -
__NSGlobalBlock__
全局block 存储于数据区(.data区)
只要捕获了auto变量的就是NSStackBlock类型,没有捕获auto变量(捕获静态变量)的是NSGlobalBlock,NSStackBlock调用copy方法就会从栈区拷贝到堆区成为NSMallocBlock类型;
block类型 | 环境 | 内存分配 |
---|---|---|
NSStackBlock | 捕获auto变量 | 栈区 |
NSMallocBlock | NSStackBlock调用copy方法 | 堆区 |
NSGlobalBlock | 没有捕获auto变量 | 数据区 |
需要注意的是,在ARC环境下,即使block没有捕获auto变量,block最终也会是NSMallocBlock类型;
int i = 0;
void (^block)(int) = ^(int a) {
NSLog(@"%d",i);
};
NSLog(@"%@",[block class]); // MRC: __NSStackBlock__ ARC: __NSMallocBlock__
这是因为在ARC环境下,编译器会根据情况自动将栈区block copy到堆上;如以下情形:
- block被强引用(赋值给__strong指针时)
- block作为函数返回值
- block作为Cocoa API方法名中含有usingBlock的参数
- block作为GCD方法参数时
因此ARC环境下,以下声明的block都会是NSMallocBlock类型:
@property (nonatomic, strong) void (^block)(void);
@property (nonatomic, copy) void (^block)(void);
ARC环境block为NSStackBlock的情形如下,只是这种场景极少,因为大部分block都会被赋值给变量;
int main(int argc, const char * argv[]) {
@autoreleasepool {
int i = 0;
NSLog(@"%@",^{NSLog(@"%d",i);});
}
return 0;
}
__block修改变量的原理
int main(int argc, const char * argv[]) {
@autoreleasepool {
__block int i = 0;
void (^block)(void) = ^{
i = 1;
};
block();
}
return 0;
}
同样转换为C/C++代码,看下__block这个简单的声明到底做了什么事情;
struct __Block_byref_i_0 {
void *__isa;
__Block_byref_i_0 *__forwarding;
int __flags;
int __size;
int I;
};
struct __main_block_impl_0 {
struct __block_impl impl;
struct __main_block_desc_0* Desc;
__Block_byref_i_0 *i; // by ref
__main_block_impl_0(void *fp, struct __main_block_desc_0 *desc, __Block_byref_i_0 *_i, int flags=0) : i(_i->__forwarding) {
impl.isa = &_NSConcreteStackBlock;
impl.Flags = flags;
impl.FuncPtr = fp;
Desc = desc;
}
};
static void __main_block_func_0(struct __main_block_impl_0 *__cself) {
__Block_byref_i_0 *i = __cself->i; // bound by ref
(i->__forwarding->i) = 1;
}
加了__block后不同点:
- 外部变量i不再是原本类型变量,而是被转换包装成了一个
__Block_byref_i_0
的结构体i;原先的变量值存储于结构体内部的同名成员i中; - block内部捕获
__Block_byref_i_0
结构体指针,访问变量的方式为引用访问bound by ref
,通过结构体再访问/更改到内部的值; -
__Block_byref_i_0
结构体中有一个指向自己的__forwarding
指针;
访问结构体成员值时也是通过这个__forwarding指针“迂回”的访问;
这个__forwarding指针看起来是多余的,因为上面访问值的方式完全可以直接通过结构体本身访问:
static void __main_block_func_0(struct __main_block_impl_0 *__cself) {
__Block_byref_i_0 *i = __cself->i; // bound by ref
i->i = 1; // 替换 (i->__forwarding->i) = 1;
}
__forwarding看起来“多此一举”,但实际上设计的很巧妙;
前面也提到过,block被copy到堆上,其捕获的变量也会copy一份到堆上;一段简单的代码可以验证:
int main(int argc, const char * argv[]) {
@autoreleasepool {
int i = 0;
NSLog(@"%p",&i); // 0x7ffeefbff57c
void (^block)(void) = ^{ // ARC环境,会copy
NSLog(@"%p",&i); // 0x100410110
};
block();
}
return 0;
}
int main(int argc, const char * argv[]) {
@autoreleasepool {
int i = 0;
NSLog(@"%p",&i); // 0x7ffeefbff57c
^{ // ARC环境,没有被赋值,不会copy
NSLog(@"%p",&i); // 0x7ffeefbff578
}();
}
return 0;
}
可以看出,block被copy到堆的情况,捕获的i变量的地址变小了就是被copy到堆上了;以上代码如果改为__block变量,NSLog的结果是block内外变量地址是相同的;这也也验证了我们前面所述,这也是为什么__block变量能被修改的原因;
__forwarding指针这里的作用就是针对堆的block,原本栈区指向自己,之后指向被copy到堆的block。然后堆上的变量的__forwarding再指向自己。这样不管__block变量是copy到堆上,还是在栈上,都可以通过(i->__forwarding->i)来访问到变量值。
以上就是__block变量能被修改的原因,简单总结就是两点:
- __block变量,最终包装成了一个结构体。block捕获的是这个结构体指针;保证了block内外变量的一致性;
- __forwarding指针解决不同存储段(堆,栈)变量的访问;
下篇:block底层原理探究(二):内存管理