iOS进阶 -- block捕获变量原理

3,396 阅读14分钟

前言

在上一篇《Block基础探索》中探索了block的分类和循环引用的处理。在这一过程中,还有一些问题我们不甚了解,例如block底层是一个结构体,这个结构体是什么样的结构?block是怎样捕获的self,又是如何捕获变量的?本篇我们从block源码来继续探索下。

一、Block底层结构

block是什么?是匿名函数?或者是一个对象?但是好像都不对。还记得上一篇中探索block循环引用的原因时,使用过xcrun查看了底层,本节继续使用这种方法来看下。

在探索前,先分析下block的几种形式,再一一看下底层实现。根据使用block的情况看,block的形式可以分为以下几种:

  • 有参数
  • 有返回值
  • 有参数也有返回值
  • 无参数也无返回值 由于第三种包含前两种,所以实际上我们xcrun两次即可,两次的代码和结果分别如下:

1、有参数有返回值

int(^block)(int a) = ^int(int a){
    return 0;
};

Xnip2021-12-13_16-48-36.png

2、无参数无返回值

void(^block)(void) = ^{
};

Xnip2021-12-13_16-50-38.png

对比两个结果,当有参数和返回值时,__ViewController__viewDidLoad_block_func_0也有对应类型的参数和返回值,否则就没有参数和返回值;而两种情况下,两者的__ViewController__viewDidLoad_block_impl_0并没有差别。因此可以得出结论,block的参数和返回值影响的只是代码块的实现,而不会影响block本身。

block除了参数和返回值,还会捕获外部变量,根据捕获变量的情况,又可以分为以下几种:

  • 捕获基本类型变量
  • 捕获对象类型变量
  • 通过__block捕获基本类型变量
  • 通过__block捕获对象类型变量

下面以是否使用__block修饰分为两组进行xcrun,因为参数和返回值对于block本身结构没有影响,故这里采用无参数无返回值的block进行实验。

捕获不用__block修饰的变量

1、基本类型

int a = 10;
void(^block)(void) = ^{
    NSLog(@"value = %d", a);
};

Xnip2021-12-13_18-00-33.png

block的底层是一个结构体,其中包含一个int a成员,在其调用的代码块函数中,还有一个局部变量int a接收外部传入的值,而这个值正是int a = 10的值。由此可以发现,block代码块中的aViewDidLoad中的a已经不是同一个变量。

如果直接在block中修改a的值,我们预期的是改变外部的a,但实际是block内部a的改变,由此产生了代码歧义。所以,苹果并不允许直接在block中修改外部的局部变量,如果要改的话,需要加上__block,这一点后面也会分析到。

2、对象类型

NSObject *obj = [NSObject alloc];
void(^block)(void) = ^{
    NSLog(@"value = %@", obj);
};

Xnip2021-12-13_18-01-55.png

与捕获基本类型相比,捕获对象多了两个函数__ViewController__viewDidLoad_block_copy_0__ViewController__viewDidLoad_block_dispose_0,这两个函数分别调用了_Block_object_assign_Block_object_dispose,其实是用来持有和释放对象的,这在后面也会得到验证。

除了上面的不同外,还有一点就是在block结构体中的成员变量变成了一个对象所属类型指针变量,其值为外部传入的对象。与基本类型相同的是,在代码块函数中接收的也是外部传入的对象。

不过需要区分一点,对于对象类型,在block内部修改外部局部变量,由于指针指向同一片内存,修改变量的成员或者属性,是可以修改的,但是如果是将变量重新赋值一个新的对象,则也是不被允许的,这一点的原因和基本类型一致。

捕获__block修饰的变量

1、基本类型

__block int a = 10;
void(^block)(void) = ^{
    NSLog(@"value = %d", a);
};

Xnip2021-12-14_00-26-57.png

加了__block的基本类型变量,在被捕获时,会多出一个__Block_byref_obj_0类型的结构体,并且在ViewDidLoad方法中,先将int a = 10包装成了__Block_byref_obj_0,其中__forwarding为a的地址。

在block的结构体中,成员a不再是int类型,而是一个__Block_byref_obj_0结构体指针,在结构体的构造函数中传入包装a的__Block_byref_obj_0结构体的地址。因为是指针传递,所以block捕获到的a,与外部的a是同一块内存,当block内部改变时,外部的a也会发生改变,这样做不会产生如普通基本类型的代码歧义。

此外,加了__block后还多了copy和dispose两个函数,这两个函数的作用是什么呢,下文会继续探索。

