一、前言
最近对App中的埋点代码进行了改造(随着项目的增大,散落在系统各处的埋点代码实在不好管理),利用AOP的方式将散布在各个页面中的埋点代码统一起来组成一个独立的模块,从而减少埋点行为对业务代码的侵入。
熟悉iOS AOP编程的同学肯定听过Aspects这个框架,我们也使用这个框架实现Method Swizzle,通过Method Swizzle给指定的方法添加side-effect,在side-effect中做相应的埋点上报操作。
在学习Aspects源码的过程中,对Runtime以及OC的消息转发机制有了一个深入的了解,写几篇文章记录一下,文章中会穿插一些对于Aspect源码的分析内容,如有错误的地方,欢迎大家斧正。
二、Block Memory Layout
Everything hides in the source code: libclosure-67
block的本质可以看做一个函数,这点从它的定义可以看出来(Block_private.h):
1 | struct Block_layout { |
逐条分析:
void *isa
: oc中的所有对象都有该指针,指向其所属的类。oc对象接收到消息后,根据isa指针找到其所属的类,然后获取存储在类中的property_list、method_list等信息,然后进行下一步的操作;volatile int32_t flags
: 标识位,block内部操作时会用到,比如copy、dispose等,其中包含了block的reference count、是否heap block等信息;int32_t reserved
: 保留变量void (*invoke)(void *, ...)
: 函数指针,指向block的实现函数地址;struct Block_descriptor_1 *descriptor
: 附加描述信息,存储了一些如block大小、copy/dispose函数指针、签名(用于构造方法签名)等信息。
Aspects源码截取:构造blockMethodSignature
1 | // 1. 初始化desc指针,指向block的descriptor结构体起始地址 |
在Block_private.h中还能看到如下信息:
1 | // Block_private.h |
从其命名方式:Global/Stack(栈)/Malloc(堆)可以推测出Block的类型:NSGlobalBlock(全局Block)、NSStackBlock(栈区Block)、NSMallocBlock(堆区Block)。下面结合代码分别分析一下这三种block。
2.1 NSGlobalBlock
我们知道,程序中的全局变量存储在内存中的数据区(.data区),这块内存中的内容在编译期就已经完全确定了。因此,这种Block无法捕捉任何变量,也无需任何运行时状态来参与运行。
1 | typedef void(^TestBlock)(void); |
可以看出,NSGlobalBlock的存储区域与静态变量的地址相近,二者都存储于全局数据区。
2.2 NSStackBlock和NSMallocBlock
- MRC下,无论是对于作为函数临时变量的block和对象属性的block,都默认存储在栈区,其生命周期随着变量作用域结束而结束,如果想要保留block,可以显示调用copy方法将block拷贝到堆中。
1 | - (void)testBlock { |
- ARC下,由于编译期会隐式地为非全局block添加copy操作,因此所有的非全局block都存储于堆区。这个copy操作属于深拷贝,将block拷贝到堆中,这个拷贝的block对象强引用它捕获的变量,因此ARC下要注意block的隐式copy引起的retain cycle问题。
1 | - (void)testBlock { |
因此,MRC下block有三种:NSGlobalBlock、NSStackBlock、NSMallocBlock,
而ARC下由于编译器隐式添加copy操作,只有两种block:NSGlobalBlock、NSMallocBlock。
三、Block持有外部变量分析
上一节中说过block结构体中有专门存放捕获的变量的区域,那么block是如何捕获到外部变量的呢?分几种情况分析。
3.1 block持有非__block修饰的基本类型变量
先看代码:
1 | - (void)testBlock { |
从输出可以看出,打印变量j的地址,before block和after block的地址相同,且二者与block内部的地址不同。(ARC和MRC下,输出的结果一样)
分析原因
使用clang -rewrite-objc
命令重写.m文件,只选取关键代码
1 |
|
其中__TestObject__testBlock_block_impl_0
是testBlock的实现,包含了一个isa指针(本例中它指向NSConcreteStackBlock, 说明这个block是分配在栈上的)、一个impl函数指针(本例中它指向__TestObject__testBlock_block_func_0
)、一个Desc结构体指针,从block的实现来看,捕获到的外部变量会追加到Desc指针后面,使得__TestObject__testBlock_block_impl_0
结构体变大。
从__TestObject__testBlock_block_func_0
中可以看到:int j = __cself->j; // bound by copy
这样一句代码,block将捕获的外部变量复制一份到其内部,这也说明了为什么block内部打印的变量j的地址与外部不一致。
3.2 block持有__block修饰的基本类型变量
1 | - (void)testBlock { |
从输出可以看到,MRC下,block前后和block内部打印的变量j的地址都相同,而且都是存在于栈中;ARC下,由于block的隐式copy操作,block内部和block执行后打印变量j的地址是在堆中,而block之前的地址是在栈中。
分析原因
使用clang重写oc代码
1 |
|
可以看到,block内部除了isa
指针、impl
函数指针、Desc
指针外,增加了一个__Block_byref_j_0
类型的结构体指针,block捕获的外部变量由该结构体管理,block通过持有该结构体指针实现了对外部变量修改的目的。而且__TestObject__testBlock_block_desc_0
中新增了copy和dispose两个函数指针,用于实现对__Block_byref_j_0
结构体的内存管理。
__Block_byref_j_0
结构体中包含了:
- isa指针: 指向该__block变量的类型,本例中是基本数据类型,因此,isa指针为
(void *)0
; - forwarding指针: 指向该结构体,用于取值;
- flags: 标识位,对于基本数据类型该值为0,对于对象类型该值为3554432;
- size: 该结构体的大小;
- j: 不同的捕获变量,该值命名类型不同,本例中用于储存捕获的整型变量的值;
在block内部和block前后读写变量j的值,都是读取或修改j->__forwarding->j
或者j.__forwarding->j
的值,由于block内外获取到的__forwarding
指针指向同一结构体地址,因此使得block内部修改变量影响到了block外部。
3.3 block持有__block修饰的对象
1 | - (void)testBlock { |
可以看到,无论是MRC或者ARC下,blockObj对象的地址都不变,在堆中;MRC下,blockObj对象的指针地址不变,在栈中;而ARC下,blockObj对象的指针地址在copy之后发生了变化,指针从栈中拷贝到了堆中。
这也说明了block对其持有的对象的copy操作只是浅拷贝,拷贝的是指针,而指针指向的对象始终存在于堆中的某个区域
分析原因
使用clang重写
1 | struct __Block_byref_blockObj_0 { |
与上例的结构基本相同,不同的是__Block_byref_blockObj_0
结构体中增加了copy、dispose两个函数指针用于实现block持有对象的内存管理,__Block_byref_blockObj_0
持有的是捕获对象的指针。
3.4 block持有类的属性
1 | - (void)testBlock { |
MRC和ARC下,block内外打印的对象地址相同,且不需要__block
修饰,block也能捕获并修改类的属性。
分析原因
1 | struct __TestObject__testBlock_block_impl_0 { |
从代码中可以看到,构建block结构体时,其内部持有的是该类的实例对象:TestObject *self
。而在创建block时,传入的是类的当前的实例对象self:
1 | TestBlock block = ((void (*)())&__TestObject__testBlock_block_impl_0((void *)__TestObject__testBlock_block_func_0, &__TestObject__testBlock_block_desc_0_DATA, self, 570425344)); |
因此,block内部持有的实际上是self,在读取和修改类的属性时,使用的实际上是
1 | (*(NSString **)((char *)self + OBJC_IVAR_$_TestObject$_str)) |
所以,无需__block
修饰,block也能修改类的属性,而且无论在MRC还是ARC下,打印的属性地址都是堆中的某个区域(基本类型的属性不同,是在栈中)。
四、Block copy过程以及导致retain cycle的原因分析
ARC下,为了延长分配在栈中block的生命周期,编译期会对非全局block默认加copy操作,将其copy到堆中。对于基本数据类型,copy一份到堆中,对于对象类型变量,copy其指针到堆中。而我们常说的block的循环引用就是这个copy操作导致的,那么为什么block的copy操作会导致某些情况下的循环引用呢?下面通过源码分析一下。
1 | // libclosure-67/Block_private.h |
首先是在Block_private.h头文件中的一些枚举值,包含了用于描述block对象的一些flags;
核心方法是_Block_copy
函数,这个函数的流程如下:
- 声明一个
Block_layout
结构体类型的指针aBlock; - 检查传入的参数arg是否为空,为空则return NULL;
- 将aBlock指针指向arg;
- 判断block的flags是否包含BLOCK_NEEDS_FREE,如果包含,说明这是一个堆block,将其引用计数+1;
- 判断是否global block,如果是,直接返回相同的block;
- 如果是一个栈block,执行以下操作:根据传入的block中的size信息创建一块同样大小的内存空间,并使用result指针指向其起始地址;判断result是否为空,如果为空返回NULL;将aBlock按位拷贝(memmove)到result指向的内存空间中;更新块标识,初始化引用计数为0;设置拷贝的block引用计数为1;如果有辅助copy函数,调用辅助函数;设置result的isa指针指向
_NSConcreteMallocBlock
,即说明这是一个堆block。
block辅助copy/dispose函数
在上一节的3.2、3.3、3.4例子中可以看到,编译期自动生成了copy、dispose函数并添加到Block_layout
中
1 | static void __TestObject__testBlock_block_copy_0(struct __TestObject__testBlock_block_impl_0*dst, struct __TestObject__testBlock_block_impl_0*src) {_Block_object_assign((void*)&dst->self, (void*)src->self, 3/*BLOCK_FIELD_IS_OBJECT*/);} |
这里我们重点关注copy函数,__TestObject__testBlock_block_copy_0
函数调用了_Block_object_assign
函数进行辅助的拷贝操作(主要是对block持有的变量的copy、内存管理等)。
源码如下:
1 | // Block_private.h |
流程分析:
- 首先是block field有关的一些枚举值,列举了要copy的变量的类型:object/block/byref/caller等;
- 判断block field,如果是object类型,调用
_Block_retain_object
函数对object进行一次retain操作; - 如果是
_Block_byref_copy
类型,调用_Block_byref_copy
函数进行__Block_byref_
结构体的copy操作 - 其他case,比如
__weak __block
、__weak __block id
或者__weak __block void (^object)(void)
类型,不进行copy和retain操作,只是进行指针赋值。
看到这里,应该就明白block中循环引用是怎么造成的了吧。
结合第三节的例子3.4和上述流程2可以看出,block持有实例对象的属性(无论是self.xxx或_xxx)时,实际上持有的是当前的实例对象self,而这种情况下在进行copy操作时,调用block的辅助copy函数时,会对self进行一次retain操作,使self的引用计数+1,如果此时实例对象再强引用block的话,就会出现retain cycle,导致对象和block相互引用而无法释放。
五、总结及参考
通过上述分析,对于block的内存结构和MRC、ARC下block的行为以及block如何持有外部变量、如何copy等有了一个大致的了解。如果对于block的dispose等操作感兴趣的同学,可以去下载官方完整源码阅读: