iOS底層原理總結 - 探尋block的本質(一)

xx_cc發表於2018-05-20

面試題

  1. block的原理是怎樣的?本質是什麼?
  2. __block的作用是什麼?有什麼使用注意點?
  3. block的屬性修飾詞為什麼是copy?使用block有哪些使用注意?
  4. block在修改NSMutableArray,需不需要新增__block?

首先對block有一個基本的認識

block本質上也是一個oc物件,他內部也有一個isa指標。block是封裝了函式呼叫以及函式呼叫環境的OC物件。

探尋block的本質

首先寫一個簡單的block

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        int age = 10;
        void(^block)(int ,int) = ^(int a, int b){
            NSLog(@"this is block,a = %d,b = %d",a,b);
            NSLog(@"this is block,age = %d",age);
        };
        block(3,5);
    }
    return 0;
}
複製程式碼

使用命令列將程式碼轉化為c++檢視其內部結構,與OC程式碼進行比較

xcrun -sdk iphoneos clang -arch arm64 -rewrite-objc main.m

c++與oc程式碼對比

上圖中將c++中block的宣告和定義分別與oc程式碼中相對應顯示。將c++中block的宣告和呼叫分別取出來檢視其內部實現。

定義block變數

// 定義block變數程式碼
void(*block)(int ,int) = ((void (*)(int, int))&__main_block_impl_0((void *)__main_block_func_0, &__main_block_desc_0_DATA, age));
複製程式碼

上述定義程式碼中,可以發現,block定義中呼叫了__main_block_impl_0函式,並且將__main_block_impl_0函式的地址賦值給了block。那麼我們來看一下__main_block_impl_0函式內部結構。

__main_block_imp_0結構體

__main_block_imp_0結構體

__main_block_imp_0結構體內有一個同名建構函式__main_block_imp_0,建構函式中對一些變數進行了賦值最終會返回一個結構體。

那麼也就是說最終將一個__main_block_imp_0結構體的地址賦值給了block變數

__main_block_impl_0結構體內可以發現__main_block_impl_0建構函式中傳入了四個引數。(void *)__main_block_func_0、&__main_block_desc_0_DATA、age、flags。其中flage有預設值,也就說flage引數在呼叫的時候可以省略不傳。而最後的 age(_age)則表示傳入的_age引數會自動賦值給age成員,相當於age = _age。

接下來著重看一下前面三個引數分別代表什麼。

(void *)__main_block_func_0

__main_block_func_0

在__main_block_func_0函式中首先取出block中age的值,緊接著可以看到兩個熟悉的NSLog,可以發現這兩段程式碼恰恰是我們在block塊中寫下的程式碼。 那麼__main_block_func_0函式中其實儲存著我們block中寫下的程式碼。而__main_block_impl_0函式中傳入的是(void *)__main_block_func_0,也就說將我們寫在block塊中的程式碼封裝成__main_block_func_0函式,並將__main_block_func_0函式的地址傳入了__main_block_impl_0的建構函式中儲存在結構體內。

&__main_block_desc_0_DATA

&__main_block_desc_0_DATA

我們可以看到__main_block_desc_0中儲存著兩個引數,reserved和Block_size,並且reserved賦值為0而Block_size則儲存著__main_block_impl_0的佔用空間大小。最終將__main_block_desc_0結構體的地址傳入__main_block_func_0中賦值給Desc。

age

age也就是我們定義的區域性變數。因為在block塊中使用到age區域性變數,所以在block宣告的時候這裡才會將age作為引數傳入,也就說block會捕獲age,如果沒有在block中使用age,這裡將只會傳入(void *)__main_block_func_0,&__main_block_desc_0_DATA兩個引數。

這裡可以根據原始碼思考一下為什麼當我們在定義block之後修改區域性變數age的值,在block呼叫的時候無法生效。

int age = 10;
void(^block)(int ,int) = ^(int a, int b){
     NSLog(@"this is block,a = %d,b = %d",a,b);
     NSLog(@"this is block,age = %d",age);
};
     age = 20;
     block(3,5); 
     // log: this is block,a = 3,b = 5
     //      this is block,age = 10
複製程式碼

因為block在定義的之後已經將age的值傳入儲存在__main_block_imp_0結構體中並在呼叫的時候將age從block中取出來使用,因此在block定義之後對區域性變數進行改變是無法被block捕獲的。

此時回過頭來檢視__main_block_impl_0結構體

__main_block_impl_0結構體

首先我們看一下__block_impl第一個變數就是__block_impl結構體。 來到__block_impl結構體內部

