iOS底層原理總結 - 探尋block的本質(一)
面試題
-
block的原理是怎樣的?本質是什麽?
-
__block的作用是什麽?有什麽使用註意點?
-
block的屬性修飾詞為什麽是copy?使用block有哪些使用註意?
-
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++中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結構體的地址賦值給了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函數中首先取出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中存儲著兩個參數,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結構體
首先我們看一下__block_impl第一個變量就是__block_impl結構體。
來到__block_impl結構體內部
我們可以發現__block_impl結構體內部就有一個isa指針。因此可以證明block本質上就是一個oc對象。而在構造函數中將函數中傳入的值分別存儲在__main_block_impl_0結構體實例中,最終將結構體的地址賦值給block。
接著通過上面對__main_block_impl_0結構體構造函數三個參數的分析我們可以得出結論:
-
__block_impl結構體中isa指針存儲著&_NSConcreteStackBlock地址,可以暫時理解為其類對象地址,block就是_NSConcreteStackBlock類型的。
-
block代碼塊中的代碼被封裝成__main_block_func_0函數,FuncPtr則存儲著__main_block_func_0函數的地址。
-
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;
}
通過打斷點可以看出我們自定義的結構體可以被賦值成功,以及裏面的值。
接下來斷點來到block代碼塊中,看一下堆棧信息中的函數調用地址。Debuf workflow -> always show Disassembly
通過上圖可以看到地址確實和FuncPtr中的代碼塊地址一樣。
總結
此時已經基本對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++代碼看一下內部結構中兩個參數的區別。
從上述源碼中可以看出,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++代碼查看全局變量調用方式
通過上述代碼可以發現,__main_block_imp_0並沒有添加任何變量,因此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++代碼查看其內部結構
上圖中可以發現,self同樣被block捕獲,接著我們找到test方法可以發現,test方法默認傳遞了兩個參數self和_cmd。而類方法test2也同樣默認傳遞了類對象self和方法選擇器_cmd。
對象方法和類方法對比
不論對象方法還是類方法都會默認將self作為參數傳遞給方法內部,既然是作為參數傳入,那麽self肯定是局部變量。上面講到局部變量肯定會被block捕獲。
接著我們來看一下如果在block中使用成員變量或者調用實例的屬性會有什麽不同的結果。
- (void)test
{
void(^block)(void) = ^{
NSLog(@"%@",self.name);
NSLog(@"%@",_name);
};
block();
}
上圖中可以發現,即使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最終都是繼承自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的三種類型
但是我們上面提到過,上述代碼轉化為c++代碼查看源碼時卻發現block的類型與打印出來的類型不一樣,c++源碼中三個block的isa指針全部都指向_NSConcreteStackBlock類型地址。
我們可以猜測runtime運行時過程中也許對類型進行了轉變。最終類型當然以runtime運行時類型也就是我們打印出的類型為準。
block在內存中的存儲
通過下面一張圖看一下不同block的存放區域
上圖中可以發現,根據block的類型不同,block存放在不同的區域中。
數據段中的__NSGlobalBlock__直到程序結束才會被回收,不過我們很少使用到__NSGlobalBlock__類型的block,因為這樣使用block並沒有什麽意義。
__NSStackBlock__類型的block存放在棧中,我們知道棧中的內存由系統自動分配和釋放,作用域執行完畢之後就會被立即釋放,而在相同的作用域中定義block並且調用block似乎也多此一舉。
__NSMallocBlock__是在平時編碼過程中最常使用到的。存放在堆中需要我們自己進行內存管理。
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;
}
查看打印內容
通過打印的內容可以發現正如上圖中所示。
沒有訪問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++代碼可以更清楚的理解。
為了避免這種情況發生,可以通過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類型嗎?下面表格已經展示的很清晰了。
所以在平時開發過程中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);
下一篇文章:iOS底層原理總結 - 探尋block的本質(二)
作者:xx_cc
鏈接:https://www.jianshu.com/p/8865ff43f30e
iOS底層原理總結 - 探尋block的本質(一)