2、对象类型

__block NSObject *obj = [NSObject alloc];
void(^block)(void) = ^{
    obj = [NSObject alloc];
    NSLog(@"value = %@", obj);
};

Xnip2021-12-14_10-21-24.png

Xnip2021-12-14_09-31-40.png

加了__block后的对象类型,也会多一个__Block_byref_obj_0类型的结构体,这一点和基本类型一致。这个例子中,专门在block中专门给obj重新赋值,查看其内部实现逻辑为:

  • 将外部对象的指针地址传入__Block_byref_obj_0中,并将__Block_byref_obj_0的地址传入block中
  • 在代码块函数__ViewController__viewDidLoad_block_func_0中,有代码 __Block_byref_obj_0 *obj = __cself->obj;,通过block结构体的构造函数和这一步操作,将外部对象的指针传入block内部
  • 因此,在block内部重新赋值时,并不会报错,因为内外指针一致,会一起修改。

需要注意的是,无论是否使用__block修饰,当捕获的变量是对象时,在block内部都是可以修改对象的成员和属性的,并且内外值是同时改变的,因为两者都是操作同一片内存。不同点在于:

  • 如果不使用__block修饰,是通过两个指针指向同一片内存,所以修改对象成员和属性是被允许的,内外的修改是一致的,但是在block内部重新给对戏那个赋值,则改变了内部指针的指向,对外部没有影响,从而产生歧义,不被允许;
  • 而使用__block修饰,则是将外部对象的指针传进block中,实际是一个指针,因而在block内部重新赋值后,还可以保证内外指向的是同一片区域

小结

本小节主要探索的是block的本质和捕获变量的底层实现,这里做下总结:

  • 1、block的底层是一个结构体,并且该结构体成员不包含block的参数和返回值,参数和返回值只是作为代码块函数的参数和返回值
  • 2、未使用__block修饰的外部变量,如果是基本类型,则是值传递,block内外是两个变量,如果是对象类型,则是指针传递,block内外是两个指针指向同一变量,可以修改对象的成员和属性,但是不能在内部给对象重新赋值
  • 3、使用__block修饰的外部变量被捕获时,都会被包装成一个__Block_byref_obj_0结构体,将外部变量的指针传入block内部,因此即使修改指针的指向,内外变量也可以保持一致,不会歧义

不过,还有些问题没有清楚,

  • block如何进行拷贝
  • 对象类型的copy和销毁函数,是如何copy和销毁对象的 不要着急,在下一节中,将结合源码和clang的结果继续进行探索。

二、block源码探索

在上面的探索中,block的isa都指向为&_NSConcreteStackBlock,但是上面的几个例子中的block,按道理应该属于堆block,难道是之前的判断方法不正确?其实不然,因为上述例子是在编译时,此时还不能确定block类型,因此用&_NSConcreteStackBlock占位。

在上面的例子中打下断点,并开启汇编模式Always show Disassembly,如图所示:

Xnip2021-12-14_14-11-45.png

之后会进入汇编代码界面:

Xnip2021-12-14_14-16-49.png

如上图,下一个执行的跳转函数为objc_retainBlock,接着单步执行进入下一函数

Xnip2021-12-14_14-18-00.png

在该函数的最后ret指令处打断点,然后register read x0读取返回值,得到如下结果:

Xnip2021-12-14_14-22-42.png

可以发现在经过__Block_copy函数后,block变成了堆block,在这个过程中一定经历了一些操作,我们可以在__Block_copy函数中一探究竟。

从上面的结果可以发现,__Block_copy函数在libsystem_blocks.dylib库中,但是该库并未开源,不过有一个替代库libclosure可供研究,其源码地址为libclosure

打开libclosure源码,全局搜索__Block_copy,发现其返回值是一个Block_layout结构体,源码定义代码如下:

struct Block_layout {
    void * __ptrauth_objc_isa_pointer isa;
    volatile int32_t flags; // contains ref count //在后面的方法中会用到,用于跟枚举做逻辑&运算
    int32_t reserved;
    BlockInvokeFunction invoke; // block代码块函数,调用block要执行的代码
    struct Block_descriptor_1 *descriptor;

    // imported variables
};

这里Block_descriptor_1后面是一个导入变量,根据不同的情况决定是否会导入,像这样的变量有三种类型,分别为Block_descriptor_1、Block_descriptor_2、Block_descriptor_3,源码中定义如下:

