阅读 2571

OC中block的底层实现原理

1. block的本质

block本质上是一个OC对象,它内部也有isa指针,这个对象封装了函数调用地址以及函数调用环境(函数参数、返回值、捕获的外部变量等)。当我们定义一个block,在编译后它的底层存储结构是怎样的呢?

下面我们来看一个例子,定义了一个block,并在block里面访问量block外面的变量age,它底层存储结构如下图所示,block底层就是一个结构体__main_block_impl_0

block的本质

  • impl->isa:就是isa指针,可见它就是一个OC对象。
  • impl->FuncPtr:是一个函数指针,也就是底层将block中要执行的代码封装成了一个函数,然后用这个指针指向那个函数。
  • Desc->Block_size:block占用的内存大小。
  • age:捕获的外部变量age,可见block会捕获外部变量并将其存储在block的底层结构体中。

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

2. block的变量捕获机制

block外部的变量是可以被block捕获的,这样就可以在block内部使用外部的变量了。不同类型的变量的捕获机制是不一样的。下面我们来看一个示例:

int c = 1000; // 全局变量
static int d = 10000; // 静态全局变量

int main(int argc, const char * argv[]) {
    @autoreleasepool {

        int a = 10; // 局部变量
        static int b = 100; // 静态局部变量
        void (^block)(void) = ^{
             NSLog(@"a = %d",a);
             NSLog(@"b = %d",b);
             NSLog(@"c = %d",c);
             NSLog(@"d = %d",d);
         };
         a = 20;
         b = 200;
         c = 2000;
         d = 20000;
         block();
    }
    return 0;
}

// ***************打印结果***************
2020-01-07 15:08:37.541840+0800 CommandLine[70672:7611766] a = 10
2020-01-07 15:08:37.542168+0800 CommandLine[70672:7611766] b = 200
2020-01-07 15:08:37.542201+0800 CommandLine[70672:7611766] c = 2000
2020-01-07 15:08:37.542222+0800 CommandLine[70672:7611766] d = 20000
复制代码

关于运行结果我们后面再做讲解,我们先来看一下定义的这个block的在编译后底层存储结构是怎么样的呢?(可以在命令行运行xcrun -sdk iphoneos clang -arch arm64 -rewrite-objc main.m将这个main.m文件转成编译后的c/c++文件,然后在这个文件搜索__main_block_impl_0就可以找到这个block的结构体)。

struct __main_block_impl_0 {
  struct __block_impl impl;
  struct __main_block_desc_0* Desc;
  int a;
  int *b;
};
复制代码

我们可以发现,我定义了4个变量,结果只有2个局部变量被捕获了,而且2个局部变量的捕获方式还不一样。为什么会这样呢?下面来一一解释:

2.1 全局变量的捕获

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

2.2 静态局部变量的捕获

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

2.3 普通局部变量的捕获

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

那有人可能就有疑问了,为什么普通局部变量要捕获值,跟静态局部变量一样捕获地址不行吗?是的,不行。因为普通局部变量a在出了大括号后就会被释放掉了,这个时候如果我们在大括号外面调用这个block,block内部通过a的指针去访问a的值就会抛出异常,因为a已经被释放了。而静态局部变量的生命周期是和整个程序的生命周期是一样的,也就是说在整个程序运行过程中都不会释放b,所以不会出现这种情况。

那有人又有疑问了,既然静态局部变量一直都不会被释放,那block为什么还要捕获它,直接拿来用不就可以了吗?这是因为静态局部变量作用域只限制在这个大括号类,出了这个大括号,虽然它还存在,但是外面无法访问它。而前面已经介绍过,block里面的代码在底层是被封装成了一个函数,那这个函数肯定是在b所在的大括号外面,所以这个函数是无法直接访问到b的,所以block必须将其捕获。

2.4 block捕获变量小结

  • 全局变量--不会捕获,是直接访问。
  • 静态局部变量--是捕获变量地址。
  • 普通局部变量--是捕获变量的值。

所以我们判断一个变量是否会被block捕获关键就在于这个变量是局部变量还是全局变量。那我们来看一下以下几种情况中block是否会捕获self:

- (void)blockTest{
    
    // 第一种
    void (^block1)(void) = ^{
        NSLog(@"%p",self);
    };
    
    // 第二种
    void (^block2)(void) = ^{
        self.name = @"Jack";
    };
    
    // 第三种
    void (^block3)(void) = ^{
        NSLog(@"%@",_name);
    };
    
    // 第四种
    void (^block4)(void) = ^{
        [self name];
    };
}
复制代码

