1. 程式人生 > IOS開發 >一道Block面試題的深入挖掘

一道Block面試題的深入挖掘

0. 序言

最近看到了一道Block的面試題,還蠻有意思的,來給大家分享一下。

本文從一道Block面試題出發,層層深入到達Block原理的講解,把面試題吃得透透的。

題外話:

很多人覺得Block的定義很怪異,很難記住。但其實和C語言的函式指標的定義對比一下,你很容易就可以記住。

// Block
returnType (^blockName)(parameterTypes)

// 函式指標
returnType (*c_func)(parameterTypes)
複製程式碼

例如輸入和返回引數都是字串:

(char *) (*c_func)(const char *);
(NSString *) (^block)(NSString
*); 複製程式碼

好了,下面正式開始~

1. 面試題

1.1 問題1

以下程式碼存在記憶體洩露麼?

  • 不存在
  • 存在
- (void)viewDidLoad {
    [super viewDidLoad];
    NSNotificationCenter *__weak center = [NSNotificationCenter defaultCenter];
    id token = [center addObserverForName:UIApplicationDidEnterBackgroundNotification
                                   object:nil
queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification * _Nonnull note) { [self doSomething]; [center removeObserver:token]; }]; } - (void)doSomething { } 複製程式碼

答案是存在

1.1.1 分析
  • block中,我們使用到的外部變數有self

    centercenter使用了__weak說明符肯定沒問題。

  • center持有tokentoken持有block(下一題的截圖會看到),block持有self,也就是說token不釋放,self肯定沒法釋放。

    可能有同學對center持有token有疑問,可以在lldbpo wkCenter

  • 我們注意到[center removeObserver:token];這步會把tokencenter中移除掉。按理說,centerself是不是就可以被釋放了呢?

我們來看看編譯器怎麼說:

編譯器告訴我們,token在被block捕獲之前沒有初始化[center removeObserver:token];是沒法正確移除token的,所以self也沒法被釋放!

為什麼沒有被初始化?

因為token在後面的方法執行完才會被返回。方法執行的時候token還沒有被返回,所以捕獲到的是一個未初始化的值!

1.2 問題2

以下程式碼存在記憶體洩露麼?

  • 不存在
  • 存在
- (void)viewDidLoad {
    [super viewDidLoad];
    NSNotificationCenter *__weak center = [NSNotificationCenter defaultCenter];
    id __block token = [center addObserverForName:UIApplicationDidEnterBackgroundNotification
                                           object:nil
                                            queue:[NSOperationQueue mainQueue]
                                       usingBlock:^(NSNotification * _Nonnull note) {
        [self doSomething];
        [center removeObserver:token];
    }];
}

- (void)doSomething {
    
}

複製程式碼

這次程式碼在token之前加入了__block說明符。

提示:這次編譯器沒有警告說token沒有被初始化了。

答案是還是存在

1.2.1 分析

首先,證明token的值是正確的,同時大家也可以看到token確實是持有block的。

那麼,為什麼還會洩露呢?

因為,雖然centertoken的持有已經沒有了,token現在還被block持有。

可能還有同學會問:

加入了__block說明符,token物件不是還是center返回之後才能拿到麼,為什麼加了之後就沒問題了呢?

原因會在Block原理部分詳細說明。

1.3 問題3

以下程式碼存在記憶體洩露麼?

  • 不存在
  • 存在
- (void)viewDidLoad {
    [super viewDidLoad];
    NSNotificationCenter *__weak center = [NSNotificationCenter defaultCenter];
    id __block token = [center addObserverForName:UIApplicationDidEnterBackgroundNotification
                                           object:nil
                                            queue:[NSOperationQueue mainQueue]
                                       usingBlock:^(NSNotification * _Nonnull note) {
        [self doSomething];
        [center removeObserver:token];
        token = nil;
    }];
}

- (void)doSomething {
    
}

- (void)dealloc {
    NSLog(@"%s",__FUNCTION__);
}
複製程式碼

答案是不存在

1.3.1 分析

我們可以驗證一下:

可以看到,我們新增token = nil;之後,ViewController被正確釋放了。這一步,解除了tokenblock之間的迴圈引用,所以正確釋放了。

