1、block 的底层结构
假设对于以下 block:
1 | int a = 10; |
将以上代码转换成对应的 C++ 代码,可以看到,Block 底层的结构如下:
(备注:上图 __main_block_func_0 函数里实际上还有 block 里 NSLog(@"myblock is %d",a);
相关代码,为了看起来方便,这里直接去掉了。)
- impl 是 block 的实现,其中包含了 isa 指针以及 block 函数实现。
- Desc 是 block 的描述信息,包含了 block 大小等信息。
- 如果 block 内部还访问了局部变量,block 结构体中还会包含捕获的局部变量。
block 结构体的第一个成员变量是结构体(不是结构体指针) __block_impl impl
,所以,block 结构体相当于就是:
从 block 的结构体包含 isa 指针可以看出,block 的本质就是一个对象,NSBlock 对象也是继承自 NSObject 对象的。并且如果 block 访问了外部变量的话,还会把外部变量包装到自己内部。
.cpp 中关于执行 block 的逻辑如下:
可以看出,执行 block,实际上就是 block->FuncPtr()
,也就是通过 block 对象执行其实现函数。
2、外部变量的捕获
当在 block 内访问外部变量时,block 对变量的捕获方式如下:
例如,对于以下代码:
1 | void test() |
其中有一个 auto 变量 a(对于一个局部变量,无论是基本数据类型还是 OC 对象类型,默认就是 auto 变量)、一个 static 变量 b,将代码转换成 C++ 代码,如下:
1 | struct __test_block_impl_0 { |
可以发现,对于局部变量,访问 auto 变量,block 捕获的是其值,而访问 static 变量,block 捕获的是指针,所以在 block 内部可以直接修改 static 局部变量的值,但不可以修改 auto 变量的值。
对于全局变量,可以保证变量的生命周期足够长,block 内部也就没有必要再去捕获,block 内部访问全局变量直接访问即可,也可以在 block 内部直接修改全局变量。
如果在 block 内部访问了 self
,block 也会捕获 self
,因为 self
是局部变量。如果访问了 self
内部的属性,捕获的也是 self
,而不是内部具体的属性。为什么 self
是局部变量呢?我们在方法内能访问 self
,是因为 OC 方法在底层转换为 C 语言函数后,会默认自动带上 self
和 _cmd
两个参数,我们在方法内部访问的 self
实际上就是参数传进来的 self
,方法的参数也是一种局部变量。
当 block 内部访问了对象类型的 auto 变量时:
如果 block 是在栈上,将不会对 auto 变量产生强引用
如果 block 被拷贝到堆上
- 会调用 block 内部的
copy
函数 copy
函数内部会调用_Block_object_assign
函数_Block_object_assign
函数会根据 auto 对象类型变量的修饰符(__strong
、__weak
、__unsafe_unretained
)做出相应的操作,形成强引用(retain)或者弱引用
- 会调用 block 内部的
如果 block 从堆上移除
- 会调用 block 内部的
dispose
函数 dispose
函数内部会调用_Block_object_dispose
函数_Block_object_dispose
函数会自动释放引用的 auto 变量(release)
- 会调用 block 内部的
3、block 类型
block 有 3 种类型,可以通过调用 class 方法(例如:[myBlock class])或者 isa 指针查看具体类型,最终都是继承自 NSBlock 类型。
Block 的三种类型:
- 全局 block
__NSGlobalBlock__
(_NSConcreteGlobalBlock
) - 栈 block
__NSStackBlock__
(_NSConcreteStackBlock
) - 堆 block
__NSMallocBlock__
(_NSConcreteMallocBlock
)
在 MRC 情况下:
在 ARC 情况下:
在 ARC 环境下,为了延长 block 的生命周期,避免栈 block 那样作用域结束 block 就被废弃的情况,编译器会根据需要自动将栈上的 block 复制到堆上(变成堆 block),复制到堆上调用的就是 block 的 copy
方法,比如以下情况:
- block 作为函数返回值时
- 将 block 赋值给
__strong
指针时 - block 作为 Cocoa API 中方法名含有
usingBlock
的方法参数时 - block 作为 GCD API 的方法参数时
MRC 下 block 属性的建议写法:
1 | @property (nonatomic, copy) void (^block)(void); |
ARC 下 block 属性的建议写法:
1 | @property (nonatomic, strong) void (^block)(void); |
4、__block 修饰符
如果我们直接在 block 中修改 auto 变量,编译器会报错,要求使用 __block
修饰 auto 变量,如下:
当使用 __block
修饰了 auto 变量后,就不再报错,并且成功修改了 auto 变量的值:
备注:
向下面这样就不需要使用 __block
修饰仍然可以生效,因为 block 相当于只是使用了变量,而不是直接修改了对象的值:
(1) __block 修饰符作用
__block
可以用于解决 block 内部无法修改 auto 变量值的问题__block
不能修饰全局变量、静态变量(static)- 编译器会将
__block
修饰的变量包装成一个对象
接下来将前述的代码转成 C++ 代码,看下 __block
修饰 auto 变量后的逻辑:
从上述 C++ 代码可知,当使用 __block
修饰了 auto 变量 age 后:
会在当前 block 结构体创建
__Block_byref_age_0
结构体类型的指针成员变量。__Block_byref_age_0 *age;
__Block_byref_age_0
结构体中包含 isa 指针,所以也是一个对象。void *__isa;
__Block_byref_age_0
结构体中包含一个指向自己的指针 __forwarding。__Block_byref_age_0 *__forwarding;
__Block_byref_age_0
结构体中包含了之前在 block 中访问的、被__block
修饰的 auto 变量。int age;
当在 block 中修改被
__block
修饰的 auto 变量时,是通过__forwarding
间接修改的。(age->__forwarding->age) = 200;
并且,当 block 访问了
__block
修饰的 auto 变量后,block 结构体中的Desc
结构体多了copy
和dispose
两个和内存管理有关的两个函数。
(2) __forwarding 指针的作用
从上述可知,当在 block 中修改被 __block
修饰的 auto 变量时,是通过 __forwarding
间接修改的。
现在将对应的 C++ 代码中结构体拿出来,对 block 强制转换成对应结构体类型:
1 | struct __Block_byref_age_0 { |
断点调试结果如下,验证了前述的结论:
为什么 block 在修改 __block
修饰的 age
变量时候,要通过 __Block_byref_age_0
结构体中指向结构体自身的 __forwarding
指针去间接找到自己成员变量 age
然后去修改呢?
这样设计的目的是为了方便内存管理。block 被复制到堆上时,会将 block 中引用的变量也复制到堆中。
重新看下源码:
1 | static void __main_block_func_0(struct __main_block_impl_0 *__cself) { |
通过源码可以知道,当修改 __block
修饰的变量时,是根据变量生成的结构体 __Block_byref_age_0
找到其中 __forwarding
指针,__forwarding
指针指向的是结构体自己,因此可以找到 age
变量进行修改。
当 block 在栈中时,__Block_byref_age_0
结构体内的 __forwarding
指针指向结构体自己。
而当 block 被复制到堆中时,栈中的 __Block_byref_age_0
结构体也会被复制到堆中一份,而此时栈中的 __Block_byref_age_0
结构体中的 __forwarding
指针指向的就是堆中的 __Block_byref_age_0
结构体,堆中 __Block_byref_age_0
结构体内的 __forwarding
指针依然指向自己。
此时当对 age 进行修改时:
1 | // 栈中的age |
通过 __forwarding
指针巧妙的将修改的变量赋值在堆中的 __Block_byref_age_0
中。
通过一张图展示 __forwarding
指针的作用:
因此 block 内部拿到的变量实际就是在堆上的。当 block 进行 copy
被复制到堆上时,_Block_object_assign
函数内做的这一系列操作。
(3)__block 的内存管理
对 __block
变量的引用:
- 当 block 在栈上时,并不会对
__block
变量产生强引用 - 当 block 被
copy
到堆时- 会调用 block 内部的
copy
函数 copy
函数内部会调用_Block_object_assign
函数- 利用
_Block_object_assign
函数对__block
变量的进行相应持有- 编译器会将
__block
变量包装成一个结构体对象 A(例如前述的struct __Block_byref_age_0 *age
,并对这个结构体对象 A 进行强引用。 - 对于 OC 对象,
_Block_object_assign
函数会根据所指向对象的修饰符(__strong
、__weak
、__unsafe_unretained
)做出相应的操作,在结构体对象 A 中形成强引用(retain
)或者弱引用(注意:这里仅限于 ARC 时会retain
,MRC 时不会retain
)
- 编译器会将
- 会调用 block 内部的
简而言之,block 访问了 __block
修饰的 auto 变量,编译器会自动生成对应的结构体对象,block 会对这个结构体对象进行强引用,但是这个结构体对象内部对 auto 变量是强引用还是弱引用,取决于这个 auto 变量对象修饰符(__strong
、__weak
、__unsafe_unretained
)。
例如:
1 | typedef void (^Block)(void); |
将上述代码转化为 C++ 代码查看不同变量之间的区别:
1 | struct __main_block_impl_0 { |
上述 __main_block_impl_0
结构体中看出,没有使用 __block
修饰的变量(object
和 weakObj
)则根据他们本身被 block 捕获的指针类型对他们进行强引用或弱引用。而一旦使用 __block
修饰的变量,__main_block_impl_0
结构体内一律使用强指针引用生成的结构体。
接着我们来看 __block
修饰的变量生成的结构体有什么不同:
1 | struct __Block_byref_age_0 { |
如上面分析的那样,__block
修饰对象类型的变量生成的结构体内部多了 __Block_byref_id_object_copy
和 __Block_byref_id_object_dispose
两个函数,用来对对象类型的变量进行内存管理的操作。而结构体对对象的引用类型,则取决于 block 捕获的对象类型的变量。weakPerson
是弱指针,所以 __Block_byref_weakPerson_2
对 weakPerson
就是弱引用,person
是强指针,所以 __Block_byref_person_1
对 person
就是强引用。
对 __block
变量的释放:
当 block 从堆中移除时
- 会调用 block 内部的
dispose
函数
+dispose
函数内部会调用_Block_object_dispose
函数 _Block_object_dispose
函数会自动释放引用的__block
变量(release
)
5、block 的循环引用
(1) Block 内访问 self 导致的循环引用
在 Block 内部访问 访问 self
可能会导致循环引用,例如:
1 | - (void)testFunc { |
解决办法是使用 __weak
修饰 self
,为了延长 self
生命周期,避免 Block 在执行过程中 self
被销毁,在 block 内部使用 __strong
修饰的 self
:
1 | - (void)testFunc { |
但是,为什么 Block 内部使用 __strong
修饰 self
后,为什么不会重新循环引用呢?
很简单,Block 外部使用 __weak
修饰 self
,会使 Block 捕获 self
时候对 self
进行弱引用,内部再使用 __strong
修饰 self
,相当于在 Block 对应函数实现里对 self
进行一次引用计数 +1,延长了 self
的生命周期,但 Block 对捕获的 self
仍然是弱引用的。
(2) NSTimer 导致的循环引用
例如,使用 NSTimer
可能产生循环引用:
1 | self.timer = [NSTimer scheduledTimerWithTimeInterval:1.0 target:self selector:@selector(timerTest) userInfo:nil repeats:YES]; |
NSTimer
循环引用的原因:NSTimer
被添加到主线程的 RunLoop 中,在停止 NSTimer
之前,NSTimer
会被 RunLoop 一直持有。而 NSTimer
又持有了 Target,导致了 Target 无法释放。
其解决办法主要有下面三种方式:
a、使用 block
1 | __weak typeof(self) weakself = self; |
b、使用代理对象
1 | // XXXProxy.h |
使用方式:
1 | self.timer = [NSTimer scheduledTimerWithTimeInterval:1.0 target:[XXXProxy proxyWithTarget:self] selector:@selector(timerTest) userInfo:nil repeats:YES]; |
这样就避免了循环引用。
这里使用的是我们自己继承自 NSObject
实现的代理对象,但是系统提供了一个效率更高的代理对象:NSProxy
,使用该对象做代理时,不再从自己方法列表查找方法,直接走消息转发流程,而且该类不是继承自 NSObject
的,而是实现 <NSObject>
协议的基类:
1 | @interface NSProxy <NSObject> { |
使用方式如下:
1 | // XXXProxy.h |
接下来看一个关于NSProxy 比较有意思的示例,以下代码如何打印?
1 | ViewController *vc = [[ViewController alloc] init]; |
按照以往理解,以上代码应该打印 0,但实际结果是打印 1,因为 NSProxy
不是任何类的子类,也是一个基类,内部调用的所有方法,都将直接进行转发。所以上面[proxy isKindOfClass:[ViewController class]]
等价于[vc isKindOfClass:[ViewController class]]
而如果 XXXProxy
继承自 NSObject
,那么结果就是打印 0 了。
c、使用 GCD 替代
另外需要注意的是,由于 NSTimer
是基于 Runloop 的,所以如果 Runloop 循环期间任务很重的话,很容易出现 NSTimer
计时不准问题,解决办法就是使用 GCD 实现定时器,GCD 是直接跟系统内核挂钩的,不会受 Runloop 影响:
1 | // 队列 |
- 本文章采用 知识共享署名 4.0 国际许可协议 进行许可,完整转载、部分转载、图片转载时均请注明原文链接。