iOS深度剖析block背后的用法和原理

281 阅读14分钟

准备工作

可以先了解下OC的内存布局,在抽丝剥茧的来看block的底层代码,才会事半功倍,可参考我的另一篇文章从C语言看OC的内存布局

什么是block

  • block的本质是一个封装了函数调用和函数调用其上下文的oc对象(有isa指针),是oc对于闭包的对象实现。
  • block执行的代码,是在编译的时候已经生成好的。
  • block是OC中可以捕获自动变量的匿名函数,一对{}包裹的内容是匿名函数的作用域
{
int temp = 5;
int(^Blcok)(int) = ^int(int num){
  return num*temp;
}
Block(2);
}

block的底层实现原理

通过一个列子来看编译后的block在底层数据结构是如何的(放在main函数中)

  • 编译后的结构体含义:
  1. __main_block_impl_0:__main:代表block被定义在main函数中,_0代表main函数中的第一个block, 如果你在方法名为method的函数中定义一个block,就会出现struct __method_block_impl_方法顺序这样的结构体,包含了自动变量的一个结构体 。
  2. __block_impl:它也是block的真实定义,只是为block服务的。
  3. __main_block_desc_0:这个结构体中包涵了block的相关描述信息。
  4. __main_block_func_0是FuncPtr对应的结构体。
  • 结构中相关参数含义:
  1. impl->isa:就是isa指针,可见它就是一个OC对象。
  2. impl->FuncPtr:是一个函数指针,也就是底层将block中要执行的代码封装成了一个函数,然后用这个指针指向那个函数,具体是__main_block_func_0。
  3. Desc->Block_size:block占用的内存大小。
  4. age:捕获的外部变量age,可见block会捕获外部变量并将其存储在block的底层结构体中。
  • 调用流程

调用block()时,通过函数指针FuncPtr找到封装的函数__main_block_func_0),并将block的地址作为参数传给这个函数进行执行。
把block传给函数是因为函数执行中需要用到的某些数据是存在block的结构体中的(比如捕获的外部变量)。如果定义的是带参数的block,调用block时是将block地址和block的参数一起传给封装好的函数

static void __main_block_func_0(struct __main_block_impl_0 *__cself) {

            printf("block\n");
}

block的内存管理

block变量补获机制

block外部的变量是可以被block捕获的,不同类型的变量的捕获机制是不一样的。先看一个示例

打印结果

2020-03-03 16:13:11.961928+0800 TESTTerminal[7330:255792] a = 10
2020-03-03 16:13:11.962546+0800 TESTTerminal[7330:255792] b = 200
2020-03-03 16:13:11.962612+0800 TESTTerminal[7330:255792] c = 2000
2020-03-03 16:13:11.962681+0800 TESTTerminal[7330:255792] d = 20000

先通过命令行运行xcrun -sdk iphoneos clang -arch arm64 -rewrite-objc main.m将这个main.m文件转成编译后的c/c++文件,看下block的在编译后底层存储结构是怎么样的:

struct __main_block_impl_0 {
  struct __block_impl impl;
  struct __main_block_desc_0* Desc;
  int a;
  int *b;
};

总结:只有2个局部变量被捕获了,而且2个局部变量的捕获方式还不一样,这就牵涉到block变量补获机制了

全局变量的捕获

不管是普通全局变量还是静态全局变量,block都不会捕获。因为全局变量在哪里都可以访问,所以block内部不捕获也是可以直接访问全局变量的,所以外部更改全局变量的值时,block内部打印的就是最新更改的值。

静态局部变量的捕获

我们发现定义的静态局部变量b被block捕获后,在block结构体里面是以int *b;的形式来存储的,也就是说block其实是捕获的变量b的地址,block内部是通过b的地址去获取或修改b的值,所以block外部更改b的值会影响block里面获取的b的值,block里面更改b的值也会影响block外面b的值。

普通局部变量的捕获

  • 普通局部变量:就是在一个函数或代码块中定义的类似int a = 10;的变量,它其实是省略了auto关键字,等价于auto int a = 10,所以也叫自动变量(auto)
  • 普通局部变量和静态局部变量的区别
  1. 普通局部变量被block捕获后再block底层结构体中是以int a;的形式存储,并且在block内部重新定义了一个变量来存储这个值
  2. block外部和里面的a其实是2个不同的变量,所以外面更改a的值不会影响block里面的a。所以打印的结果是a = 10。