有人可能會說:

使用__weak typeof(self) wkSelf = self;就可以解決self不釋放的問題。

確實這可以解決self不釋放的問題,但是這裡仍然存在記憶體洩露!

2. Block的原理

雖然面試題解決了,但是還有幾個問題沒有弄清楚:

  • 為什麼沒有__block說明符token未被初始化,而有這個說明符之後就沒問題了呢?
  • tokenblock為什麼會形成迴圈引用呢?

2.1 Block捕獲自動變數

剛剛的面試題比較複雜,我們先來看一個簡單的:

Block轉換為C函式之後,Block中使用的自動變數會被作為成員變數追加到 __X_block_impl_Y結構體中,其中 X一般是函式名, Y是第幾個Block,比如main函式中的第0個結構體: __main_block_impl_0

typedef void (^MyBlock)(void);

int main(int argc,const char * argv[])
{
  @autoreleasepool
  {
     int age = 10;
     MyBlock block = ^{
         NSLog(@"age = %d",age);
     };
     age = 18;
     block();
  }
  return 0;
}
複製程式碼

順便說一下,這個輸出:age = 10

在命令列中對這個檔案進行一下處理:

clang -w -rewrite-objc main.m
複製程式碼

或者

xcrun -sdk iphoneos clang -arch arm64 -w -rewrite-objc main.m
複製程式碼

區別是下面指定了SDK和架構程式碼會少一點。

處理完之後會生成一個main.cpp的檔案,開啟後會發現程式碼很多,不要怕。搜尋int main就能看到熟悉的程式碼了。

int main(int argc,const char * argv[])
{
  /* @autoreleasepool */
  { __AtAutoreleasePool __autoreleasepool; 
     int age = 10;
     MyBlock block = ((void (*)())&__main_block_impl_0((void *)__main_block_func_0,&__main_block_desc_0_DATA,age));
     age = 18;
     ((void (*)(__block_impl *))((__block_impl *)block)->FuncPtr)((__block_impl *)block);
  }
  return 0;
}
複製程式碼

下面是main函式中涉及到的一些結構體:

struct __main_block_impl_0 {
  struct __block_impl impl; //block的函式的imp結構體
  struct __main_block_desc_0* Desc; // block的資訊
  int age; // 值引用的age值
  __main_block_impl_0(void *fp,struct __main_block_desc_0 *desc,int _age,int flags=0) : age(_age) {
    impl.isa = &_NSConcreteStackBlock; // 棧型別的block
    impl.Flags = flags;
    impl.FuncPtr = fp; // 傳入了函式具體的imp指標
    Desc = desc;
  }
};

struct __block_impl {
  void *isa; // block的型別:全域性、棧、堆
  int Flags;
  int Reserved;
  void *FuncPtr; // 函式的指標!就是通過它呼叫block的!
};

static struct __main_block_desc_0 { // block的資訊
  size_t reserved;
  size_t Block_size; // block的大小
} __main_block_desc_0_DATA = { 0,sizeof(struct __main_block_impl_0)};
複製程式碼

有了這些資訊,我們再看看

MyBlock block = ((void (*)())&__main_block_impl_0((void *)__main_block_func_0,age));
複製程式碼

可以看到,block初始化的時候age是值傳遞,所以block結構體中age=10,所以列印的是age = 10

2.2 __block說明符

Block中修改捕獲的自動變數有兩種方法:

  • 使用靜態變數、靜態全域性變數、全域性變數

    從Block語法轉化為C語言函式中訪問靜態全域性變數、全域性變數,沒有任何不同,可以直接訪問。而靜態變數使用的是靜態變數的指標來進行訪問。

    自動變數不能採用靜態變數的做法進行訪問。原因是,自動變數是在儲存在棧上的,當超出其作用域時,會被棧釋放。而靜態變數是儲存在堆上的,超出作用域時,靜態變數沒有被釋放,所以還可以訪問。

  • 新增 __block修飾符

    __block儲存域類說明符。儲存域說明符會指定變數儲存的域,如棧auto、堆static、全域性extern,暫存器register。

比如剛剛的程式碼加上 __block說明符:

typedef void (^MyBlock)(void);

