1, 前言
我们还是从面试题开始:
1, block的本质是什么?
2, block如何捕获变量?
3, block有哪几种类型? 区别是什么?
4, block的copy有了解吗? 什么情况下需要copy? 或者系统是否调用copy?
2, 探索本质
这里我们从面试题开始, 一步一步开始探索block的本质, 寻找答案.
2.2 block本质是什么?
答: block的本质是个oc对象, 内部也有isa指针.
我们写一个最简单的block, 然后编译成cpp文件, 来一探究竟.
为了简单, 我们新建一个命令行工程, 在main.m文件中写一个最简单的block,
例如写一个下面的block:
#import <Foundation/Foundation.h>
int main(int argc, const char * argv[]) {
@autoreleasepool {
// insert code here...
void (^block00)(void) = ^{
NSLog(@"Hello, World!");
};
block00();
}
return 0;
}
然后在终端中打开main.m文件所在的文件夹, 输入编译命令(去掉$符)
$ xcrun -sdk iphoneos clang -arch arm64 rewrite-objc main.m
这样我们就在main.m文件夹中得到了编译后的main.cpp文件, 为了方便查看, 我们将其拖到工程目录中
image.png
但如果此时我们运行项目, 会有一堆报错, 编译错误, 是因为xcode会把main.cpp文件也参与了编译, 工程中就会与多个main方法之类的各种各样的报错. 为了不让main.cpp参与编译, 我们可以这样设置:
image.png
现在工程就不会报错了, 我们就可以愉快的查看编译后的代码了. 编译后的代码有三万多行, 看完很费劲也没必要, 这里我们只挑选重点的代码来看就可以了. 在看之前, 这里先po出block代码的底层结构, 我们可以顺着这个图, 一个个去搜索main.cpp文件中的代码
image.png
2.1.1 编译后的main函数
我们在main.cpp中找到编译后的main函数, 可在main函数中看到编译后我们的代码是这样子的(注释是我加上去了):
image.png
看不懂代码不要紧, 我们还是能从中看出思路的. 为了更方便看懂这部分代码, 我们可以把带括号的类似(void *)这种转换代码去掉, 得出一个很简洁的代码. 这里就看的很清楚了:
1, 我们写的block00在编译的时候回调用__main_block_impl_0函数并传入两个参数__main_block_func_0和&__main_block_desc_0_DATA; 并且拿到函数的返回值并取其地址赋值给void (^block00)(void).
2, 当我们执行block00的时候, 就会根据void (^block00)(void)的地址去寻找代码块并执行其中的代码
image.png
接下来, 我们从__main_block_impl_0开始, 一步一步的继续探寻.
2.1.2 __main_block_impl_0结构体
在main.cpp文件中, 我们搜索__main_block_impl_0, 可以找到这里,
image.png
对比上面的两个截图, 我们可以知道, 当调用__main_block_impl_0函数的时候穿进去的__main_block_func_0参数就是block00内部要执行的代码的封装:
第二个参数__main_block_desc_0_DATA是block00的大小
image.png
到这里, 我们的block00代码本质就可以看的很清晰了, 其本质是一个结构体对象, 里面包含着两个参数:
struct __block_impl impl: 封装了block的结构体, 里面包含了block要执行的代码;
struct __main_block_desc_0* Desc: block的信息, 主要是block大小.
他们之间的关系可以如下图:
这里顺便贴上main.cpp加了注释的代码截图
image.png
到这里, block00的本质我们讲解完了, 下面我们讲一下block00调用过程
2.1 block调用过程?
这里我们重新贴上截图
image.png
可以看到, 是直接通过FuncPtr(block00)来调用block代码的, 为什么能直接通过FuncPtr来直接调动呢?
这里就设计到一个底层的知识: 结构体的指向的地址就是第一个变量的地址.
通过2.1小节我们已经知道FuncPtr(block00)中的block00参数就是__main_block_impl_0, 而__main_block_impl_0结构体的第一个变量就是struct __block_impl impl, 所以就直接取到了impl;
而impl再走8个字节就找到了FuncPtr.
到这里, block的本质和调用过程就讲解完了.
但上面的例子里面我们发现, 只是个很简单的没有参数的block, 如果有参数的block, 参数是怎样传递进去的呢?接下来一节我们看block变量捕获
3, block变量捕获
为什么要设计block变量捕获机制? 是为了保证block内部能够正常访问外部的变量, 所以设计了block变量捕获机制.
这里先直接贴出一张表格, 并直接说block变量捕获的过程.
block捕获变量分两种情况: 局部变量捕获和全局变量捕获. 局部变量是会被捕获到block内部的, 而且如果是static修饰的变量的访问方式是通过指针传递值, auto修饰的变量访问是值传递; 全局变量是不会被捕获到block内部的, 而且访问方式是直接访问.
接下来我们分两个小节来分别探究一下局部变量和全局变量的捕获过程.
3.1局部变量的捕获
我们还是在main.m文件中写这样的一个block, 大家思考一下, 打印的结果是什么??一定要想一下!!!
#import <Foundation/Foundation.h>
int main(int argc, const char * argv[]) {
@autoreleasepool {
// insert code here...
auto int age = 11;
static int height = 21;
void (^block00)(void) = ^{
NSLog(@"age = %d, height = %d", age, height);
};
age = 20;
height = 30;
block00();
}
return 0;
}
可能大家都猜出了结果是age = 11, height = 21. 但实际上是这样的打印结果:
为什么我们在block执行之前修改的age没有在block中打印出来, 而height会被打印出来呢?? 下面我们就到源码中去一探究竟.
同样的, 我们用一开始的那一行命令去编译, 编译之后, 我们同样看main.m源码
image.png
同样, 我们对圈起来的那部分代码做简化, 同时加上注释方便大家看, 大家注意方框的123的标号
image.png
我们先从编译的角度看: 那么顺序是1→2→3.
1, 编译后的block结构体中, 对auto修饰的局部变量age会生成一个block的内部变量age; 对于static修饰的局部变量height, block内部会生成一个带星号的height指针.
2, 调用block的构造方法时候, 会把外部传进来的age直接赋值给block内部的age, 外部传进来的&height地址赋值给height.
3, 编译block时候, age直接取值, height取地址值.
接下来我们从调用block时候来看: 顺序是3→2→1.
这里就是把上面的过程反过来看, 就不赘述了.
总结起来, block对局部变量的捕获分为主要分为两种情况(register几乎不用, 这里不讲了): auto修饰的和static修饰的局部变量:
在编译block的时候, auto修饰的age=11变量的值已经被直接捕获到block内部, 所以当外部修改age=20值的时候, block内部的age值并没有被改变, 所以打印还是age=11; static修饰的height=21变量, 当block编译的时候, 是取其地址&height赋值到block内部的*block, 当外部修改height=31之后, 调用block的时候, 就会根据指针指向的地址获取到height=31, 所以打印出来的height=31; 前者是值传递, 后者是指针传递.
此外, auto修饰的局部变量age在所处的{}方法内部执行完之后就会被销毁了, 而block此时如果调用age就会引起null崩溃, 所以需要值传递到block内部; 而static修饰的height是一直存在内存中的, 所以可以通过指针传递来获取到. 这也是两种方式的区别的原因.
说完局部变量, 下面讲全局变量.
3.2全局变量的捕获
同样的, 我们写一个block, 也定义了两个全局变量age和height, 然后跑起来看一下打印, 也编译一下看main.cpp代码, 下面的截图包含了所有要讲的东西, 注意看下序号:
1, 定义了两个全局变量age和height;
2, 通过打印我们知道block打印的是修改后的age和height的值;
3, 编译后发现, 全局变量并没有被捕获进block里面;
4, 在编译封装后的block代码里面, age和height是直接访问获取的.
总结起来, block里面并没有捕获全局变量, 而是通过直接访问全局变量来获取值的. 因为全局变量在整个程序的生命周期都会存在, 所以没必要捕获到block内部, 而通过直接访问就可以获取到值了.
到这里, block对变量的捕获就讲完了. 接下来我们填上前面挖的坑: block的类型.
4, block的类型
4.1block的三种类型
block有三种类型, 以及他们的继承关系分别是:
NSGlobalBlock → NSConcreteGlobalBlock → NSBlock
NSStackBlock → NSConcreteStackBlock → NSBlock
NSMallocBlock → NSConcreteMallocBlock → NSBlock
它们最终都是继承自NSBlock. 可以通过class方法或者isa指针来查看他们的类型.
举个例子, 我们可以这样查看一个block的类型
这三种block在内存中的分布式这样的(网上找的图):
image.png
这个图中, 我们需要注意的点有:
1, text区一般存放程序代码, 比如我们main函数、类都放这里;
2, data区一般存放全局变量;
3, 堆区是动态分配的内存, 需要程序员申请和管理(arc之前, arc之后不用了);
4, 栈区放局部变量, 系统自动分配和销毁内存.
我们怎么知道一个block是什么类型?
这里直接贴出结果, 然后再举个例子来看(这里先忽略NSMallocBlock), 就可以理解的很明白了.
接下来, 我们用代码来看看是不是真的.
image.png
图中方框部分可以看到, block01打印出来却是NSMallocBlock类型, 这是为什么呢? 是因为目前我们arc下编译器会自动帮我们做一些操作, 比如对block01就自动进行了copy操作, 所以我么要切换成mrc模式才能看得出本质.
image.png
这时候打印出来的结果是这样的
image.png
这时候我们就可以看到打印是原来的类型了.
在MRC模式下, 我们顺便讲一个NSStaticBlock的内存问题的例子, 方便我们理解接下来要讲的copy操作.例子是这样的, 我们定义一个全局的block00, 然后在自定义的test00方法中对其赋值, 然后在main方法里调用, 打印出来的结果age=-272632504, 而不是age=11. 这是因为block00是NSStaticBlock被存放在了栈区, 当test00{}走完之后整个作用域内的内容都被销毁了, 而捕获的age的值也就被销毁了, 当block00再去访问的时候就会出现了内存错误.
image.png
怎么解决NSStaticBlock在栈区被销毁的风险呢? 思路就是想办法把block放到堆区, 调用copy方法, 调用了copy方法之后的block也就变成了NSMallocBlock类型了. 看下面的代码和打印结果:
image.png
这里, 我们会有一个隐忧, 就是我们平时写代码, 声明一个block的时候是怎么保证被存放在堆区了呢? 其实这个一般情况下我们不用担心, 因为我们习惯性的会用copy关键词来修饰block. 而在ARC下, 编译器会自动帮我们对block进行了copy操作, 存放在了堆区了.
接下来我们详细说block的copy, 这部分可能有点晦涩难懂, 但不要紧, 我们试探究竟.
5, block的copy操作
前面有提到过一下在ARC模式下, 编译器会根据需要对block进行copy操作, 那么具体是哪些情况下会有copy呢? 这一小节我们就回答这个问题并且给出声明block的建议. 另外, 关于对象类型的auto变量, 单独开一节来讨论.
5.1 ARC下的block会有自动的copy操作
在ARC环境下, 编译器会根据情况对block进行copy操作, 比如以下情况:
1, block作为函数的返回值时;
2, 将block赋值给__strong修饰的指针时;
3, block作为cocoa API中方法名中含有usingBlock的参数时;
4, block作为GCD中方法的参数时.
验证上面的几种情况
接下来我们验证一下上面的第一种情况. 分ARC和MRC情况下讨论.
ARC下代码和注释里面都进行了解释, 按照标号的顺序看下去:
我们把代码稍微改一下, 跑起来, 会直接崩溃, 原因已经在截图里的注释说明了
这也就能说明ARC下自动将原来放在栈区的NSStaticBlock进行了copy操作到栈区, 并且变成了NSMallocBlock. 第二种情况的验证, 也是一样, 看截图:
第三和第四种情况, 这里就不进行验证了, 相信大家平时敲代码的时候都已经知道了.下面是建议
5.2 ARC和MRC下声明block的建议
#warning MRC模式下声明block
@property (nonatomic, copy) void (^block)(void);
#warning ARC模式下声明block
@property (nonatomic, copy) void (^block)(void);
@property (nonatomic, strong) void (^block)(void);
上面我们的所有例子里, block引用的变量都是基本数据类型int的变量, 但实际开发中, 我们写的block更多是引用对象类型的变量. auto修饰的对象类型的变量在block中的引用跟基本数据类型是有很大不同的, 这里也涉及到了copy和内存管理知识, 是比较复杂,下面单独开一节来讲这种情况.
6, auto修饰的对象变量的copy
这里先贴出一张截图, 总结了block对auto变量的copy操作:
为了能理解上面的知识总结, 我们还是老办法, 用代码和编译后的cpp代码来看.顺着标号的顺序来看重点代码: 因为位置不够的关系, 第4、5就在这里说:
4,如果block被拷贝到堆区上时,
main_block_desc内部就会调用main_block_copy函数,
main_block_copy函数内部会调用_Block_object_assign.
而且_Block_object_assign会根据变量修饰符(__strong, __weak, _unsafe_unretained)做出相应的操作, 形成强引用(retain)或者弱引用.
5, 如果block从堆区上移除时,
就会调用block内部的main_block_dispose函数,
main_block_dispose函数内部会调用_Block_object_dispose函数,
_Block_object_dispose函数会自动释放引用的auto变量(release).
这样, 一个整套的copy的操作就完成了, 并且引用计数也是平衡的, 这样就可以保证在block的生命周期内, 内部引用的对象变量不会被释放, 避免nill的内存错误.
这里我们写多一个__weak修饰person, 编译的过程中你会遇到编译器的这个提示
这是因为__weak是runtime动态下才会起作用的, 而我们当前是静态编译代码, 为了解决这个问题, 我们需要告诉编译器runtime的版本并且是arc下的环境, 我们使用这个命令
xcrun -sdk iphoneos clang -arch arm64 -rewrite-objc -fobjc-arc -fobjc-runtime=ios-8.0.0 main.m
编译后我们看代码, 已经标出来了, __weak修饰的person, 编译后会有__weak修饰
接下来, 我们讲下一个知识点, 变量的修改.
7, __block之中变量的修改
在开发中, 我们经常需要在block中修改变量, 有一定的开发经验的小伙伴都知道在开发中怎么修改变量了. 但block内部是如何实现变量的修改的, 可能很多人还是不知道. 现在我们就来一探究竟.
在开始之前, 照例先来一个___block修饰符特点的小总结:
1,__block修饰符可以用于解决block内部无法修改auto变量值的问题;
2, __block修饰符不能修饰全局变量和静态变量(static);
3, 编译器会将__block修饰的auto变量包装成一个对象.
在开始之前, 我们不如先知道一个相反的问题:
为什么block内部不能直接修改变量?
先看下面的代码, 如果在block中直接修改变量, 编译就直接报错了:
好了, 我们继续刚刚讲的, 删掉age = 21这一行, 然后编译成cpp代码. 看下面的截图, 我们已经知道在编译后block内部的代码会被封装成一个函数, 也就是说: 如果我们直接在block内部修改age变量的话, 相当于在__main_block_func_0函数中修改另一个函数main中声明的变量, 这是不可以的. 所以我们知道了不能在block中修改变量的原因是本函数作用域内的变量是不能在别的函数中修改的.
这里拓展一下, block能否修改NSMutableArray中的元素?答案是可以的. 如图, 实际上在block内部知识访问了array的指针地址, 但是并没有对array指针地址的值进行修改, 所以是可以的!但如果是对array进行指针的值的修改却是不行的, 会报错.
如何在block中修改变量
在一般的开发中, 我们要在block内部修改变量的话主要有三种方式:
1, 把要修改的变量设置成全局变量, 比如static修饰;
2, __block修饰要修改的变量.
我们常用的是方法2, 接下来我们就探究一下方法2的本质. 同样的, 我们看截图:
按照顺序我们来看:
1, 在main方法里面生成block时会传入一个(__Block_byref_age_0 *)&age参数;
2, __main_block_impl_0里面会持有一个__Block_byref_age_0 *age指针;
3,__Block_byref_age_0里面持有一个age变量;
4, __main_block_desc_0里面会调用copy和dispose函数;
5,在编译后的block代码内部持有一个__Block_byref_age_0 *age, 是通过age->__forwarding->age来获取到要修改的变量的.
这里, 我们对__block修饰的auto变量在block内部如何访问和修改, 已经有了大致的了解了.
上面讲解了__block修饰基本数据类型的情况, 下面来讲修饰对象类型的变量.
__block修饰对象类型的变量
还是以Person类为例, 我们先po出一个截图的总结
相比于基本数据类型, __block修饰对象类型的变量的时候, 主要区别是:
1, 会在__Block_byref_person_0结构体里面多调用copy和dispose方法, 并且内部的*person指针会指向Person类, 内部是弱引用还是强引用是根据外部是否有__weak来决定的;
截图的左下角对block编译后下面三者的关系做了一个解释:
编译后的__main_block_impl_0、
__Block_byref_person_0、
原来的Person类.
到这里, __block修饰的变量的访问和实现原理我们已经讲完了. 接下来我们讲block的另一个超热门面试题: 循环引用
9,block的循环引用
经过前面的讲解, 相信大家已经知道循环引用产生的原因和解决的办法了.
block循环引用的原因
先看截图里的代码, 这是会产生循环引用的代码, 产生的原因在截图的方框部分已经说明了: Person持有block变量, 而block变量会通过self指针持有Person对象, 这就产生了循环引用.
解决循环引用的三种方法
解决循环引用的方法就是破坏循环引用, 我们可以打断强引用block-->Person指针也可以打断Person-->block指针, 但一般来说我们是打断前者的强引用.下面的方法都可以使用弱引用打断循环引用.
在ARC下, 解决循环引用有三种方法, 分别是:
1,__weak解决(最推荐的方法)
__weak typeof(self) weakSelf = self;
2,__unsafe_unretained方法(不推荐, 可能引起野指针错误)
__unsafe_unretained id weakSelf = self;
self.block = ^{
NSLog(@"age=%d",weakSelf.age);
};
3, 使用__block方法(block必须执行一次才有效)
__block id weakSelf = self;
self.block = ^{
NSLog(@"age=%d", weakSelf.age);
weakSelf = nil;
};
self.block();
综合来说, 还是最推荐__weak的方法.
MRC下循环引用的解决(可以不看)
下面简单说下MRC下的循环引用解决方法:
1,__unsafe_unretained方法
__unsafe_unretained id weakSelf = self;
self.block = ^{
NSLog(@"age=%d",weakSelf.age);
};
2, 使用__block方法(不需要执行也可以)
__block id weakSelf = self;
self.block = ^{
NSLog(@"age=%d", weakSelf.age);
weakSelf = nil;
};
到这里, block的知识已经讲完了, 过程也是煞费了一点时间, 如果有错误的地方, 环境指正.
网友评论