2015年12月9日 星期三

[iOS] kkbox-ios-dev note Blocks

Block是Cocoa/Cocoa Touch Framework中的匿名函數(Anonymous Functions)的實作。所謂匿名函式,就是一段具有物件性質的程式碼,這一段程式碼可以當作函數執行,
另一方面,又可以當作物件傳遞;因為可以當作物件傳遞,所以可以讓某段程式碼,
變成是某個物件的某個property,或是當做method或是function的參數傳遞,
就是因為這種特性,造成最常使用block的時機就是拿block實作callback。
在有block之前,在Cocoa/Cocoa Touch Framework上要處理callback,
最常見的就是使用delegate(此外也可以使用比較具有C語言風格的方式,傳遞callback function的pointer, 或是使用target/action pattern)。在iOS4有了block之後,
可以看到蘋果自已便大幅改寫了UIKit等Framework的API,把本使用delegate處理
callback的地方,都大幅換成了block。

Block的語法
一直以來還是有不少人不滿block語法,甚至有人成立了一個叫做fuckingblocksyntax.com的網站。這個網站的網域名稱不怎麼優雅,不過裡頭倒是清楚整理了我們應該如何宣告block。

在C#裡頭,我們可能會把所有的callback都叫做event,但是在Cocoa與Cocoa Touch的世界裡頭,我們只會把來自硬體的輸入叫做event。
由於block的主用途,就在於處理callback,所以block經常就用在各種非同步工作的callback上,要執行各種非同步的工作,就往往要使用thred。

什麼時候該用BLock?什麼時候該用Delegate?
即使block可以大幅取代delegate處理callback,但是從蘋果自已的API設計中可以看到,並不是所有的delegate都 被block取代,在Cocoa與Cocoa touch framework
中,仍然大幅度使用delegate。那麼,我們就要問:當我們在設計API的時候,什麼狀況下應該使用block?什麼時候又該使用delegate?

通常的區分方式是:如果一個method或function的呼叫只有單一的callback,
那麼就使用block,如果可能會有多個不同的callback,那麼就使用delegate。

這麼做的好處是:當一個method或function呼叫會有多種callback的時候,很有
可能某些callback是沒有必要實作的。

如果使用delegate實作,那麼,在delegate需要實作的protocol中,我們可以用
@required與@optional關鍵字區分哪些是一定需要實作的delegate method。

但相對的,用block處理callback,就會很難區分某個block是否是必須要實作:
在xcode6.3之前,Objective-C並沒有nullabel、nonnull等關鍵字,讓我們知道
某個property、或某個method要傳入的block可不可以是nil,我們也往往搞不清楚
在這些地方傳入nil,會不會發生什麼危險的事情。

舉個例子。在iOS7之後蘋果鼓勵開發者使用NSURLSession處理網路連線,NSURLSession
就充分表現了「單一callback用block,多重callback用delegate」這一點。
假如我們現在想要把KKBOX的官網首頁抓下來,我們只要建立一個
NSURLSessionDataTask物件,一般來說,我們只需要處理「這個連線做完事情的下一步
該做什麼」,所以一般也只需要實作這個task的completion handler,就是傳入
網路連線結束之後要執行的block;一般連線結束,大概就是成功抓到資料或是連線
失敗兩種狀況,所以我們可以透過data與error這兩物件判斷是哪種狀況:失敗的話error就
不會是nil,我們就要處理error,反之就要處理data。

    NSURL *URL = [NSURL URLWithString:@"http://kkbox.com"];
    NSURLRequest *request = [NSURLRequest requestWithURL:URL];
    NSURLSessionTask *task = [[NSURLSession sharedSession] dataTaskWithRequest:request completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
        if (error) {
            // handle error
            return;
        }

        // handle data
    }];

    [task resume];
