ReactiveCocoa教程:上半部【譯】

原文:ReactiveCocoa Tutorial – The Definitive Introduction: Part 1/2
下半部翻譯:ReactiveCocoa教程:下半部【翻譯】

譯注:在學(xué)習(xí)ReactiveCocoa的過(guò)程中看了不少文章,但對(duì)我而言最適合入門的當(dāng)數(shù)此系列。
雖然已經(jīng)是14年的文章,但基本對(duì)于ReactiveCocoa在OC上的使用仍然基本符合。這上下兩篇文章最好之處在于通過(guò)兩個(gè)應(yīng)用實(shí)例囊括了大部分ReactiveCocoa的常見(jiàn)用法,雖然沒(méi)有提及RACCommand具體用法,也瑕不掩瑜。
為便于之后自己查看,同時(shí)加深理解,故對(duì)文章作簡(jiǎn)單翻譯。因?yàn)榈谝淮巫龇g,如果翻譯中有不妥之處,敬請(qǐng)諒解,亦歡迎大家交流:)

作為一個(gè)iOS開(kāi)發(fā)人員,幾乎你寫(xiě)的每一行代碼都是為了處理各種事件:按鈕的點(diǎn)擊、網(wǎng)絡(luò)的反饋、屬性的變更(KVO)、位置的改變(CoreLocation)等等。但與此同時(shí),這些事件相應(yīng)又有著如action、delegate、KVO、callback等各種不一樣的實(shí)現(xiàn)。ReactiveCocoa為此定義了一套接口與事件的標(biāo)準(zhǔn),以便于用戶憑借一套基礎(chǔ)的工具對(duì)這些事件進(jìn)行鏈接,過(guò)濾及重組。

看到這里,你是感到迷惑?興奮?還是迫不及待?那就繼續(xù)往下讀吧 :]

ReactiveCocoa是如下兩種編程風(fēng)格的集合體:

  • 使用高階函數(shù)的函數(shù)式編程,如:以某一函數(shù)作為另一函數(shù)的入?yún)?/li>
  • 注重?cái)?shù)據(jù)流及變更傳遞的響應(yīng)式編程

故而ReactiveCocoa被稱為響應(yīng)式函數(shù)編程(Functional Reactive Programming 或FRP)框架。

讀到這里你可能感到相當(dāng)迷惑,但請(qǐng)放心,解惑這正是本教程的目的!雖然編程范式也是一個(gè)引人入勝的話題,但相比學(xué)術(shù)理論,本教程余下的部分,將希望通過(guò)完成一個(gè)實(shí)例讓你更好的理解ReactiveCocoa。

響應(yīng)式樂(lè)園

在本教程中,你將通過(guò)一個(gè)非常簡(jiǎn)單的應(yīng)用實(shí)例學(xué)習(xí)響應(yīng)式編程。在開(kāi)始之前,作為準(zhǔn)備工作,請(qǐng)先下載初始工程并編譯運(yùn)行。

ReactivePlayground是一個(gè)包含用戶登錄界面的簡(jiǎn)單應(yīng)用。在用戶名及密碼校驗(yàn)成功后,你將看到一只非常可愛(ài)的小貓。


接下來(lái)我們最好花點(diǎn)時(shí)間去熟悉一下初始工程的源碼。由于工程相當(dāng)?shù)暮?jiǎn)單,所以這不會(huì)花費(fèi)太多的時(shí)間。

打開(kāi)查看RWViewController.m文件。嘗試回答以下問(wèn)題:點(diǎn)擊Sign In按鈕會(huì)觸發(fā)哪些事件?顯示/隱藏登錄失敗的文本需要哪些條件?在這個(gè)簡(jiǎn)單例子中,你可能只需一兩分鐘就能解答這些問(wèn)題。但如果情況復(fù)雜一點(diǎn),你就可能需要花比較多的的時(shí)間去做相同的分析。

而使用ReactiveCocoa的話,你就能夠更清晰地理解應(yīng)用實(shí)際的意圖了。事不宜遲,讓我們現(xiàn)在立馬開(kāi)始吧!

添加 ReactiveCocoa 框架

往項(xiàng)目中添加ReactiveCocoa框架最簡(jiǎn)單的途徑是使用CocoaPods。如果你之前從來(lái)沒(méi)有使用過(guò)CocoaPods,你最好學(xué)習(xí)一下CocoaPods官網(wǎng)上的教程,或者至少跟著官網(wǎng)教程的初始步驟操作一遍,來(lái)保證你已經(jīng)達(dá)到了安裝的先決條件。

注意:如果基于某些原因你實(shí)在不想使用CocoaPods的話,只要你跟隨ReactiveCocoa GitHub上文檔進(jìn)行導(dǎo)入操作,也同樣可以使用ReactiveCocoa。

如果你還打開(kāi)著ReactivePlayground項(xiàng)目的話,現(xiàn)在請(qǐng)先關(guān)閉。CocoaPods會(huì)創(chuàng)建一個(gè)Xcode workspace文件,用以替代原有的項(xiàng)目文件。

打開(kāi)終端。跳轉(zhuǎn)到你項(xiàng)目所在的文件夾,并輸入以下指令:

touch Podfile
open -e Podfile

這創(chuàng)建了一個(gè)名為Podfile的文件并通過(guò)TextEdit打開(kāi)。然后復(fù)制粘貼以下文本到TextEdit窗口中:(譯注:使用Sublime等文本編輯器亦可,不過(guò)不要使用系統(tǒng)自帶的文本編輯器)

platform :ios, '7.0'
 
pod 'ReactiveCocoa', '2.1.8'