int main(int argc,const char * argv[])
{
@autoreleasepool
{
   int __block age = 10;
   MyBlock block = ^{
       age = 18;
   };
   block();
}
return 0;
}
複製程式碼

在命令列中對這個檔案進行一下處理:

xcrun -sdk iphoneos clang -arch arm64 -w -rewrite-objc main.m
複製程式碼

我們看到main函式發生了變化:

  • 原來的age變數:int age = 10;

  • 現在的age變數:__Block_byref_age_0 age = {(void*)0,(__Block_byref_age_0 *)&age,sizeof(__Block_byref_age_0),10};

int main(int argc,const char * argv[])
{
  /* @autoreleasepool */
  { __AtAutoreleasePool __autoreleasepool; 
     __Block_byref_age_0 age = {(void*)0,0,sizeof(__Block_byref_age_0),10};
     MyBlock block = ((void (*)())&__main_block_impl_0((void *)__main_block_func_0,570425344));
     ((void (*)(__block_impl *))((__block_impl *)block)->FuncPtr)((__block_impl *)block);
  }
  return 0;
}
複製程式碼

原來我們知道新增 __block說明符,我們就可以在block裡面修改自動變量了。

恭喜你,現在你達到了第二層!__block說明符,其實會把自動變數包含到一個結構體中。

這也就解釋了問題1為什麼加入__block說明符,token可以正確拿到值。

MyBlock block = ((void (*)())&__main_block_impl_0((void *)__main_block_func_0,570425344));
複製程式碼

這次block初始化的過程中,把age這個結構體傳入到了block結構體中,現在就變成了指標引用

struct __Block_byref_age_0 {
  void *__isa; //isa指標
  __Block_byref_age_0 *__forwarding; // 指向自己的指標
  int __flags; // 標記
  int __size; // 結構體大小
  int age; // 成員變數,儲存age值
};

struct __main_block_impl_0 {
  struct __block_impl impl;
  struct __main_block_desc_0* Desc;
  __Block_byref_age_0 *age; // 結構體指標引用
  __main_block_impl_0(void *fp,__Block_byref_age_0 *_age,int flags=0) : age(_age->__forwarding) {
    impl.isa = &_NSConcreteStackBlock;
    impl.Flags = flags;
    impl.FuncPtr = fp;
    Desc = desc;
  }
};
複製程式碼

我們再來看看block中是如何修改age對應的值:

static void __main_block_func_0(struct __main_block_impl_0 *__cself) {
    __Block_byref_age_0 *age = __cself->age; // 通過結構體的self指標拿到age結構體的指標
    (age->__forwarding->age) = 18; // 通過age結構體指標修改age值
}
複製程式碼

看到這裡可能不明白__forwarding的作用,我們之後再講。現在知道是age是指標引用修改成功的就可以了。

2.3 Block儲存域

從C程式碼中我們可以看到Block的是指是Block結構體例項__block變數實質是棧上__block變數結構體例項。從初始化函式中我們可以看到,impl.isa = &_NSConcreteStackBlock;,即之前我們使用的是棧Block。

其實,Block有3中型別:

  • _NSConcreteGlobalBlock類物件儲存在程式的資料區(.data區)。
  • _NSConcreteStackBlock類物件儲存在棧上。
  • _NSConcreteMallocBlock類物件儲存在堆上。
void (^blk)(void) = ^{
  NSLog(@"Global Block");
};

int main() {
  blk();
  NSLog(@"%@",[blk class]);//列印:__NSGlobalBlock__
}
複製程式碼

全域性Block肯定是儲存在全域性資料區的,但是在函式棧上建立的Block,如果沒有捕獲自動變數,Block的結構例項還是 _NSConcreteGlobalBlock,而不是 _NSConcreteStackBlock

void (^blk0)(void) = ^{ // 沒有截獲自動變數的Block
    NSLog(@"Stack Block");
};
blk0();
NSLog(@"%@",[blk0 class]); // 列印:__NSGlobalBlock__

int i = 1;
void (^blk1)(void) = ^{ // 截獲自動變數i的Block
    NSLog(@"Capture:%d",i);
};
blk1();
NSLog(@"%@",[blk1 class]); // 列印:__NSMallocBlock__
複製程式碼

