1. 程式人生 > 其它 >iOS底層原理探索-Block本質(一)

iOS底層原理探索-Block本質(一)

首先,在學習之前,增加一些動力。經常在面試中,會被問及到這些問題:

block的本質是什麼?

__block的作用是什麼?原理是什麼?有哪些使用注意點?

我們知道block在使用的時候,一般用copy修飾,用copy修飾發生了什麼?具體過程是怎樣的?

帶著這些疑問,我們開始今天的學習。

block的資料結構長什麼樣?

首先,我們寫一個簡單的block,以及block的呼叫:

int age = 10;
void(^block)(int, int) = ^(int a, int b){
    NSLog(@"呼叫該block----%d", age);
};
block(100, 100);

通過clang編譯指令xcrun -sdk iphoneos clang -arch arm64 -rewrite-objc main.m

將main.m檔案轉換為底層程式碼,通過查詢可以看到上面程式碼轉換為底層程式碼的相關程式碼:

int age = 10;
void(*block)(int, int) = ((void (*)(int, int))&__main_block_impl_0((void *)__main_block_func_0, &__main_block_desc_0_DATA, age));

((void (*)(__block_impl *, int, int))((__block_impl *)block)->FuncPtr)((__block_impl *)block, 100, 100);

可以看到,block最後被轉換為__main_block_impl_0型別

__main_block_impl_0型別是個什麼樣的結構存在的呢?

通過檢視定義能夠知道,__main_block_impl_0的定義為:

struct __main_block_impl_0 {
  struct __block_impl impl;
  struct __main_block_desc_0* Desc;
  int age;//函式呼叫的外部引數
  __main_block_impl_0(void *fp, struct __main_block_desc_0 *desc, int _age, int flags=0) : age(_age) {
    impl.isa = &_NSConcreteStackBlock;
    impl.Flags = flags;
    impl.FuncPtr = fp;
    Desc = desc;
  }
};

其中,__main_block_impl_0裡面第一個型別__block_impl和第二個型別__main_block_desc_0的定義分別為:

struct __block_impl {
  void *isa;//isa指標
  int Flags;
  int Reserved;
  void *FuncPtr;//函式地址
};

static struct __main_block_desc_0 {
  size_t reserved;
  size_t Block_size;
} 

首先,我們看到__main_block_func_0是一個函式;

main函式中__main_block_impl_0((void *)__main_block_func_0, &__main_block_desc_0_DATA, age)將引數__main_block_func_0傳到了block裡面,賦值給__main_block_impl_0裡面的fp,然後做了 impl.FuncPtr = fp。

最後在執行block的時候,是執行的block->FuncPtr,就呼叫到了impl.FuncPtr,也就是fp,也就是__main_block_func_0

block內部直觀表示大致如下:

總結:

  1. block是一個具有isa指標的oc物件
  2. block是封裝了函式以及函式呼叫環境的OC物件

封裝的函式是指block{}內部的程式碼,被轉換成一個函式__main_block_func_0,並將函式地址封裝在了__main_block_impl_0(block型別)內部的impl.FuncPtr
函式呼叫環境是指,函式呼叫的時候需要的引數,從圖中可以看出,函式需要的變數age,已經被封裝在了__main_block_impl_0裡面。
接下來,我們分析下block轉換為底層原始碼的程式碼

int age = 10;
void(block)(int, int) = ((void ()(int, int))&__main_block_impl_0((void )__main_block_func_0, &__main_block_desc_0_DATA, age));
上句程式碼是Block的定義
一般小括號為強制轉換,為了方便觀察,可以將小括號以及小括號裡面的內容刪掉。
簡化為:
void(
block)(int, int) = &__main_block_impl_0(__main_block_func_0, &__main_block_desc_0_DATA, age));
等號後面,是一個函式,函式名為__main_block_impl_0,函式有三個引數。並且獲取函式地址後賦值給block物件。也就是block是一個指標變數。其內部存放的是__main_block_impl_0型別的地址。

而檢視__main_block_impl_0的定義