上面文本規(guī)定了使用平臺(tái)為iOS,最小的SDK版本號(hào)為7.0,并添加ReactiveCocoa框架作為依賴包。文檔保存后,請(qǐng)返回終端窗口,并輸入以下命令:(譯注:ReactiveCocoa2.X為OC版本,之后包括最新的版本都基于Swift)

pod install

你將會(huì)會(huì)看到與下文相似的輸出:

Analyzing dependencies
Downloading dependencies
Installing ReactiveCocoa (2.1.8)
Generating Pods project
Integrating client project
 
[!] From now on use `RWReactivePlayground.xcworkspace`.

這說(shuō)明ReactiveCocoa已經(jīng)成功下載,而且CocoaPods已經(jīng)創(chuàng)建好了Xcode workspace文件用以整合相關(guān)的框架到你現(xiàn)有的應(yīng)用中。打開(kāi)新生成的workspace文件RWReactivePlayground.xcworkspace,瀏覽由CocoaPods在項(xiàng)目導(dǎo)航中生成的結(jié)構(gòu):

CocoaPods創(chuàng)建了一個(gè)新的工作空間,并在原始項(xiàng)目RWReactivePlayground外添加了包含有ReactiveCocoa 框架的Pods項(xiàng)目。CocoaPods就這樣輕而易舉地達(dá)到了管理依賴包的目的!

你想必已經(jīng)注意到個(gè)項(xiàng)目叫做響應(yīng)式樂(lè)園(ReactivePlayground),那么接下來(lái),自然就是我們的游樂(lè)時(shí)間了……

游樂(lè)時(shí)間

正如在介紹中提到的,ReactiveCocoa提供了一套接口標(biāo)準(zhǔn)來(lái)處理應(yīng)用中出現(xiàn)的各式事件流。這在ReactiveCocoa的術(shù)語(yǔ)中被稱作信號(hào),對(duì)應(yīng)的是RACSignal類。

下面打開(kāi)應(yīng)用的初始視圖控制器RWViewController.m,在文件開(kāi)頭添加以下代碼來(lái)導(dǎo)入ReactiveCocoa的頭文件:

#import <ReactiveCocoa/ReactiveCocoa.h>

你暫時(shí)還不需要替換現(xiàn)有的代碼,現(xiàn)在要做的只是在原有的代碼基礎(chǔ)上豐富一下。先在viewDidLoad方法的結(jié)尾處添加下面代碼:

[self.usernameTextField.rac_textSignal subscribeNext:^(id x) {
  NSLog(@"%@", x);
}];

編譯運(yùn)行應(yīng)用,在用戶名輸入框中隨意輸入點(diǎn)文本。留意一下控制臺(tái)你就能發(fā)現(xiàn)如下相似的輸出:

2013-12-24 14:48:50.359 RWReactivePlayground[9193:a0b] i
2013-12-24 14:48:50.436 RWReactivePlayground[9193:a0b] is
2013-12-24 14:48:50.541 RWReactivePlayground[9193:a0b] is 
2013-12-24 14:48:50.695 RWReactivePlayground[9193:a0b] is t
2013-12-24 14:48:50.831 RWReactivePlayground[9193:a0b] is th
2013-12-24 14:48:50.878 RWReactivePlayground[9193:a0b] is thi
2013-12-24 14:48:50.901 RWReactivePlayground[9193:a0b] is this
2013-12-24 14:48:51.009 RWReactivePlayground[9193:a0b] is this 
2013-12-24 14:48:51.142 RWReactivePlayground[9193:a0b] is this m
2013-12-24 14:48:51.236 RWReactivePlayground[9193:a0b] is this ma
2013-12-24 14:48:51.335 RWReactivePlayground[9193:a0b] is this mag
2013-12-24 14:48:51.439 RWReactivePlayground[9193:a0b] is this magi
2013-12-24 14:48:51.535 RWReactivePlayground[9193:a0b] is this magic
2013-12-24 14:48:51.774 RWReactivePlayground[9193:a0b] is this magic?

你會(huì)發(fā)現(xiàn)每次你修改文本框中的輸入,block中的代碼塊就會(huì)執(zhí)行。不需要target-action,不需要delegate——只憑借信號(hào)和block就能實(shí)現(xiàn)。這多棒!

ReactiveCocoa的信號(hào)(表現(xiàn)為RACSignal類)會(huì)向他的訂閱者們發(fā)送事件流。發(fā)送的事件分為三種類型:nexterrorcompleted。一個(gè)信號(hào)在因?yàn)閳?bào)錯(cuò)或完成的終止前可以發(fā)送若干個(gè)事件。在教程的上半部分將會(huì)把重點(diǎn)放在next事件上。你可以在教程的下半部學(xué)習(xí)有關(guān)errorcompleted的知識(shí)。

RACSignal提供了若干的方法用以訂閱這些不同的事件類型。每個(gè)方法都會(huì)接受一個(gè)或多個(gè)block作為入?yún)ⅲ?dāng)新的事件出現(xiàn)時(shí),block中的邏輯代碼就會(huì)執(zhí)行。在這個(gè)例子中,你可以看到subscribeNext:方法就提供了一個(gè)block,在每個(gè)next事件到達(dá)時(shí)執(zhí)行里面的代碼。

ReactiveCocoa框架使用category為很多標(biāo)準(zhǔn)的UIKit控件添加了信號(hào),借此你可以對(duì)它們的各種事件進(jìn)行訂閱。這正是文本輸入框的rac_textSignal屬性的由來(lái)。

理論學(xué)習(xí)就到此為止,現(xiàn)在是時(shí)候使用ReactiveCocoa來(lái)實(shí)現(xiàn)一些真正的功能了!

ReactiveCocoa有多個(gè)用于操作事件流的方法。舉個(gè)例子,假如你僅僅關(guān)心長(zhǎng)度超過(guò)3字節(jié)的用戶名,你可以使用filter方法來(lái)達(dá)到這個(gè)目的。更新你剛剛添加到viewDidLoad方法的代碼如下:

[[self.usernameTextField.rac_textSignal
  filter:^BOOL(id value) {
    NSString *text = value;
    return text.length > 3;
  }]
  subscribeNext:^(id x) {
    NSLog(@"%@", x);
  }];

編譯運(yùn)行,并往文本輸入框中隨意輸入一些內(nèi)容,此時(shí)控制臺(tái)就只會(huì)打印文本長(zhǎng)度超過(guò)3個(gè)字節(jié)的文本。

2013-12-26 08:17:51.335 RWReactivePlayground[9654:a0b] is t
2013-12-26 08:17:51.478 RWReactivePlayground[9654:a0b] is th
2013-12-26 08:17:51.526 RWReactivePlayground[9654:a0b] is thi
2013-12-26 08:17:51.548 RWReactivePlayground[9654:a0b] is this
2013-12-26 08:17:51.676 RWReactivePlayground[9654:a0b] is this 
2013-12-26 08:17:51.798 RWReactivePlayground[9654:a0b] is this m
2013-12-26 08:17:51.926 RWReactivePlayground[9654:a0b] is this ma
2013-12-26 08:17:51.987 RWReactivePlayground[9654:a0b] is this mag
2013-12-26 08:17:52.141 RWReactivePlayground[9654:a0b] is this magi
2013-12-26 08:17:52.229 RWReactivePlayground[9654:a0b] is this magic
2013-12-26 08:17:52.486 RWReactivePlayground[9654:a0b] is this magic?

你剛創(chuàng)建的是一個(gè)非常簡(jiǎn)單的管道(譯注:pipeline,指一個(gè)信號(hào)被訂閱的完整過(guò)程)。這正是響應(yīng)式編程的的本質(zhì),通過(guò)傳遞一系列的數(shù)據(jù)流實(shí)現(xiàn)你應(yīng)用中的功能。

下圖中描繪了整個(gè)流程:


可以看到,rac_textSignal是這個(gè)事件的源頭。之后數(shù)據(jù)流通過(guò)一個(gè)過(guò)濾器,只允許包含長(zhǎng)度超過(guò)3的的字符串的事件通過(guò)。符合條件的事件最后來(lái)到subscribeNext:方法,事件傳遞的值在block中打印。

值得注意的是,filter方法的返回值也是一個(gè)RACSignal實(shí)例。你可以通過(guò)以下代碼來(lái)分解這個(gè)管道的幾個(gè)環(huán)節(jié):

RACSignal *usernameSourceSignal = 
    self.usernameTextField.rac_textSignal;
 
RACSignal *filteredUsername = [usernameSourceSignal  
  filter:^BOOL(id value) {
    NSString *text = value;
    return text.length > 3;
  }];
 
[filteredUsername subscribeNext:^(id x) {
  NSLog(@"%@", x);
}];

正因?yàn)槊總€(gè)對(duì)RACSignal的操作都同樣返回一個(gè)RACSignal實(shí)例,這種被稱為流式接口(fluent interface)的特性讓你可以構(gòu)造一個(gè)連續(xù)的管道而不必用本地變量分割每一步操作。

注意:ReactiveCocoa大量的使用了block。如果你對(duì)block并不熟悉,你可能需要閱讀蘋(píng)果官方的Blocks Programming Topics文檔。如果你像我一樣已經(jīng)對(duì)block相當(dāng)熟悉,但是難以記住相關(guān)的語(yǔ)法,這個(gè)有趣的網(wǎng)站f*****gblocksyntax.com應(yīng)該能夠幫到你!(避免冒犯,我們屏蔽了相關(guān)單詞,但這個(gè)鏈接還是相當(dāng)實(shí)用的。)

簡(jiǎn)單轉(zhuǎn)換

如果你剛才把代碼分解成數(shù)個(gè)RACSignal,現(xiàn)在就需要將它恢復(fù)成流式語(yǔ)法:

[[self.usernameTextField.rac_textSignal
  filter:^BOOL(id value) {
    NSString *text = value; // implicit cast
    return text.length > 3;
  }]
  subscribeNext:^(id x) {
    NSLog(@"%@", x);
  }];

在上面注釋所在的地方,id類型被隱式轉(zhuǎn)換為NSString類型,這樣做并不簡(jiǎn)練。但幸運(yùn)的是,由于這個(gè)block接收的值總為NSString類型,所以你可以直接改變它的入?yún)㈩愋汀8麓a如下:

[[self.usernameTextField.rac_textSignal
  filter:^BOOL(NSString *text) {
    return text.length > 3;
  }]
  subscribeNext:^(id x) {
    NSLog(@"%@", x);
  }];

編譯運(yùn)行,確保運(yùn)行結(jié)果跟先前的一致。

什么是事件(Event)

目前為止本教程提到了幾種不同的事件類型,但未曾詳述這些事件的結(jié)構(gòu)。而實(shí)際上,事件中可以包含任何類型的值!

為了證明這個(gè)觀點(diǎn),你需要往先前的管道中添加新的操作。更新viewDidLoad中你的代碼如下:

[[[self.usernameTextField.rac_textSignal
  map:^id(NSString *text) {
    return @(text.length);
  }]
  filter:^BOOL(NSNumber *length) {
    return [length integerValue] > 3;
  }]
  subscribeNext:^(id x) {
    NSLog(@"%@", x);
  }];

編譯運(yùn)行后,此時(shí)控制臺(tái)打印的是文本的長(zhǎng)度而非文本內(nèi)容:

2013-12-26 12:06:54.566 RWReactivePlayground[10079:a0b] 4
2013-12-26 12:06:54.725 RWReactivePlayground[10079:a0b] 5
2013-12-26 12:06:54.853 RWReactivePlayground[10079:a0b] 6
2013-12-26 12:06:55.061 RWReactivePlayground[10079:a0b] 7
2013-12-26 12:06:55.197 RWReactivePlayground[10079:a0b] 8
2013-12-26 12:06:55.300 RWReactivePlayground[10079:a0b] 9
2013-12-26 12:06:55.462 RWReactivePlayground[10079:a0b] 10
2013-12-26 12:06:55.558 RWReactivePlayground[10079:a0b] 11
2013-12-26 12:06:55.646 RWReactivePlayground[10079:a0b] 12

新添加的map方法通過(guò)提供的block對(duì)事件中的數(shù)據(jù)進(jìn)行了轉(zhuǎn)換。其對(duì)每一個(gè)接收到的事件都通過(guò)提供的block進(jìn)行處理,并將返回值作為next事件發(fā)送出去。在上面的代碼中,map方法就得到了NSString類型的輸入并獲取到它的長(zhǎng)度,之后轉(zhuǎn)換為NSNumber類型返回給下一步。

我們可以結(jié)合下圖進(jìn)一步理解它是如何運(yùn)作的:


如你所見(jiàn),在map方法之后的所有環(huán)節(jié)現(xiàn)在都接收到NSNumber類型的實(shí)例。你可以使用map方法將接收到的數(shù)據(jù)轉(zhuǎn)換成任何你想要的類型,只要轉(zhuǎn)換的目標(biāo)是一個(gè)對(duì)象。

注意:在上述例子中,text.length的類型是原始類型NSUInteger。為了將其作為事件的內(nèi)容,必須對(duì)其進(jìn)行封裝(boxed)。幸而Objective-C 已經(jīng)提供了一個(gè)語(yǔ)法糖去實(shí)現(xiàn)這一個(gè)功能——@(text.length)

游樂(lè)時(shí)間到此結(jié)束了!是時(shí)候運(yùn)用你目前為止學(xué)到的概念對(duì)ReactivePlaygroundapp的代碼進(jìn)行更新。你可以先把你從開(kāi)始學(xué)習(xí)本教程后添加的代碼統(tǒng)統(tǒng)清除掉。

創(chuàng)建狀態(tài)驗(yàn)證信號(hào)

你首先要做的是創(chuàng)建兩個(gè)信號(hào)用以驗(yàn)證用戶名跟密碼是否有效。在RWViewController.mviewDidLoad方法末添加如下代碼:

RACSignal *validUsernameSignal =
  [self.usernameTextField.rac_textSignal
    map:^id(NSString *text) {
      return @([self isValidUsername:text]);
    }];
 
RACSignal *validPasswordSignal =
  [self.passwordTextField.rac_textSignal
    map:^id(NSString *text) {
      return @([self isValidPassword:text]);
    }];

上述代碼使用map方法對(duì)兩個(gè)文本輸入框的rac_textSignal進(jìn)行轉(zhuǎn)換。輸出為一個(gè)封裝成NSNumber的布爾值。

下一步要做的是將這些信號(hào)繼續(xù)轉(zhuǎn)換,為文本輸入框提供一個(gè)合適的背景色。基本上來(lái)說(shuō),你可以訂閱這個(gè)信號(hào)并將結(jié)果直接應(yīng)用到文本輸入框上更新背景色。其中一種可行的實(shí)現(xiàn)如下:

[[validPasswordSignal
  map:^id(NSNumber *passwordValid) {
    return [passwordValid boolValue] ? [UIColor clearColor] : [UIColor yellowColor];
  }]
  subscribeNext:^(UIColor *color) {
    self.passwordTextField.backgroundColor = color;
  }];

(請(qǐng)先不要如上添加代碼,更加優(yōu)雅的實(shí)現(xiàn)還在后頭!)

從概念上講,你的目的是將這個(gè)信號(hào)的輸出直接賦值給文本輸入框的backgroundColor屬性。然而上面代碼是一種相當(dāng)糟糕的實(shí)現(xiàn),已經(jīng)完全落后了!

很幸運(yùn),ReactiveCocoa提供了一個(gè)宏讓你可以優(yōu)雅地實(shí)現(xiàn)這一點(diǎn)。直接在viewDidLoad的兩個(gè)信號(hào)下添加如下代碼:

RAC(self.passwordTextField, backgroundColor) =
  [validPasswordSignal
    map:^id(NSNumber *passwordValid) {
      return [passwordValid boolValue] ? [UIColor clearColor] : [UIColor yellowColor];
    }];
 
RAC(self.usernameTextField, backgroundColor) =
  [validUsernameSignal
    map:^id(NSNumber *passwordValid) {
     return [passwordValid boolValue] ? [UIColor clearColor] : [UIColor yellowColor];
    }];

RAC宏將一個(gè)信號(hào)的輸出和一個(gè)對(duì)象的屬性綁定起來(lái)。宏接受兩個(gè)參數(shù),第一個(gè)是包含改變屬性的對(duì)象,第二個(gè)為屬性的名字。每次當(dāng)信號(hào)發(fā)出一個(gè)新的事件,事件的值就會(huì)傳遞給綁定的屬性。

這難道不是一個(gè)相當(dāng)優(yōu)雅的解決方案么?

在編譯運(yùn)行前,找到updateUIState方法并移除頭兩行代碼:

self.usernameTextField.backgroundColor = self.usernameIsValid ? [UIColor clearColor] : [UIColor yellowColor];
self.passwordTextField.backgroundColor = self.passwordIsValid ? [UIColor clearColor] : [UIColor yellowColor];

去掉原來(lái)的非響應(yīng)式代碼。