但NSURLSession本常還是具有delegate。我們在發送連線的時候,除了處理連線結束
後要做什麼之外,有時候也可能會想處理在連線中途所發生的其他狀況,像是:HTTP
連線收到302轉址,遇到有問題的SSL憑證、server要求用戶輸入帳號密碼,這些狀況
我們要不要提示使用者?或,如果這是一個傳遞大檔、很花時間的連線,我們有沒有必要顯
示連線進度條?這些狀況還是會傳遞給NSURLSession的delegate,而如果我們要處理這些狀況、就要實作以下這些delegate methods。

- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task
                     willPerformHTTPRedirection:(NSHTTPURLResponse *)response
                                     newRequest:(NSURLRequest *)request
                              completionHandler:(void (^)(NSURLRequest * __nullable))completionHandler;
- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task
                            didReceiveChallenge:(NSURLAuthenticationChallenge *)challenge 
                              completionHandler:(void (^)(NSURLSessionAuthChallengeDisposition disposition, NSURLCredential * __nullable credential))completionHandler;
- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task
                              needNewBodyStream:(void (^)(NSInputStream * __nullable bodyStream))completionHandler;

_block關鍵字
在一個block裡頭如果使用了在block之外的變數,會將這份變數先複製一份再使用,
也就是說,在沒有特別宣告的狀況下,對我們目前所在的block來說,所有外部物件的
變數都是唯讀,只能讀取不能變更。至於block裡頭用到的objective-C物件,則都會
被多retain一次。

如果我們想要讓某個block可以改動某個外部的變數,我們就要在這個需要可以被block
改動的變數前面,加上_block關鍵字。
這樣是不合法的程式:

    int i = 1;
    void (^block)(void) = ^{
        i = i + 1;
    };

應該寫成:
    __block int i = 1;
    void (^block)(void) = ^{
        i = i + 1;
    };

__weak關鍵字
在使用了block之後,記憶體管理會變得非常複雜,所以最好是在開啟了ARC自動記憶體
管理之後再使用block。不過,即使開啟了ARC,還是可能會遇到循環retain的問題。
由於block中用到的Objective-C物件都會被多retain一次,這邊所指的Objective-C物件
也包含self,所以,即使有個物件的property是一個block,而這個block裡頭又用到了
self,就會遇到循環retain而無法釋放記憶體的問題:self要被釋放才會去釋放這個property
,但是這個property作為block又retain了self導致self無法被釋放。

下面這段code就有循環retain的問題:
@interface MyClass : NSObject
- (void)doSomething;
@property (nonatomic, copy) void (^myBlock)(void);
@end

@implementation MyClass

- (instancetype)init {
 self = [super init];
 if (self) {
  self.myBlock = ^ {
   [self doSomething];
  };
 }
}

- (void)doSomething {
}
@end
如果我們不想讓self被myBlock給retain起來,我們就要把self變成weak reference
再傳入到block中。像是改成這樣:
__weak MyClass *weakSelf = self;
self.myBlock = ^ {
 [weakSelf doSomething];
}

Block作為Objective-C物件
前面提到Block其實可以當成是一種物件,我們接下來就要來看block作為物件的特性。

記憶體管理
由於Objective-C物件需要做記憶體管理,因此,如果你還在手動管理記體,在建立
block的時候,也必須手動管理Block的記憶體。我們可以使用Block_Copy()和
Block_Release()這兩個C Function對block做copy或release或,我們也可以把block
給cast成id型別,便可以對block呼叫copy與release。不過在啟用ARC之,我們便不需要
,Compiler也會阻止我們手動管理記憶體。

Block的型別
Objective-C當中每個物件都具有class,而每個class都繼承自NSObject,由於
block具有物件的性質,因此block本身也有class。不過,一個block是屬於那一種
class平時對我們來說並不會有太大的意義,畢竟我們在建立block的時候,並不會指定要
建立那一種class的block,我們也不會去subclass某種block的class。基本上,當我們
寫好一個block之後,這個block最後會變成那個class,全部都是由compiler決定。

在C語言當中,記憶體分成三塊:global、stack與heap,compier在編譯程式碼
的時候,會根據我們所寫出來的block到底使用到那一塊記憶體,將這個block變成不同的class,包括__NSGlobalBlock__、__NSStackBlock__與__NSMallocBlock__。知道這件事情通常對我們不會有什麼幫助、但是可以讓我們了解蘋果的compiler曾經發生過的bug:在某個狀況下,有個block應該要使用global的記憶體,但是compiler卻誤判成只使用stack的記憶體。