struct __main_block_impl_0 {
  struct __block_impl impl;
  struct __main_block_desc_0* Desc;
  int age;//函式呼叫的外部引數
  __main_block_impl_0(void *fp, struct __main_block_desc_0 *desc, int _age, int flags=0) : age(_age) {
    impl.isa = &_NSConcreteStackBlock;//block的型別是_NSConcreteStackBlock
    impl.Flags = flags;
    impl.FuncPtr = fp;
    Desc = desc;
  }
};

__main_block_impl_0函式與結構體名一樣,該函式沒有寫返回值,但其實是返回結構體本身,該函式稱為建構函式。
那麼,block其實指向的是__main_block_impl_0結構體的地址。

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)};

sizeof(struct __main_block_impl_0):__main_block_impl_0即block的大小

((void (*)(__block_impl *, int, int))((__block_impl *)block)->FuncPtr)((__block_impl *)block, 100, 100);
上句程式碼為block的執行,簡化後的結果為:
(block->FuncPtr)(block, 100, 100);
利用block找到FuncPtr函式,進行呼叫。

看一下(block->FuncPtr)(block, 100, 100);
block即__main_block_impl_0型別,裡面並沒有FuncPtr函式

__main_block_impl_0裡面的__block_impl型別裡面才有FuncPtr函式,怎麼block直接就呼叫了FuncPtr函式呢?

這是因為,裡面有個強制轉換操作,將block強制轉換為__block_impl *型別,這樣,就可以直接訪問__block_impl裡面的FuncPtr函式,即__block_impl->FuncPtr。
那,為什麼這個可以將__main_block_impl_0強制轉換為__block_impl型別呢?
這是因為,結構體__block_impl型別是__main_block_impl_0型別的第一個成員,那麼__main_block_impl_0型別的記憶體地址跟__block_impl型別的記憶體地址是一樣的,因此,可以強制轉換。

從另一個角度去分析,__main_block_impl_0裡面的__block_impl是一個結構體,而不是指標,相當於直接把__block_impl型別的內容放入__main_block_impl_0之中,也就相當於可以直接進行__main_block_impl_0->FuncPtr訪問。

block捕獲機制

block內部訪問區域性變數

來段簡單的程式碼:

int age = 10;
void(^block)(void) = ^{
    NSLog(@"呼叫該block----%d", age);
};
age = 20;
block();

很容易,我們知道最後的執行結果是:

呼叫該block----10

那麼,是怎樣一個原理呢?

同樣,我們通過clang命令,將程式碼轉換為底層程式碼:

int age = 10;

void(*block)(void) = ((void (*)())&__main_block_impl_0((void *)__main_block_func_0, &__main_block_desc_0_DATA, age));

age = 20;

((void (*)(__block_impl *))((__block_impl *)block)->FuncPtr)((__block_impl *)block);

可以看到,block將age作為引數,傳到__main_block_impl_0裡面。

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

在__main_block_impl_0裡面,block自己定義了一個同名變數age。
並通過age(_age)將_age的值賦值給age,即
age(_age) 等價於 age = _age;

執行
age = 20;
只是將int age = 10變為int age = 20,並沒有改變block裡面age的值

執行
block();
呼叫block實現,就呼叫了__main_block_impl_0裡面的FuncPtr函式,而FuncPtr函式裡面已經封裝了age

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

該age是__cself->age,即block內部的age。而block內部的age=10。
因此,打印出來的age=10;也就是常說的值傳遞。

為什麼值傳遞的值不可以賦值或者修改呢?

這個我們留到下一小節進行講述

block內部訪問static修飾的區域性變數

如果用static修飾,會是怎麼樣呢?

int age = 10;
static int height = 170;
void(^block)(void) = ^{
    NSLog(@"呼叫該block----%d, %d", age, height);
};
age = 20;
height = 180;
block();

結果:呼叫該block----10, 180
int age = 10;
static int height = 170;
void(*block)(void) = ((void (*)())&__main_block_impl_0((void *)__main_block_func_0, &__main_block_desc_0_DATA, age, &height));
age = 20;
height = 180;
((void (*)(__block_impl *))((__block_impl *)block)->FuncPtr)((__block_impl *)block);