#define BLOCK_DESCRIPTOR_1 1
struct Block_descriptor_1 {
    uintptr_t reserved; // 保留字段
    uintptr_t size; // block结构体的总大小,可以参考上节例子cpp文件中__ViewController__viewDidLoad_block_desc_0构造函数中的sizeof
};

#define BLOCK_DESCRIPTOR_2 1
struct Block_descriptor_2 {
    // requires BLOCK_HAS_COPY_DISPOSE
    BlockCopyFunction copy;  // 外部传入的copy函数
    BlockDisposeFunction dispose; // 外部传入的dispose函数,用于销毁变量
}; // 当捕获对象类型或__block修饰的基本类型时,才会有Block_descriptor_2

#define BLOCK_DESCRIPTOR_3 1
struct Block_descriptor_3 {
    // requires BLOCK_HAS_SIGNATURE
    const char *signature;  // block的签名,例如汇编查看返回值时的"v8@?0"
    const char *layout;     // contents depend on BLOCK_HAS_EXTENDED_LAYOUT
};

flag成员表示block的状态,有一个枚举来表示,代码如下:

enum {
    BLOCK_DEALLOCATING =      (0x0001),  // runtime
    BLOCK_REFCOUNT_MASK =     (0xfffe),  // runtime
    BLOCK_INLINE_LAYOUT_STRING = (1 << 21), // compiler
#if BLOCK_SMALL_DESCRIPTOR_SUPPORTED
    BLOCK_SMALL_DESCRIPTOR =  (1 << 22), // compiler
#endif
    BLOCK_IS_NOESCAPE =       (1 << 23), // compiler
    BLOCK_NEEDS_FREE =        (1 << 24), // runtime
    BLOCK_HAS_COPY_DISPOSE =  (1 << 25), // compiler
    BLOCK_HAS_CTOR =          (1 << 26), // compiler: helpers have C++ code
    BLOCK_IS_GC =             (1 << 27), // runtime
    BLOCK_IS_GLOBAL =         (1 << 28), // compiler
    BLOCK_USE_STRET =         (1 << 29), // compiler: undefined if !BLOCK_HAS_SIGNATURE
    BLOCK_HAS_SIGNATURE  =    (1 << 30), // compiler
    BLOCK_HAS_EXTENDED_LAYOUT=(1 << 31// compiler
};

三、block捕获变量的过程分析

3.1 block拷贝 堆->栈

上一小节看到了block的源码结构是一个Block_layout结构体,现在回到__Block_copy函数中来,在源码中函数的实现如下所示:

Xnip2021-12-14_15-03-02.png

全局搜索__Block_copy函数的调用,可以发现两个调用地方

Xnip2021-12-14_17-26-59.png

Xnip2021-12-14_17-27-10.png

第一个是__NSMallocBlock__的retain方法中,此时是将block拷贝到堆上,这是block的第一层拷贝。

第二个是_Block_object_assign函数中,这是捕获了外部的一个block变量做的拷贝。

结合__Block_copy函数源码及其调用时机分析函数实现流程如下:

  • 如果是一个全局block,即aBlock->flags & BLOCK_IS_GLOBAL则直接返回aBlock
  • 如果不是全局blcok,则进行拷贝,将blcok从栈拷贝到堆,并且将isa改为_NSConcreteMallocBlock,同时result->flags |= BLOCK_NEEDS_FREE | 2;,再一次进入时,flag已经包含BLOCK_NEEDS_FREE,也就不会再copy一次。

3.2 block拷贝外部变量

上一小节__Block_copy函数将block从栈拷贝到堆,这是block的第一层拷贝。本小节看一下block的第二层拷贝 -- 对于外部变量的拷贝。

对外部变量的拷贝,分四种情况:

  • 1、不加__block的基本类型
  • 2、不加__block的对象类型
  • 3、有__block修饰的基本类型
  • 4、有__block修饰的对象类型

3.2.1 不加__block的基本类型

第一种很简单,只是一个值拷贝,内外是两个值相同的不同变量,并且因为是基本类型,所以不需要考虑释放的问题。

3.2.2 不加__block的对象类型

第二种是对象类型,所以结合源码来分析下。在第一节的例子中,通过xcrun得到的cpp代码实现中有一个__ViewController__viewDidLoad_block_desc_0结构体,该结构体其实是Block_descriptor_1、Block_descriptor_2三者的合体,可以对比看下:

static struct __ViewController__viewDidLoad_block_desc_0 {

  size_t reserved;  // => 对应Block_descriptor_1的reserved

