翻譯自ReactiveCocoa and MVVM, an Introduction. 文中引用的 Gist 可能無法顯示。為了和諧社會, 請科學上網。
MVC
任何一個正經開發過一陣子軟件的人都熟悉MVC,它意思是Model View Controller, 是一個在復雜應用設計中組織代碼的公認模式. 它也被證實在 iOS 開發中有著第二種含義: Massive View Controller(重量級視圖控制器)。它讓許多程序員絞盡腦汁如何去使代碼被解耦和組織地讓人滿意. 總的來說, iOS 開發者已經得出結論: 他們需要給視圖控制器瘦身, 並進一步分離事物;但該怎麼做呢?
MVVM
於是MVVM流行起來, 它代表Model View View-Model, 它在這幫助我們創建更易處理, 更佳設計的代碼.
在有些情況違背蘋果建議的編碼方式不是很能講得通。我不是說不贊成這樣子, 我指的是可能會弊大於利。比如我不建議你去實現個自己的 view controller 基類並試著自己處理視圖生命周期.
帶著這種情緒, 我想提個問題: 使用除蘋果推薦的 MVC 之外的應用設計模式是愚蠢的麼?
不,有兩個原因。
蘋果沒有為解決重量級試圖控制器問題提供真正的指導. 他們留給我們來解決如何向代碼添加更多清晰的思路. 用 MVVM 來實現這個目的想必是極好哒. (在今年 WWDC 的一些視頻中, 蘋果工程師在屏幕上的示例代碼的確少許出現了 view-model, 不知道是否因為有它才成為了示例代碼)
MVVM, 至少是我將要在這裡展示的 MVVM 的風格, 都跟 MVC 十分兼容. 仿佛我們將 MVC 進行到下一個邏輯步驟.
我不會提及 MVC/MVVM 的歷史, 因為其他地方已經有所介紹, 並且我也不精通. 我將會關注如何用它進行 iOS/Mac 開發.
定義 MVVM
Model - model 在 MVVM 中沒有真正的變化. 取決於你的偏好, 你的 model 可能會或可能不會封裝一些額外的業務邏輯工作. 我更傾向於把它當做一個容納表現數據-模型對象信息的結構體, 並在一個單獨的管理類中維護的創建/管理模型的統一邏輯。
View - view 包含實際 UI 本身(不論是 UIView 代碼, storyboard 和 xib), 任何視圖特定的邏輯, 和對用戶輸入的反饋. 在 iOS 中這不僅需要 UIView 代碼和那些文件, 還包括很多需由 UIViewController 處理的工作。
View-Model 這個術語本身會帶來困惑, 因為它混搭了兩個我們已知的術語, 但卻是完全不同的東東. 它不是傳統數據-模型結構中模型的意思(又來了, 只是我喜歡這個例子). 它的職責之一就是作為一個表現視圖顯示自身所需數據的靜態模型;但它也有收集, 解釋和轉換那些數據的責任. 這留給了 view (controller) 一個更加清晰明確的任務: 呈現由 view-model 提供的數據。
關於 view-model 的更多內容
view-model 一詞的確不能充分表達我們的意圖. 一個更好的術語可能是 “View Coordinator”(感謝Dave Lee提的這個 “View Coordinator” 術語, 真是個好點子)。你可以認為它就像是電視新聞主播背後的研究人員和作家團隊。它從必要的資源(數據庫, 網絡服務調用, 等)中獲取原始數據, 運用邏輯, 並處理成 view (controller) 的展示數據. 它(通常通過屬性)暴露給視圖控制器需要知道的僅關於顯示視圖工作的信息(理想地你不會暴漏你的 data-model 對象)。 它還負責對上游數據的修改(比如更新模型/數據庫, API POST 調用)。
MVC 世界中的 MVVM
我認為 MVVM 這個首字母縮寫如同 view-model 術語一樣, 對如何使用它們進行 iOS 開發體現得有點不太准確. 讓我們再檢查下這個首字母縮寫, 了解下它是怎麼與 MVC 融為一體的。
為了圖解表示, 我們顛倒了 MVC 中的 V 和 C, 於是首字母縮寫更能准確地反映出組件間的關系方位, 給我們帶來 MCV. 我也會對 MVVM 這麼干, 將 V(iew) 移到 VM 的右邊最終成為了 MVMV. (我相信這些首字母縮寫起初不排成這樣更合理的順序是有原因的。)
這是這兩種模式如何在 iOS 中組裝在一起的簡單映射:
我試圖遵循區塊尺寸(非常)大致對應它們負責的工作量。
注意到視圖控制器有多大?
你可以看到我們巨大的視圖控制器和 view-model 之間有大塊工作上的重合。
你也可以看看視圖控制器在 MVVM 中的足跡有多大一部分是跟視圖重合的。
你大可安心獲知我們並沒有真的去除視圖控制器的概念或拋棄 “controller” 術語來匹配 MVVM。 (唷. )我們正要將重合的那塊工作剝離到 view-model 中, 並讓視圖控制器的生活更加簡單。
我們實際上最終以 MVMCV 告終. Model View-Model Controller View. 我確信我無拘無束的應用設計模式駭客行為會讓人大吃一驚。
我們的結果:
現在視圖控制器僅關注於用 view-model 的數據配置和管理各種各樣的視圖, 並在先關用戶輸入時讓 view-model 獲知並需要向上游修改數據. 視圖控制器不需要了解關於網絡服務調用, Core Data, 模型對象等. (事實上有時通過 view-model 頭文件而不是復制一大堆屬性來暴漏 model 是很務實的, 後面還會有)
view-model 會在視圖控制器上以一個屬性的方式存在. 視圖控制器知道 view-model 和它的公有屬性, 但是 view-model 對視圖控制器一無所知. 你早就該對這個設計感覺好多了因為我們的關注點在這兒進行更好地分離.
幫助你理解我們如何把組件組裝在一起還有組件對應職責的另一種方式, 就是著眼於我們新的應用構建模塊層級圖.
(感謝Dave Lee @kastiglione)
View-Model 和 View Controller, 在一起,但獨立
我們來看個簡單的 view-model 頭文件來對我們新構件的長相有個更好地概念. 為了情節簡單, 我們構建按了一個偽造的推特客戶端來查看任何推特用戶的最新回復, 通過輸入他們的姓名並點擊 “Go”. 我們的樣例界面將會是這樣:
有一個讓用戶輸入他們姓名的 UITextField , 和一個寫著 “Go” 的 UIButton
有顯示被查看的當前用戶頭像和姓名的 UIImageView 和 UILabel 各一個
下面放著一個顯示最新回復推文的 UITableView
允許無限滾動
View-Model 實例
我們的 view-model 頭文件應該長這樣:
//MYTwitterLookupViewModel.h @interface MYTwitterLookupViewModel: NSObject @property (nonatomic, assign, readonly, getter=isUsernameValid) BOOL usernameValid; @property (nonatomic, strong, readonly) NSString *userFullName; @property (nonatomic, strong, readonly) UIImage *userAvatarImage; @property (nonatomic, strong, readonly) NSArray *tweets; @property (nonatomic, assign, readonly) BOOL allTweetsLoaded; @property (nonatomic, strong, readwrite) NSString *username; - (void) getTweetsForCurrentUsername; - (void) loadMoreTweets;
相當直截了當的填充. 注意到這些壯麗的 readonly 屬性了麼?這個 view-model 暴漏了視圖控制器所必需的最小量信息, 視圖控制器實際上並不在乎 view-model 是如何獲得這些信息的. 現在我們兩者都不在乎. 僅僅假定你習慣於標准的網絡服務請求, 校驗, 數據操作和存儲.
view-model 不做的事兒
對視圖控制器以任何形式直接起作用或直接通告其變化
View Controller(視圖控制器)
視圖控制器從 view-model 獲取的數據將用來:
當 usernameValid 的值發生變化時觸發 “Go” 按鈕的 enabled 屬性
當 usernameValid 等於 NO 時調整按鈕的 alpha 值為0. 5(等於 YES 時設為1. 0)
更新 UILable 的 text 屬性為字符串 userFullName 的值
更新 UIImageView 的 image 屬性為 userAvatarImage 的值
用 tweets 數組中的對象設置表格視圖中的 cell (後面會提到)
當滑到表格視圖底部時如果 allTweetsLoaded 為 NO, 提供一個 顯示 “loading” 的 cell
視圖控制器將對 view-model 起如下作用:
每當 UITextField 中的文本發生變化, 更新 view-model 上僅有的 readwrite 屬性 username
當 “Go” 按鈕被按下時調用 view-model 上的 getTweetsForCurrentUsername 方法
當到達表格中的 “loading” cell 時調用 view-model 上的 loadMoreTweets 方法
視圖控制器不做的事兒:
發起網絡服務調用
管理 tweets 數組
判定 username 內容是否有效
將用戶的姓和名格式化為全名
下載用戶頭像並轉成 UIImage(如果你習慣在 UIImageView 上使用類別從網絡加載圖片, 你可以暴漏 URL 而不是圖片. 這樣沒有讓 view-model 和 UIKit 更完全擺脫, 但我視 UIImage 為數據而非數據的確切顯示. 這些東西不是固定死的. )
流汗
請再次注意視圖控制器總的責任是處理 view-model 中的變化.
子 View-Model
我提到過使用 view-model 上的 tweets 數組中的對象配置表格視圖的 cell. 通常你會期待展現 tweets 的是數據-模型對象. 你可能已經對其感到奇怪, 因為我們試圖通過 MVVM 模式不暴漏數據-模型對象. (前面提到過的)
view-model 不必在屏幕上顯示所有東西. 你可用子 view-model 來代表屏幕上更小, 更潛在被封裝的部分. 如果一個視圖上的一小塊兒(比如表格的 cell)在 app 中可以被重用以及(或)表現多個數據-模型對象, 子 view-model 會格外有利.
你不總是需要子 view-model. 比如, 我可能用表格 header 視圖來渲染我們“tweetboat plus”應用的頂部. 它不是個可重用的組件, 所以我可能僅是將我們已經給視圖控制器用過的相同的 view-model 傳給那個自定義的 header 視圖. 它會用到 view-model 中它需要的信息, 而無視余下的部分. 這對於保持子視圖同步是極好的方式, 因為它們可以有效地與信息中相同確切的上下文作用, 並觀察確切相同屬性的更新.
在我們的例子中, tweets 數組將會被下面這樣的子 view-model 充滿:
//MyTweetCellViewModel.h @interface MYTweetCellViewModel: NSObject @property (nonatomic, strong, readonly) NSString *tweetAuthorFullName; @property (nonatomic, strong, readonly) UIImage *tweetAuthorAvatarImage; @property (nonatomic, strong, readonly) NSString *tweetContent;
你可能認為這也太像普通”推特”裡的數據-模型對象了吧. 為啥要干將其轉化成 view-model 的工作?即使類似, view-model 讓我們限制信息只暴露給我們需要的地方, 提供額外數據轉換的屬性, 或為特定的視圖計算數據. (此外, 當可以不暴露可變數據-模型對象時也是極好的, 因為我們希望 view-model 自己承擔起更新它們的任務, 而不是靠視圖或視圖控制器. )
View-Model 從哪來?
那麼 view-model 是何時何處被創建的呢?視圖控制器創建它們自己的 view-model 麼?
View-Model 產生 View-Model
嚴格來說, 你應該為 app delegate 中的頂級視圖控制器創建一個 view-model. 當展示一個新的視圖控制器時, 或很小的視圖被 view-model 表現時, 你應要求當前的 view-model 為你創建一個子 view-model.
加入我們想要在用戶輕拍應用頂部的頭像時添加一個資料視圖控制器. 我們可以為一級 view-model 添加類似如下方法:
- (MYTwitterUserProfileViewModel *) viewModelForCurrentUser;
然後在我們的一級視圖控制器中這麼用它:
//MYMainViewController.m - (IBAction) didTapPrimaryUserAvatar { MYTwitterUserProfileViewModel *userProfileViewModel = [self.viewModel viewModelForCurrentUser]; MYTwitterUserProfileViewController *profileViewController = [[MYTwitterUserProfileViewController alloc] initWithViewModel: userProfileViewModel]; [self.navigationController pushViewController: profileViewController animated:YES]; }
在這個例子中我將會展現當前用戶的資料視圖控制器, 但是我的資料視圖控制器需要一個 view-model. 我這的主視圖控制器不知道(也不該知道)用於創建關聯相關用戶 view-model 的全部必要數據, 所以它請求它自己的 view-model 來干這種創建新 view-model 的苦差事.
View-Model 列表
至於我們的推特 cell, 當數據驅動屏幕(在這個例子中或許是通過網絡服務調用)聚到一起時, 我將會代表性地提前為對應的 cell 創建所有的 view-model. 所以在我們這個方案中, tweets 將會是一個 MYTweetCellViewModel 對象數組. 在我的表格視圖中的 cellForRowAtIndexPath 方法中, 我將會在正確的索引上簡單地抓取 view-model, 並把它賦值給我的 cell 上的 view-model 屬性.
Functional Core, Imperative Shell
view-model 這種通往應用設計的方法是一塊應用設計之路上的墊腳石, 這種被稱作“Functional Core, Imperative Shell”的應用設計由Gary Bernhardt創造. (我最近十分有幸去聽Andy Matuschak關於這方面的演講, 他為”胖的數值層, 瘦的對象層”提出充分理由. 雖然觀點相似, 但關注於我們怎樣移除對象和它們狀態的邊界影響性質, 並用 Swift 中的新數據結構構建更加函數式, 可測試的數值層. )
Functional Core
view-model 就是 “functional core”, 盡管實際上在 iOS/Objective-C 中達到純函數水平是很棘手的(Swift 提供了一些附加的函數性, 這會讓我們更接近). 大意是讓我們的 view-model 盡可能少的對剩余的”應用世界”的依賴和影響. 那意味著什麼?想起你第一次學編程時可能學到的簡單函數吧. 它們可能接受一兩個參數並輸出一個結果. 數據輸入, 數據輸出.這個函數可能是做一些數學運算或是將姓和名結合到一起. 無論應用的其他地方發生啥, 這個函數總是對相同的輸入產生相同的輸出. 這就是函數式方面.
這就是我們為 view-model 謀求的東西. 他們富有邏輯和轉換數據並將結果存到屬性的功能. 理想上相同的輸入(比如網絡服務響應)將會導出相同的輸出(屬性的值). 這意味著盡可能多地消除由”應用世界”剩余部分帶來的可能影響輸出的因素, 比如使用一堆狀態. 一個好的第一步就是不要再 view-model 頭文件中引入 UIKit.h.(這是個重大原則, 但也有些灰色區域. 比如, 你可能認為 UIImage 是數據而不是展示信息. PS: 我愛這麼干. 既然這樣的話就得引入 UIKit. h 以便使用 UIImage 類)UIKit 其性質就是將要影響許多應用世界. 它包含很多”副作用”, 憑借改變一個值或調用一個函數將觸發很多間接(甚至未知)的改變.
更新: 剛剛看了 Andy 在函數式 Swift 會議上給出的另一個超贊的演講, 於是又想到了一些. 要清楚你的 view-model 仍然只是一個對象, 而不用維護一些狀態(否則它將不會是你視圖中非常好用的模型了. )但你仍該努力將盡可能多的邏輯移到無狀態的函數”值”中. 再重復一次, Swift在這方面比 Objective-C 更加可行.
Imperative (Declarative?) Shell
命令式外殼 (Imperative Shell) 是我們需要做所有的狀態轉換, 應用世界改變的苦差事的地方, 為的是將 view-model 數據轉成給用戶在屏幕上看到的東西. 這是我們的視圖(控制器), 實際上我們在這分離 UIKit 的工作. 我仍將特別注意盡可能消除狀態並用 ReactiveCocoa 這種陳述性質的東西做這方面工作, 而 iOS 和 UIKit 在設計上是命令式的. (表格的 data source 就是個很好的例子, 因為它的委托模式強制將狀態應用到委托中, 為了當請求發生時能夠為表格視圖提供信息. 實際上委托模式通常強制一大堆狀態的使用)
可測試的核心
iOS 的單元測試是個髒, 苦, 亂的活兒. 至少我去做的時候得出的是這麼個結論. 就這方面我還出讀過一兩本書, 但當開始做視圖控制器的 mocking 和 swizzling 使其一些邏輯可測試時, 我目光呆滯. 我最終把單元測試歸入模型和任何同類別模型管理類中. (譯者注: mock 是測試常用的手段, 而 method swizzling 是基於 Objective-C Runtime 交換方法實現的黑魔法)
這個函數式核心一樣的 view-model 的最大優點, 除了 bug 數量隨著狀態數遞減之外, 就是變得非常能夠進行單元測試. 如果你有那種每次輸入相同而產生的輸出也相同的方法, 那就非常適合單元測試的世界. 我們現在將我們的數據用獲取/邏輯/轉換提取出, 避免了視圖控制器的復雜性. 那意味著構建棒棒哒測試時不需要用瘋狂的 mock 對象, method swizzling, 或其他瘋癫的變通方法(希望能有).
連接一切
那麼當 view-model 的共有屬性發生變化時我們如何更新我們的視圖呢?
絕大部分時間我們用對應的 view-model 來初始化視圖控制器, 有點類似我們剛剛在上文見到的:
MYTwitterUserProfileViewController *profileViewController = [[MYTwitterUserProfileViewController alloc] initWithViewModel: userProfileViewModel];
有時你無法在初始化時將 view-model 傳入, 比如在 storyboard segue 或 cell dequeuing 的情況下. 這時你應該在討論中的視圖(控制器)中暴露一個公有可寫的 view-model 屬性.
MYTwitterUserCell *cell = [self. tableView dequeueReusableCellWithIdentifier: @"MYTwitterUserCell" forIndexPath: indexPath]; // grab the cell view-model from the vc view-model and assign it cell. viewModel = self. viewModel. tweets[indexPath. row];
有時我們可以在鉤子程序調用前傳入 view-model, 比如 init 和 viewDidLoad, 我們可以從view-model 的屬性初始化所有 UI 元素的狀態.
//dontDoThis1.m - (id) initWithViewModel:(MYTwitterLookupViewModel *) viewModel { self = [super init]; if (!self) return nil; _viewModel = viewModel; return self; } - (void) viewDidLoad { [super viewDidLoad]; _goButton.enabled = viewModel.isUsernameValid; _goButton.alpha = viewModel.isUsernameValid ? 1 : 0.5; // etc }
好棒!我們已經配置好了初始值. 當 view-model 上的數據改變時怎麼辦? 當”go” 按鈕在什麼時候可用了怎麼辦?當用戶標簽和頭像在什麼時候從網絡上下載並填充了怎麼辦?
我們可以將視圖控制器暴露給 view-model, 以便於當相關數據變化或類似事件發送時它可以調用一個 “updateUI” 方法. (別這麼干. )在 view-model 上將視圖控制器作為一個委托?當 view-model 內容有變化時發個通知?(不不不不. )
我們的視圖控制器會感知一些變化的發生. 我們可以使用從 UITextfield 得來的委托方法在每當有字符變化時通過檢查 view-model 來更新按鈕的狀態.
//dontDoThisEither.m - (void)textFieldDidChange:(UITextField *)sender { // update the view-model self.viewModel.username = sender.text; // check if things are now valid self.goButton.enabled = self.viewModel.isUsernameValid; self.goButton.alpha = self.viewModel.isUsernameValid ? 1.0 : 0.5; }
這種方法解決的場景是在只有再文本框發生變化時才會影響 view-model 中的 isUsernameValid 值. 假使還有其他變量/動作改變 isUsernameValid 的狀態將會怎麼樣?對於 view-model 中的網絡調用會怎麼樣?或許我們該為 view-model 上的方法加一個完成後回調處理, 這樣我們此時就可以更新 UI 的一切東西了?使用珍貴而笨重的 KVO 方法怎麼樣?
我們或許最終使用多種多樣我們熟悉的機制將 view-model 和視圖控制器所有的接觸點都連起來, 但你已經知道了標題上不是這麼寫的. 這樣在代碼中創建了大量的入口點, 僅僅為了簡單的更新 UI 就要在代碼中完全重新創建應用狀態上下文.
進入 ReactiveCocoa
ReactiveCocoa(RAC) 是來拯救我們的, 並恰好返回給我們一點理智. 讓我們看看如何做到.
思考在一個新的用戶頁面上控制信息的流動, 當表單合法時更新提交按鈕的狀態. 你現在可能會照下面這麼做:
你最後通過使用狀態, 小心翼翼地代碼中許多不同且零碎無關的內容穿到簡單的邏輯上. 看看你信息流中所有不同的入口點?(這還只是一個 UI 元素中的一條邏輯線. )我們程序中現在用的抽象概念還不夠厲害, 不能為我們追蹤所有事物的關系, 所以我們停止自己去干這蛋疼事兒.
讓我們看看陳述版本:
這看起來可能像是為我們應用流程文檔中的一張老舊的計算機科學圖解. 通過陳述式的編程, 我們使用了更高層次的抽象, 來讓我們實際編程更靠近我們在腦海中設計流程的方式. 我們讓電腦為我們做更多工作. 實際的代碼更加像這幅圖了.
RACSignal
RACSignal (信號)就 RAC 來說是構造單元. 它代表我們最終將要收到的信息. 當你能將未來某時刻收到的消息具體表示出來時, 你可以開始預先(陳述性)運用邏輯並構建你的信息流,而不是必須等到事件發生(命令式).
信號會為了控制通過應用的信息流而獲得所有這些異步方法(委托, 回調 block, 通知, KVO, target/action 事件觀察, 等)並將它們統一到一個接口下.這只是直觀理解. 不僅是這些, 因為信息會流過你的應用, 它還提供給你輕松轉換/分解/合並/過濾信息的能力.
那麼什麼是信號呢?這是一個信號:
信號是一個發送一連串值的物體. 但是我們這兒的信號啥也不干, 因為它還沒有訂閱者. 如果有訂閱者監聽時(已訂閱)信號才會發信息. 它將會向那個訂閱者發送0或多個載有數值的”next”事件, 後面跟著一個”complete”事件或一個”error”事件. (信號類似於其他語言/工具包中的 “promise”, 但更強大, 因為它不僅限於向它的訂閱者一次只傳遞一個返回值. )
正如我之前提到的, 如果覺得需要的話你可以過濾, 轉換, 分解和合並那些值. 不同的訂閱者可能需要使用信號通過不同方式發送的值.
信號發送的值是從哪獲得的?
信號是一些等待某事發生的異步代碼, 然後把結果值發送給它們的訂閱者. 你可以用 RACSignal 的類方法 createSignal: 手動創建信號:
//networkSignal.m RACSignal *networkSignal = [RACSignal createSignal:^RACDisposable *(idsubscriber) { NetworkOperation *operation = [NetworkOperation getJSONOperationForURL:@"http://someurl"]; [operation setCompletionBlockWithSuccess:^(NetworkOperation *theOperation, id *result) { [subscriber sendNext:result]; [subscriber sendCompleted]; } failure:^(NetworkOperation *theOperation, NSError *error) { [subscriber sendError:error]; }];
我在這用一個具有成功和失敗 block (偽造)的網絡操作創建了一個信號. (如果我想讓信號在被訂閱時才讓網絡請求發生, 還可以用 RACSignal 的類方法 defer. )我在成功的 block 裡使用提供的 subscriber 對象調用 sendNext: 和 sendCompleted: 方法, 或在失敗的 block 中調用 sendError:. 現在我可以訂閱這個信號並將在響應返回時接收到 json 值或是 error.
幸運的是, RAC 的創造者實際上使用它們自己的庫來創建真的事物(捉摸一下), 所以對於我們在日常需要什麼, 他們有很強烈的想法. 他們為我們提供了很多機制, 來從我們通常使用的現存的異步模式中拉取信號. 別忘了如果你有一個沒有被某個內建信號覆蓋到的異步任務, 你可以很容易地用 createSignal: 或類似方法來創建信號.
一個被提供的機制就是 RACObserve() 宏. (如果你不喜歡宏, 你可以簡單地看看罩子下面並用稍微多些冗雜的描述. 這也非常好. 在我們得到 Swift 版本的替代之前, 這也有在 Swift 中使用 RAC 的解決方案. )這個宏是 RAC 中對 KVO 中那些悲慘的 API 的替代. 你只需要傳入對象和你想觀察的那個對象某屬性的 keypath. 給出這些參數後, RACObserve 會創建一個信號, 一旦它有了訂閱者, 它就立刻發送那個屬性的當前值, 並在發送那個屬性在這之後的任何變化.
RACSignal *usernameValidSignal = RACObserve(self. viewModel, usernameIsValid);
這僅是提供用於創建信號的一個工具. 這裡有幾個立即可用的方式, 來從內置控制流機制中拉取信號:
//signals.m RACSignal *controlUpdate = [myButton rac_signalForControlEvents:UIControlEventTouchUpInside]; // signals for UIControl events send the control event value (UITextField, UIButton, UISlider, etc) // subscribeNext:^(UIButton *button) { NSLog(@"%@", button); // UIButton instance } RACSignal *textChange = [myTextField rac_textSignal]; // some special methods are provided for commonly needed control event values off certain controls // subscribeNext:^(UITextField *textfield) { NSLog(@"%@", textfield.text); // "Hello!" } RACSignal *alertButtonClicked = [myAlertView rac_buttonClickedSignal]; // signals for some delegate methods send the delegate params as the value // e.g. UIAlertView, UIActionSheet, UIImagePickerControl, etc // (limited to methods that return void) // subscribeNext:^(NSNumber *buttonIndex) { NSLog(@"%@", buttonIndex); // "1" } RACSignal *viewAppeared = [self rac_signalForSelector:@selector(viewDidAppear:)]; // signals for arbitrary selectors that return void, send the method params as the value // works for built in or your own methods // subscribeNext:^(NSNumber *animated) { NSLog(@"viewDidAppear %@", animated); // "viewDidAppear 1" }
記住你也能輕松創建自己的信號, 包括替代那些沒有內建支持的其他委托. 我們現在能夠從所有這些不連貫的異步/控制流工具中拉取出信號並將他們合並, 試想想這該多酷!這些會成為我們之前看到的陳述性圖表中的節點. 真是興奮.
什麼是訂閱者?
簡言之, 訂閱者就是一段代碼, 它等待信號給它發送一些值, 然後訂閱者就能處理這些值了. (它也可以作用於 “complete” 和 “error” 事件. )
這有一個簡單的訂閱者, 是通過向信號的實例方法 subscribeNext 傳入一個 block 來創建的. 我們在這通過 RACObserve() 宏創建信號來觀察一個對象上屬性的當前值, 並把它賦值給一個內部屬性.
- (void) viewDidLoad { // . . . // create and get a reference to the signal RACSignal *usernameValidSignal = RACObserve(self. viewModel, isUsernameValid); // update the local property when this value changes [usernameValidSignal subscribeNext: ^(NSNumber *isValidNumber) { self. usernameIsValid = isValidNumber. boolValue }]; }
注意 :RAC 只處理對象, 而不處理像 BOOL 這樣的原始值. 不過不用擔心, RAC 通常會幫你這些轉換.
幸運的是 RAC 的創造者也意識到這種綁定行為的普遍必要性, 所以他們提供了另一個宏 RAC(). 與 RACObserve() 相同, 你提供想要與即將到來的值綁定的對象和參數, 在其內部它所做的是創建一個訂閱者並更新其屬性的值. 我們的例子現在看起來像這樣:
- (void) viewDidLoad { //. . . RAC(self, usernameIsValid) = RACObserve(self. viewModel, isUsernameValid); }
考慮下我們的目標, 這麼干有點傻啊. 我們不需要將信號發送的值存到屬性中(這會創建狀態), 我們真正要做的是用從那個值獲取到信息來更新 UI.
轉換數據流
現在我們進入 RAC 為我們提供的用於轉換數值流的方法. 我們將會利用 RACSignal 的實例方法 map.
//transformingStreams.m - (void) viewDidLoad { //... RACSignal *usernameIsValidSignal = RACObserve(self.viewModel, isUsernameValid); RAC(self.goButton, enabled) = usernameIsValidSignal; RAC(self.goButton, alpha) = [usernameIsValidSignal map:^id(NSNumber *usernameIsValid) { return usernameIsValid.boolValue ? @1.0 : @0.5; }]; }
這樣現在我們將 view-model 上的 isUsernameValid 發生的變化直接綁定到 goButton 的 enabled 屬性上. 酷吧?對 alpha 的綁定更酷, 因為我們正在使用 map 方法將值轉換成與 alpha 屬性相關的值. (注意在這裡我們返回的是一個 NSNumber 對象而不是原始float值. 這基本上是唯一的污點: 你需要負責為 RAC 將原始值轉化為對象, 因為它不能幫你導出來.
多個訂閱者, 副作用, 昂貴的操作
訂閱信號鏈時要明白重要的一件事是每當一個新值通過信號鏈被發送出去時, 實際上會給每個訂閱者都發送一次. 直到意識到這就我們而言是有意義的, 信號發出的值不存儲在任何地方(除了 RAC 在內部實現中). 當信號需要發送一個新的值時, 它會遍歷所有的訂閱者並給每個訂閱者發送那個值. (這是對信號鏈實際工作的簡化說明, 但基本想法是對的)
這為什麼重要?這意味著信號鏈某處存在的任何副作用, 任何影響應用世界的轉變, 將會發生多次. 這對新接觸 RAC 的用戶來說是意想不到的. (這也違反了函數式構建的理念-數據輸入, 數據輸出).
一個做作的例子可能是: 信號鏈某處的信號在每次按鈕被按下時更新 self 中的一個計數器屬性. 如果信號鏈有多個訂閱者, 計數器的增長將會比你想的還要多. 你需要努力從信號鏈中盡可能剔除副作用. 當副作用不可避免時, 你可以使用一些恰當的預防機制. 我將會在另一篇文章中探索.
除副作用之外, 你需要注意帶有昂貴操作和可變數據的信號鏈. 網絡請求就是一個三者兼得的例子:
網絡請求影響了應用的網絡層(副作用).
網絡請求為信號鏈引入了可變數據. (兩個完全一樣請求可能返回了不同的數據. )
網絡請求反應慢啊.
例如, 你可能有個信號在每次按鈕按下時發送一個值, 而你想將這個值轉換成網絡請求的結果. 如果有多個訂閱者要這個處理信號鏈上返回的這個值, 你將發起多個網絡請求.
網絡請求明顯是經常需要的. 正如你所期望, RAC 提供這些情況的解決方案, 也就是 RACCommand 和多點廣播. 我將會在下一篇文章中更深入地分析.
Tweetboat Plus
既然簡短的介紹(嗯哼)扯遠了, 讓我們著眼於如何用 ReactiveCocoa 將 view-model 與視圖控制器連接起來.//
// View Controller // - (void) viewDidLoad { [super viewDidLoad]; RAC(self. viewModel, username) = [myTextfield rac_textSignal]; RACSignal *usernameIsValidSignal = RACObserve(self. viewModel, usernameValid); RAC(self. goButton, alpha) = [usernameIsValidSignal map: ^(NSNumber *valid) { return valid. boolValue ? @1 : @0. 5; }]; RAC(self. goButton, enabled) = usernameIsValidSignal; RAC(self. avatarImageView, image) = RACObserve(self. viewModel, userAvatarImage); RAC(self. userNameLabel, text) = RACObserve(self. viewModel, userFullName); @weakify(self); [[[RACSignal merge: @[RACObserve(self. viewModel, tweets), RACObserve(self. viewModel, allTweetsLoaded)]] bufferWithTime: 0 onScheduler: [RACScheduler mainThreadScheduler]] subscribeNext: ^(id value) { @strongify(self); [self. tableView reloadData]; }]; [[self. goButton rac_signalForControlEvents: UIControlEventTouchUpInside] subscribeNext: ^(id value) { @strongify(self); [self. viewModel getTweetsForCurrentUsername]; }]; } -(UITableViewCell*)tableView: (UITableView *)tableView cellForRowAtIndexPath: (NSIndexPath *)indexPath { // if table section is the tweets section if (indexPath. section == 0) { MYTwitterUserCell *cell = [self. tableView dequeueReusableCellWithIdentifier: @"MYTwitterUserCell" forIndexPath: indexPath]; // grab the cell view model from the vc view model and assign it cell. viewModel = self. viewModel. tweets[indexPath. row]; return cell; } else { // else if the section is our loading cell MYLoadingCell *cell = [self. tableView dequeueReusableCellWithIdentifier: @"MYLoadingCell" forIndexPath: indexPath]; [self. viewModel loadMoreTweets]; return cell; } } // // MYTwitterUserCell // // this could also be in cell init - (void) awakeFromNib { [super awakeFromNib]; RAC(self. avatarImageView, image) = RACObserve(self, viewModel. tweetAuthorAvatarImage); RAC(self. userNameLabel, text) = RACObserve(self, viewModel. tweetAuthorFullName); RAC(self. tweetTextLabel, text) = RACObserve(self, viewModel. tweetContent); }
讓我們過一遍這個例子.
RAC(self. viewModel, username) = [myTextfield rac_textSignal];
在這我們用 RAC 庫中的方法從 UITextField 拉取一個信號. 這行代碼將 view-model 上的可讀寫屬性 username 綁定到文本框上的用戶輸入的任何更新.
RACSignal *usernameIsValidSignal = RACObserve(self. viewModel, usernameValid); RAC(self. goButton, alpha) = [usernameIsValidSignal map: ^(NSNumber *valid) { return valid. boolValue ? @1 : @0. 5; }]; RAC(self. goButton, enabled) = usernameIsValidSignal;
在這我們用 RACObserve 方法在 view-model 的 usernameValid 屬性上創建了一個信號 usernameIsValidSignal. 無論何時屬性發生變化, 它將會沿著管道發送一個新的 @YES 或 @NO. 我們拿到那個值並將其綁定到 goButton 的兩個屬性上. 首先我們將 alpha 分別對應 YES 或 NO 更新到1或0. 5(記著在這必須返回 NSNumber). 然後我們直接將信號綁定到 enabled 屬性, 因為 YES 和 NO 在這無需轉換就能完美地運作.
RAC(self. avatarImageView, image) = RACObserve(self. viewModel, userAvatarImage); RAC(self. userNameLabel, text) = RACObserve(self. viewModel, userFullName);
下面我們為表頭的圖像視圖和用戶標簽創建綁定, 再次在 view-model 上對應的屬性上用 RACObserve 宏創建信號.
@weakify(self); [[[RACSignal merge: @[RACObserve(self. viewModel, tweets), RACObserve(self. viewModel, allTweetsLoaded)]] bufferWithTime: 0 onScheduler: [RACScheduler mainThreadScheduler]] subscribeNext: ^(id value) { @strongify(self); [self. tableView reloadData]; }];
這貨看上去有點詭異, 所以我們在這上多花點時間. 我們想在 view-model 上 tweets 數組或 allTweetsLoaded 屬性發生變化時更新表格視圖. (在這個例子中, 我們要用一個簡單的方法來重新加載整張表. )所以我們將這兩個屬性被觀察後創建的兩個信號合並成一個更大的信號, 當兩個屬性中有一個發生變化, 這個信號就會發送值. (你一貫認為信號的值是同類型的, 不會像這個信號有一樣混雜的值. 這很可能在 Swift 版本的 RAC 中強制要求, 但在這我們不關心發出的真實值, 我們只是用它來觸發表格式圖的重新加載. )
那麼這兒看起來最嚇人的部分可能是信號鏈中的 bufferWithTime: onScheduler: 方法. 需要它來圍繞 UIKit 中的一個問題進行變通. tweets 和 allTweetsLoaded 這兩個屬性我們都需要追蹤, 萬一 tweets 變化和 allTweetsLoaded 為否(不管怎樣我們都得重新加載表格). 有時兩個屬性都將在同一准確的時間發生變化, 意味著合並後的大信號中的兩個信號都會發送一個值, 那麼 reloadData 方法將會在同一個運行循環中被調用兩次. UIKit 不喜歡這樣. bufferWithTime: 在給明的時間內抓取所有下一個到來的值, 當給定的時間過後將所有值合在一起發給訂閱者. 通過傳入0作為時間, bufferWithTime: 將會抓取那個合並信號在特定的運行循環中發出的全部值, 並將他們一起發送出去. (NSTimer 以同樣的方式工作, 這不是巧合, 因為 bufferWithTime: 是用 NSTimer 構建的. )暫時不用擔心 scheduler, 試把它想做指明這些值必須在主線程上被發送. 現在我們確保 reloadData 每次運行循環只被調用一次.
注意我在這用 @weakify/@strongify 宏切換 strong 和 weak. 這在創建所有這些 block 時非常重要. 在 RAC 的 block 中使用 self 時self 將會被捕獲為強引用並得到保留環, 除非你尤其意識到要破除保留環
[[self. goButton rac_signalForControlEvents: UIControlEventTouchUpInside] subscribeNext: ^(id value) { @strongify(self); [self. viewModel getTweetsForCurrentUsername]; }];
我將會在下一篇文章中在這裡引入 RACCommand, 但目前我們只是當按鈕被觸碰時手動調用 view-model 的 getTweetsForCurrentUsername 方法.
我們已經搞定了 cellForRowAtIndexPath 的第一部分, 那麼我在這將只說下 loading cell:
MYLoadingCell *cell = [self. tableView dequeueReusableCellWithIdentifier: @"MYLoadingCell" forIndexPath: indexPath]; [self. viewModel loadMoreTweets]; return cell;
這是另一塊我們以後將利用到 RACCommand 的地方, 但目前我們只是調用 view-model 的 loadMoreTweets 方法. 我們將只是信任如果 cell 顯示或隱藏多次的話 view-model 會避免多次內部調用.
- (void) awakeFromNib { [super awakeFromNib]; RAC(self. avatarImageView, image) = RACObserve(self, viewModel. tweetAuthorAvatarImage); RAC(self. userNameLabel, text) = RACObserve(self, viewModel. tweetAuthorFullName); RAC(self. tweetTextLabel, text) = RACObserve(self, viewModel. tweetContent); }
這段現在應該非常直接了, 除此之外我想指出一點. 我們正在將圖片和文字綁定到 UI 上對應的屬性, 但注意 viewModel 出現在 RACObserve 宏中逗號右邊. 這些 cell 終將被重用, 新的 view-models 將會被賦值. 如果我們不將 viewModel 放在逗號右邊, 那就會監聽 viewModel 屬性的變化然後每次都要重新設置綁定;如果放在逗號右邊, RACObserve 將會為我們負責這些事兒. 因此我們只需要設定一次綁定並讓 Reactive Cocoa 做剩余的部分. 這是在綁定表格 cell 時為了性能需要記住的好東西. 我在實踐中即使是有很多表格 cell 依然沒有出過問題.
福利-消除更多的狀態
有時候你可以在 view-model 中暴露 RACSignal 對象來替代像字符串和圖像這樣的屬性, 這能在 view-model 上消除更多的狀態. 然後視圖控制器就不需要自己用 RACObserve 創建信號了, 並只是直接影響這些信號. 要意識到如果你的信號在被 UI 訂閱/綁定到 UI 之前發出過一個值, 那麼你將不會收到那個”初始”的值.
結論
本文篇幅略長, 但別被嚇著. 這還有好多沒講的, 而且是干貨兒, 是舒展你大腦的好方法. 這毫無疑問是不同的編程風格. 花一會兒功夫停止機械地試圖用命令式方案去解決問題. 即使你一開始不是經常用這種編程風格, 我認為這有助於理解和提醒我們有截然不同的途徑來解決我們程序員的困惑.
下一次我將稍微深入 view-model 內部中本文沒提到的內容, 並介紹下 RACCommand(希望篇幅能短很多). 然後我們將投入到一個真實案例中, 那是我的一個叫做Three Cents的 app 中的一個相當復雜的頁面, 它混合了網絡調用, CoreData, 多重 UI 狀態, 等等!
拓展閱讀
Introduction to MVVM by Ash Furrow
Functional Reactive Programming on iOS by Ash Furrow
A sample app by Ash Furrow
MVC, MVVM, FRP, And Building Bridges by Jonathan Penn
MVVM Tutorial with ReactiveCocoa by Colin Eberhardt on the Ray Wenderlich site.
Basic MVVM with ReactiveCocoa by Colin Wheeler
On MVVM, and Architecture Questions by Chris Trott