從上面可以看出,age傳的是值,height是傳的指標

struct __main_block_impl_0 {
    struct __block_impl impl;
    struct __main_block_desc_0* Desc;
    int age;//新建同名變數age
    int *height;//新建同名變數height,但是此height是指標變數
    __main_block_impl_0(void *fp, struct __main_block_desc_0 *desc, int _age, int *_height, int flags=0) : age(_age), height(_height) {
        impl.isa = &_NSConcreteStackBlock;
        impl.Flags = flags;
        impl.FuncPtr = fp;
        Desc = desc;
    }
};

__main_block_impl_0內部,新建的age是int,而height是指標int *。

執行height = 180;

執行block();

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

呼叫的時候,age是值傳遞,height是指標

由於height傳的值是指標,*height已經修改為180,因此,block內部的height也被修改,因此最後打印出來的height是180

block內部訪問全域性變數

如果是全域性變數或者static修飾的全域性變數,執行結果又有什麼不一樣呢?
int age = 10;
static int height = 170;
int main(int argc, const char * argv[]) {
    @autoreleasepool {
        void(^block)(void) = ^{
            NSLog(@"呼叫該block----%d, %d", age, height);
        };
        age = 20;
        height = 190;
        block();
    }
    return 0;
}

執行結果:呼叫該block----20, 190

轉換為底層程式碼後:

從底層原始碼可以看出,__main_block_impl_0內部沒有新定義age,或者height。
說明block內部並沒有捕獲外部的全域性變數。

最後呼叫函式,列印的age和height是全域性變數。

通過以上程式碼,我們可以發現,block訪問外部變數,有一個變數捕獲機制(capture)捕獲機制

怎麼理解捕獲呢?

在block內部專門新建一個變數,用來儲存外部的值,稱為捕獲。
通過以上例子,可以得出:

block訪問外部變數總結:

為什麼使用auto修飾的變數,block捕獲的值,而使用static修飾的區域性變數,block捕獲的是指標呢?

這是因為,auto修飾的變數,隨時可能被銷燬,因此,需要及時把值捕獲進去。
而static修飾的變數,在程式整個生命週期都存在,所以,可以對變數進行修改,因此只需要捕獲指標即可。
全域性變數,儲存在靜態全域性區,整個程式的生命週期都存在,因此,不需要捕獲

- (void)test
{
    void(^block)(void) = ^{
        NSLog(@"呼叫該block----%@", self);
    };
    block();
}
問:該block裡面的self,是否會被捕獲?

同樣,使用clang轉換為底層程式碼,可以看到block定義:

struct __YZPerson__test_block_impl_0 {
  struct __block_impl impl;
  struct __YZPerson__test_block_desc_0* Desc;
  YZPerson *self;
  __YZPerson__test_block_impl_0(void *fp, struct __YZPerson__test_block_desc_0 *desc, YZPerson *_self, int flags=0) : self(_self) {
    impl.isa = &_NSConcreteStackBlock;
    impl.Flags = flags;
    impl.FuncPtr = fp;
    Desc = desc;
  }
};

可以看到,捕獲到了self。

問:為什麼會捕獲self呢?
- (void)test
{
    void(^block)(void) = ^{
        NSLog(@"呼叫該block----%@", self);
    };
    block();
}

最後轉化為:

static void _I_YZPerson_test(YZPerson * self, SEL _cmd) {
    void(*block)(void) = ((void (*)())&__YZPerson__test_block_impl_0((void *)__YZPerson__test_block_func_0, &__YZPerson__test_block_desc_0_DATA, self, 570425344));
    ((void (*)(__block_impl *))((__block_impl *)block)->FuncPtr)((__block_impl *)block);
}

可以看出,在test函式裡面,其實是有兩個隱式變數:self和SEL型別的_cmd
self是以引數傳進去的,因此,self屬於區域性變數,需要進行捕獲。

既然這樣的話,那麼:

- (void)test
{
    void(^block)(void) = ^{
        NSLog(@"呼叫該block----%@", _name);
    };
    block();
}
_name又是如何存在的呢?捕獲還是不捕獲?捕獲的話是直接捕獲還是怎樣的呢?

不多說,咱還是直接看底層程式碼:

從圖片中可以看到,block並沒有捕獲name,而是通過捕獲的self,訪問的_name。
其實可以理解,因為name屬於YZPerson裡面的一個屬性,_name是YZPerson裡面的一個成員變數,_name其實是self->_name,因此,是通過捕獲self,訪問_name成員變數的。

這樣說明了,在block內部通過訪問成員變數,就相當於裡面引用了self,因此,還是需要留意迴圈引用的問題。

block型別

block有三種類型:

__NSGlobalBlock__
__NSStackBlock__
__NSMallocBlock__

block的三種類型,可以通過呼叫class方法或者isa指標檢視具體型別,最終都是繼承NSBlock型別,再往上是繼承NSObject型別。

舉個例子:

void(^block)(void) = ^{
    NSLog(@"呼叫該block----");
};

NSLog(@"1-%@", [block class]);
NSLog(@"2-%@", [[block class] superclass]);
NSLog(@"3-%@", [[[block class] superclass] superclass]);
NSLog(@"4-%@", [[[[block class] superclass] superclass] superclass]);
NSLog(@"5-%@", [[[[[block class] superclass] superclass] superclass] superclass]);
NSLog(@"6-%@", [[[[[[block class] superclass] superclass] superclass] superclass] superclass]);

執行結果:
2020-03-25 11:08:14.326871+0800 block學習[53532:3297757] 1-__NSGlobalBlock__
2020-03-25 11:08:14.327226+0800 block學習[53532:3297757] 2-__NSGlobalBlock
2020-03-25 11:08:14.327276+0800 block學習[53532:3297757] 3-NSBlock
2020-03-25 11:08:14.327317+0800 block學習[53532:3297757] 4-NSObject
2020-03-25 11:08:14.327349+0800 block學習[53532:3297757] 5-(null)
2020-03-25 11:08:14.327377+0800 block學習[53532:3297757] 6-(null)

可以看出,該block的型別是 NSGlobalBlock,其繼承關係是:NSGlobalBlock : __NSGlobalBlock : NSBlock : NSObject

至於為什麼NSObject的superclass是nil可以參考iOS中物件的本質

問:那,三種類型具體什麼時候是哪種型別呢?

先上個總結圖:

具體的實驗結果可以參考iOS之Block基本使用

其中,在ARC下,你會發現,

int a = 3;//區域性變數
void(^block)(void) = ^{
    NSLog(@"呼叫了block, a = %d", a);
};
NSLog(@"%@", block);
結果:<__NSMallocBlock__: 0x28343ea60>

按照之前的總結圖,block的儲存型別不應該是NSStackBlock嗎?怎麼打印出來的卻是NSMallocBlock?

這是因為,在ARC中,系統以及自動幫我們做了copy操作。從而將本應該是NSStackBlock經過copy操作後,變為NSMallocBlock。

每一種型別的block呼叫copy後的結果如下:

為什麼ARC需要幫我們把本儲存在NSStackBlock的block經過copy操作,轉移儲存在NSMallocBlock上呢?

一個在MRC下的例子:

void(^block)(void);
void test()
{
    int age = 10;
    block = ^{
       NSLog(@"呼叫該block----%d", age);
    };
}

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        test();
        block();
    }
    return 0;
}

結果:
呼叫該block-----272632728

可以看到,age的值不是10,而是一串莫名其妙的數字

這是因為,block引用了auto變數,block型別是NSStackBlock。
在test()括號執行完畢後,block其實已經被釋放了,再次呼叫block,裡面的age就不是10了。
因此,我們需要將存在棧上的block通過copy操作,轉移儲存在堆上。將生命週期交給程式設計師自己控制。

總結:

在ARC環境下,編譯器會根據以下情況自動將棧上的block複製到堆上:

block作為函式返回值時
將block賦值給strong指標時(即block有強指標引用)
block作為Cocoa API中方法名含有usingBlock的方法引數時
block作為GCD API的方法引數時
三個block型別具體儲存在哪一個區域

block與copy、retain、release操作

對不同型別的block,呼叫其retainCount,觀看其有何不同點:

以下是驗證程式:

NSGlobalBlock

- (void)viewDidLoad {
    [super viewDidLoad];
    
    //沒有訪問auto變數,儲存在NSGlobalBlock
    void(^block)(void) = ^{
        
    };
    NSLog(@"%@", block);
    [block retain];
    [block retain];
    [block retain];
    NSLog(@"[block retainCount] = %d", [block retainCount]);
    NSLog(@"%@", block);
}

列印結果:
2020-07-15 18:42:26.133369+0800 block2[9804:1002328] <__NSGlobalBlock__: 0x10fd29178>
2020-07-15 18:42:26.133506+0800 block2[9804:1002328] [block retainCount] = 1
2020-07-15 18:42:26.133586+0800 block2[9804:1002328] <__NSGlobalBlock__: 0x10fd29178>

NSStackBlock

- (void)viewDidLoad {
    [super viewDidLoad];
    
    int a = 3;//區域性變數
    //訪問auto變數,儲存在NSStackBlock
    void(^block)(void) = ^{
        NSLog(@"呼叫了block, a = %d", a);
    };
    NSLog(@"%@", block);
    [block retain];
    [block retain];
    [block retain];
    NSLog(@"[block retainCount] = %d", [block retainCount]);
    NSLog(@"%@", block);
}

列印結果:
2020-07-15 18:43:43.936774+0800 block2[9825:1003381] <__NSStackBlock__: 0x7ffeebdf7f88>
2020-07-15 18:43:43.936910+0800 block2[9825:1003381] [block retainCount] = 1
2020-07-15 18:43:43.936996+0800 block2[9825:1003381] <__NSStackBlock__: 0x7ffeebdf7f88>

NSMallocBlock

- (void)viewDidLoad {
    [super viewDidLoad];
    
    int a = 3;//區域性變數
    //訪問auto變數,儲存在NSStackBlock
    void(^block)(void) = [^{
        NSLog(@"呼叫了block, a = %d", a);
    } copy];//呼叫copy,儲存在NSMallocBlock
    NSLog(@"%@", block);
    [block retain];
    [block retain];
    [block retain];
    NSLog(@"[block retainCount] = %d", [block retainCount]);
    NSLog(@"%@", block);
}

列印結果:
2020-07-15 18:44:48.964249+0800 block2[9842:1004304] <__NSMallocBlock__: 0x600000cd53e0>
2020-07-15 18:44:48.964366+0800 block2[9842:1004304] [block retainCount] = 1
2020-07-15 18:44:48.964456+0800 block2[9842:1004304] <__NSMallocBlock__: 0x600000cd53e0>

block與copy、retain、release操作的總結:

不同於NSObjec的copy、retain、release操作:

Block_copy與copy等效,Block_release與release等效;

對Block不管是retain、copy、release都不會改變引用計數retainCount,retainCount 始終是1;

NSGlobalBlock:retain、release、copy操作都無效;

NSStackBlock:retain、release操作無效,必須注意的是,NSStackBlock在函式返回後,Block記憶體將被回收。即使retain也沒用。容易犯的錯誤是[[mutableAarry addObject:stackBlock],在函數出棧後,從mutableAarry中取到的stackBlock已經被回收,變成了野指標。正確的做法是先將stackBlock copy到堆上,然後加入陣列:[mutableAarry addObject:[[stackBlock copy] autorelease]]。
支援copy,copy之後生成新的NSMallocBlock型別物件。

NSMallocBlock:支援retain、release,雖然retainCount始終是1,但記憶體管理器中仍然會增加、減少計數。
copy之後不會生成新的物件,只是增加了一次引用,類似retain;

儘量不要對Block使用retain操作。