這時(shí)再編譯運(yùn)行。文本輸入框就會(huì)在內(nèi)容無(wú)效的情況下高亮,在有效時(shí)底色又變回透明。

由于圖像更有助理解,我們把現(xiàn)在的邏輯圖像化。在下圖你可以看到兩個(gè)獲取文本信號(hào)的管道,先把他們映射為檢驗(yàn)有效性的布爾值,然后再映射為與文本輸入框的底色屬性綁定的UIColor


你是否對(duì)單獨(dú)創(chuàng)建validPasswordSignalvalidUsernameSignal信號(hào)感到不解?為什么不直接為文本輸入框創(chuàng)建一個(gè)連續(xù)流暢的管道呢?親愛(ài)的讀者耐心點(diǎn),這瘋狂舉動(dòng)背后的真正目的將立馬揭曉!

信號(hào)合成

在當(dāng)前App,登錄按鈕設(shè)定只有在用戶名和密碼都有效輸入時(shí)才能使用。我們現(xiàn)在要做的就是用響應(yīng)式來(lái)重實(shí)現(xiàn)這個(gè)功能!

現(xiàn)在的代碼已經(jīng)實(shí)現(xiàn)了兩個(gè)發(fā)送布爾值的信號(hào)validUsernameSignalvalidPasswordSignal來(lái)對(duì)用戶名和密碼的輸入進(jìn)行驗(yàn)證。接下來(lái)的任務(wù)就是合成這兩個(gè)信號(hào),用以共同決定登錄按鈕是否可用。

viewDidLoad的末端添加如下代碼:

RACSignal *signUpActiveSignal =
  [RACSignal combineLatest:@[validUsernameSignal, validPasswordSignal]
                    reduce:^id(NSNumber *usernameValid, NSNumber *passwordValid) {
                      return @([usernameValid boolValue] && [passwordValid boolValue]);
                    }];

上面的代碼使用combineLatest:reduce:方法獲取validUsernameSignalvalidPasswordSignal的最近一個(gè)信號(hào)值并組合成一個(gè)全新的信號(hào)。每當(dāng)兩個(gè)源信號(hào)的其中一個(gè)發(fā)送新值,reduce里的block代碼塊就會(huì)執(zhí)行,其返回的值會(huì)作為合成信號(hào)的值發(fā)送出去。

注意:RACSignal合成方法可以合成任意數(shù)量的信號(hào),而reduce block的入?yún)⒑驮葱盘?hào)一一對(duì)應(yīng)。ReactiveCocoa有一個(gè)巧妙的工具類RACBlockTrampoline,用以內(nèi)部處理reduce block的可變?nèi)雲(yún)⒘斜怼?shí)際上,ReactiveCocoa的實(shí)現(xiàn)上還隱藏著很多精妙的技巧,非常值得我們深入探究!

現(xiàn)在你已經(jīng)有一個(gè)合適的信號(hào)了,在viewDidLoad的末端添加以下代碼。把信號(hào)與按鈕的可用屬性關(guān)聯(lián)起來(lái)。

[signUpActiveSignal subscribeNext:^(NSNumber *signupActive) {
   self.signInButton.enabled = [signupActive boolValue];
 }];

在運(yùn)行這代碼前,先把舊的實(shí)現(xiàn)去除。把文件頂部的這兩個(gè)屬性刪掉:

@property (nonatomic) BOOL passwordIsValid;
@property (nonatomic) BOOL usernameIsValid;

再把viewDidLoad的以下代碼刪掉:

// handle text changes for both text fields
[self.usernameTextField addTarget:self
                           action:@selector(usernameTextFieldChanged)
                 forControlEvents:UIControlEventEditingChanged];
[self.passwordTextField addTarget:self 
                           action:@selector(passwordTextFieldChanged)
                 forControlEvents:UIControlEventEditingChanged];