__block_impl結構體內部

我們可以發現__block_impl結構體內部就有一個isa指標。因此可以證明block本質上就是一個oc物件。而在建構函式中將函式中傳入的值分別儲存在__main_block_impl_0結構體例項中,最終將結構體的地址賦值給block。

接著通過上面對__main_block_impl_0結構體建構函式三個引數的分析我們可以得出結論:

1. __block_impl結構體中isa指標儲存著&_NSConcreteStackBlock地址,可以暫時理解為其類物件地址,block就是_NSConcreteStackBlock型別的。

2. block程式碼塊中的程式碼被封裝成__main_block_func_0函式,FuncPtr則儲存著__main_block_func_0函式的地址。

3. Desc指向__main_block_desc_0結構體物件,其中儲存__main_block_impl_0結構體所佔用的記憶體。

呼叫block執行內部程式碼

// 執行block內部的程式碼
((void (*)(__block_impl *, int, int))((__block_impl *)block)->FuncPtr)((__block_impl *)block, 3, 5);
複製程式碼

通過上述程式碼可以發現呼叫block是通過block找到FunPtr直接呼叫,通過上面分析我們知道block指向的是__main_block_impl_0型別結構體,但是我們發現__main_block_impl_0結構體中並不直接就可以找到FunPtr,而FunPtr是儲存在__block_impl中的,為什麼block可以直接呼叫__block_impl中的FunPtr呢?

重新檢視上述原始碼可以發現,(__block_impl *)block將block強制轉化為__block_impl型別的,因為__block_impl是__main_block_impl_0結構體的第一個成員,相當於將__block_impl結構體的成員直接拿出來放在__main_block_impl_0中,那麼也就說明__block_impl的記憶體地址就是__main_block_impl_0結構體的記憶體地址開頭。所以可以轉化成功。並找到FunPtr成員。

上面我們知道,FunPtr中儲存著通過程式碼塊封裝的函式地址,那麼呼叫此函式,也就是會執行程式碼塊中的程式碼。並且回頭檢視__main_block_func_0函式,可以發現第一個引數就是__main_block_impl_0型別的指標。也就是說將block傳入__main_block_func_0函式中,便於重中取出block捕獲的值。

如何驗證block的本質確實是__main_block_impl_0結構體型別。

通過程式碼證明一下上述內容: 同樣使用之前的方法,我們按照上面分析的block內部結構自定義結構體,並將block內部的結構體強制轉化為自定義的結構體,轉化成功說明底層結構體確實如我們之前分析的一樣。

struct __main_block_desc_0 { 
    size_t reserved;
    size_t Block_size;
};
struct __block_impl {
    void *isa;
    int Flags;
    int Reserved;
    void *FuncPtr;
};
// 模仿系統__main_block_impl_0結構體
struct __main_block_impl_0 { 
    struct __block_impl impl;
    struct __main_block_desc_0* Desc;
    int age;
};
int main(int argc, const char * argv[]) {
    @autoreleasepool {
        int age = 10;
        void(^block)(int ,int) = ^(int a, int b){
            NSLog(@"this is block,a = %d,b = %d",a,b);
            NSLog(@"this is block,age = %d",age);
        };
// 將底層的結構體強制轉化為我們自己寫的結構體,通過我們自定義的結構體探尋block底層結構體
        struct __main_block_impl_0 *blockStruct = (__bridge struct __main_block_impl_0 *)block;
        block(3,5);
    }
    return 0;
}
複製程式碼

通過打斷點可以看出我們自定義的結構體可以被賦值成功,以及裡面的值。

blockStruct

接下來斷點來到block程式碼塊中,看一下堆疊資訊中的函式呼叫地址。Debuf workflow -> always show Disassembly

Debuf workflow -> always show Disassembly

通過上圖可以看到地址確實和FuncPtr中的程式碼塊地址一樣。

總結

此時已經基本對block的底層結構有了基本的認識,上述程式碼可以通過一張圖展示其中各個結構體之間的關係。

圖示block結構體內部之間的關係

block底層的資料結構也可以通過一張圖來展示

block底層的資料結構

block的變數捕獲

為了保證block內部能夠正常訪問外部的變數,block有一個變數捕獲機制。

區域性變數

auto變數

上述程式碼中我們已經瞭解過block對age變數的捕獲。 auto自動變數,離開作用域就銷燬,通常區域性變數前面自動新增auto關鍵字。自動變數會捕獲到block內部,也就是說block內部會專門新增加一個引數來儲存變數的值。 auto只存在於區域性變數中,訪問方式為值傳遞,通過上述對age引數的解釋我們也可以確定確實是值傳遞。

