前一篇文章我們介紹了冷信號與熱信號的概念,可能有同學會問了,為什麼RAC要搞得如此復雜呢,只用一種信號不就行了麼?要解釋這個問題,需要繞一些圈子。
前面可能比較難懂,如果不能很好理解,請仔細閱讀相關文檔。
最前面提到了RAC是一套基於Cocoa的FRP框架,那就來說說FRP吧。FRP的全稱是Functional Reactive Programming,中文譯作函數式響應式編程,是RP(Reactive Programm,響應式編程)的FP(Functional Programming,函數式編程)實現。說起來很拗口。太多的細節不多討論,我們著重關注下FRP的FP特征。
FP有個很重要的概念是和我們的主題相關的,那就是純函數。
純函數就是返回值只由輸入值決定、而且沒有可見的副作用)的函數或者表達式。這和數學中的函數是一樣的,比如:
f(x) = 5x + 1
這個函數在調用的過程中除了返回值以外的沒有任何對外界的影響,除了入參x以外也不受任何其他外界因素的影響。
那麼副作用都有哪些呢?我來列舉以下幾個情況:
函數的處理過程中,修改了外部的變量,例如全局變量。一個特殊點的例子,就是如果把OC的一個方法看做一個函數,所有的成員變量的賦值都是對外部變量的修改。是的,從FP的角度看OOP是充滿副作用的。
函數的處理過程中,觸發了一些額外的動作,例如發送了一個全局的Notification,在console裡面輸出了一行信息,保存了文件,觸發了網絡,更新了屏幕等。
函數的處理過程中,受到外部變量的影響,例如全局變量,方法裡面用到的成員變量。注意block中捕獲的外部變量也算副作用。
函數的處理過程中,受到線程鎖的影響算副作用。
由此我們可以看出,在目前的iOS編程中,我們是很難擺脫副作用的。甚至可以這麼說,我們iOS編程的目的其實就是產生各種副作用。(基於用戶觸摸的外界因素,最終反饋到網絡變化和屏幕變化上。)
接下來我們來分析副作用與冷熱信號的關系。既然iOS編程中少不了副作用,那麼RAC在實際的使用中也不可避免地要接觸副作用。下面通過一個業務場景,來看看冷信號中副作用的坑:
self.sessionManager = [[AFHTTPSessionManager alloc] initWithBaseURL:[NSURL URLWithString:@"http://api.xxxx.com"]]; self.sessionManager.requestSerializer = [AFJSONRequestSerializer serializer]; self.sessionManager.responseSerializer = [AFJSONResponseSerializer serializer]; @weakify(self) RACSignal *fetchData = [RACSignal createSignal:^RACDisposable *(id subscriber) { @strongify(self) NSURLSessionDataTask *task = [self.sessionManager GET:@"fetchData" parameters:@{@"someParameter": @"someValue"} success:^(NSURLSessionDataTask *task, id responseObject) { [subscriber sendNext:responseObject]; [subscriber sendCompleted]; } failure:^(NSURLSessionDataTask *task, NSError *error) { [subscriber sendError:error]; }]; return [RACDisposable disposableWithBlock:^{ if (task.state != NSURLSessionTaskStateCompleted) { [task cancel]; } }]; }]; RACSignal *title = [fetchData flattenMap:^RACSignal *(NSDictionary *value) { if ([value[@"title"] isKindOfClass:[NSString class]]) { return [RACSignal return:value[@"title"]]; } else { return [RACSignal error:[NSError errorWithDomain:@"some error" code:400 userInfo:@{@"originData": value}]]; } }]; RACSignal *desc = [fetchData flattenMap:^RACSignal *(NSDictionary *value) { if ([value[@"desc"] isKindOfClass:[NSString class]]) { return [RACSignal return:value[@"desc"]]; } else { return [RACSignal error:[NSError errorWithDomain:@"some error" code:400 userInfo:@{@"originData": value}]]; } }]; RACSignal *renderedDesc = [desc flattenMap:^RACStream *(NSString *value) { NSError *error = nil; RenderManager *renderManager = [[RenderManager alloc] init]; NSAttributedString *rendered = [renderManager renderText:value error:&error]; if (error) { return [RACSignal error:error]; } else { return [RACSignal return:rendered]; } }]; RAC(self.someLablel, text) = [[title catchTo:[RACSignal return:@"Error"]] startWith:@"Loading..."]; RAC(self.originTextView, text) = [[desc catchTo:[RACSignal return:@"Error"]] startWith:@"Loading..."]; RAC(self.renderedTextView, attributedText) = [[renderedDesc catchTo:[RACSignal return:[[NSAttributedString alloc] initWithString:@"Error"]]] startWith:[[NSAttributedString alloc] initWithString:@"Loading..."]]; [[RACSignal merge:@[title, desc, renderedDesc]] subscribeError:^(NSError *error) { UIAlertView *alertView = [[UIAlertView alloc] initWithTitle:@"Error" message:error.domain delegate:nil cancelButtonTitle:@"OK" otherButtonTitles:nil]; [alertView show]; }];
不知道大家有沒有被這麼一大段的代碼嚇到,我想要表達的是,在真正的工程中,我們的業務邏輯是很復雜的,而一些坑就隱藏在如此看似復雜但是又很合理的代碼之下。所以我盡量模擬了一些需求,使得代碼看起來更豐富。下面我們還是來仔細看下這段代碼的邏輯吧:
創建了一個AFHTTPSessionManager用來做網絡接口的數據獲取。
創建了一個名為fetchData的信號來通過網絡獲取信息。
創建一個名為title的信號從獲取的data中取得title字段,如果沒有該字段則反饋一個錯誤。
創建一個名為desc的信號從獲取的data中取得desc字段,如果沒有該字段則反饋一個錯誤。
針對desc這個信號做一個渲染,得到一個名為renderedDesc的新信號,該信號會在渲染失敗的時候反饋一個錯誤。
把title信號所有的錯誤轉換為字符串@"Error"並且在沒有獲取值之前以字符串@"Loading..."占位,之後與self.someLablel的text屬性綁定。
把desc信號所有的錯誤轉換為字符串@"Error"並且在沒有獲取值之前以字符串@"Loading..."占位,之後與self.originTextView的text屬性綁定。
把renderedDesc信號所有的錯誤轉換為屬性字符串@"Error"並且在沒有獲取值之前以屬性字符串@"Loading..."占位,之後與self.renderedTextView的text屬性綁定。
訂閱title、desc、renderedDesc這三個信號的任何錯誤,並且彈出UIAlertView。
這些代碼體現了RAC的一些優勢,例如良好的錯誤處理和各種鏈式處理。很不錯,對不對?但是很遺憾的告訴大家,這段代碼其實有很嚴重的錯誤。
如果你去嘗試運行這段代碼,並且打開Charles查看,你會驚奇的發現,這個網絡請求發送了6次。沒錯,是6次請求。我們也可以想象到類似的代碼存在其他副作用的問題,重新刷新了6次屏幕,寫入6次文件,發了6個全局通知。
下面來分析,為什麼是6次網絡請求呢?首先根據上面的知識,可以推斷出名為fetchData信號是一個冷信號。那麼這個信號在訂閱的時候就會執行裡面的過程。那這個信號是在什麼時候被訂閱了呢?仔細回看了代碼,我們發現並沒有訂閱這個信號,只是調用這個信號的flattenMap產生了兩個新的信號。
這裡有一個很重要的概念,就是任何的信號轉換即是對原有的信號進行訂閱從而產生新的信號。由此我們可以寫出flattenMap的偽代碼如下:
- (instancetype)flattenMap_:(RACStream * (^)(id value))block { { return [RACSignal createSignal:^RACDisposable *(id subscriber) { return [self subscribeNext:^(id x) { RACSignal *signal = (RACSignal *)block(x); [signal subscribeNext:^(id x) { [subscriber sendNext:x]; } error:^(NSError *error) { [subscriber sendError:error]; } completed:^{ [subscriber sendCompleted]; }]; } error:^(NSError *error) { [subscriber sendError:error]; } completed:^{ [subscriber sendCompleted]; }]; }]; }
除了沒有高度復用和缺少一些disposable的處理以外,上述代碼大致可以比較直觀地說明flattenMap的機制。觀察會發現其實是在調用這個方法的時候,生成了一個新的信號,並在這個新信號的執行過程中對self進行的了訂閱。還需要注意一個細節,就是這個返回信號在未來訂閱的時候,才會間接的訂閱self。後續的startWith、catchTo等都可以這樣理解。
回到我們的問題,那就是說,在fetchData被flattenMap之後,它就會因為名為title和desc信號的訂閱而訂閱。而後續對desc也會進行flattenMap,得到了renderedDesc,因此未來renderedDesc被訂閱的時候,fetchData也會被間接訂閱。這就解釋了,為什麼後續我們用RAC宏進行綁定的時候,fetchData會訂閱3次。由於fetchData是冷信號,所以3次訂閱意味著它的過程被執行了3次,也就是有3次網絡請求。
另外的3次訂閱來自RACSignal類的merge方法。根據上述的描述,我們也可以猜測merge方法也一定是創建了一個新的信號,在這個信號被訂閱的時候,把它包含的所有信號訂閱。所以我們又得到了額外的3次網絡請求。
由此可以看到,不熟悉冷熱信號對業務造成的影響。我們可以想象對用戶流量的影響,對服務器負載的影響,對統計的影響,如果這是一個點贊的接口,會不會造成多次點贊?後果不堪設想啊。而這些都可以通過將fetchData轉換為熱信號來解決。
接下來也許你會問,如果我的整個計算過程中都沒有副作用,是否就不會有這個問題?答案是肯定的。試想下剛才那段代碼如果沒有網絡請求,換成一些標准化的計算會怎樣。雖然可以肯定它不會出現bug,但是不要忽視其中的運算也會執行多次。純函數還有一個概念就是引用透明)。在純函數式語言(例如Haskell)中對此可以進行一定的優化,也就是說純函數的調用在相同參數下的返回值第二次不需要計算,所以在純函數式語言裡面的FRP並沒有冷信號的擔憂。然而Objective-C語言中並沒有這種純函數優化,因此有大規模運算的冷信號對性能是有一定影響的。
從上文內容可以看出,如果我們想更好地掌握RAC這個框架,區分冷信號與熱信號是十分重要的。接下來的系列第三篇文章,我會揭示冷信號與熱信號的本質,幫助大家正確的理解冷信號與熱信號。