细说ReactiveCocoa的冷信号与热信号(二):为什么要区分冷热信号
前一篇文章我們介紹了冷信號與熱信號的概念,可能有同學會問了,為什么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<RACSubscriber> 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];}];不知道大家有沒有被這么一大段的代碼嚇到,我想要表達的是,在真正的工程中,我們的業務邏輯是很復雜的,而一些坑就隱藏在如此看似復雜但是又很合理的代碼之下。所以我盡量模擬了一些需求,使得代碼看起來更豐富。下面我們還是來仔細看下這段代碼的邏輯吧:
這些代碼體現了RAC的一些優勢,例如良好的錯誤處理和各種鏈式處理。很不錯,對不對?但是很遺憾的告訴大家,這段代碼其實有很嚴重的錯誤。
如果你去嘗試運行這段代碼,并且打開Charles查看,你會驚奇的發現,這個網絡請求發送了6次。沒錯,是6次請求。我們也可以想象到類似的代碼存在其他副作用的問題,重新刷新了6次屏幕,寫入6次文件,發了6個全局通知。
下面來分析,為什么是6次網絡請求呢?首先根據上面的知識,可以推斷出名為fetchData信號是一個冷信號。那么這個信號在訂閱的時候就會執行里面的過程。那這個信號是在什么時候被訂閱了呢?仔細回看了代碼,我們發現并沒有訂閱這個信號,只是調用這個信號的flattenMap產生了兩個新的信號。
這里有一個很重要的概念,就是任何的信號轉換即是對原有的信號進行訂閱從而產生新的信號。由此我們可以寫出flattenMap的偽代碼如下:
- (instancetype)flattenMap_:(RACStream * (^)(id value))block { {return [RACSignal createSignal:^RACDisposable *(id<RACSubscriber> 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這個框架,區分冷信號與熱信號是十分重要的。接下來的系列第三篇文章,我會揭示冷信號與熱信號的本質,幫助大家正確的理解冷信號與熱信號。
總結
以上是生活随笔為你收集整理的细说ReactiveCocoa的冷信号与热信号(二):为什么要区分冷热信号的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Leaf:美团分布式ID生成服务开源
- 下一篇: Spring Cloud源码分析(一)E