『狀態』驅動的世界:ReactiveCocoa

Draveness發表於2019-02-25

這篇以及之後的文章主要會對 ReactiveObjc v2.5 的實現進行分析,從最簡單的例子中瞭解 ReactiveCocoa 的工作原理以及概念,也是筆者個人對於 RAC 學習的總結與理解。本文主要會圍繞 RAC 中核心概念 RACSignal 展開,詳細瞭解其底層實現。

狀態驅動

2015 年的夏天的時候,做了幾個簡單的開源框架,想做點其它更有意思的框架卻沒什麼思路,就開始看一些跟程式設計沒有太大關係的書籍。

112017-01-30-out-of-control

其中一本叫做《失控》給了我很大的啟發,其中有一則故事是這樣的:

布魯克斯開展了一個雄心勃勃的研究生課題專案,研發更接近昆蟲而非恐龍的機器人。

布魯克斯的設想在一個叫「成吉思」的機巧裝置上成形。成吉思有橄欖球大小,像只蟑螂似的。布魯克斯把他的精簡理念發揮到了極致。小成吉思有 6 條腿卻沒有一丁點兒可以稱為「腦」的東西。所有 12 個電機和 21 個感測器分佈在沒有中央處理器的可解耦網路上。然而這 12 個充當肌肉的電機和 21 個感測器之間的互動作用居然產生了令人驚歎的複雜性和類似生命體的行為。

成吉思的每條小細腿都在自顧自地工作,和其餘的腿毫無關係。每條腿都通過自己的一組神經元——一個微型處理器——來控制其動作。每條腿只需管好自己!對成吉思來說,走路是一個團隊合作專案,至少有六個小頭腦在工作。它體內其餘更微小的腦力則負責腿與腿之間的通訊。昆蟲學家說這正是螞蟻和蟑螂的解決之道——這些爬行昆蟲的足肢上的神經元負責為該足肢進行思考。

—— 《失控》第三章·第二節 快速、廉價、失控

書中對於機器人的介紹比較冗長,在這裡就簡單總結一下:機器人的每一條腿都單獨進行工作,通過感測器感應的狀態做出響應:

  • 如果腿抬起來了,那麼它要落下去;
  • 如果腿在向前動,要讓另外五條腿距離它遠一點;

這種去中心化的方式,簡化了整個系統的構造,使得各個元件只需要關心狀態,以及狀態對應的動作;不再需要一箇中樞系統來組織、管理其它的元件,並負責大多數的業務邏輯。這種自底向下的、狀態驅動的構建方式能夠使用多個較小的元件,減少臃腫的中樞出現的可能性,從而降低系統的複雜度。

ReactiveCocoa 與訊號

ReactiveCocoa 對於狀態的理解與《失控》一書中十分類似,將原有的各種設計模式,包括代理、Target/Action、通知中心以及觀察者模式各種『輸入』,都抽象成了訊號(也可以理解為狀態流)讓單一的元件能夠對自己的響應動作進行控制,簡化了檢視控制器的負擔。

在 ReactiveCocoa 中最重要的訊號,也就是 RACSignal 物件是這一篇文章介紹的核心;文章中主要會介紹下面的程式碼片段出現的內容:

在上述程式碼執行時,會在控制檯中列印出以下內容:

程式碼片段基本都是圍繞 RACSignal 類進行的,文章會分四部分對上面的程式碼片段的工作流程進行簡單的介紹:

  • 簡單瞭解 RACSignal
  • 訊號的建立
  • 訊號的訂閱與傳送
  • 訂閱的回收過程

RACSignal 簡介

RACSignal 其實是抽象類 RACStream 的子類,在整個 ReactiveObjc 工程中有另一個類 RACSequence 也繼承自抽象類 RACStream

122017-01-30-RACSignal-Hierachy

RACSignal 可以說是 ReactiveCocoa 中的核心類,也是最重要的概念,整個框架圍繞著 RACSignal 的概念進行組織,對 RACSignal 最簡單的理解就是它表示一連串的狀態:

132017-01-30-What-is-RACSignal

在狀態改變時,對應的訂閱者 RACSubscriber 就會收到通知執行相應的指令,在 ReactiveCocoa 的世界中所有的訊息都是通過訊號的方式來傳遞的,原有的設計模式都會簡化為一種模型,這篇文章作為 ReactiveCocoa 系列的第一篇文章並不會對這些問題進行詳細的展開和介紹,只會對 RACSignal 使用過程的原理進行簡單的分析。

