探究Block原理(上篇)-Block本質及存儲域問題

主要內容:
1.理解Block的本質
2.理解Block的存儲域分類
3.理解Block的Copy原理

一、探究Block的本質

從一個最簡單的Block使用示例說起,我們分析如下代碼:

//main.m文件:
#import <Foundation/Foundation.h>
int main(int argc, char * argv[]) {
    int num = 10;
    void (^myBlock)(void) =^{NSLog(@"num = %d",num);};
    myBlock();
    return 0;
}

Objective-C語言是基於C、C++的,爲了深入理解Block的底層結構,我們可以通過如下的編譯器命令將上述代碼轉換成C++源碼:

clang -rewrite-objc 源代碼文件名(如此例中的main.m)

轉化後的C++源碼如下:

struct __block_impl {
  void *isa;
  int Flags;
  int Reserved;
  void *FuncPtr;
};

struct __main_block_impl_0 {
  struct __block_impl impl;
  struct __main_block_desc_0* Desc;
  int num;
  __main_block_impl_0(void *fp, struct __main_block_desc_0 *desc, int _num, int flags=0) : num(_num) {
    impl.isa = &_NSConcreteStackBlock;
    impl.Flags = flags;
    impl.FuncPtr = fp;
    Desc = desc;
  }
};

static void __main_block_func_0(struct __main_block_impl_0 *__cself) {
  int num = __cself->num; // bound by copy
NSLog((NSString *)&__NSConstantStringImpl__var_folders_wd_fhcn9bn91v56nlzv9mt5z8ym0000gn_T_main_9e3646_mi_0,num);}

static struct __main_block_desc_0 {
  size_t reserved;
  size_t Block_size;
}
 __main_block_desc_0_DATA = { 0, sizeof(struct __main_block_impl_0)};

int main(int argc, char * argv[]) {
    int num = 10;
    void (*myBlock)(void) =((void (*)())&__main_block_impl_0((void *)__main_block_func_0, &__main_block_desc_0_DATA, num));
    ((void (*)(__block_impl *))((__block_impl *)myBlock)->FuncPtr)((__block_impl *)myBlock);
    return 0;
}

對比OC代碼與C++源碼中的main函數,我們發現創建Block其實是調用了__main_block_impl_0結構體的構造函數;而Block中待執行代碼也都被封裝到了__main_block_func_0函數中。

另外值得注意的是,這些C++的結構體和函數的命名,是根據Block語法所屬的函數名(此處爲main)和Block語法在該函數出現的順序值(此處爲0)來設定的;

根據這些對應關係,我們對C++源碼中的內容一一分析:

1.__main_block_imp_0結構體

__main_block_impl_0結構體對應了Block的定義,結構體內部包含了三個成員變量implDescnum,num其實就是被捕獲的變量(後續再講),另外還有一個同名的構造函數__main_block_impl_0;具體代碼如下:

struct __main_block_impl_0 {
  struct __block_impl impl;
  struct __main_block_desc_0* Desc;
  int num;
  __main_block_impl_0(void *fp, struct __main_block_desc_0 *desc, int _num, int flags=0) : num(_num) {
    impl.isa = &_NSConcreteStackBlock;
    impl.Flags = flags;
    impl.FuncPtr = fp;
    Desc = desc;
  }
};

Block通過調用這裏的構造函數得以創建,調用時需傳入了四個參數:(void *fp, struct __main_block_desc_0 *desc, int _num, int flags=0),前三個參數對應成員變量的初始化,而最後一個參數flags攜帶默認值可暫不考慮。

2.__block_impl結構體

__main_block_imp_0結構體的第一個成員變量impl,就是__block_impl結構體類型;尤其注意該結構體中包含有isa指針,從這一點就可以說明Block本質上還是一個OC對象,因爲OC中只有對象纔會具有isa指針的概念。而FuncPtr是一個函數指針,在__main_block_imp_0構造函數調用時被賦值;

3.__main_block_desc_0結構體

__main_block_imp_0結構體構造函數中傳入參數desc,其實就是__main_block_desc_0對象。該結構體包含兩個成員變量:
reserved:系統保留值
Block_size:代表Block的大小

4.__main_block_func_0函數

__main_block_imp_0結構體構造函數中傳入函數指針fp,其實就是__main_block_func_0函數的地址。該函數將Block中所有的代碼封裝爲函數,以待被調用;

重要總結:
1.Block對應底層__main_block_impl_0結構體,其中包含有isa指針,這說明Block本質上還是一個OC對象;
2.Block中待執行的代碼,在底層也被封裝爲__main_block_func_0函數,以實現調用;說明Block還攜帶了函數執行的環境

Block的特點:
1.Block相當於其他語言中的閉包或者匿名函數;
2.Block與函數區別在於,Block相當於函數加上函數執行的上下文環境(捕獲外部變量下面會講到);

二、Block的存儲域

1.Block的存儲域分類

在之前Block結構體構造函數中,我們很容易能找到這樣一句代碼:

impl.isa = &_NSConcreteStackBlock;

我們已經知道Block也是一個Objective-C對象,每個OC對象都有一個isa指針指向其類對象,這裏的情況也是類似的;Block的isa指針指向了_NSConcreteStackBlock類對象,即此時的Block是以_NSConcreteStackBlock類爲模板創建的實例;

除此之外,其實還有兩個與之類似的類_NSConcreteGlobalBlock_NSConcreteMallocBlock,不同的Block類創建的對象用於不同的存儲域,也對應了對應不同的OC類型,具體整理如下:

clang類 OC類 內存區域
_NSConcreteGlobalBlock NSGlobalBlock 靜態區
_NSConcreteStackBlock NSStackBlock 棧區
_NSConcreteMallocBlock NSMallocBlock 堆區

下面通過打印的方式驗證Block對象本質,具體代碼如下:

- (void)testBlock5 {
    void(^block)(int a) = ^(int a) {
        NSLog(@"This is a block");
    };
    
    NSLog(@"%@",[block class]);
    NSLog(@"%@",[[block class] superclass]);
    NSLog(@"%@",[[[block class] superclass] superclass]);
    NSLog(@"%@",[[[[block class] superclass] superclass] superclass]);
}

//打印結果:
//__NSGlobalBlock__
//__NSGlobalBlock
//NSBlock
//NSObject

觀察打印結果,我們看到Block最終繼承於NSObject類型,這又一次驗證了Block本質就是OC對象的結論;而打印結果中出現的__NSGlobalBlock__說明此處的Block的存儲域爲靜態區;

2.區分Block不同存儲域類型的方法

Block的不同存儲域對其的使用影響巨大,而正確區分Block類型的關鍵在於:Block中是否引用了自動變量(需要MRC下測試),總結起來如下:

Block類型 環境 內存區域
_NSConcreteGlobalBlock( NSGlobalBlock) 沒有訪問自動變量;
或者只用到靜態區變量
靜態區
_NSConcreteStackBlock( NSStackBlock) 訪問了自動變量 棧區
_NSConcreteMallocBlock( NSMallocBlock) __NSStackBlock__調用了copy 堆區

我們可以使用代碼對上述情況進行驗證,但需要首先切換ARC到MRC環境下,因爲在ARC環境下的編譯器爲我們做了很多優化的工作,比如自動將棧區的Block拷貝到堆區,這樣我們也就不容易捕獲到Block初始狀態的位置了。所以需要暫時將開發環境切換至MRC下來測試。相關的測試代碼如下:

- (void)testBlock7 {
    //1.Block內部沒有調用外部自動變量
    void (^block1)(void) = ^{
        NSLog(@"Block");
    };
    
    //2.Block內部調用外部自動變量
    int a = 10;
    void (^block2)(void) = ^{
        NSLog(@"Block-%d",a);
    };
    
    //3.拷貝棧上的block
    void (^block3)(void) = ^{
        NSLog(@"Block-%d",a);
    };
    
    //打印Block類型
    NSLog(@"%@ %@ %@", [block1 class], [block2 class], [[block3 copy] class]);
}

//打印結果:
//__NSGlobalBlock__ __NSStackBlock__ __NSMallocBlock__

分析代碼:
**NSGlobalBlock:**Block中沒有引用自動變量或者只用到靜態區變量,這種Block與全局變量一樣設置在程序的靜態區,直到程序結束纔會被回收;此類型的Block不依賴執行時的狀態,所以整個程序只需一個實例,用的也較少;

**NSStackBlock:**Block中訪問自動變量,並且存放在棧中,棧中的內存由系統自動分配和釋放,作用域執行完畢之後就會被立即釋放;所以我們有可能遇到Block內存銷燬之後才使用它的情況,開發中遇到的很多問題也都是因此而起;

NSMallocBlock_NSStackBlock__執行copy操作會生成__NSMallocBlock__;棧Block被拷貝後存放在堆中後,需要我們自己進行內存管理,否則還可能造成一些循環引用的問題;

三、Block的Copy的問題

Block有着不同的存儲域類型,尤其是配置在棧上的Block(即__NSStackBlock__類型的Block),如果其所屬的作用域結束該Block就會被釋放。此時若繼續使用Block,就需要執行copy操作,將其由棧區拷貝到堆區得到__NSMallocBlock__,而__NSMallocBlock__也會在其引用計數爲0的時候被釋放;

關於Block的拷貝,其實還需要分爲MRC和ARC兩種環境來考慮,下面是具體的分析:

1.MRC下的Block拷貝

在MRC環境下,我們只能顯式的通過copy來實現Block的拷貝;通常爲了避免Block的釋放,我們定義Block屬性的時候必須使用copy修飾符也正是基於這個原因。下面是在MRC環境下測試棧Block的使用,具體代碼如下:

typedef void(^PrintBlock)(void);