要搞清楚这个问题,我们主要是要搞清楚self是局部变量还是全局变量。可能很多人以为它是全局变量,其实他是一个局部变量。再强调一遍,self是局部变量。那有人就疑惑了,它为什么是局部变量?它是在哪里定义的。要搞清楚这个问题,我们需要对OC的方法调用机制有一定了解。

OC中一个对象调用方法,其实就是给这个这个对象发送消息。比如我调用[self blockTest],它转成C语言后就变成了objc_msgSend(self, @selector(blockTest))。OC的blockTest方法是没有参数的,但是转成objc_msgSend后就多出来了2个参数,一个就是self,是指向函数调用者的,另一个参数就是要调用的这个方法。所以对于所有的OC方法来说,它们都有这2个默认的参数,第一个参数就是self,所以self就是这么通过参数的形式传进来的,它的确是一个局部变量。

这个问题解决了,那上面几种情况就简单了,这4中情况下block都会捕获self。对第一、二、四这3种情况都好理解,第三种情况可能有人会有疑惑,block里面都没有用到self为什么还会捕获self呢。其实_name这种写法并不是没有self,它只是将self给省略了,它其实等同于self->_name,所以这种情况要格外注意。

3. 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-01-08 09:07:46.253895+0800 AppTest[72445:7921459] block1的类:__NSGlobalBlock__
2020-01-08 09:07:46.254027+0800 AppTest[72445:7921459] block2的类:__NSStackBlock__
2020-01-08 09:07:46.254145+0800 AppTest[72445:7921459] block3的类:__NSMallocBlock__
复制代码

block的类有三种,就是上面打印的结果:__NSGlobalBlock____NSStackBlock____NSMallocBlock__。有人可能会疑惑,打印第2和3这两个block时为不像block1那样先定义一个block1然后再打印block1。这是因为在ARC模式下,如果一个__NSStackBlock__类型的block被一个强指针指着,那系统会自动对这个block进行一次copy操作将这个block变成__NSMallocBlock__类型,这样会影响运行的结果。

下面我们一一介绍一下这三种类型的区别:

3.1 __NSGlobalBlock__

如果一个block里面没有访问普通局部变量(也就是说block里面没有访问任何外部变量或者访问的是静态局部变量或者访问的是全局变量),那这个block就是__NSGlobalBlock____NSGlobalBlock__类型的block在内存中是存在数据区的(也叫全局区或静态区,全局变量和静态变量是存在这个区域的)。__NSGlobalBlock__类型的block调用copy方法的话什么都不会做

下面我们再来看下__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-01-08 11:03:34.331652+0800 AppTest[72667:7957820] --- __NSGlobalBlock__
2020-01-08 11:03:34.331777+0800 AppTest[72667:7957820] --- __NSGlobalBlock
2020-01-08 11:03:34.331883+0800 AppTest[72667:7957820] --- NSBlock
2020-01-08 11:03:34.331950+0800 AppTest[72667:7957820] --- NSObject
复制代码

看了block最终也是继承自NSObject,__NSGlobalBlock__的继承链为:__NSGlobalBlock__ : __NSGlobalBlock : NSBlock : NSObject

3.2 __NSStackBlock__

如果一个block里面访问了普通的局部变量,那它就是一个__NSStackBlock__,它在内存中存储在栈区,栈区的特点就是其释放不受开发者控制,都是由系统管理释放操作的,所以在调用__NSStackBlock__类型block时要注意,一定要确保它还没被释放。如果对一个__NSStackBlock__类型block做copy操作,那会将这个block从栈复制到堆上。

__NSStackBlock__的继承链是:__NSStackBlock__ : __NSStackBlock : NSBlock : NSObject

3.3 __NSMallocBlock__

一个__NSStackBlock__类型block做调用copy,那会将这个block从栈复制到堆上,堆上的这个block类型就是__NSMallocBlock__,所以__NSMallocBlock__类型的block是存储在堆区。如果对一个__NSMallocBlock__类型block做copy操作,那这个block的引用计数+1。

__NSMallocBlock__的继承链是:__NSMallocBlock__ : __NSMallocBlock : NSBlock : NSObject

ARC环境下,编译器会根据情况,自动将栈上的block复制到堆上。有一下4种情况会将栈block复制到堆上:

a. block作为函数返回值时:

typedef void (^MyBlock)(void);

- (MyBlock)createBlock{
    int a = 10;
    return ^{
        NSLog(@"******%d",a);
    };
}
复制代码

b. 将block赋值给强指针时:

将定义的栈上的block赋值给强指针myBlock,就变成了堆block。

- (void)test{
    int a = 10; // 局部变量

    void (^myBlock)(void) = ^{
        NSLog(@"a = %d",a);
    };
    block();
}
复制代码