這一小節會對 RACStream 以及 RACSignal 中與 RACStream 相關的部分進行簡單的介紹。

RACStream

RACStream 作為抽象類本身不提供方法的實現,其實現內部原生提供的而方法都是抽象方法,會在呼叫時直接丟擲異常:

142017-01-30-RACStream-AbstractMethod

上面的這些抽象方法都需要子類覆寫,不過 RACStreamOperations 分類中使用上面的抽象方法提供了豐富的內容,比如說 -flattenMap: 方法:

其他方法比如 -skip:-take:-ignore: 等等例項方法都構建在這些抽象方法之上,只要子類覆寫了所有抽象方法就能自動獲得所有的 Operation 分類中的方法。

152017-01-30-RACStream-Operation

RACSignal 與 Monad

如果你對 Monad 有所瞭解,那麼你應該知道 bindreturn 其實是 Monad 中的概念,但 Monad 並不是本篇文章所覆蓋的內容,並不會具體解釋它到底是什麼。

ReactiveCocoa 框架中借鑑了很多其他平臺甚至語言中的概念,包括微軟中的 Reactive Extension 以及 Haskell 中的 Monad,RACStream 提供的抽象方法中的 +return:-bind: 就與 Haskell 中 Monad 完全一樣。

很多人都說 Monad 只是一個自函子範疇上的一個么半群而已;在筆者看來這種說法雖然是正確的,不過也很扯淡,這句話解釋了還是跟沒解釋一樣,如果有人再跟你用這句話解釋 Monad,我覺得你最好的迴應就是買一本範疇論糊他一臉。如果真的想了解 Haskell 中的 Monad 到底是什麼?可以從程式碼的角度入手,多寫一些程式碼就明白了,這個概念理解起來其實根本沒什麼困難的,當然也可以看一下 A Fistful of Monads,寫寫其中的程式碼,會對 Monad 有自己的認知,當然,請不要再寫一篇解釋 Monad 的教程了(手動微笑)。

首先來看一下 +return 方法的 實現

該方法接受一個 NSObject 物件,並返回一個 RACSignal 的例項,它會將一個 UIKit 世界的物件 NSObject 轉換成 ReactiveCocoa 中的 RACSignal

162017-01-30-RACSignal-Return

RACReturnSignal 也僅僅是把 NSObject 物件包裝一下,並沒有做什麼複雜的事情:

但是 -bind: 方法的 實現 相比之下就十分複雜了:

筆者在這裡對 -bind: 方法進行了大量的省略,省去了其中對各種 RACDisposable 的處理過程。

-bind: 方法會在原訊號每次發出訊息時,都執行 RACSignalBindBlock 對原有的訊號中的訊息進行變換生成一個新的訊號:

172017-01-30-RACSignal-Bind

在原有的 RACSignal 物件上呼叫 -bind: 方法傳入 RACSignalBindBlock,圖示中的右側就是具體的執行過程,原訊號在變換之後變成了新的藍色的 RACSignal 物件。

RACSignalBindBlock 可以簡單理解為一個接受 NSObject 物件返回 RACSignal 物件的函式:

其函式簽名可以理解為 id -> RACSignal,然而這種函式是無法直接對 RACSignal 物件進行變換的;不過通過 -bind: 方法就可以使用這種函式操作 RACSignal,其實現如下:

  1. RACSignal 物件『解包』出 NSObject 物件;
  2. NSObject 傳入 RACSignalBindBlock 返回 RACSignal

如果在不考慮 RACSignal 會發出錯誤或者完成訊號時,-bind: 可以簡化為更簡單的形式:

呼叫 -subscribeNext: 方法訂閱當前訊號,將訊號中的狀態解包,然後將原訊號中的狀態傳入 bindingBlock 中並訂閱返回的新的訊號,將生成的新狀態 x 傳回原訊號的訂閱者。

這裡通過兩個簡單的例子來了解 -bind: 方法的作用:

上面的程式碼中直接使用了 +return: 方法將 value 打包成了 RACSignal * 物件:

182017-01-30-Before-After-Bind-RACSignal

在 BindSignal 中的每一個數字其實都是由一個 RACSignal 包裹的,這裡沒有畫出,在下一個例子中,讀者可以清晰地看到其中的區別。

上圖簡要展示了變化前後的訊號中包含的狀態,在執行上述程式碼時,會在終端中列印出:

這是一個最簡單的例子,直接使用 -return: 打包 NSObject 返回一個 RACSignal,接下來用一個更復雜的例子來幫助我們更好的瞭解 -bind: 方法:

下圖相比上面例子中的圖片更能精確的表現出 -bind: 方法都做了什麼:

192017-01-30-Before-After-Bind-RACSignal-Complicated

訊號中原有的狀態經過 -bind: 方法中傳入 RACSignalBindBlock 的處理實際上返回了多個 RACSignal

在原始碼的註釋中清楚地寫出了方法的實現過程:

  1. 訂閱原訊號中的值;
  2. 將原訊號發出的值傳入 RACSignalBindBlock 進行轉換;
  3. 如果 RACSignalBindBlock 返回一個訊號,就會訂閱該訊號並將訊號中的所有值傳給訂閱者 subscriber
  4. 如果 RACSignalBindBlock 請求終止訊號就會向訊號發出 -sendCompleted 訊息;
  5. 所有訊號都完成時,會向訂閱者傳送 -sendCompleted
  6. 無論何時,如果訊號發出錯誤,都會向訂閱者傳送 -sendError: 訊息。

如果想要了解 -bind: 方法在執行的過程中是如何處理訂閱的清理和銷燬的,可以閱讀文章最後的 -bind: 中對訂閱的銷燬 部分。

訊號的建立

訊號的建立過程十分簡單,-createSignal: 是推薦的建立訊號的方法,方法其實只做了一次轉發:

該方法其實只是建立了一個 RACDynamicSignal 例項並儲存了傳入的 didSubscribe 程式碼塊,在每次有訂閱者訂閱當前訊號時,都會執行一遍,向訂閱者傳送訊息。

RACSignal 類簇

雖然 -createSignal: 的方法簽名上返回的是 RACSignal 物件的例項,但是實際上這裡返回的是 RACDynamicSignal,也就是 RACSignal 的子類;同樣,在 ReactiveCocoa 中也有很多其他的 RACSignal 子類。

使用類簇的方式設計的 RACSignal 在建立例項時可能會返回 RACDynamicSignalRACEmptySignalRACErrorSignalRACReturnSignal 物件:

202017-01-30-RACSignal-Subclasses

其實這幾種子類並沒有對原有的 RACSignal 做出太大的改變,它們的建立過程也不是特別的複雜,只需要呼叫 RACSignal 不同的類方法:

212017-01-30-RACSignal-Instantiate-Object

RACSignal 只是起到了一個代理的作用,最後的實現過程還是會指向對應的子類:

RACReturnSignal 的建立過程為例:

這個訊號的建立過程和 RACDynamicSignal 的初始化過程一樣,都非常簡單;只是將傳入的 value 簡單儲存一下,在有其他訂閱者 -subscribe: 時,向訂閱者傳送 value

RACEmptySignalRACErrorSignal 的建立過程也異常的簡單,只是對傳入的資料進行簡單的儲存,然後在訂閱時傳送出來:

這兩個建立過程的唯一區別就是一個傳送的是『空值』,另一個是 NSError 物件。

訊號的訂閱與資訊的傳送

ReactiveCocoa 中訊號的訂閱與資訊的傳送過程主要是由 RACSubscriber 類來處理的,而這也是訊號的處理過程中最重要的一部分,這一小節會先分析整個工作流程,之後會深入程式碼的實現。

222017-01-30-RACSignal-Subcribe-Process

在訊號建立之後呼叫 -subscribeNext: 方法返回一個 RACDisposable,然而這不是這一流程關心的重點,在訂閱過程中生成了一個 RACSubscriber 物件,向這個物件傳送訊息 -sendNext: 時,就會向所有的訂閱者傳送訊息。

訊號的訂閱

訊號的訂閱與 -subscribe: 開頭的一系列方法有關:

232017-01-30-RACSignal-Subscribe-Methods

訂閱者可以選擇自己想要感興趣的資訊型別 next/error/completed 進行關注,並在對應的資訊發生時呼叫 block 進行處理回撥。

所有的方法其實只是對 nextBlockcompletedBlock 以及 errorBlock 的組合,這裡以其中最長的 -subscribeNext:error:completed: 方法的實現為例(也只需要介紹這一個方法):

方法中傳入的所有 block 引數都應該是非空的。

拿到了傳入的 block 之後,使用 +subscriberWithNext:error:completed: 初始化一個 RACSubscriber 物件的例項:

在拿到這個物件之後,呼叫 RACSignal-subscribe: 方法傳入訂閱者物件:

RACSignal 類中其實並沒有實現這個例項方法,需要在上文提到的四個子類對這個方法進行覆寫,這裡僅分析 RACDynamicSignal 中的方法:

這裡暫時不需要關注與 RACDisposable 有關的任何內容,我們會在下一節中詳細介紹。

RACPassthroughSubscriber 就像它的名字一樣,只是對上面建立的訂閱者物件進行簡單的包裝,將所有的訊息轉發給內部的 innerSubscriber,也就是傳入的 RACSubscriber 物件:

如果直接簡化 -subscribe: 方法的實現,你可以看到一個看起來極為敷衍的程式碼:

方法只是執行了在建立訊號時傳入的 RACSignalBindBlock

總而言之,訊號的訂閱過程就是初始化 RACSubscriber 物件,然後執行 didSubscribe 程式碼塊的過程。

242017-01-30-Principle-of-Subscribing-Signals

資訊的傳送

RACSignalBindBlock 中,訂閱者可以根據自己的興趣選擇自己想要訂閱哪種訊息;我們也可以按需傳送三種訊息:

252017-01-30-RACSignal-Subcription-Messages-Sending

而現在只需要簡單看一下這三個方法的實現,就能夠明白資訊的傳送過程了(真是沒啥好說的,不過為了湊字數完整性):

-sendNext: 只是將方法傳入的值傳入 nextBlock 再呼叫一次,並沒有什麼值得去分析的地方,而剩下的兩個方法實現也差不多,會呼叫對應的 block,在這裡就省略了。

訂閱的回收過程

在建立訊號時,我們向 -createSignal: 方法中傳入了 didSubscribe 訊號,這個 block 在執行結束時會返回一個 RACDisposable 物件,用於在訂閱結束時進行必要的清理,同樣也可以用於取消因為訂閱建立的正在執行的任務。

而處理這些事情的核心類就是 RACDisposable 以及它的子類:

262017-01-30-RACDisposable-And-Subclasses

這篇文章中主要關注的是左側的三個子類,當然 RACDisposable 的子類不止這三個,還有用於處理 KVO 的 RACKVOTrampoline,不過在這裡我們不會討論這個類的實現。

RACDisposable

在繼續分析討論訂閱的回收過程之前,筆者想先對 RACDisposable 進行簡要的剖析和介紹:

272017-01-30-RACDisposable

RACDisposable 是以 _disposeBlock 為核心進行組織的,幾乎所有的方法以及屬性其實都是對 _disposeBlock 進行的操作。

關於 _disposeBlock 中的 self

這一小節的內容是可選的,跳過不影響整篇文章閱讀的連貫性。

_disposeBlock 是一個私有的指標變數,當 void (^)(void) 型別的 block 被傳入之後都會轉換成 CoreFoundation 中的型別並以 void * 的形式存入 _disposeBlock 中:

奇怪的是,_disposeBlock 中不止會儲存程式碼塊 block,還有可能儲存橋接之後的 self

這裡,剛開始看到可能會覺得比較奇怪,有兩個疑問需要解決:

  1. 為什麼要提供一個 -init 方法來初始化 RACDisposable 物件?
  2. 為什麼要向 _disposeBlock 中傳入當前物件?

對於 RACDisposable 來說,雖然一個不包含 _disposeBlock 的物件沒什麼太多的意義,但是對於 RACSerialDisposable 等子類來說,卻不完全是這樣,因為 RACSerialDisposable-dispose 時,並不需要執行 disposeBlock,這樣就浪費了記憶體和 CPU 時間;但是同時我們需要一個合理的方法準確地判斷當前物件的 isDisposed

所以,使用向 _disposeBlock 中傳入 NULL 的方式來判斷 isDisposed;在 -init 呼叫時傳入 self 而不是 NULL 防止狀態被誤判,這樣就在不引入其他例項變數、增加物件的設計複雜度的同時,解決了這兩個問題。

如果仍然不理解上述的兩個問題,在這裡舉一個錯誤的例子,如果 _disposeBlock 在使用時只傳入 NULL 或者 block,那麼在 RACCompoundDisposable 初始化時,是應該向 _disposeBlock 中傳入什麼呢?

  • 傳入 NULL 會導致在初始化之後 isDisposed == YES,然而當前物件根本沒有被回收;
  • 傳入 block 會導致無用的 block 的執行,浪費記憶體以及 CPU 時間;

這也就是為什麼要引入 self 來作為 _disposeBlock 內容的原因。

-dispose: 方法的實現

這個只有不到 20 行的 -dispose: 方法已經是整個 RACDisposable 類中最複雜的方法了:

但是其實它的實現也沒有複雜到哪裡去,從 _disposeBlock 例項變數中呼叫 CFBridgingRelease 取出一個 disposeBlock,然後執行這個 block,整個方法就結束了。

RACSerialDisposable

RACSerialDisposable 是一個用於持有 RACDisposable 的容器,它一次只能持有一個 RACDisposable 的例項,並可以原子地換出容器中儲存的物件:

執行緒安全的 RACSerialDisposable 使用 pthred_mutex_t 互斥鎖來保證在訪問關鍵變數時不會出現執行緒競爭問題。

-dispose 方法的處理也十分簡單:

使用鎖保證執行緒安全,並在內部的 _disposable 換出之後在執行 -dispose 方法對訂閱進行處理。

RACCompoundDisposable

RACSerialDisposable 只負責一個 RACDisposable 物件的釋放不同;RACCompoundDisposable 同時負責多個 RACDisposable 物件的釋放。

相比於只管理一個 RACDisposable 物件的 RACSerialDisposableRACCompoundDisposable 由於管理多個物件,其實現更加複雜,而且為了效能和記憶體佔用之間的權衡,其實現方式是通過持有兩個例項變數:

在物件持有的 RACDisposable 不超過 RACCompoundDisposableInlineCount 時,都會儲存在 _inlineDisposables 陣列中,而更多的例項都會儲存在 _disposables 中:

282017-01-30-RACCompoundDisposable

RACCompoundDisposable 在使用 -initWithDisposables:初始化時,會初始化兩個 RACDisposable 的位置用於加速銷燬訂閱的過程,同時為了不浪費記憶體空間,在預設情況下只佔用兩個位置:

如果傳入的 otherDisposables 多於 RACCompoundDisposableInlineCount,就會建立一個新的 CFMutableArrayRef 引用,並將剩餘的 RACDisposable 全部傳入這個陣列中。

RACCompoundDisposable 中另一個值得注意的方法就是 -addDisposable:

在向 RACCompoundDisposable 中新增新的 RACDisposable 物件時,會先嚐試在 _inlineDisposables 陣列中尋找空閒的位置,如果沒有找到,就會加入到 _disposables 中;但是,在新增 RACDisposable 的過程中也難免遇到當前 RACCompoundDisposable 已經 dispose 的情況,而這時就會直接 -dispose 剛剛加入的物件。

訂閱的銷燬過程

在瞭解了 ReactiveCocoa 中與訂閱銷燬相關的類,我們就可以繼續對 -bind: 方法的分析了,之前在分析該方法時省略了 -bind: 在執行過程中是如何處理訂閱的清理和銷燬的,所以會省略對於正常值和錯誤的處理過程,首先來看一下簡化後的程式碼:

在簡化的程式碼中,訂閱的清理是由一個 RACCompoundDisposable 的例項負責的,向這個例項中新增 RACSerialDisposable 以及 RACDisposable 物件,並在 RACCompoundDisposable 銷燬時銷燬。

completeSignaladdSignal 兩個 block 主要負責處理新建立訊號的清理工作:

先通過一個例子來看一下 -bind: 方法呼叫之後,訂閱是如何被清理的:

在每個訂閱建立以及所有的值傳送之後,訂閱就會被就地銷燬,呼叫 disposeBlock,並從 RACCompoundDisposable 例項中移除:

原訂閱的銷燬時間以及繫結訊號的控制是由 SignalCount 控制的,其表示 RACCompoundDisposable 中的 RACSerialDisposable 例項的個數,在每次有新的訂閱被建立時都會向 RACCompoundDisposable 加入一個新的 RACSerialDisposable,並在訂閱傳送結束時從陣列中移除,整個過程用圖示來表示比較清晰:

292017-01-30-RACSignal-Bind-Disposable

紫色的 RACSerialDisposable 為原訂閱建立的物件,灰色的為新訊號訂閱的物件。

總結

這是整個 ReactiveCocoa 原始碼分析系列文章的第一篇,想寫一個跟這個系列有關的程式碼已經很久了,文章中對於 RACSignal 進行了一些簡單的介紹,專案中絕大多數的方法都是很簡潔的,行數並不多,程式碼的組織方式也很易於理解。雖然沒有太多讓人意外的東西,不過整個工程還是很值得閱讀的。

References

方法實現對照表

| 方法 | 實現 | | :-: | :-: | | +return: | RACSignal.m#L89-L91| | -bind: | RACSignal.m#L93-176 |

Follow: Draveness · GitHub

Source: http://draveness.me/racsignal

相關文章