如果沒有開啟ARC,以下這段程式碼會在執行到block()這一行的時候,發生Bad Access錯誤:

- (NSArray *)blocks {
 int i = 1;
 return @[^{return i;}];
}

- (void)callBlock {
 int (^block)(void) = [self blocks][0];
 block();
}

原因是:在-blocks所回傳的NSArray中所包含的block物件中,使用到了i這個只出現在blocks這個 method內部的int變數,因為這個變數只在這個method中使用,
compiler便認為i應該使用stack的記憶體, 因此也把回傳的block建立成__NSMallocBlock__;於是當我們在-callBlock裡頭呼叫block()的時候,
原本的記憶體已經被釋放,於是產生記憶體管理錯誤。

那些事情不要拿Block來做

在很多狀況下,使用block相當方便,但由於因為block的記憶體管理問題,
有些事情使用block反而相當痛苦,就我個人而言,最痛苦的經驗應該就是拿block寫
遞迴。舉個例子,假如要使用block來寫一個費式數列,可能會寫這樣。

int (^fibs)(int) = ^(int n) {
 if (n == 0) {
  return 0;
 }
 if (n == 1) {
  return 1; 
 }

 return fibs(n-1) + fibs(n-2);
}

看起來好像沒有問題,但是一執行就會馬上crash










原因是在fibs這個Block的scope裡頭,fibs這個變數被指向NULL。一般來說,
對nil物件做任何Objective-C呼叫都沒事,但是如果一個Block變數指向NULL,
一呼叫就會因為Bad Access錯誤而crash。而當你寫出這段一定會crash的程式
compiler也都不會發出警告...誰說用了ARC就不會有記憶體管理問題呢?

那麼,我們在fibs前面加上個__block看看?

__block int (^fibs)(int) = ^(int n) {
 if (n == 0) {
  return 0,
 }
 if (n == 1) {
  return 1,
 }
 return fibs(n-1) + fibs(n-2)
}
結果這樣的程式會因為循環retain而造成記憶體漏水














這段Code要寫成這樣才不會有問題:
__block int (^fibs)(int) = ^(int n) {
 if (n == 0) {
  return 0,
 }
 if (n == 1) {
  return 1,
 }
 return fibs(n-1) + fibs(n-2)
}
fibs = fibs_;















Callback Hell

Block通常用在處理callback,而我們往往會在callback裡頭又做另外一件事情,
而這件事情又可能有另外一個callback,而這個callback裡頭要做的事情又
有另外一個callback....於是,我們可能會寫出這種深度非常深的程式碼:

[some object doSomethingWithCallback:^{
 [some object doSomethingWithCallback:^{
  [some object doSomethingWithCallback:^{
   [some object doSomethingWithCallback:^{
     // do something
   }];
  }];
 }];
}];
這種狀況我們稱之為Callback Hell--無限延長的Callback地獄,這種現象
除了會出現在Objective-C的block之外,也出現在各式各樣的程式語言中,
尤其是在JavaScript開發中,特別常討論Callback Hell。為了要解決Callback Hell,
從ECMAScript 6開始,就改成使用promise的寫法處理Callback,而像是
Parse的Bolts Framework,便是將Promis從JavaScript port到Objective-C中。

使用了Botls Framework之後,我們可以將各種要非同步執行的工作,包裝成
BFTask物件,我們便可以將上面那段code,改寫成這個樣子:
[[[[someObject doSomething] continueWithBlock:^id(BFTask *task)]{
 return [someObject doSomething];
}] continueWithBlock:^id(BFTask *task) {
 return [someObject doSomething];
}] continueWithBlock:^id(BFTask *task) {
 return nil;
}];

雖然一樣是callback之間不斷串連,但是在程式碼中,我們把callback之間從不斷加涫的關系, 變成了不斷往下的關系,也因此變得好讀。

沒有留言:

張貼留言