变量作用域和生命周期

以上面的列子来说明

  • 全局变量:在一个文件内定义的全局变量,作用域是声明此变量所在的文件,但是在另一个文件中通过extern 全局变量名的声明,就可以使用全局变量。
  • 静态全局变量:用static修饰的全局变量,作用域是声明此变量所在的文件,其他的文件即使用extern声明也不能使用
  • 普通局部变量:作用域在函数作用域内,和函数生命周期一样
  • 静态局部变量:生命周期和整个程序的生命周期是一样的,但是作用域只限制在这个函数大括号类,出了这个大括号,外面无法访问它

思考

  1. 为什么普通局部变量要捕获值,跟静态局部变量一样捕获地址不行吗?

答:因为普通局部变量a在出了大括号后就会被释放掉了,这个时候如果我们在大括号外面调用这个block,block内部通过a的指针去访问a的值就会抛出异常,因为a已经被释放了。而静态局部变量的生命周期是和整个程序的生命周期是一样的,也就是说在整个程序运行过程中都不会释放b.

  1. 既然静态局部变量一直都不会被释放,那block为什么还要捕获它?

静态局部变量作用域只限制在这个大括号类,出了这个大括号,虽然它还存在,但是外面无法访问它。block里面的代码在底层是被封装成了一个函数,那这个函数肯定是在b所在的大括号外面,所以这个函数是无法直接访问到b的,所以block必须将其捕获。

    答案正是因为变量作用域和生命周期不同

block中对象型的局部变量的捕获

block中对象类型和对基本数据类型变量的捕获是不一样的,对象类型的变量涉及到强引用和弱引用的问题。

如果block是在栈上,不管捕获的对象时强指针还是弱指针,block内部都不会对这个对象产生强引用。所以我们主要来看下block在堆上的情况

下面看下强引用的对象被block捕获后在底层结构体中是如何存储的-

// OC代码
int main(int argc, const char * argv[]) {
    @autoreleasepool {

        Person *person = [[Person alloc] init];
        person.age = 20;

        void (^block)(void) = ^{
            NSLog(@"age--- %ld",person.age);
         };
        block();

    }
    return 0;
}

// 底层结构体
struct __main_block_impl_0 {
  struct __block_impl impl;
  struct __main_block_desc_0* Desc;
  Person *__strong person;
};

可以看到和基本数据类型不同的是,person对象被block捕获后,在结构体中多了一个修饰关键字__strong。

同理,如果是弱引用,则底层结构体将是下面这样

struct __main_block_impl_0 {
  struct __block_impl impl;
  struct __main_block_desc_0* Desc;
  Person *__weak weakPerson;
};

block中weakPerson的关键字变成了__weak

  • 捕获的对象类型变量的关键字

block中修饰被捕获的对象类型变量的关键字主要是__strong、__weak,另外外还有一个__unsafe_unretained。

  • copy函数的流程

当block被拷贝到堆上时是调用的copy函数,copy函数内部会调用_Block_object_assign函数,_Block_object_assign函数就会根据这3个关键字来进行操作

如果关键字是__strong,那block内部就会对这个对象进行一次retain操作,引用计数+1,也就是block会强引用这个对象。也正是这个原因,导致在使用block时很容易造成循环引用。

如果关键字是__weak或__unsafe_unretained,那block对这个对象是弱引用,不会造成循环引用。所以我们通常在block外面定义一个__weak或__unsafe_unretained修饰的弱指针指向对象,然后在block内部使用这个弱指针来解决循环引用的问题。

self变量的捕获

OC中一个对象调用方法,其实就是给这个这个对象发送消息。比如我调用[self test],它转成C语言后就变成了objc_msgSend(self, @selector(test)),self这里就是一个局部变量。

 - (void)test{
    void (^block1)(void) = ^{
        NSLog(@"%p",self);
    };
}

block变量捕获总结

所以我们判断一个变量是否会被block捕获关键就在于这个变量是局部变量还是全局变量

block类型(内存中的位置)

block既然是一个OC对象,那它就有类,那它的类时什么呢?我们可以通过调用block的class方法或object_getClass()函数来得到block的类