  size_t Block_size;  // => 对应Block_descriptor_1的size

  // => 对应Block_descriptor_2的copy
  void (*copy)(struct __ViewController__viewDidLoad_block_impl_0*, struct __ViewController__viewDidLoad_block_impl_0*);  

  void (*dispose)(struct __ViewController__viewDidLoad_block_impl_0*);  // => 对应Block_descriptor_2的dispose

}

其中copy和dispose的赋值代码如下:

Xnip2021-12-14_22-30-08.png

copy和dispose两者传入的值为__ViewController__viewDidLoad_block_copy_0__ViewController__viewDidLoad_block_dispose_0,在这两个函数中分别调用_Block_object_assign_Block_object_dispose

首先在源码中搜索_Block_object_assign,发现如下实现:

Xnip2021-12-14_22-37-25.png

该函数看起来很长,但是内部其实只有一个switch分支。分支的条件由flags确定,flags来源于以下枚举:

// Runtime support functions used by compiler when generating copy/dispose helpers
// Values for _Block_object_assign() and _Block_object_dispose() parameters

enum {

    // see function implementation for a more complete description of these fields and combinations

    BLOCK_FIELD_IS_OBJECT   =  3// id, NSObject, __attribute__((NSObject)), block, ...

    BLOCK_FIELD_IS_BLOCK    =  7// a block variable

    BLOCK_FIELD_IS_BYREF    =  8// the on stack structure holding the __block variable

    BLOCK_FIELD_IS_WEAK     = 16// declared __weak, only used in byref copy helpers