static變數

static 修飾的變數為指標傳遞,同樣會被block捕獲。

接下來分別新增aotu修飾的區域性變數和static修飾的區域性變數,重看原始碼來看一下他們之間的差別。

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        auto int a = 10;
        static int b = 11;
        void(^block)(void) = ^{
            NSLog(@"hello, a = %d, b = %d", a,b);
        };
        a = 1;
        b = 2;
        block();
    }
    return 0;
}
// log : block本質[57465:18555229] hello, a = 10, b = 2
// block中a的值沒有被改變而b的值隨外部變化而變化。
複製程式碼

重新生成c++程式碼看一下內部結構中兩個引數的區別。

區域性變數c++程式碼

從上述原始碼中可以看出,a,b兩個變數都有捕獲到block內部。但是a傳入的是值,而b傳入的則是地址。

為什麼兩種變數會有這種差異呢,因為自動變數可能會銷燬,block在執行的時候有可能自動變數已經被銷燬了,那麼此時如果再去訪問被銷燬的地址肯定會發生壞記憶體訪問,因此對於自動變數一定是值傳遞而不可能是指標傳遞了。而靜態變數不會被銷燬,所以完全可以傳遞地址。而因為傳遞的是值得地址,所以在block呼叫之前修改地址中儲存的值,block中的地址是不會變得。所以值會隨之改變。

全域性變數

我們同樣以程式碼的方式看一下block是否捕獲全域性變數

int a = 10;
static int b = 11;
int main(int argc, const char * argv[]) {
    @autoreleasepool {
        void(^block)(void) = ^{
            NSLog(@"hello, a = %d, b = %d", a,b);
        };
        a = 1;
        b = 2;
        block();
    }
    return 0;
}
// log hello, a = 1, b = 2
複製程式碼

同樣生成c++程式碼檢視全域性變數呼叫方式

全域性變數c++程式碼

通過上述程式碼可以發現,__main_block_imp_0並沒有新增任何變數,因此block不需要捕獲全域性變數,因為全域性變數無論在哪裡都可以訪問。

區域性變數因為跨函式訪問所以需要捕獲,全域性變數在哪裡都可以訪問 ,所以不用捕獲。

最後以一張圖做一個總結

block的變數捕獲

總結:區域性變數都會被block捕獲,自動變數是值捕獲,靜態變數為地址捕獲。全域性變數則不會被block捕獲

疑問:以下程式碼中block是否會捕獲變數呢?

#import "Person.h"
@implementation Person
- (void)test
{
    void(^block)(void) = ^{
        NSLog(@"%@",self);
    };
    block();
}
- (instancetype)initWithName:(NSString *)name
{
    if (self = [super init]) {
        self.name = name;
    }
    return self;
}
+ (void) test2
{
    NSLog(@"類方法test2");
}
@end
複製程式碼

同樣轉化為c++程式碼檢視其內部結構

c++程式碼

上圖中可以發現,self同樣被block捕獲,接著我們找到test方法可以發現,test方法預設傳遞了兩個引數self和_cmd。而類方法test2也同樣預設傳遞了類物件self和方法選擇器_cmd。

物件方法和類方法對比

不論物件方法還是類方法都會預設將self作為引數傳遞給方法內部,既然是作為引數傳入,那麼self肯定是區域性變數。上面講到區域性變數肯定會被block捕獲。

接著我們來看一下如果在block中使用成員變數或者呼叫例項的屬性會有什麼不同的結果。

- (void)test
{
    void(^block)(void) = ^{
        NSLog(@"%@",self.name);
        NSLog(@"%@",_name);
    };
    block();
}
複製程式碼

c++程式碼

上圖中可以發現,即使block中使用的是例項物件的屬性,block中捕獲的仍然是例項物件,並通過例項物件通過不同的方式去獲取使用到的屬性。

block的型別

block物件是什麼型別的,之前稍微提到過,通過原始碼可以知道block中的isa指標指向的是_NSConcreteStackBlock類物件地址。那麼block是否就是_NSConcreteStackBlock型別的呢?

我們通過程式碼用class方法或者isa指標檢視具體型別。

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        // __NSGlobalBlock__ : __NSGlobalBlock : NSBlock : NSObject
        void (^block)(void) = ^{
            NSLog(@"Hello");
        };
        
        NSLog(@"%@", [block class]);
        NSLog(@"%@", [[block class] superclass]);
        NSLog(@"%@", [[[block class] superclass] superclass]);
        NSLog(@"%@", [[[[block class] superclass] superclass] superclass]);
    }
    return 0;
}
複製程式碼