可以看到沒有捕獲自動變數的Block列印的類是NSGlobalBlock,表示儲存在全域性資料區。 但為什麼捕獲自動變數的Block列印的類卻是設定在堆上的NSMallocBlock,而非棧上的NSStackBlock?這個問題稍後解釋。

設定在棧上的Block,如果超出作用域,Block就會被釋放。若 __block變數也配置在棧上,也會有被釋放的問題。所以, copy方法呼叫時,__block變數也被複制到堆上,同時impl.isa = &_NSConcreteMallocBlock;。複製之後,棧上 __block變數的__forwarding指標會指向堆上的物件。因 此 __block變數無論被分配在棧上還是堆上都能夠正確訪問。

編譯器如何判斷何時需要進行copy操作呢?

在ARC開啟時,自動判斷進行 copy

  • 手動呼叫copy
  • 將Block作為函式引數返回值返回時,編譯器會自動進行 copy
  • 將Block賦值給 copy修飾的id類或者Block型別成員變數,或者__strong修飾的自動變數。
  • 方法名含有usingBlockCocoa框架方法或GCD相關API傳遞Block。

如果不能自動 copy,則需要我們手動呼叫 copy方法將其複製到堆上。比如向不包括上面提到的方法或函式的引數中傳遞Block時。

ARC環境下,返回一個物件時會先將該物件複製給一個臨時例項指標,然後進行retain操作,再返回物件指標。runtime/objc-arr.mm提到,Block的retain操作objc_retainBlock函式實際上是Block_copy函式。在實行retain操作objc_retainBlock後,棧上的Block會被複制到堆上,同時返回堆上的地址作為指標賦值給臨時變數。

2.4 __block變數儲存域

__forwarding

當Block從棧複製到堆上時候,__block變數也被複制到堆上並被Block持有。

  • 若此時 __block變數已經在堆上,則被該Block持有。
  • 若配置在堆上的Block被釋放,則它所持有的 __block變數也會被釋放。
__block int val = 0;
void (^block)(void) = [^{ ++val; } copy];
++val;
block();
複製程式碼

利用 copy操作,Block和 __block變數都從棧上被複制到了堆上。無論是{ ++val; }還是++val;都轉換成了++(val->__forwarding->val);

Block中的變數val為複製到堆上的 __block變數結構體例項,而Block外的變數val則為複製前棧上的 __block變數結構體例項,但這個結構體的__forwarding成員變數指向堆上的 __block變數結構體例項。所以,無論是是在Block內部還是外部使用 __block變數,都可以順利訪問同一個 __block變數。

3. 面試題C程式碼

下面我們看看面試題的C程式碼。

@interface Test : NSObject
@end
@implementation Test
- (void)test_notification {
    NSNotificationCenter *__weak center = [NSNotificationCenter defaultCenter];
    id __block token = [center addObserverForName:@"com.demo.perform.once"
                                           object:nil
                                            queue:[NSOperationQueue mainQueue]
                                       usingBlock:^(NSNotification * _Nonnull note) {
        [self doSomething];
        [center removeObserver:token];
        token = nil;
    }];
}
- (void)doSomething {

}
@end
複製程式碼

3.1 重寫

在命令列中對這個檔案進行一下處理,因為用到了 __weak說明符,需要額外指定一些引數:

xcrun -sdk iphoneos clang -arch arm64 -w -rewrite-objc -fobjc-arc -mios-version-min=8.0.0 -fobjc-runtime=ios-8.0.0 main.m
複製程式碼

這個會更復雜一些,但我們只看重要的部分:

struct __Block_byref_token_0 {
  void *__isa;
__Block_byref_token_0 *__forwarding;
 int __flags;
 int __size;
 void (*__Block_byref_id_object_copy)(void*,void*);
 void (*__Block_byref_id_object_dispose)(void*);
 __strong id token; // id型別的token變數 (strong)
};