    BLOCK_BYREF_CALLER      = 128, // called from __block (byref) copy/dispose support routines.

};

捕获不加__block的对象类型的情况下,根据cpp文件的参数,传入的是flags值为3,即BLOCK_FIELD_IS_OBJECT,因此会调用_Block_retain_object函数,该函数在源码中的定义为:

static void (*_Block_retain_object)(const void *ptr) = _Block_retain_object_default;
static void _Block_retain_object_default(const void *ptr __unused) { }

在源码中搜索_Block_object_dispose,代码如下:

Xnip2021-12-14_22-51-51.png

进入BLOCK_FIELD_IS_OBJECT分支下的_Block_release_object函数,发现其定义如下:

static void (*_Block_release_object)(const void *ptr) = _Block_release_object_default;
static void _Block_release_object_default(const void *ptr __unused) { }

结合两个函数,发现其实BLOCK_FIELD_IS_OBJECT下copy和dispose什么都没做,也就是说捕获不加__block的对象类型时,外部传入的变量并没有额外的操作,其实是交给ARC来处理了。

3.2.3 有__block修饰的变量

有__block修饰的对象被捕获时,会将其包装成一个__Block_byref_obj_0结构体,在cpp文件中定义为:

struct __Block_byref_obj_0 {
 void *__isa;
 __Block_byref_obj_0 *__forwarding;
 int __flags;
 int __size;
 void (*__Block_byref_id_object_copy)(void*, void*);
 void (*__Block_byref_id_object_dispose)(void*);
 NSObject *obj;
};

这是block的第二次拷贝,即将外部对象拷贝到block内,并包装成__Block_byref_obj_0结构体,其包装代码为:

__attribute__((__blocks__(byref))) __Block_byref_obj_0 obj = {
(void*)0,
(__Block_byref_obj_0 *)&obj, 
33554432, 
sizeof(__Block_byref_obj_0), 
__Block_byref_id_object_copy_131, 
__Block_byref_id_object_dispose_131, 
((NSObject *(*)(id, SEL))(void *)objc_msgSend)((id)objc_getClass("NSObject"),sel_registerName("alloc"))};

copy和dispose如下:

Xnip2021-12-14_23-38-28.png

同样是调用了_Block_object_assign_Block_object_dispose函数,不过flags传的值为8,即BLOCK_FIELD_IS_BYREF

查看_Block_object_assign,发现此时调用的是_Block_byref_copy函数,进入该函数定义处:

Xnip2021-12-14_23-44-01.png

该函数的返回值是一个Block_byref结构体,源码中定义为:

struct Block_byref {
    void * __ptrauth_objc_isa_pointer isa; // 8
    struct Block_byref *forwarding;  // 8  用于存储外部变量的指针
    volatile int32_t flags; // contains ref count//4
    uint32_t size; // 4
};

struct Block_byref_2 {
    // requires BLOCK_BYREF_HAS_COPY_DISPOSE
    BlockByrefKeepFunction byref_keep; //= __Block_byref_id_object_copy_131
    BlockByrefDestroyFunction byref_destroy; // = __Block_byref_id_object_dispose_131
};

struct Block_byref_3 {
    // requires BLOCK_BYREF_LAYOUT_EXTENDED
    const char *layout;
};

可以发现,原来cpp文件中的__Block_byref_obj_0就是Block_byref、Block_byref_2、Block_byref_3三者的组合。

继续回到源码_Block_byref_copy函数,该函数会接受一个arg参数,其实是包装成Block_byref指针的外部变量,并赋值给Block_byref *类型的变量src

当判断(src->forwarding->flags & BLOCK_REFCOUNT_MASK) == 0时,要进行block的第三层拷贝,会开辟一个新的Block_byref *变量copy,并将src相关的值赋给该变量。见如下代码:

struct Block_byref *copy = (struct Block_byref *)malloc(src->size);
copy->isa = NULL;
// byref value 4 is logical refcount of 2: one for caller, one for stack
copy->flags = src->flags | BLOCK_BYREF_NEEDS_FREE | 4;
copy->forwarding = copy; // patch heap copy to point to itself
src->forwarding = copy;  // patch stack to point to heap copy
copy->size = src->size;

这里可以看到,src和copy的forwarding都指向了copy,而copy也是一个指针,当操作copy指针修改其指向内存的内容时,src和copy都会发生改变。

如果src->flags & BLOCK_BYREF_HAS_COPY_DISPOSE有值,即外部传入了copy和dispose,此时还需要拷贝copy和dispose,并调用byref_keep函数,代码如下:

struct Block_byref_2 *src2 = (struct Block_byref_2 *)(src+1);

struct Block_byref_2 *copy2 = (struct Block_byref_2 *)(copy+1);
copy2->byref_keep = src2->byref_keep;
copy2->byref_destroy = src2->byref_destroy;

if (src->flags & BLOCK_BYREF_LAYOUT_EXTENDED) {
    struct Block_byref_3 *src3 = (struct Block_byref_3 *)(src2+1);
    struct Block_byref_3 *copy3 = (struct Block_byref_3*)(copy2+1);
    copy3->layout = src3->layout;
}

// 捕获到了外界的变量 - 内存处理 - 生命周期的保存
(src2->byref_keep)(copy, src);

Block_descriptor_2中的不同,这里传入的copy和dispose是存储在Block_byref_2中的,对应cpp中的代码为:

Xnip2021-12-15_10-56-37.png

Xnip2021-12-15_10-56-49.png

注意这里的 dst+40 和 src+40,这实际上是做了内存平移,对照Block_byref、Block_byref_2、Block_byref_3的定义,从Block_byref首地址开始,偏移40,最终找到layout,实际存储的就是外部变量。

而此时传入的flag为131,即BLOCK_FIELD_IS_OBJECT | BLOCK_BYREF_CALLER,对比_Block_object_assign_Block_object_dispose源码,发现在该分支下copy做了赋值,将src持有的对象拷贝到copy上,dispose则什么都没做,其实也是交给了ARC来进行内存管理。

Xnip2021-12-14_22-37-25.png

Xnip2021-12-14_22-51-51.png

上面是__block修饰的对象类型,如果是基本类型,也会包装成Block_byref,不过其flags传入为0,因此在_Block_object_assign_Block_object_dispose调用时,没有匹配分支,不会进行操作,也不用考虑基本类型的释放问题。

_block的变量拷贝问题比较复杂,这里稍微总结一下

  • 1、调用__Block_copy将block从栈拷贝到堆,这是block的第一层拷贝
  • 2、将外部变量拷贝到block_byref中,然后调用_Block_byref_copy,这是block的第二层拷贝
  • 3、在_Block_byref_copy内部调用block_byref_2的copy函数,将内部的变量进行拷贝,这是block的第三层拷贝

总结

本文结合源码和xcrun,主要探索了block的底层原理,总结起来有如下几点:

  • 1、block底层是一个结构体,结构体成员因是否捕获变量而改变
  • 2、捕获的变量不是__block修饰时,如果是基本类型最基本的block拷贝到堆上这一次拷贝,如果是对象类型,则需要拷贝外部变量到block中
  • 3、如果是__block修饰的变量,会进行三层拷贝,具体可见3.2.3小节

以上即为对于block底层的探索,结合上一篇的blcok基础探索,完善了关于block总结。本篇文章的探索还是有些许绕,感谢您能看到最后,也欢迎提出不同意见。