列印內容

block的型別

從上述列印內容可以看出block最終都是繼承自NSBlock型別,而NSBlock繼承於NSObjcet。那麼block其中的isa指標其實是來自NSObject中的。這也更加印證了block的本質其實就是OC物件。

block的3種型別

block有3中型別

__NSGlobalBlock__ ( _NSConcreteGlobalBlock )
__NSStackBlock__ ( _NSConcreteStackBlock )
__NSMallocBlock__ ( _NSConcreteMallocBlock )
複製程式碼

通過程式碼檢視一下block在什麼情況下其型別會各不相同

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        // 1. 內部沒有呼叫外部變數的block
        void (^block1)(void) = ^{
            NSLog(@"Hello");
        };
        // 2. 內部呼叫外部變數的block
        int a = 10;
        void (^block2)(void) = ^{
            NSLog(@"Hello - %d",a);
        };
       // 3. 直接呼叫的block的class
        NSLog(@"%@ %@ %@", [block1 class], [block2 class], [^{
            NSLog(@"%d",a);
        } class]);
    }
    return 0;
}
複製程式碼

通過列印內容確實可以發現block的三種型別

block的三種型別

但是我們上面提到過,上述程式碼轉化為c++程式碼檢視原始碼時卻發現block的型別與列印出來的型別不一樣,c++原始碼中三個block的isa指標全部都指向_NSConcreteStackBlock型別地址。

我們可以猜測runtime執行時過程中也許對型別進行了轉變。最終型別當然以runtime執行時型別也就是我們列印出的型別為準。

block在記憶體中的儲存

通過下面一張圖看一下不同block的存放區域

不同型別block的存放區域

上圖中可以發現,根據block的型別不同,block存放在不同的區域中。 資料段中的__NSGlobalBlock__直到程式結束才會被回收,不過我們很少使用到__NSGlobalBlock__型別的block,因為這樣使用block並沒有什麼意義。

__NSStackBlock__型別的block存放在棧中,我們知道棧中的記憶體由系統自動分配和釋放,作用域執行完畢之後就會被立即釋放,而在相同的作用域中定義block並且呼叫block似乎也多此一舉。

__NSMallocBlock__是在平時編碼過程中最常使用到的。存放在堆中需要我們自己進行記憶體管理。

block是如何定義其型別

block是如何定義其型別,依據什麼來為block定義不同的型別並分配在不同的空間呢?首先看下面一張圖

block是如何定義其型別

接著我們使用程式碼驗證上述問題,首先關閉ARC回到MRC環境下,因為ARC會幫助我們做很多事情,可能會影響我們的觀察。

// MRC環境!!!
int main(int argc, const char * argv[]) {
    @autoreleasepool {
        // Global:沒有訪問auto變數:__NSGlobalBlock__
        void (^block1)(void) = ^{
            NSLog(@"block1---------");
        };   
        // Stack:訪問了auto變數: __NSStackBlock__
        int a = 10;
        void (^block2)(void) = ^{
            NSLog(@"block2---------%d", a);
        };
        NSLog(@"%@ %@", [block1 class], [block2 class]);
        // __NSStackBlock__呼叫copy : __NSMallocBlock__
        NSLog(@"%@", [[block2 copy] class]);
    }
    return 0;
}
複製程式碼

檢視列印內容

block型別

通過列印的內容可以發現正如上圖中所示。 沒有訪問auto變數的block是__NSGlobalBlock__型別的,存放在資料段中。 訪問了auto變數的block是__NSStackBlock__型別的,存放在棧中。 __NSStackBlock__型別的block呼叫copy成為__NSMallocBlock__型別並被複制存放在堆中。

上面提到過__NSGlobalBlock__型別的我們很少使用到,因為如果不需要訪問外界的變數,直接通過函式實現就可以了,不需要使用block。

但是__NSStackBlock__訪問了aotu變數,並且是存放在棧中的,上面提到過,棧中的程式碼在作用域結束之後記憶體就會被銷燬,那麼我們很有可能block記憶體銷燬之後才去呼叫他,那樣就會發生問題,通過下面程式碼可以證實這個問題。

void (^block)(void);
void test()
{
    // __NSStackBlock__
    int a = 10;
    block = ^{
        NSLog(@"block---------%d", a);
    };
}
int main(int argc, const char * argv[]) {
    @autoreleasepool {
        test();
        block();
    }
    return 0;
}
複製程式碼