同時(shí)也去除updateUIStateusernameTextFieldChangedpasswordTextFieldChanged`方法。哇!你剛剛可是刪除了一大堆非響應(yīng)式的代碼啊!你之后絕對(duì)會(huì)慶幸你做的一切的。

最后,也別忘了從viewDidLoad中移除updateUIState的調(diào)用。

此時(shí)如果你編譯運(yùn)行,并留意一下登錄按鈕。就會(huì)發(fā)現(xiàn)它像原來(lái)一樣只有在用戶名和密碼有效的情況下有效。

更新應(yīng)用的邏輯圖如下:


這體現(xiàn)了兩個(gè)非常重要的概念,你可以憑借這兩點(diǎn)用ReactiveCocoa實(shí)現(xiàn)一些相當(dāng)強(qiáng)大的功能。

  • 拆分:信號(hào)可以有多個(gè)訂閱者和作為多個(gè)管道后續(xù)環(huán)節(jié)的來(lái)源。在上圖中,驗(yàn)證賬號(hào)和密碼的布爾值信號(hào)就被單獨(dú)拆分,并用于兩個(gè)不同的方面。
  • 組合:多個(gè)信號(hào)可以組合為全新的信號(hào)。在這個(gè)例子中,兩個(gè)布爾值信號(hào)被組合到了一起。但不局限于此,實(shí)際上你可以組合任意值類型的信號(hào)。

這些改動(dòng)讓?xiě)?yīng)用不再需要保留用以記錄兩個(gè)文本輸入框是否有效的私有屬性。這是你選用響應(yīng)式編程的其中一個(gè)關(guān)鍵差異——你不再需要使用實(shí)例變量去記錄這些瞬時(shí)狀態(tài)。

響應(yīng)式登錄

應(yīng)用現(xiàn)在已經(jīng)如上圖說(shuō)明那樣的響應(yīng)式管道去管理文本輸入框和按鈕的狀態(tài)。但是,按鈕的點(diǎn)擊事件任然是使用action機(jī)制,所以下一步我們就把剩下的應(yīng)用邏輯完全用響應(yīng)式操作替換掉!

登錄按鈕的觸摸事件(Touch Up Inside event)是在storyboard中與RWViewController.msignInButtonTouched方法進(jìn)行綁定的。由于你接下來(lái)需要使用響應(yīng)式的等效實(shí)現(xiàn)對(duì)其進(jìn)行替換,所以你首先要做的就是斷開(kāi)storyboard現(xiàn)有的綁定。

打開(kāi)Main.storyboard,按著ctrl同時(shí)點(diǎn)擊登錄按鈕,喚起outlet / action關(guān)聯(lián)視圖并點(diǎn)擊x刪除關(guān)聯(lián)。如果你感到迷惑,下圖為你標(biāo)記了刪除按鈕所在:


你已經(jīng)見(jiàn)識(shí)過(guò)ReactiveCocoa框架是怎樣在UIKit控件中添加屬性和方法的了。至今為止你使用過(guò)當(dāng)文本改變時(shí)發(fā)送事件的rac_textSignal。為了處理相關(guān)事件,你現(xiàn)在需要使用另一個(gè)ReactiveCocoa添加到UIKit的方法:rac_signalForControlEvents

回到RWViewController.m,在viewDidLoad末端添加以下代碼:

[[self.signInButton
   rac_signalForControlEvents:UIControlEventTouchUpInside]
   subscribeNext:^(id x) {
     NSLog(@"button clicked");
   }];

上面的代碼基于按鈕的UIControlEventTouchUpInside事件創(chuàng)建了一個(gè)信號(hào)并添加了訂閱,每當(dāng)這個(gè)事件觸發(fā)時(shí)就會(huì)進(jìn)行日志打印。

編譯運(yùn)行以確認(rèn)日志信息真能打印出來(lái)。記住按鈕只有在用戶名和密碼都有效時(shí)才能使用,所以在點(diǎn)擊前記得往兩個(gè)文本輸入框中輸入一些內(nèi)容。

你會(huì)在Xcode的控制臺(tái)看到相似如下的信息:

2013-12-28 08:05:10.816 RWReactivePlayground[18203:a0b] button clicked
2013-12-28 08:05:11.675 RWReactivePlayground[18203:a0b] button clicked
2013-12-28 08:05:12.605 RWReactivePlayground[18203:a0b] button clicked
2013-12-28 08:05:12.766 RWReactivePlayground[18203:a0b] button clicked
2013-12-28 08:05:12.917 RWReactivePlayground[18203:a0b] button clicked

現(xiàn)在登錄按鈕已經(jīng)有了一個(gè)點(diǎn)擊事件的信號(hào),下一步就是將它與登錄過(guò)程關(guān)聯(lián)起來(lái)。那么問(wèn)題來(lái)了——但這是好事,你也無(wú)懼任何困難對(duì)嗎?打開(kāi)RWDummySignInService.h并觀察提供的接口:

typedef void (^RWSignInResponse)(BOOL);
 
@interface RWDummySignInService : NSObject
 
- (void)signInWithUsername:(NSString *)username
                  password:(NSString *)password 
                  complete:(RWSignInResponse)completeBlock;
 
@end

這個(gè)服務(wù)接收用戶名,密碼和一個(gè)block作為參數(shù)。提供的block在登錄成功或失敗后執(zhí)行。你可以直接在subscribeNext:的block種使用這個(gè)接口,但為什么要這樣做?處理這種異步的、事件推動(dòng)的行為對(duì)ReactiveCocoa來(lái)說(shuō)簡(jiǎn)直是小菜一碟!

注意:為了簡(jiǎn)化,這個(gè)教程中使用了一個(gè)假的服務(wù),所以你不需要依賴于任何外部的接口。那么問(wèn)題來(lái)了,怎樣在不使用信號(hào)的情況下使用API呢?(譯注:原文為“However, you’ve now run up against a very real problem, how do you use APIs not expressed in terms of signals?”語(yǔ)義貌似與前后文不符,翻譯時(shí)相當(dāng)有些困惑,歡迎大家指教)

創(chuàng)建信號(hào)

很幸運(yùn),現(xiàn)有的異步API很容易就能改造成信號(hào)的形式。首先,從RWViewController.m文件移除signInButtonTouched: method方法。因?yàn)橹髸?huì)有等效的響應(yīng)式實(shí)現(xiàn),所以這不再需要了。

繼續(xù)在RWViewController.m添加以下方法:

-(RACSignal *)signInSignal {
  return [RACSignal createSignal:^RACDisposable *(id<RACSubscriber> subscriber) {
    [self.signInService
     signInWithUsername:self.usernameTextField.text
     password:self.passwordTextField.text
     complete:^(BOOL success) {
       [subscriber sendNext:@(success)];
       [subscriber sendCompleted];
     }];
    return nil;
  }];
}

上面的方法用用戶名和密碼創(chuàng)建了一個(gè)信號(hào)。現(xiàn)在先來(lái)分析一下它的組成部分。

上面的代碼使用RACSignalcreateSignal:方法創(chuàng)建信號(hào)。描述這個(gè)信號(hào)的block是這個(gè)方法唯一的入?yún)ⅰ.?dāng)這個(gè)信號(hào)有訂閱者的時(shí)候,block中的代碼就會(huì)執(zhí)行。

這個(gè)block傳入了一個(gè)遵守RACSubscriber協(xié)議的subscriber實(shí)例,實(shí)例中包含發(fā)送事件的方法;你可以發(fā)送任意數(shù)量的事件到下一環(huán)節(jié),也可以通過(guò)error或者complete事件終止信號(hào)。在這個(gè)例子中,subscriber實(shí)例發(fā)送了表示登錄結(jié)果的next事件,緊跟一個(gè)complete事件。

這個(gè)block的返回類型是一個(gè)RACDisposable對(duì)象,這讓你可以處理一些可能需要的清除工作,比如當(dāng)一個(gè)訂閱被取消或廢棄的時(shí)候。由于這個(gè)信號(hào)并不需要作清除處理,所以直接結(jié)果返回nil

如你所見(jiàn),把一個(gè)異步API包裝到信號(hào)中去是出乎意料的簡(jiǎn)單啊!

現(xiàn)在去使用一下這個(gè)新信號(hào)。更新在上一部分你添加到viewDidLoad末端的代碼如下:

[[[self.signInButton
   rac_signalForControlEvents:UIControlEventTouchUpInside]
   map:^id(id x) {
     return [self signInSignal];
   }]
   subscribeNext:^(id x) {
     NSLog(@"Sign in result: %@", x);
   }];

上面的代碼使用早前使用過(guò)的map方法將按鈕點(diǎn)擊信號(hào)轉(zhuǎn)換為登錄信號(hào)。然后訂閱者簡(jiǎn)單地打印了結(jié)果。

如果編譯運(yùn)行并點(diǎn)擊登錄事件,你就會(huì)在Xcode的控制臺(tái)看到上面代碼的運(yùn)行結(jié)果……
跟你想象中的大相徑庭!

2014-01-08 21:00:25.919 RWReactivePlayground[33818:a0b] Sign in result:
                                   <RACDynamicSignal: 0xa068a00> name: +createSignal:

subscribeNext的block的確已經(jīng)接收了一個(gè)信號(hào),但是并不是登錄信號(hào)的結(jié)果!

是時(shí)候分析一下這個(gè)管道好讓你明白中間發(fā)生了什么了:


當(dāng)你點(diǎn)擊按鈕的時(shí)候,rac_signalForControlEvents發(fā)送了一個(gè)next事件(源按鈕作為事件的值)。之后在map方法中創(chuàng)建并返回了登錄信號(hào),意味著接下來(lái)的管道環(huán)節(jié)現(xiàn)在接受到的是一個(gè)RACSignal。也就是你在subscribeNext:環(huán)節(jié)所獲取到的。

上面的狀況有時(shí)被稱為信號(hào)里的信號(hào)(signal of signals);換句話說(shuō)就是一個(gè)外部的信號(hào)包含著一個(gè)內(nèi)部的信號(hào)。如果你真的想這么做的話,你可以在外部信號(hào)的subscribeNext:block中訂閱內(nèi)部的信號(hào)。但這想必會(huì)讓代碼變得相當(dāng)混亂。幸而這是一個(gè)常見(jiàn)問(wèn)題,ReactiveCocoa已經(jīng)為這種情形提供了解決方法。

信號(hào)里的信號(hào)

這種問(wèn)題的解決方法相當(dāng)直接,只要把map方法像下面一樣替換成flattenMap方法就可以了:

[[[self.signInButton
   rac_signalForControlEvents:UIControlEventTouchUpInside]
   flattenMap:^id(id x) {
     return [self signInSignal];
   }]
   subscribeNext:^(id x) {
     NSLog(@"Sign in result: %@", x);
   }];

這把按鈕的點(diǎn)擊事件像之前一樣映射給了登錄信號(hào),但也將內(nèi)部信號(hào)的事件發(fā)送給了外部信號(hào)。
編譯運(yùn)行并留意控制臺(tái)。這時(shí)打印的應(yīng)該是登錄是否成功了:

2013-12-28 18:20:08.156 RWReactivePlayground[22993:a0b] Sign in result: 0
2013-12-28 18:25:50.927 RWReactivePlayground[22993:a0b] Sign in result: 1

多么令人振奮啊!

現(xiàn)在這管道已經(jīng)想你期望一樣運(yùn)作了,最后一步需要做的就是在subscribeNext添加登錄成功后的導(dǎo)航邏輯。替換代碼如下:

[[[self.signInButton
  rac_signalForControlEvents:UIControlEventTouchUpInside]
  flattenMap:^id(id x) {
    return [self signInSignal];
  }]
  subscribeNext:^(NSNumber *signedIn) {
    BOOL success = [signedIn boolValue];
    self.signInFailureText.hidden = success;
    if (success) {
      [self performSegueWithIdentifier:@"signInSuccess" sender:self];
    }
  }];

subscribeNext:block中獲取了登錄信號(hào)的結(jié)果,根據(jù)結(jié)果更新signInFailureText文本框的內(nèi)容,同時(shí)按需導(dǎo)航到下一頁(yè)面。編譯運(yùn)行,再次欣賞一下這可愛(ài)的貓咪吧!

你是否注意到現(xiàn)在的應(yīng)用有一個(gè)小小的用戶習(xí)慣問(wèn)題?當(dāng)?shù)卿浄?wù)驗(yàn)證時(shí),登錄按鈕應(yīng)當(dāng)是不可用的。這能夠避免用戶重復(fù)登錄。而且,如果登錄失敗了,當(dāng)用戶再次嘗試登錄時(shí)錯(cuò)誤信息應(yīng)當(dāng)隱藏起來(lái)。

但是怎樣添加這些邏輯到現(xiàn)有的管道中呢?影響按鈕可用狀態(tài)的跟改變,過(guò)濾或者其他你現(xiàn)今遇到過(guò)的概念掛不上勾。相對(duì)的,這其實(shí)是一種副作用,或者說(shuō)是當(dāng)新的事件發(fā)生時(shí)你希望管道執(zhí)行的邏輯,而那并不改變對(duì)應(yīng)事件的本質(zhì)。

添加副作用

替換現(xiàn)有代碼如下:

[[[[self.signInButton
   rac_signalForControlEvents:UIControlEventTouchUpInside]
   doNext:^(id x) {
     self.signInButton.enabled = NO;
     self.signInFailureText.hidden = YES;
   }]
   flattenMap:^id(id x) {
     return [self signInSignal];
   }]
   subscribeNext:^(NSNumber *signedIn) {
     self.signInButton.enabled = YES;
     BOOL success = [signedIn boolValue];
     self.signInFailureText.hidden = success;
     if (success) {
       [self performSegueWithIdentifier:@"signInSuccess" sender:self];
     }
   }];

你可以看到上面在按鈕點(diǎn)擊事件創(chuàng)建后添加了一個(gè)doNext:環(huán)節(jié)。注意doNext:是一個(gè)副作用,所以block沒(méi)有返回任何值;它并不影響事件的內(nèi)容。

doNext:的block中把按鈕的的可用屬性設(shè)為NO,同時(shí)隱藏了失敗文本。直到subscribeNext:的block中按鈕才再次變?yōu)榭捎茫⒏鶕?jù)登錄的結(jié)果決定顯示或隱藏失敗文本。

更新包含了副作用的管道圖表如下。


編譯運(yùn)行應(yīng)用,確保登錄按鈕像預(yù)想一樣切換可用與不可用的狀態(tài)。

而到了這里,你的工作已經(jīng)完成了——應(yīng)用已經(jīng)完全實(shí)現(xiàn)了響應(yīng)式重構(gòu)。

如果你在過(guò)程中感到疑惑,你可以下載最終的項(xiàng)目(包含了框架引用),你也可以從GitHub上獲取代碼,那里有對(duì)應(yīng)教程中每一步操作的提交記錄。

注意:在異步操作時(shí)禁用按鈕也是一個(gè)很常見(jiàn)的問(wèn)題,ReactiveCocoa同樣給出了解決方案。RACCommand封裝了這個(gè)概念,通過(guò)一個(gè)可用信號(hào)去關(guān)聯(lián)按鈕的可用屬性。你回頭也可以試試使用這個(gè)類。

最后

希望這篇教程能夠?yàn)槟阍谧约旱捻?xiàng)目上使用ReactiveCocoa建立了一個(gè)良好的基礎(chǔ)。要熟練運(yùn)用這些概念還需要一定的練習(xí),但如同其他編程語(yǔ)言一樣,一旦你掌握了,也不過(guò)如此。ReactiveCocoa最為核心的就是信號(hào),說(shuō)白了也就是事件流。還有比這個(gè)更加簡(jiǎn)單的么?

同時(shí)我發(fā)現(xiàn)在ReactiveCocoa中有趣的是,你能使用好幾種方式去解決同一個(gè)問(wèn)題。你可以試試在這個(gè)應(yīng)用中實(shí)踐一下,自己調(diào)整信號(hào)與管道的拆分與組合來(lái)達(dá)到相同的效果。

謹(jǐn)記使用ReactiveCocoa最重要的目的是使你的代碼更加整潔和易懂。就我個(gè)人而言,使用清晰的管道和流式語(yǔ)法能讓我更容易理解應(yīng)用的工作流程。

在本系列教程的下半部,你會(huì)學(xué)到一些更深層的主題,比如錯(cuò)誤的處理和在其他線程中執(zhí)行代碼。而在那之前,就先愉快地實(shí)踐一下暫時(shí)所學(xué)吧!

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,835評(píng)論 6 534
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場(chǎng)離奇詭異,居然都是意外死亡,警方通過(guò)查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 98,676評(píng)論 3 419
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái),“玉大人,你說(shuō)我怎么就攤上這事。” “怎么了?”我有些...
    開(kāi)封第一講書(shū)人閱讀 176,730評(píng)論 0 380
  • 文/不壞的土叔 我叫張陵,是天一觀的道長(zhǎng)。 經(jīng)常有香客問(wèn)我,道長(zhǎng),這世上最難降的妖魔是什么? 我笑而不...
    開(kāi)封第一講書(shū)人閱讀 63,118評(píng)論 1 314
  • 正文 為了忘掉前任,我火速辦了婚禮,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 71,873評(píng)論 6 410
  • 文/花漫 我一把揭開(kāi)白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開(kāi)封第一講書(shū)人閱讀 55,266評(píng)論 1 324
  • 那天,我揣著相機(jī)與錄音,去河邊找鬼。 笑死,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,330評(píng)論 3 443
  • 文/蒼蘭香墨 我猛地睜開(kāi)眼,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼!你這毒婦竟也來(lái)了?” 一聲冷哼從身側(cè)響起,我...
    開(kāi)封第一講書(shū)人閱讀 42,482評(píng)論 0 289
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒(méi)想到半個(gè)月后,有當(dāng)?shù)厝嗽跇?shù)林里發(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 49,036評(píng)論 1 335
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 40,846評(píng)論 3 356
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 43,025評(píng)論 1 371
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,575評(píng)論 5 362
  • 正文 年R本政府宣布,位于F島的核電站,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 44,279評(píng)論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開(kāi)封第一講書(shū)人閱讀 34,684評(píng)論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)。三九已至,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開(kāi)封第一講書(shū)人閱讀 35,953評(píng)論 1 289
  • 我被黑心中介騙來(lái)泰國(guó)打工, 沒(méi)想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個(gè)月前我還...
    沈念sama閱讀 51,751評(píng)論 3 394
  • 正文 我出身青樓,卻偏偏與公主長(zhǎng)得像,于是被迫代替她去往敵國(guó)和親。 傳聞我的和親對(duì)象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 48,016評(píng)論 2 375

推薦閱讀更多精彩內(nèi)容