細說 ReactiveCocoa 的冷訊號與熱訊號(2):為什麼要區分

發表於2015-10-23

前一篇文章我們介紹了冷訊號與熱訊號的概念,可能有同學會問了,為什麼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在實際的使用中也不可避免地要接觸副作用。下面通過一個業務場景,來看看冷訊號中副作用的坑:

不知道大家有沒有被這麼一大段的程式碼嚇到,我想要表達的是,在真正的工程中,我們的業務邏輯是很複雜的,而一些坑就隱藏在如此看似複雜但是又很合理的程式碼之下。所以我儘量模擬了一些需求,使得程式碼看起來更豐富。下面我們還是來仔細看下這段程式碼的邏輯吧:

  1. 建立了一個AFHTTPSessionManager用來做網路介面的資料獲取。
  2. 建立了一個名為fetchData的訊號來通過網路獲取資訊。
  3. 建立一個名為title的訊號從獲取的data中取得title欄位,如果沒有該欄位則反饋一個錯誤。
  4. 建立一個名為desc的訊號從獲取的data中取得desc欄位,如果沒有該欄位則反饋一個錯誤。
  5. 針對desc這個訊號做一個渲染,得到一個名為renderedDesc的新訊號,該訊號會在渲染失敗的時候反饋一個錯誤。
  6. title訊號所有的錯誤轉換為字串@"Error"並且在沒有獲取值之前以字串@"Loading..."佔位,之後與self.someLableltext屬性繫結。
  7. desc訊號所有的錯誤轉換為字串@"Error"並且在沒有獲取值之前以字串@"Loading..."佔位,之後與self.originTextViewtext屬性繫結。
  8. renderedDesc訊號所有的錯誤轉換為屬性字串@"Error"並且在沒有獲取值之前以屬性字串@"Loading..."佔位,之後與self.renderedTextViewtext屬性繫結。
  9. 訂閱titledescrenderedDesc這三個訊號的任何錯誤,並且彈出UIAlertView

這些程式碼體現了RAC的一些優勢,例如良好的錯誤處理和各種鏈式處理。很不錯,對不對?但是很遺憾的告訴大家,這段程式碼其實有很嚴重的錯誤。

如果你去嘗試執行這段程式碼,並且開啟Charles檢視,你會驚奇的發現,這個網路請求傳送了6次。沒錯,是6次請求。我們也可以想象到類似的程式碼存在其他副作用的問題,重新重新整理了6次螢幕,寫入6次檔案,發了6個全域性通知。

下面來分析,為什麼是6次網路請求呢?首先根據上面的知識,可以推斷出名為fetchData訊號是一個冷訊號。那麼這個訊號在訂閱的時候就會執行裡面的過程。那這個訊號是在什麼時候被訂閱了呢?仔細回看了程式碼,我們發現並沒有訂閱這個訊號,只是呼叫這個訊號的flattenMap產生了兩個新的訊號。

這裡有一個很重要的概念,就是任何的訊號轉換即是對原有的訊號進行訂閱從而產生新的訊號。由此我們可以寫出flattenMap的虛擬碼如下:

除了沒有高度複用和缺少一些disposable的處理以外,上述程式碼大致可以比較直觀地說明flattenMap的機制。觀察會發現其實是在呼叫這個方法的時候,生成了一個新的訊號,並在這個新訊號的執行過程中對self進行的了訂閱。還需要注意一個細節,就是這個返回訊號在未來訂閱的時候,才會間接的訂閱self。後續的startWithcatchTo等都可以這樣理解。

回到我們的問題,那就是說,在fetchDataflattenMap之後,它就會因為名為titledesc訊號的訂閱而訂閱。而後續對desc也會進行flattenMap,得到了renderedDesc,因此未來renderedDesc被訂閱的時候,fetchData也會被間接訂閱。這就解釋了,為什麼後續我們用RAC巨集進行繫結的時候,fetchData會訂閱3次。由於fetchData是冷訊號,所以3次訂閱意味著它的過程被執行了3次,也就是有3次網路請求。

另外的3次訂閱來自RACSignal類的merge方法。根據上述的描述,我們也可以猜測merge方法也一定是建立了一個新的訊號,在這個訊號被訂閱的時候,把它包含的所有訊號訂閱。所以我們又得到了額外的3次網路請求。

由此可以看到,不熟悉冷熱訊號對業務造成的影響。我們可以想象對使用者流量的影響,對伺服器負載的影響,對統計的影響,如果這是一個點讚的介面,會不會造成多次點贊?後果不堪設想啊。而這些都可以通過將fetchData轉換為熱訊號來解決。

接下來也許你會問,如果我的整個計算過程中都沒有副作用,是否就不會有這個問題?答案是肯定的。試想下剛才那段程式碼如果沒有網路請求,換成一些標準化的計算會怎樣。雖然可以肯定它不會出現bug,但是不要忽視其中的運算也會執行多次。純函式還有一個概念就是引用透明)。在純函式式語言(例如Haskell)中對此可以進行一定的優化,也就是說純函式的呼叫在相同引數下的返回值第二次不需要計算,所以在純函式式語言裡面的FRP並沒有冷訊號的擔憂。然而Objective-C語言中並沒有這種純函式優化,因此有大規模運算的冷訊號對效能是有一定影響的。

從上文內容可以看出,如果我們想更好地掌握RAC這個框架,區分冷訊號與熱訊號是十分重要的。接下來的系列第三篇文章,我會揭示冷訊號與熱訊號的本質,幫助大家正確的理解冷訊號與熱訊號。

相關文章