- (void)test{
    int age = 10;
    void (^block1)(void) = ^{
        NSLog(@"-----");
    };
    NSLog(@"block1的类:%@",[block1 class]);
    
    NSLog(@"block2的类:%@",[^{
        NSLog(@"----%d",age);
    } class]);

   NSLog(@"block3的类:%@",[[^{
       NSLog(@"----%d",age);
   } copy] class]);
}
打印结果:
2020-03-03 16:57:08.835770+0800 TESTTerminal[7707:279045] block1的类:__NSGlobalBlock__
2020-03-03 16:57:08.836103+0800 TESTTerminal[7707:279045] block2的类:__NSStackBlock__
2020-03-03 16:57:08.836713+0800 TESTTerminal[7707:279045] block3的类:__NSMallocBlock__

在ARC模式下,如果一个__NSStackBlock__类型的block被一个强指针指着,那系统会自动对这个block进行一次copy操作将这个block变成__NSMallocBlock__类型,所以打印第2和3这两个block时不能像block1那样先定义一个block1然后再打印block1。

所以总的有三种类型的block:

全局Block

block里面没有访问普通局部变量则是全局block,如果访问的是静态局部变量、全局变量、静态全局变量则也是NSGlobalBlock

新的知识点:__NSGlobalBlock__的继承链

- (void)test{
    void (^block)(void) = ^{
        NSLog(@"-----");
    };
    NSLog(@"--- %@",[block class]);
    NSLog(@"--- %@",[[block class] superclass]);
    NSLog(@"--- %@",[[[block class] superclass] superclass]);
    NSLog(@"--- %@",[[[[block class] superclass] superclass] superclass]);
}

// 打印结果
2020-03-03 17:15:57.196270+0800 TESTTerminal[7900:290870] --- __NSGlobalBlock__
2020-03-03 17:15:57.196484+0800 TESTTerminal[7900:290870] --- __NSGlobalBlock
2020-03-03 17:15:57.196570+0800 TESTTerminal[7900:290870] --- NSBlock
2020-03-03 17:15:57.196646+0800 TESTTerminal[7900:290870] --- NSObject

所以,从下往上继承链为:NSGlobalBlock 》 __NSGlobalBlock 》 NSBlock 》NSObject。

栈上Block

block里面访问了普通的局部变量,那它就是一个__NSStackBlock__。

__NSStackBlock__的继承链

2020-03-03 17:19:09.653250+0800 TESTTerminal[7950:292962] --- __NSMallocBlock__
2020-03-03 17:19:09.653384+0800 TESTTerminal[7950:292962] --- __NSMallocBlock
2020-03-03 17:19:09.653475+0800 TESTTerminal[7950:292962] --- NSBlock
2020-03-03 17:19:09.653564+0800 TESTTerminal[7950:292962] --- NSObject

堆上Block

NSStackBlock__类型的block调用copy,那会将这个block从栈复制到堆上,堆上的这个block类型就是__NSMallocBlock 堆上的这个block类型就是__NSMallocBlock__的继承链

2020-03-03 17:22:01.014922+0800 TESTTerminal[7991:295230] --- __NSMallocBlock__
2020-03-03 17:22:01.015191+0800 TESTTerminal[7991:295230] --- __NSMallocBlock
2020-03-03 17:22:01.015388+0800 TESTTerminal[7991:295230] --- NSBlock
2020-03-03 17:22:01.016270+0800 TESTTerminal[7991:295230] --- NSObject

总结 在ARC环境下,定义block属性用copy或strong关键字都会将栈区block复制到堆上,除此外编译器会根据下面4种情况会将栈block复制到堆上:

  1. block作为函数返回值时:
typedef void (^MyBlock)(void);

- (MyBlock)createBlock{
    int a = 10;
    return ^{
        NSLog(@"******%d",a);
    };
}
  1. 将block赋值给强指针时
- (void)test{
    int a = 10; // 局部变量

    void (^myBlock)(void) = ^{
        NSLog(@"a = %d",a);
    };
    myBlock();
}
  1. 当block作为参数传给Cocoa API时
[UIView animateWithDuration:1.0f animations:^{
        
}];

4 block作为GCD的API的参数时

dispatch_async(dispatch_get_main_queue(), ^{
        
});