@interface ViewController ()
@property (nonatomic ,copy)PrintBlock block1;
@property (nonatomic ,copy)PrintBlock block2;
@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    [self createBlock];
    self.block1();
    self.block2();
    NSLog(@"block1:%@", [self.block1 class]); //報錯Thread 1: EXC_BAD_ACCESS (code=1, address=0x7ffeeb90b8c0)
    NSLog(@"block2:%@", [self.block2 class]);
}

- (void)createBlock {
    int a = 10;
    //此處採用直接賦值的方式,不會觸發setter方法
    _block1 = ^{
        NSLog(@"This is block1-%d",a);
    };
    
    self.block2 = ^{
        NSLog(@"This is block2-%d",a);
    };
    //離開此作用域,block1就會被釋放
    NSLog(@"block1:%@、block2:%@", [self.block1 class],[self.block2 class]);
}
@end

打印結果及分析如下:

block1:__NSStackBlock__、block2:__NSMallocBlock__
This is block1-10
This is block2-10

由於block1採用的是直接賦值的方式,沒有調用setter方法,所以block1並沒有被拷貝到堆上,是一個棧上的Block,這樣也就直接導致了第二次打印block1時所發生的野指針崩潰;

2.ARC下的Block拷貝

在ARC環境下,編譯器會根據情況自動將棧上的Block複製到堆上,總結起來包含以下幾種情況:

  • Block作爲函數返回值時;這就類似與MRC中對返回值Block執行了[[returnedBlock copy] autorelease];
  • Block被強引用,如Block被賦值給__strong或者id類型;
  • Block作爲GCD API的方法參數時;
  • Block作爲系統方法名含有usingBlock的方法參數時;

下面的代碼演示了這些情況:

typedef void(^Block)(void);
-(Block)getBlock{
    //ARC下的Block中訪問了auto變量,此時block類型應爲__NSStackBlock__
   int a = 10;
   return  ^{
        NSLog(@"---------%d", a);
    };
}

- (void)testBlock9 {
    //1.測試block作爲函數返回值時
    NSLog(@"bock1-:%@",[[self getBlock] class]);
    
    //2.測試將block賦值給__strong指針時
    int a = 10;
    
    //2.1.block內沒有訪問auto變量
    Block block21 = ^{
        NSLog(@"block21");
    };
    NSLog(@"block21-%@",[block21 class]);
    
    //2.2.block內訪問了auto變量,但沒有賦值給__strong指針
    NSLog(@"block22-%@",[^{
        NSLog(@"block22-%d", a);
    } class]);

    //2.3.block賦值給__strong指針
    Block block23 = ^{
        NSLog(@"block23");
    };
    NSLog(@"block23-%@",[block23 class]);
    
    //3.block作爲Cocoa API中方法名含有usingBlock的方法參數時
    NSArray *array = @[@"1",@"2",@"3"];
    [array enumerateObjectsUsingBlock:^(id  _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
        
    }];
    
    //4.block作爲GCD API的方法參數時
    //Block中的延時操作完成時,系統將會對Block進行釋放
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
        
    });
}

//打印結果如下:
//bock1-:__NSMallocBlock__
//block21-__NSGlobalBlock__
//block22-__NSStackBlock__
//block23-__NSGlobalBlock__
3.其他存儲域Block的拷貝

上面講述的重點都於對棧Blok的拷貝,若是對於已經配置在堆上或者配置在靜態區的上的Block調用copy方法又將如何呢?下面是不同存儲域的Block執行copy進行的總結:

Block類型 副本源的配置存儲域 複製效果
_NSConcreteStackBlock 棧區 從棧複製到堆
_NSConcreteGlobalBlock 靜態區 什麼也不做
_NSConcreteMallocBlock 堆區 引用增加
4. 總結Block需要拷貝的原理

Block默認創建於其所在函數的函數棧上,所以當函數作用域結束時就會隨之銷燬;

在MRC環境下,沒有編譯器的優化,所以我們非常強調要使用copy將Block拷貝到堆上,從而避免Block在其作用域結束時被直接釋放;

在ARC環境下,編譯器會根據情況自動將棧上的Block複製到堆上,對於Block使用copy還是strong效果是一樣的,所以寫不寫copy都行。在ARC環境下對於Block依然使用copy,更像是從MRC遺留下來的“傳統”,時刻提醒我們:編譯器自動對Block進行了拷貝操作。如果不寫copy ,該類的調用者有可能會忘記或者根本不知道“編譯器會自動對Block進行了拷貝操作”,他們有可能會在調用之前自行拷貝屬性值,這種操作多餘而低效。

最後,總結Block修飾符的使用:

//MRC下block屬性的建議寫法:
@property (copy, nonatomic) void (^block)(void);

//ARC下block屬性的建議寫法:
@property (strong, nonatomic) void (^block)(void);
@property (copy, nonatomic) void (^block)(void);

參考鏈接

1.蘋果官方Block文檔
2.深入研究 Block 捕獲外部變量和 __block 實現原理

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章