c. 当block作为参数传给Cocoa API时:

比如:

[UIView animateWithDuration:1.0f animations:^{
        
}];
复制代码

d. block作为GCD的API的参数时:

比如:

dispatch_async(dispatch_get_main_queue(), ^{
        
});
复制代码

另外在MRC环境下,定义block属性建议使用copy关键字,这样会将栈区的block复制到堆区。

@property (copy,nonatomic) void(^block)void;
复制代码

ARC环境下,定义block属性用copystrong关键字都会将栈区block复制到堆上,所以这两种写法都可以。

@property (strong, nonatomic) void (^block)(void); 
@property (copy, nonatomic) void (^block)(void);
复制代码

4. block对对象型的局部变量的捕获

block对对象类型和对基本数据类型变量的捕获是不一样的,对象类型的变量涉及到强引用和弱引用的问题,强引用和弱引用在block底层是怎么处理的呢?

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

首先来看下强引用的对象被block捕获后在底层结构体中是如何存储的。这里用下面这条命令来将OC代码转成c/c++代码:

xcrun -sdk iphoneos clang -arch arm64 -rewrite-objc -fobjc-arc -fobjc-runtime=ios-8.0.0 main.m
复制代码
// 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

我们再来看下弱引用对象被捕获后是什么样的:

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

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

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

    }
    return 0;
}

// 底层block
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。那这结果关键字起什么作用呢?

当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内部使用这个弱指针来解决循环引用的问题。

block从堆上移除时,则会调用block内部的dispose函数,dispose函数内部调用_Block_object_dispose函数会自动释放强引用的变量。

4. __block修饰符的作用

在介绍__block之前,我们先来看下下面这段代码:

- (void)test{
    int age = 10;
    void (^block)(void) = ^{
        age = 20;
    };
}
复制代码

这段代码有什么问题吗?编译器会直接报错,在block中不可以修改这个age的值。为什么呢?

因为age是一个局部变量,它的作用域和生命周期就仅限在是test方法里面,而前面也介绍过了,block底层会将大括号中的代码封装成一个函数,也就相当于现在是要在另外一个函数中访问test方法中的局部变量,这样肯定是不行的,所以会报错。

如果我想在block里面更改age的值要怎么做呢?我们可以将age定义成静态局部变量static int age = 10;。虽然静态局部变量的作用域也是在test方法里面,但是它的生命周期是和程序一样的,而且block捕获静态局部变量实际是捕获的age的地址,所以block里面也是通过age的地址去更改age的值,所以是没有问题的。

但我们并不推荐这样做,因为静态局部变量在程序运行过程中是不会被释放的,所以还是要尽量少用。那还有什么别的方法来实现这个需求呢?这就是我们要讲的__block关键字。

- (void)test1{
    __block int age = 10;
    void (^block)(void) = ^{
        age = 20;
    };
    block();
    NSLog(@"%d",age);
}
复制代码

当我们用__block关键字修饰后,底层到底做了什么让我们能在block里面访问age呢?下面我们来看下上面代码转成c++代码后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; // 如果这block是在堆上那么这个指针就是指向它自己,如果这个block是在栈上,那这个指针是指向它拷贝到堆上后的那个block
 int __flags;
 int __size; // 结构体大小
 int age; // 真正捕获到的age
};
复制代码

我们可以看到,age__block修饰后,在block的结构体中变成了__Block_byref_age_0 *age;,而__Block_byref_age_0是个结构体,里面有个成员int age;,这个才是真正捕获到的外部变量age,实际上外部的age的地址也是指向这里的,所以不管是外面还是block里面修改age时其实都是通过地址找到这里来修改的。

所以age__block修饰后它就不再是一个test1方法内部的局部变量了,而是被包装成了一个对象,age就被存储在这个对象中。之所以说是包装成一个对象,是因为__Block_byref_age_0这个结构体的第一个成员就是isa指针。

__block修饰变量的内存管理:

__block不管是修饰基础数据类型还是修饰对象数据类型,底层都是将它包装成一个对象(我这里取个名字叫__blockObj),然后block结构体中有个指针指向__blockObj。既然是一个对象,那block内部如何对它进行内存管理呢?

当block在栈上时,block内部并不会对__blockObj产生强引用。

当block调用copy函数从栈拷贝到堆中时,它同时会将__blockObj也拷贝到堆上,并对__blockObj产生强引用。

当block从堆中移除时,会调用block内部的dispose函数,dispose函数内部又会调用_Block_object_dispose函数来释放__blockObj

文章分类
iOS
文章标签