__block修饰符

💣💣💣下面代码会报错

- (void)test{
    int age = 10;
    void (^block)(void) = ^{
        age = 20;
    };
}

普通变量,数值型或对象型,没有加__block时,Block捕获的是变量,不是变量的地址,所以编译器层面禁止对捕获的变量进行修改。

因为age是一个局部变量,它的作用域和生命周期就仅限在是test方法里面,而前面也介绍过了,block底层会将大括号中的代码封装成一个函数,也就相当于现在是要在另外一个函数中访问test方法中的局部变量,这样肯定是不行的,虽然可以使用【静态局部变量】,但是为静态局部变量在程序运行过程中是不会被释放的,所以还是要尽量少用。

  • 解决方案,通过__block来处理
- (void)test{
    __block int age = 10;
    void (^block)(void) = ^{
        age = 20;
    };
    block();
    NSLog(@"%d",age);
}

用__block关键字修饰后,最终底层数据结构如下:

struct __main_block_impl_0 {
  struct __block_impl impl;
  struct __main_block_desc_0* Desc;
  __Block_byref_age_0 *age; // by ref
};

struct __Block_byref_age_0 {
  void *__isa; // isa指针
__Block_byref_age_0 *__forwarding; 
 int __flags;
 int __size; // 结构体大小
 int age; // 真正捕获到的age
};

总结:

  • 被__block修饰的变量最终会变成是一个结构体,类似__Block_byref_age_0(__Block_byref_变量名_变量顺序)
  • __Block_byref_age_0内部的age才是真正捕获到的外部变量age
  • block外部age的地址也是指向结构体里面的age
  • 不管是block外面还是block内部修改age其实都是通过地址找到结构体中的真实变量来修改的

思考❓❓❓: 为什么block内部可以访问局部变量,却不可以修改呢,先看下下面这段代码

-(void)test(){
    int a = 10;
    NSLog(@"block外部变量地址 = %p",&a);
    self.temp = ^{
    NSLog(@"block内部变量地址 = %p",&a);
    };
}
//打印结果
2020-03-04 09:41:05.989745+0800 TESTDemo[1077:30940] block外部变量地址 = 0x7ffee9a5219c
2020-03-04 09:41:05.989918+0800 TESTDemo[1077:30940] block内部变量地址 = 0x6000025a8020

可以发现两个的地址是不一样,前面也提到过:

普通局部变量被block捕获后再block底层结构体中是以int a;的形式存储,并且在block内部重新定义了一个变量来存储这个值.

个人理解:对于开发者而言,本意是想修改的block外部变量的值,但是如果不加__block修饰符去修改,就算编译器允许对捕获的变量进行修改,改变的也只是block内部变量的值而已,所以编译器层面禁止对捕获的变量进行修改。

block中的循环引用问题

//正确的操作:
__weak __typeof(self) weakSelf  = self;
self.block = ^{
    __strong __typeof(self) strongSelf = weakSelf; 
    [strongSelf doSomeThing];
    [strongSelf doOtherThing];如果不用__strong,则可能self为空
};
//错误的代码,循环引用
self.block = ^{
    [self doSomeThing];
};
  • 为什么需要使用weakSelf:
    因为block截获self之后self属于block结构体中的一个由__strong修饰的属性会强引用self, 所以需要使用__weak修饰的weakSelf防止循环引用。

    Apple 官方的建议是,传进 Block 之前,把 ‘self’ 转换成 weak automatic 的变量,这样在 Block 中就不会出现对 self 的强引用。如果在 Block 执行完成之前,self 被释放了,weakSelf 也会变为 nil。

  • 为什么需要使用strongSelf:
    为了保证block执行完毕之前self不会被释放,执行完毕的时候再释放,多线程环境下有可能出现self被释放的情况。

  • 为什么weak-strong不会引起循环引用
    block持有weakself,但strongSelf实质是一个局部变量(在block这个“函数”里面的局部变量),当block执行完毕就会释放自动变量strongSelf,不会对self进行一直进行强引用

总结

  • 在 Block 内如果需要单次访问 self 的方法、属性、变量,建议使用 weakSelf。
  • 如果在 Block 内需要多次 访问 self,确保安全,则需要使用 strongSelf

参考: OC中block的底层实现原理