时代背景
受到培训、疫情、大厂裁员的多重打击,IT行业现在极度内卷,而 IT行业里iOS是当之无愧的卷王之王。
没啥好说的,其他行业与iOS都不在一个维度里面,根本不与辩驳。
不适应行业,就要重新选择行业。我选择适应行业。
为什么要研究
之所以选择block进行研究,道理很简单:之前对block没有深入研究过。
因为block用起来很简单,除了要考虑:
- 1. 变量前加__block可以在block内部改变值
- 2. weak-strong搭配解决引用循环
之外,根本不用思考就可以轻松使用,而OC也是为了方便我们使用才设计出block。
初衷不是让每个开发者都要「搞明白怎么实现block,才可以使用block!」,只是很多人被迫营业了。
比如现在,大家都知道了而我们不知道,显得逼格很低,所以非常需要多深入了解一点。
进行底层研究之前,关于clang查看cpp源码文件,可以看下: iOS 使用Clang命令失败的解决
哪些方面值得研究
暂时总结出这几点:
- block本质是什么,怎么实现?
能手写做一下实现过程吗- block有哪些类型,什么情况下会改变?
- block捕获变量有哪几种方式,__block对变量会做什么操作?
- block循环引用是常见的有哪些,怎么产生的,怎么解决?
接下来的内容是我查阅资料后,加上了一些个人理解,简明扼要的说出重点供各位同学参考。
1.「Block」的本质与实现
从表层OC使用来说,block本质是个对象(Object),拥有isa指针,可以当作数据进行传递(例如放入NSDictionary中)。
从底层C++实现来说,block本质是个结构体(Struct),并且由多个不同作用的结构体和函数构成。
接下来,看一下函数内创建的简单block,执行clang编译前后的源码对比:
OC
int main() { // 对应找下面的main函数
@autoreleasepool {
void (^IIIIIIII)(void) = ^{ // block名称是IIIIIIII
123456789; // 方便找clang后所在位置,如果是字符串会变成乱码
};
}
return 0;
}
C++
struct __block_impl { // block对象
void *isa;
int Flags;
int Reserved;
void *FuncPtr;
};
static struct __main_block_desc_0 { // block描述信息
size_t reserved;
size_t Block_size;
} __main_block_desc_0_DATA = { 0, sizeof(struct __main_block_impl_0)};
struct __main_block_impl_0 { // block结构体创建
struct __block_impl impl;
struct __main_block_desc_0* Desc;
__main_block_impl_0(void *fp, struct __main_block_desc_0 *desc, int flags=0) { // 初始化方法
impl.isa = &_NSConcreteStackBlock;
impl.Flags = flags;
impl.FuncPtr = fp;
Desc = desc;
}
};
static void __main_block_func_0(struct __main_block_impl_0 *__cself) { // 执行方法
123456789;
}
int main() { // 对应上面的main函数
/* @autoreleasepool */ { __AtAutoreleasePool __autoreleasepool;
void (*IIIIIIII)(void) = ((void (*)())&__main_block_impl_0((void *)__main_block_func_0,
&__main_block_desc_0_DATA));
}
return 0;
}
思考:创建block结构体的名称「__main_block_impl_0」里的main,是不是与block所在的main函数关系呢?
我们能够发现名称其实是「__xxx_block_impl_n」这样的结构,通过实践得出:
- 如果是block在方法内创建,则xxx为所在方法名,否则xxx为block自身名称。
- 最后的数字n,在函数作用域内有n个block时,是这些block的自增id,而函数外则一直为0。
可以看出C++的代码风格,让做iOS开发的非常不适应:长长短短下划线,分不清究竟是干啥的。
原因在于C的命名风格是下划线命名法,不是驼峰命名法的,我们把这个方法名转换一下试试看:
O-C++
上面代码中相同颜色的方框,对应的是相同的结构名。按照颜色,推荐的理解顺序是红绿蓝白。
转变以后,看起来是不是没有那么陌生了呢?其中的细节,我已经在上面的代码中逐行加了注释。
我们能够看出block的结构中,最得我们关注的四个结构:
- block对象 BlockObject ➔ __block_impl
- block创建 CreateBlock ➔ __main_block_impl_0
- block执行 BlockAction ➔ __main_block_func_0
- block信息 BlockDesc ➔ __main_block_desc_0
合起来就是:对象-创建-执行-信息。
其中,在block捕获外部变量时,BlockDesc中会提供拷贝方法copy以及释放方法dispose,后面再详细说这里。
2.「Block」的类型
网上很多文章中提到的是这三种类型:
- _NSConcreteGlobalBlock「全局block」
- _NSConcreteStackBlock「栈block」
- _NSConcreteMallocBlock「堆block」
或者另外三种类型:
- __NSGlobalBlock __ 「全局block」
- __NSStackBlock __ 「栈block」
- __NSMallocBlock __ 「堆block」
但是我认为,抛开程序运行过程不提,单说这两大类名称都是不准确的。
我们从OC打印和C++源码中,可以看到:
O-Block:
打印__NSGlobalBlock,源码是_NSConcreteGlobalBlock
I-Block:
打印__NSGlobalBlock,源码却是_NSConcreteStackBlock
同一个block却出现类型不同的情况,原因在于打印是运行时,源码是编译期。
所以,我们应该通过程序运行的不同时期进行区分,而不应将两类名称进行混用。
编译期名称
- 函数外初始化的block均为_NSConcreteGlobalBlock
- 函数内初始化的block均为_NSConcreteStackBlock
- 我们无法主动初始化_NSConcreteMallocBlock类型的block
运行时名称
- _NSConcreteGlobalBlock在任何情况下,都会生成__NSGlobalBlock
- _NSConcreteStackBlock在没有引入外部变量1的情况下,会转为__NSGlobalBlock,在ARC下其他情况2都会自动拷贝到堆,转为__NSMallocBlock
注释:
1.引入外部变量:是指使用了外部变量,不论是用于打印还是作为参数。
2.其他情况:ARC下block任意使用场景都是堆状态,不必深究出现的样式多少种,底层来说只能算作是同一种情况。
至于为什么编译的_NSConcreteStackBlock,运行时如何变为__NSGlobalBlock,猜测可能是系统在运行时创建block过程中进行了判断处理。
3.「Block」的变量捕获
3.1 Block的变量捕获有哪些方式?
研究什么是变量捕获之前,先列出我们常见的多种变量类型:全局变量、静态变量、普通数值变量、OC对象以及带上__block修饰的变量,在block的代码块中打印一下,看看结果:
#import
#import
int ivar_global_int = 11111;
char * ivar_global_char = "1";
int main() {
@autoreleasepool {
static int ivar_static_int = 2;
int ivar_int = 3;
NSArray * ivar_array = @[@"1"];
NSMutableArray * ivar_marray = [[NSMutableArray alloc] init];
NSObject * ivar_object = [[NSObject alloc] init];
__block int ivar_int_block = 4;
__block NSArray * ivar_array_block = @[@"2"];
__block NSMutableArray * ivar_marray_block = [[NSMutableArray alloc] init];
__block NSObject * ivar_object_block = [[NSObject alloc] init];
void (^IIIIIIIII)(void) = ^{
ivar_global_int = 0;
NSLog(@"ivar_global_int: %d %p", ivar_global_int, &ivar_global_int);
NSLog(@"ivar_global_char: %s %p", ivar_global_char, &ivar_global_char);
NSLog(@"ivar_static_int: %d %p", ivar_static_int, &ivar_static_int);
NSLog(@"ivar_int: %d %p", ivar_int, &ivar_int);
NSLog(@"ivar_int_block: %d %p", ivar_int_block, &ivar_int_block);
NSLog(@"ivar_array: _ %p", &ivar_array);
NSLog(@"ivar_array_block: _ %p", ivar_array_block);
NSLog(@"ivar_marray: _ %p", ivar_marray);
NSLog(@"ivar_marray_block: _ %p", ivar_marray_block);
NSLog(@"ivar_object: _ %p", ivar_object);
NSLog(@"ivar_object_block: _ %p", ivar_object_block);
};
IIIIIIIII();
}
return 0;
}
2022-05-06 17:16:48.131846+0800 BlockClangTest[20824:284328] ivar_global_int: 0 0x100008010
2022-05-06 17:16:48.132069+0800 BlockClangTest[20824:284328] ivar_global_char: 1 0x100008018
2022-05-06 17:16:48.132090+0800 BlockClangTest[20824:284328] ivar_static_int: 2 0x100008020
2022-05-06 17:16:48.132103+0800 BlockClangTest[20824:284328] ivar_int: 3 0x10072c7a8
2022-05-06 17:16:48.132113+0800 BlockClangTest[20824:284328] ivar_int_block: 4 0x10072c7c8
2022-05-06 17:16:48.132123+0800 BlockClangTest[20824:284328] ivar_array: _ 0x10072c770
2022-05-06 17:16:48.132133+0800 BlockClangTest[20824:284328] ivar_array_block: _ 0x100008050
2022-05-06 17:16:48.132200+0800 BlockClangTest[20824:284328] ivar_marray: _ 0x10072c6d0
2022-05-06 17:16:48.132269+0800 BlockClangTest[20824:284328] ivar_marray_block: _ 0x10072c720
2022-05-06 17:16:48.132289+0800 BlockClangTest[20824:284328] ivar_object: _ 0x1007291b0
2022-05-06 17:16:48.132305+0800 BlockClangTest[20824:284328] ivar_object_block: _ 0x1007288b0
Program ended with exit code: 0
思考:OC的内存对齐?
从打印的前三行,我们能够看出基本数值类型的内存对齐是 8 字节:
0x100008018 - 0x100008010 = 8
0x100008020 - 0x100008018 = 8 (提示:十六进制下8需要加8才可以进位变成10)
而OC对象的内存对齐是 16 字节,上图中看不出这个结论。
不过我们可以实例化一个NSObject对象object,通过打印 sizeof(object) 结果为 8 以及 malloc_size((__bridge const void *)object) 结果为 16 间接得出。
进行clang编译后,变量相关的代码量非常多,从中找出最关键的block创建结构体指针的源码,修改了一下对齐格式便于阅读,如下:
// 生成block结构体指针
void (*IIIIIIIII)(void) = ((void (*)())&__main_block_impl_0
((void *)__main_block_func_0,
&__main_block_desc_0_DATA,
&ivar_static_int,
ivar_int,
ivar_array,
ivar_marray,
ivar_object,
(__Block_byref_ivar_int_block_0 *)&ivar_int_block,
(__Block_byref_ivar_array_block_1 *)&ivar_array_block,
(__Block_byref_ivar_marray_block_2 *)&ivar_marray_block,
(__Block_byref_ivar_object_block_3 *)&ivar_object_block,
570425344));
// 调用block方法 代码就是: IIIIIIIII()
((void (*)(__block_impl *))((__block_impl *)IIIIIIIII)->FuncPtr)((__block_impl *)IIIIIIIII);
从这段源码中能够看出, 所谓block捕获变量,不过是将这些变量作为参数传递到结构体内部!
仔细看看,我们还能察觉到有些变量没有参与这个block的创建,它们是全局变量。而参与创建的变量,有些带有&符号表示指针访问,还有一些不带的表示值访问。
另外,带__block修饰的变量,变成了编译器自动生成的新结构体 __Block_byref_xxx_n 的实例变量,并且带有&符号进行指针访问。
这些差异充分说明不同类型的变量,被捕捉的方式是不同的。
总结一下,我们可以直观的发现规律:
网上很多文章都只将局部变量分成了两类:static变量和auto变量,并将auto变量统归为值访问。但是,我认为这个说法不是很妥当,在接下来的小节3.2中,我们可以探究一下这个问题。
3.2 __block对变量会做什么操作?
从上一个小节「3.1 Block的变量捕获有哪些方式?」的第一个图的代码中,我们提取出一行带__block的简单代码进行单独分析,这行代码会编译成 __Block_byref_ivar_int_block_0 结构体和__attribute __ 开头的两段代码。
__block int ivar_int_block = 4; // 会编译成下面的struct结构体和__attribute__开头的两段代码
我们想要修改一个变量的值时,通常我们会在前面带上__block修饰符。普通auto变量被__block修饰后,会被包装成一个新的结构体,也可以看作为是一个新的OC对象:
// block新对象
struct __Block_byref_ivar_int_block_0 {
void *__isa; // isa指针
__Block_byref_ivar_int_block_0 *__forwarding; // 指向自身的指针 被copy到堆以后 指向堆对象内存地址
int __flags; // 不太清楚干啥的
int __size; // 内存大小
int ivar_int_block; // 携带的数值
};
这个新的结构体 __Block_byref_ivar_int_block_0 创建实例变量 ivar_int_block,并使用C语言的 __attribute __ 指令来设置变量的属性,包含自身内存地址、内存大小、变量数值等信息,如下:
// 相当于 __block int ivar_int_block = 4; 这行代码
__attribute__((__blocks__(byref))) __Block_byref_ivar_int_block_0 ivar_int_block = {
(void*)0, // 对应__isa
(__Block_byref_ivar_int_block_0 *)&ivar_int_block, // 对应__forwarding
0, // 对应__flags
sizeof(__Block_byref_ivar_int_block_0), // 对应__size
4 // 对应ivar_int_block
};
思考:__forwarding指针是自身内存地址,有什么用?
网上一些文章称,在变量a被block拷贝到堆以后:
1.变量a的__forwording指针地址就变成了堆中新变量b的地址
2.被拷贝到堆的新变量b,__forwording指针地址是b的自身地址
感到非常合理,但是我也不知道怎么验证。
从下面block创建结构体指针的源码中,能够发现被block捕获的不是代码中的看似带__block的int变量,而是新的实例变量,并且新变量不像普通变量一样进行值传递,而是带上了&符号进行指针传递。
所以,我们能够总结出,带有__block修饰变量应该归为指针访问,不应该和普通auto变量一样认为是值访问。
这里有两点需要思考:
- 使用__block来修饰变量,是否为block捕获变量过程的一个环节?
当然不是,没有任何直接关系:不修饰也能捕获,修饰了也可以不使用block。这里可以认为创建了一个新的变量,而这个被修饰的数值参与生成新变量的过程,与接下来的block捕获变量的过程毫无关系。
- 这里被block捕获的是谁,是旧变量的数值还是新的Block变量?
从源码中能够分析出,看似存在的“旧变量”,已经被新的Block变量收在里面,它就像是西游记里“紫金红葫芦”一样,“旧变量”想出去是出不去的,更不能被直接拿来使用了,而block创建当然也只能使用新变量。
如果不通过自己的理解来领悟事物的底层逻辑,终究还是感觉心里没谱。在网上搜寻别人的看法后,我从中发现了一些自我感觉矛盾的地方,而现在弄明白后理解的更加深刻,实现了学习-理解-批判-进步这个我瞎编的过程。
3.3 Block对被捕获变量的内存管理
关于捕获变量相关的内容,这个文章第38条讲述的很细致:
ChenYilong/iOSInterviewQuestions · GitHub
其中主要的几点总结:
- 1.Block不允许修改外部变量,因为进入 block 中的变量相当于进入了另外一个函数作用域。
- 2.在 ARC 中无论是否添加 __block,block 中的 auto 变量都会被拷贝到堆上。
- 3.改外部变量必要条件:将 auto 变量封装为结构体(对象)。
众所周知,程序员只能够管理堆上的内存,所以我们可以想到:
- 1. 全局变量和局部静态变量不在堆上,他们不需要block进行内存管理。
至于其他的变量类型,我们可以列出来进行思考:
- 2. 原本在栈上的变量。比如:基本数值类型。
- 3. 原本就在堆上的变量。比如说:OC实例对象,__block修饰的变量。
<#未完工#>