此時檢視列印內容

列印內容

可以發現a的值變為了不可控的一個數字。為什麼會發生這種情況呢?因為上述程式碼中建立的block是__NSStackBlock__型別的,因此block是儲存在棧中的,那麼當test函式執行完畢之後,棧記憶體中block所佔用的記憶體已經被系統回收,因此就有可能出現亂得資料。檢視其c++程式碼可以更清楚的理解。

c++程式碼

為了避免這種情況發生,可以通過copy將__NSStackBlock__型別的block轉化為__NSMallocBlock__型別的block,將block儲存在堆中,以下是修改後的程式碼。

void (^block)(void);
void test()
{
    // __NSStackBlock__ 呼叫copy 轉化為__NSMallocBlock__
    int age = 10;
    block = [^{
        NSLog(@"block---------%d", age);
    } copy];
    [block release];
}
複製程式碼

此時在列印就會發現資料正確

列印內容

那麼其他型別的block呼叫copy會改變block型別嗎?下面表格已經展示的很清晰了。

不同型別呼叫copy效果

所以在平時開發過程中MRC環境下經常需要使用copy來儲存block,將棧上的block拷貝到堆中,即使棧上的block被銷燬,堆上的block也不會被銷燬,需要我們自己呼叫release操作來銷燬。而在ARC環境下系統會自動呼叫copy操作,使block不會被銷燬。

ARC幫我們做了什麼

在ARC環境下,編譯器會根據情況自動將棧上的block進行一次copy操作,將block複製到堆上。

什麼情況下ARC會自動將block進行一次copy操作? 以下程式碼都在RAC環境下執行。

1. block作為函式返回值時

typedef void (^Block)(void);
Block myblock()
{
    int a = 10;
    // 上文提到過,block中訪問了auto變數,此時block型別應為__NSStackBlock__
    Block block = ^{
        NSLog(@"---------%d", a);
    };
    return block;
}
int main(int argc, const char * argv[]) {
    @autoreleasepool {
        Block block = myblock();
        block();
       // 列印block型別為 __NSMallocBlock__
        NSLog(@"%@",[block class]);
    }
    return 0;
}
複製程式碼

看一下列印的內容

列印內容

上文提到過,如果在block中訪問了auto變數時,block的型別為__NSStackBlock__,上面列印內容發現blcok為__NSMallocBlock__型別的,並且可以正常列印出a的值,說明block記憶體並沒有被銷燬。

上面提到過,block進行copy操作會轉化為__NSMallocBlock__型別,來講block複製到堆中,那麼說明RAC在 block作為函式返回值時會自動幫助我們對block進行copy操作,以儲存block,並在適當的地方進行release操作。

2. 將block賦值給__strong指標時

block被強指標引用時,RAC也會自動對block進行一次copy操作。

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        // block內沒有訪問auto變數
        Block block = ^{
            NSLog(@"block---------");
        };
        NSLog(@"%@",[block class]);
        int a = 10;
        // block內訪問了auto變數,但沒有賦值給__strong指標
        NSLog(@"%@",[^{
            NSLog(@"block1---------%d", a);
        } class]);
        // block賦值給__strong指標
        Block block2 = ^{
          NSLog(@"block2---------%d", a);
        };
        NSLog(@"%@",[block1 class]);
    }
    return 0;
}
複製程式碼

檢視列印內容可以看出,當block被賦值給__strong指標時,RAC會自動進行一次copy操作。

列印內容

3. block作為Cocoa API中方法名含有usingBlock的方法引數時

例如:遍歷陣列的block方法,將block作為引數的時候。

NSArray *array = @[];
[array enumerateObjectsUsingBlock:^(id  _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
            
}];
複製程式碼

4. block作為GCD API的方法引數時

例如:GDC的一次性函式或延遲執行的函式,執行完block操作之後系統才會對block進行release操作。

static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
            
});        
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
            
});
複製程式碼

block宣告寫法

通過上面對MRC及ARC環境下block的不同型別的分析,總結出不同環境下block屬性建議寫法。

MRC下block屬性的建議寫法

@property (copy, nonatomic) void (^block)(void);

ARC下block屬性的建議寫法

@property (strong, nonatomic) void (^block)(void); @property (copy, nonatomic) void (^block)(void);

底層原理文章專欄

底層原理文章專欄


文中如果有不對的地方歡迎指出。我是xx_cc,一隻長大很久但還沒有二夠的傢伙。需要視訊一起探討學習的coder可以加我Q:2336684744

相關文章