struct __Test__test_notification_block_impl_0 {
  struct __block_impl impl;
  struct __Test__test_notification_block_desc_0* Desc;
  Test *const __strong self; // 被捕獲的self (strong)
  NSNotificationCenter *__weak center; // center物件 (weak)
  __Block_byref_token_0 *token; // token結構體的指標
  __Test__test_notification_block_impl_0(void *fp,struct __Test__test_notification_block_desc_0 *desc,Test *const __strong _self,NSNotificationCenter *__weak _center,__Block_byref_token_0 *_token,int flags=0) : self(_self),center(_center),token(_token->__forwarding) {
    impl.isa = &_NSConcreteStackBlock;
    impl.Flags = flags;
    impl.FuncPtr = fp;
    Desc = desc;
  }
};
複製程式碼

現在我們看到block結構體 __Test__test_notification_block_impl_0中持有token,同時之前我們看到token也是持有block的,所以造成了迴圈引用。

這也就回答了問題2。

下面我們看看blockIMP函式是如何解決迴圈引用問題的:

static void __Test__test_notification_block_func_0(struct __Test__test_notification_block_impl_0 *__cself,NSNotification * _Nonnull __strong note) {
    __Block_byref_token_0 *token = __cself->token; // bound by ref
    Test *const __strong self = __cself->self; // bound by copy
    NSNotificationCenter *__weak center = __cself->center; // bound by copy
    
    ((void (*)(id,SEL))(void *)objc_msgSend)((id)self,sel_registerName("doSomething"));
    ((void (*)(id,SEL,id  _Nonnull __strong))(void *)objc_msgSend)((id)center,sel_registerName("removeObserver:"),(id)(token->__forwarding->token));
    (token->__forwarding->token) = __null;
}
複製程式碼

可以看到,token = nil;被轉換為了(token->__forwarding->token) = __null;,相當於block物件對token的持有解除了!如果你覺得看不太明白,我再轉換一下:

(__cself->token->__forwarding->token) = __null; // __cself為block結構體指標
複製程式碼

3.2 Block的型別

細心的同學可能發現:

impl.isa = &_NSConcreteStackBlock;
複製程式碼

這是一個棧型別block呀,宣告週期結束不是就該被系統回收釋放了麼。我們使用了ARC同時我們呼叫是方法名中含有usingBlock,會主動觸發 copy操作,將其複製到堆上。

4. 總結

Block最常問的就是迴圈引用、記憶體洩露問題。

注意要點:

  • __weak說明符的使用
  • __block說明符的使用
  • 誰持有誰
  • 如何解除迴圈引用

另外,需要再強調一下的是:

  • 面試題中的block程式碼如果一次都沒有執行也是會記憶體洩露的!

  • 可能有人會說使用__weak typeof(self) wkSelf = self;就可以解決self不釋放的問題。

    確實這可以解決self不釋放的問題,但是這裡 仍然存在記憶體洩露! 我們還是需要從根上解決這個問題。

補充:

上面講的時候集中在說tokenblock的迴圈引用,ViewController的問題我簡單帶過了,可能同學們看的時候沒有注意到。

我在這裡專門拎出來說一下:

tokenblock迴圈引用,同時block持有self(ViewController),導致ViewController也沒法釋放。

如果希望優先釋放ViewController(不管block是否執行),最好給ViewController加上__weak說明符。

此外,破除tokenblock的迴圈引用,實際有兩種方法:

  • 手動設定token = nil;
  • token也使用__weak說明符id __block __weak token

注意:

以下說法不夠嚴謹,也可能存在問題:

最簡單粗暴的解決辦法:大家都__weak

NSNotificationCenter *__weak wkCenter = [NSNotificationCenter >defaultCenter];
__weak typeof(self) wkSelf = self;
id __block __weak wkToken = [wkCenter addObserverForName:UIApplicationDidEnterBackgroundNotification
                                      object:nil
                                       queue:[NSOperationQueue mainQueue]
                                  usingBlock:^(NSNotification * _Nonnull note) {
   [wkSelf doSomething];
   [wkCenter removeObserver:wkToken];
}];
複製程式碼

這個問題具體要看NSNotificationCenter具體是怎麼實現的。token使用__weak說明符,但是如果NSNotificationCenter沒有持有token,在函式作用域結束時,token會被銷燬。雖然不會有迴圈引用問題,但是可能導致無法移除這個觀察者的問題。

如果覺得本文對你有所幫助,給我點個贊吧~