ReactiveCocoa是一個框架,它能讓你在iOS應用中使用函式響應式程式設計(FRP)技術。在本系列教程的第一部分中,你學到了如何將標準的動作與事件處理邏輯替換為傳送事件流的訊號。你還學到了如何轉換、分割和聚合這些訊號。
在本系列教程的第二部分,你將會學到一些ReactiveCocoa的高階功能,包括:
- 另外兩個事件型別:error 和 completed
- 節流
- 執行緒
- 延伸
- 其他
是時候深入研究一下了。
Twitter Instant
在本教程中你將要開發的應用叫Twitter Instant(基於Google Instant的概念),這個應用能搜尋Twitter上的內容,並根據輸入實時更新搜尋結果。
這個應用的初始工程包括一些基本的UI和必須的程式碼。和第一部分一樣,你需要使用CocoaPods來獲取ReactiveCocoa框架,並整合到專案中。初始工程已經包含必須的Podfile,所以開啟終端,執行下面的命令:
pod install
如果執行正確的話,你能看到和下面類似的輸出:
Analyzing dependencies Downloading dependencies Using ReactiveCocoa (2.1.8) Generating Pods project Integrating client project
這會生成一個Xcode workspcae,TwitterInstant.xcworkspace 。在Xcode中開啟它,確認其中包含兩個專案:
- TwitterInstant :應用的邏輯就在這裡。
- Pods :這裡是外部依賴。目前只包含ReactiveCocoa。
構建執行,就能看到下面的介面:
花一些時間來熟悉應用的程式碼。這個是一個很簡單的應用,基於split view controller。左欄是RWSearchFormViewController,它通過storyboard在上面新增了一些UI控制元件,通過outlet連線了search text field。右欄是RWSearchResultsViewController,目前只是UITableViewController的子類。
開啟RWSearchFormViewController.m,能看到在viewDidLoad方法中,首先定位到results view controller,然後把它分配給resultsViewController私有屬性。應用的主要邏輯都會集中在RWSearchFormViewController,這個屬效能把搜尋結果提供給RWSearchResultsViewController。
驗證搜尋文字的有效性
首先要做的就是驗證搜尋文字,來確保文字長度大於2個字元。如果你完成了本系列教程的第一部分,那這個應該很熟悉。
在RWSearchFormViewController.m中的viewDidLoad 下面新增下面的方法:
- (BOOL)isValidSearchText:(NSString *)text { return text.length > 2; }
這個方法就只是確保要搜尋的字串長度大於2個字元。這個邏輯很簡單,你可能會問“為什麼要在工程檔案中寫這麼一個單獨的方法呢?”。
目前驗證輸入有效性的邏輯的確很簡單,但如果將來邏輯需要變得更復雜呢?如果是像上面的例子中那樣,那你就只需要修改一個地方。而且這樣寫能讓你程式碼的可讀性更高,程式碼本身就說明了你為什麼要檢查字串的長度。
在RWSearchFormViewController.m的最上面,引入ReactiveCocoa:
#import <ReactiveCocoa.h>
把下面的程式碼加到viewDidLoad的最下面 :
[[self.searchText.rac_textSignal map:^id(NSString *text) { return [self isValidSearchText:text] ? [UIColor whiteColor] : [UIColor yellowColor]; }] subscribeNext:^(UIColor *color) { self.searchText.backgroundColor = color; }];
上面的程式碼做了什麼呢?
- 獲取search text field 的text signal
- 將其轉換為顏色來標示輸入是否有效
- 然後在subscribeNext:block裡將顏色應用到search text field的backgroundColor屬性
構建執行,觀察在輸入文字過短時,text field的背景會變成黃色來標示輸入無效。
用圖形來表示的話,流程和下面的類似:
當text field中的文字每次發生變化時,rac_textSignal都會傳送一個next 事件,事件包含當前text field中的文字。map這一步將文字值轉換成了顏色值,所以subscribeNext:這一步會拿到這個顏色值,並應用在text field的背景色上。
你應該還記得本系列教程第一部分裡這些內容吧?如果忘了,建議你先停在這裡,回去看一下第一部分。
在新增Twitter搜尋邏輯之前,還有一些有意思的話題要說說。
記憶體管理
看一下你新增到TwitterInstant中的程式碼,你是否好奇建立的這些管道是如何持有的呢?顯然,它並沒有分配給某個變數或是屬性,所以它也不會有引用計數的增加,那它是怎麼銷燬的呢?
ReactiveCocoa設計的一個目標就是支援匿名生成管道這種程式設計風格。到目前為止,在你所寫的所有響應式程式碼中,這應該是很直觀的。
為了支援這種模型,ReactiveCocoa自己持有全域性的所有訊號。如果一個signal有一個或多個訂閱者,那這個signal就是活躍的。如果所有的訂閱者都被移除了,那這個訊號就能被銷燬了。更多關於ReactiveCocoa如何管理這一過程,參見文件Memory Management。
上面說的就引出了最後一個問題:如何取消訂閱一個signal?在一個completed或者error事件之後,訂閱會自動移除(馬上就會講到)。你還可以通過RACDisposable 手動移除訂閱。
RACSignal的訂閱方法都會返回一個RACDisposable例項,它能讓你通過dispose方法手動移除訂閱。下面是一個例子:
RACSignal *backgroundColorSignal = [self.searchText.rac_textSignal map:^id(NSString *text) { return [self isValidSearchText:text] ? [UIColor whiteColor] : [UIColor yellowColor]; }]; RACDisposable *subscription = [backgroundColorSignal subscribeNext:^(UIColor *color) { self.searchText.backgroundColor = color; }]; // at some point in the future ... [subscription dispose];
你會發現這個方法並不常用到,但是還是有必要知道可以這樣做。
注意:根據上面所說的,如果你建立了一個管道,但是沒有訂閱它,這個管道就不會執行,包括任何如doNext: block的附加操作。
避免迴圈引用
ReactiveCocoa已經在幕後做了很多事情,這也就意味著你並不需要太多關注signal的記憶體管理。但是還有一個很重要的記憶體相關問題你需要注意。
看一下你剛才新增的程式碼:
[[self.searchText.rac_textSignal map:^id(NSString *text) { return [self isValidSearchText:text] ? [UIColor whiteColor] : [UIColor yellowColor]; }] subscribeNext:^(UIColor *color) { self.searchText.backgroundColor = color; }];
subscribeNext:block中使用了self來獲取text field的引用。block會捕獲並持有其作用域內的值。因此,如果self和這個訊號之間存在一個強引用的話,就會造成迴圈引用。迴圈引用是否會造成問題,取決於self物件的生命週期。如果self的生命週期是整個應用執行時,比如說本例,那也就無傷大雅。但是在更復雜一些的應用中,就不是這麼回事了。
為了避免潛在的迴圈引用,Apple的文件Working With Blocks中建議獲取一個self的弱引用。用本例來說就是下面這樣的:
__weak RWSearchFormViewController *bself = self; // Capture the weak reference [[self.searchText.rac_textSignal map:^id(NSString *text) { return [self isValidSearchText:text] ? [UIColor whiteColor] : [UIColor yellowColor]; }] subscribeNext:^(UIColor *color) { bself.searchText.backgroundColor = color; }];
在上面的程式碼中,__weak修飾符使bself成為了self的一個弱引用。注意現在subscribeNext:block中使用bself變數。不過這種寫法看起來不是那麼優雅。
ReactiveCocoa框架包含了一個語法糖來替換上面的程式碼。在檔案頂部新增下面的程式碼:
#import "RACEXTScope.h"
然後把程式碼替換成下面的:
@weakify(self) [[self.searchText.rac_textSignal map:^id(NSString *text) { return [self isValidSearchText:text] ? [UIColor whiteColor] : [UIColor yellowColor]; }] subscribeNext:^(UIColor *color) { @strongify(self) self.searchText.backgroundColor = color; }];
上面的@weakify 和 @strongify 語句是在Extended Objective-C庫中定義的巨集,也被包括在ReactiveCocoa中。@weakify巨集讓你建立一個弱引用的影子物件(如果你需要多個弱引用,你可以傳入多個變數),@strongify讓你建立一個對之前傳入@weakify物件的強引用。
注意:如果你有興趣瞭解@weakify 和 @strongify 實際上做了什麼,在Xcode中,選擇Product -> Perform Action -> Preprocess “RWSearchForViewController”。這會對view controller 進行預處理,展開所有的巨集,以便你能看到最終的輸出。
最後需要注意的一點,在block中使用例項變數時請小心謹慎。這也會導致block捕獲一個self的強引用。你可以開啟一個編譯警告,當發生這個問題時能提醒你。在專案的build settings中搜尋“retain”,找到下面顯示的這個選項:
好了,你已經通過理論的考驗,祝賀你。現在你應該能夠開始有意思的部分了:為你的應用新增一些真正的功能!
注意:你們中一些眼尖的讀者,那些關注了上一篇教程的讀者,無疑已經注意到可以在目前的管道中移除subscribeNext:block,轉而使用RAC巨集。如果你發現了這個,修改程式碼,然後獎勵自己一個小星星吧~
請求訪問Twitter
你將要使用Social Framework來讓TwitterInstant應用能搜尋Twitter的內容,使用Accounts Framework來獲取Twitter的訪問許可權。關於Social Framework的更詳細內容,參見iOS 6 by Tutorials中的相關章節。
在你新增程式碼之前,你需要在模擬器或者iPad真機上輸入Twitter的登入資訊。開啟設定應用,選擇Twitter選項,然後在螢幕右邊的頁面中輸入登入資訊。
初始工程已經新增了需要的框架,所以你只需引入標頭檔案。在RWSearchFormViewController.m中,新增下面的引用。
#import <Accounts/Accounts.h> #import <Social/Social.h>
就在引用的下面,新增下面的列舉和常量:
typedef NS_ENUM(NSInteger, RWTwitterInstantError) { RWTwitterInstantErrorAccessDenied, RWTwitterInstantErrorNoTwitterAccounts, RWTwitterInstantErrorInvalidResponse }; static NSString * const RWTwitterInstantDomain = @"TwitterInstant";
一會兒你就要用到它們來標示錯誤。
還是在這個檔案中,在已有屬性宣告的下面,新增下面的程式碼:
@property (strong, nonatomic) ACAccountStore *accountStore;
@property (strong, nonatomic) ACAccountType *twitterAccountType;
ACAccountsStore類能讓你訪問你的裝置能連線到的多個社交媒體賬號,ACAccountType類則代表賬戶的型別。
還是在這個檔案中,把下面的程式碼新增到viewDidLoad的最下面:
self.accountStore = [[ACAccountStore alloc] init]; self.twitterAccountType = [self.accountStore accountTypeWithAccountTypeIdentifier:ACAccountTypeIdentifierTwitter];
上面的程式碼建立了一個account store和Twitter賬戶識別符號。
當應用獲取訪問社交媒體賬號的許可權時,使用者會看見一個彈框。這是一個非同步操作,因此把這封裝進一個signal是很好的選擇。
還是在這個檔案中,新增下面的程式碼:
- (RACSignal *)requestAccessToTwitterSignal { // 1 - define an error NSError *accessError = [NSError errorWithDomain:RWTwitterInstantDomain code:RWTwitterInstantErrorAccessDenied userInfo:nil]; // 2 - create the signal @weakify(self) return [RACSignal createSignal:^RACDisposable *(id subscriber) { // 3 - request access to twitter @strongify(self) [self.accountStore requestAccessToAccountsWithType:self.twitterAccountType options:nil completion:^(BOOL granted, NSError *error) { // 4 - handle the response if (!granted) { [subscriber sendError:accessError]; } else { [subscriber sendNext:nil]; [subscriber sendCompleted]; } }]; return nil; }]; }
這個方法做了下面幾件事:
- 定義了一個error,當使用者拒絕訪問時傳送。
- 和第一部分一樣,類方法createSignal返回一個RACSignal例項。
- 通過account store請求訪問Twitter。此時使用者會看到一個彈框來詢問是否允許訪問Twitter賬戶。
- 在使用者允許或拒絕訪問之後,會傳送signal事件。如果使用者允許訪問,會傳送一個next事件,緊跟著再傳送一個completed事件。如果使用者拒絕訪問,會傳送一個error事件。
回憶一下教程的第一部分,signal能傳送3種不同型別的事件:
- Next
- Completed
- Error
在signal的生命週期中,它可能不傳送事件,傳送一個或多個next事件,在這之後還能傳送一個completed事件或一個error事件。
最後,為了使用這個signal,把下面的程式碼新增到viewDidLoad的最下面:
[[self requestAccessToTwitterSignal] subscribeNext:^(id x) { NSLog(@"Access granted"); } error:^(NSError *error) { NSLog(@"An error occurred: %@", error); }];
構建執行,應該能看到下面這樣的提示:
如果你點選OK,控制檯裡就會顯示subscribeNext:block中的log資訊了。如果你點選Don't Allow,那麼錯誤block就會執行,並且列印相應的log資訊。
Acounts Framework會記住你的選擇。因此為了測試這兩個選項,你需要通過 iOS Simulator -> Reset Contents and Settings 來重置模擬器。這個有點麻煩,因為你還需要再次輸入Twitter的登入資訊。
連結signal
一旦使用者允許訪問Twitter賬號(希望如此),應用就應該一直監測search text filed的變化,以便搜尋Twitter的內容。
應用應該等待獲取訪問Twitter許可權的signal傳送completed事件,然後再訂閱text field的signal。按順序連結不同的signal是一個常見的問題,但是ReactiveCocoa處理的很好。
把viewDidLoad中當前管道的程式碼替換成下面的:
[[[self requestAccessToTwitterSignal] then:^RACSignal *{ @strongify(self) return self.searchText.rac_textSignal; }] subscribeNext:^(id x) { NSLog(@"%@", x); } error:^(NSError *error) { NSLog(@"An error occurred: %@", error); }];
then方法會等待completed事件的傳送,然後再訂閱由then block返回的signal。這樣就高效地把控制權從一個signal傳遞給下一個。
注意:你在之前的程式碼中已經把self轉成弱引用了,所以就不用在這個管道之前再寫@weakify(self)了。
then方法會跳過error事件,因此最終的subscribeNext:error: block還是會收到獲取訪問許可權那一步傳送的error事件。
構建執行,然後允許訪問,你應該能看到search text field的輸入會在控制檯裡輸出。
2014-01-04 08:16:11.444 TwitterInstant[39118:a0b] m 2014-01-04 08:16:12.276 TwitterInstant[39118:a0b] ma 2014-01-04 08:16:12.413 TwitterInstant[39118:a0b] mag 2014-01-04 08:16:12.548 TwitterInstant[39118:a0b] magi 2014-01-04 08:16:12.628 TwitterInstant[39118:a0b] magic 2014-01-04 08:16:13.172 TwitterInstant[39118:a0b] magic!
接下來,在管道中新增一個filter操作來過濾掉無效的輸入。在本例裡就是長度不夠3個字元的字串:
[[[[self requestAccessToTwitterSignal] then:^RACSignal *{ @strongify(self) return self.searchText.rac_textSignal; }] filter:^BOOL(NSString *text) { @strongify(self) return [self isValidSearchText:text]; }] subscribeNext:^(id x) { NSLog(@"%@", x); } error:^(NSError *error) { NSLog(@"An error occurred: %@", error); }];
再次構建執行,觀察過濾器的工作:
2014-01-04 08:16:12.548 TwitterInstant[39118:a0b] magi 2014-01-04 08:16:12.628 TwitterInstant[39118:a0b] magic 2014-01-04 08:16:13.172 TwitterInstant[39118:a0b] magic!
現在用圖形來表示管道,就和下圖類似:
管道從requestAccessToTwitterSignal 開始,然後轉換為rac_textSignal。同時,next事件通過一個filter,最終到達訂閱者的block。你還能看到第一步傳送的error事件也是由subscribeNext:error:block來處理的。
現在你已經有了一個傳送搜尋文字的signal了,是時候來搜尋Twitter的內容了。你現在覺得還好嗎?我覺得應該還不錯哦~
搜尋Twitter的內容
你可以使用Social Framework來獲取Twitter搜尋API,但的確如你所料,Social Framework不是響應式的。那麼下一步就是把所需的API呼叫封裝進signal中。你現在應該熟悉這個過程了。
在RWSearchFormViewController.m中,新增下面的方法:
- (SLRequest *)requestforTwitterSearchWithText:(NSString *)text { NSURL *url = [NSURL URLWithString:@"https://api.twitter.com/1.1/search/tweets.json"]; NSDictionary *params = @{@"q" : text}; SLRequest *request = [SLRequest requestForServiceType:SLServiceTypeTwitter requestMethod:SLRequestMethodGET URL:url parameters:params]; return request; }
方法建立了一個請求,請求通過v1.1 REST API來搜尋Twitter。上面的程式碼使用q這個搜尋引數來搜尋Twitter中包含有給定字串的微博。你可以在Twitter API 文件中來閱讀更多關於搜尋API和其他傳入引數的資訊。
下一步是基於這個請求建立signal。在同一個檔案中,新增下面的方法:
- (RACSignal *)signalForSearchWithText:(NSString *)text { // 1 - define the errors NSError *noAccountsError = [NSError errorWithDomain:RWTwitterInstantDomain code:RWTwitterInstantErrorNoTwitterAccounts userInfo:nil]; NSError *invalidResponseError = [NSError errorWithDomain:RWTwitterInstantDomain code:RWTwitterInstantErrorInvalidResponse userInfo:nil]; // 2 - create the signal block @weakify(self) return [RACSignal createSignal:^RACDisposable *(id subscriber) { @strongify(self); // 3 - create the request SLRequest *request = [self requestforTwitterSearchWithText:text]; // 4 - supply a twitter account NSArray *twitterAccounts = [self.accountStore accountsWithAccountType:self.twitterAccountType]; if (twitterAccounts.count == 0) { [subscriber sendError:noAccountsError]; } else { [request setAccount:[twitterAccounts lastObject]]; // 5 - perform the request [request performRequestWithHandler: ^(NSData *responseData, NSHTTPURLResponse *urlResponse, NSError *error) { if (urlResponse.statusCode == 200) { // 6 - on success, parse the response NSDictionary *timelineData = [NSJSONSerialization JSONObjectWithData:responseData options:NSJSONReadingAllowFragments error:nil]; [subscriber sendNext:timelineData]; [subscriber sendCompleted]; } else { // 7 - send an error on failure [subscriber sendError:invalidResponseError]; } }]; } return nil; }]; }
分別講一下每個步驟:
- 首先需要定義2個不同的錯誤,一個表示使用者還沒有新增任何Twitter賬號,另一個表示在請求過程中發生了錯誤。
- 和之前的一樣,建立一個signal。
- 用你之前寫的方法,給需要搜尋的文字建立一個請求。
- 查詢account store來找到可用的Twitter賬號。如果沒有賬號的話,傳送一個error事件。
- 執行請求。
- 在請求成功的事件裡(http響應碼200),傳送一個next事件,返回解析好的JSON資料,然後再傳送一個completed事件。
- 在請求失敗的事件裡,傳送一個error事件。
現在來使用這個新的signal!
在本教程的第一部分,你學過了如何使用flattenMap來把每個next事件對映到一個新的signal。現在又要用到了。在viewDidLoad的末尾更新你的管道,新增flattenMap這一步:
[[[[[self requestAccessToTwitterSignal] then:^RACSignal *{ @strongify(self) return self.searchText.rac_textSignal; }] filter:^BOOL(NSString *text) { @strongify(self) return [self isValidSearchText:text]; }] flattenMap:^RACStream *(NSString *text) { @strongify(self) return [self signalForSearchWithText:text]; }] subscribeNext:^(id x) { NSLog(@"%@", x); } error:^(NSError *error) { NSLog(@"An error occurred: %@", error); }];
構建執行,在search text field中輸入一些文字。當文字長度超過3個字元時,你應該就能在控制檯看到搜尋Twitter的結果了。
下面是一段你將會看到的資料:
2014-01-05 07:42:27.697 TwitterInstant[40308:5403] { "search_metadata" = { "completed_in" = "0.019"; count = 15; "max_id" = 419735546840117248; "max_id_str" = 419735546840117248; "next_results" = "?max_id=419734921599787007&q=asd&include_entities=1"; query = asd; "refresh_url" = "?since_id=419735546840117248&q=asd&include_entities=1"; "since_id" = 0; "since_id_str" = 0; }; statuses = ( { contributors = ""; coordinates = ""; "created_at" = "Sun Jan 05 07:42:07 +0000 2014"; entities = { hashtags = ...
signalForSearchText:方法還會傳送error事件到subscribeNext:error: block裡。你最好自己嘗試一下。
在模擬中開啟設定應用,選擇你的Twitter賬戶,然後按“Delete Account”刪除它。
再重新執行應用,現在還是允許訪問使用者的Twitter賬號,但是沒有可用的賬號。signalForSearchText:會傳送一個error,輸出如下:
2014-01-05 07:52:11.705 TwitterInstant[41374:1403] An error occurred: Error Domain=TwitterInstant Code=1 "The operation couldn’t be completed. (TwitterInstant error 1.)"
Code=1表示是RWTwitterInstantErrorNoTwitterAccounts錯誤。在實際的應用中,你可能需要判斷錯誤碼來做一些更有用的事情,而不只是列印到控制檯。
這表明了error事件很重要的一點,當signal傳送error後,會直接到達處理error的block。這是一個例外流程。
注意:當請求Twitter返回錯誤時也是一個例外流程,嘗試一下,比較簡單的方法就是把請求引數改成無效的。
執行緒
我相信你已經想把搜尋Twitter返回的JSON值和UI連線起來了,但是在這之前還有最後一個需要做的事情。現在需要稍微做一些探索,來看一下這到底是什麼!
在subscribeNext:error:中如下圖所示的地方加一個斷點:
重新執行應用。如果需要的話,再次輸入Twitter登入資訊。在search field中輸入一些內容。當在斷點停止時,你應該能看到和下圖類似的東西:
注意斷點停在的程式碼並沒有在主執行緒,也就是截圖中的Thread 1中執行。請記住你只能在主執行緒中更新UI。因此你需要切換執行緒來在UI中展示微博的列表。
這展示了ReactiveCocoa框架很重要的一點。上面顯示的操作會在signal最開始傳送事件的執行緒中執行。嘗試在管道的其他步驟新增斷點,你可能會驚奇的發現它們也是在不同執行緒上執行的。
所以接下來你要怎麼更新UI呢?通常的做法是使用操作佇列(參見教程如何使用 NSOperations 和 NSOperationQueues)。但是ReactiveCocoa有更簡單的解決辦法。
像下面的程式碼一樣,在flattenMap:之後新增一個deliverOn:操作:
[[[[[[self requestAccessToTwitterSignal] then:^RACSignal *{ @strongify(self) return self.searchText.rac_textSignal; }] filter:^BOOL(NSString *text) { @strongify(self) return [self isValidSearchText:text]; }] flattenMap:^RACStream *(NSString *text) { @strongify(self) return [self signalForSearchWithText:text]; }] deliverOn:[RACScheduler mainThreadScheduler]] subscribeNext:^(id x) { NSLog(@"%@", x); } error:^(NSError *error) { NSLog(@"An error occurred: %@", error); }];
現在重新執行,輸入一些內容,停在斷點。你應該能看到subscribeNext:error:block中的程式碼現在實在主執行緒執行了:
這是真的嗎?一個簡單的操作,就把事件流切換到不同的執行緒了?真的是太棒了!
現在你就能安全地更新UI啦!
注意:如果你看一下RACScheduler類,就能發現還有很多選項,比如不同的執行緒優先順序,或者在管道中新增延遲。
現在要展示那些微博了。
更新UI
如果你開啟RWSearchResultsViewController.h 就會發現已經有一個displayTweets:方法了,它會讓右邊的view controller根據提供的微博陣列來展示內容。實現非常簡單,就是一個標準的UITableView資料來源。displayTweets:方法需要的唯一一個引數就是包含RWTweet例項的陣列。RWTweet模型已經包含在初始工程裡了。
subscibeNext:error:裡收到的資料目前是在signalForSearchWithText:裡由返回的JSON值轉換得到的一個NSDictionary。所以你怎麼確定字典裡的內容呢?
看一下Twitter的API文件,那裡有返回值的樣例。NSDictionary和這個結構對應,所以你能找到一個叫“statuses”的鍵,它對應的值是一個包含微博的NSArray,每個條文也是NSDictionary例項。
RWTweet已經有一個類方法tweetWithStatus:,方法從NSDictionary中取得需要的資料。所以你需要的做的就是寫一個for迴圈,遍歷陣列,為每條微博建立一個RWTweet例項。
但我們這次不這麼做。還有更好的方法。
這篇文章是關於ReactiveCocoa和函數語言程式設計。如果用函式式API來實現把資料從一個格式轉換為另一個會優雅很多。你將會用到LinqToObjectiveC來完成這個任務。
關閉TwitterInstant workspace,然後在文字編輯中開啟之前建立的Podfile。加入新的依賴:
platform :ios, '7.0' pod 'ReactiveCocoa', '2.1.8' pod 'LinqToObjectiveC', '2.0.0'
在這個檔案中開啟終端,輸入下面的命令:
pod update
能看到輸出和下面的類似:
Analyzing dependencies Downloading dependencies Installing LinqToObjectiveC (2.0.0) Using ReactiveCocoa (2.1.8) Generating Pods project Integrating client project
再次開啟workspace,檢查新的pod是否和下圖一樣顯示出來:
開啟RWSearchFormViewController.m,新增下列引用:
#import "RWTweet.h" #import "NSArray+LinqExtensions.h"
NSArray+LinqExtensions.h標頭檔案是LinqToObjectiveC裡的,它為NSArray新增了許多方法,能讓你用流式API來轉換、排序、分組和過濾其中的資料。現在就來用一下
把viewDidLoad中的程式碼更新成下面這樣的:
[[[[[[self requestAccessToTwitterSignal] then:^RACSignal *{ @strongify(self) return self.searchText.rac_textSignal; }] filter:^BOOL(NSString *text) { @strongify(self) return [self isValidSearchText:text]; }] flattenMap:^RACStream *(NSString *text) { @strongify(self) return [self signalForSearchWithText:text]; }] deliverOn:[RACScheduler mainThreadScheduler]] subscribeNext:^(NSDictionary *jsonSearchResult) { NSArray *statuses = jsonSearchResult[@"statuses"]; NSArray *tweets = [statuses linq_select:^id(id tweet) { return [RWTweet tweetWithStatus:tweet]; }]; [self.resultsViewController displayTweets:tweets]; } error:^(NSError *error) { NSLog(@"An error occurred: %@", error); }];
在上面的程式碼中,subscribeNext:block首先獲取包含微博的陣列。然後linq_select方法對陣列中的每個元素執行提供的block,來把NSDictionary的陣列轉換成RWTweet的陣列。
轉換完成就把微博傳送給result view controller。
構建執行,終於能看到微博展示在UI中了:
注意:ReactiveCocoa 和 LinqToObjectiveC 靈感的來源相似。 ReactiveCocoa以微軟的 Reactive Extensions 庫為模型,而 LinqToObjectiveC 以 Language Integrated Query APIs或者說 LINQ為模型,特別是 Linq to Objects.
非同步載入圖片
你可能注意到了每條微博的左側有一段空隙,這是用來顯示Twitter使用者頭像的。
RWTweet類有一個屬性profileImageUrl來存放頭像的URL。為了讓table view能流暢地滾動,你需要讓用URL獲取影像的程式碼不在主執行緒中執行。你可以使用Grand Central Dispatch或者NSOperationQueue來實現。但是為什麼不用ReactiveCocoa呢?
開啟RWSearchResultsViewController.m,新增下面的方法:
-(RACSignal *)signalForLoadingImage:(NSString *)imageUrl { RACScheduler *scheduler = [RACScheduler schedulerWithPriority:RACSchedulerPriorityBackground]; return [[RACSignal createSignal:^RACDisposable *(id subscriber) { NSData *data = [NSData dataWithContentsOfURL:[NSURL URLWithString:imageUrl]]; UIImage *image = [UIImage imageWithData:data]; [subscriber sendNext:image]; [subscriber sendCompleted]; return nil; }] subscribeOn:scheduler]; }
你現在應該對這個模式已經很熟悉了。
上面的方法首先獲取一個後臺scheduler,來讓signal不在主執行緒執行。然後,建立一個signal來下載圖片資料,當有訂閱者時建立一個UIImage。最後是subscribeOn:來確保signal在指定的scheduler上執行。
太神奇了!
現在還是在這個檔案中,在tableView:cellForRowAtIndex:方法的return語句之前新增下面的程式碼:
cell.twitterAvatarView.image = nil; [[[self signalForLoadingImage:tweet.profileImageUrl] deliverOn:[RACScheduler mainThreadScheduler]] subscribeNext:^(UIImage *image) { cell.twitterAvatarView.image = image; }];
因為cell是重用的,可能有髒資料,所以上面的程式碼首先重置圖片。然後建立signal來獲取圖片資料。你之前也遇到過deliverOn:這一步,它會把next事件傳送到主執行緒,這樣subscribeNext:block就能安全執行了。
這麼簡單真是好。
構建執行,現在頭像就能正確地顯示出來了:
譯註:作者在原文評論中針對cell重用的問題更新了程式碼:
[[[[self signalForLoadingImage:tweet.profileImageUrl] takeUntil:cell.rac_prepareForReuseSignal] deliverOn:[RACScheduler mainThreadScheduler]] subscribeNext:^(UIImage *image) { cell.twitterAvatarView.image = image; }];
節流
你可能注意到了,每次輸入一個字,搜尋Twitter都會馬上執行。如果你輸入很快(或者只是一直按著刪除鍵),這可能會造成應用在一秒內執行好幾次搜尋。這很不理想,原因如下:首先,多次呼叫Twitter搜尋API,但大部分返回結果都沒有用。其次,不停地更新介面會讓使用者分心。
更好的解決方法是,當搜尋文字在短時間內,比如說500毫秒,不再變化時,再執行搜尋。
你可能也猜到了,用ReactiveCocoa來處理這個問題非常簡單!
開啟RWSearchFormViewController.m,在viewDidLoad中,在filter之後新增一個throttle步驟:
[[[[[[[self requestAccessToTwitterSignal] then:^RACSignal *{ @strongify(self) return self.searchText.rac_textSignal; }] filter:^BOOL(NSString *text) { @strongify(self) return [self isValidSearchText:text]; }] throttle:0.5] flattenMap:^RACStream *(NSString *text) { @strongify(self) return [self signalForSearchWithText:text]; }] deliverOn:[RACScheduler mainThreadScheduler]] subscribeNext:^(NSDictionary *jsonSearchResult) { NSArray *statuses = jsonSearchResult[@"statuses"]; NSArray *tweets = [statuses linq_select:^id(id tweet) { return [RWTweet tweetWithStatus:tweet]; }]; [self.resultsViewController displayTweets:tweets]; } error:^(NSError *error) { NSLog(@"An error occurred: %@", error); }];
只有當,前一個next事件在指定的時間段內沒有被接收到後,throttle操作才會傳送next事件。就是這麼簡單。
構建執行,確認一下當停止輸入超過500毫秒後,才會開始搜尋。感覺比之前好一些吧?你的使用者也會這麼想的。
到現在你的Twitter Instant應用已經完成了。放鬆一下,旋轉,跳躍,閉上眼吧~
如果你卡在教程中的某個地方了,可以下載最終的工程(再開啟之前別忘記執行pod install)。或者在Github上獲取這份程式碼,每一步的構建執行都有一個commit。
譯註:最終工程裡的程式碼和文章中的有一些區別。主要是在requestAccessToTwitterSignal方法。
總結
在你準備喝杯咖啡放鬆一下之前,還是有必要來總結一下應用最終的管道圖:
資料流還是挺複雜的,現在這全都用響應式的管道清晰地表現了出來。如果不用響應式的話,你能想象到這個應用會變得多複雜嗎?資料流會變得多混亂嗎?聽起來就很麻煩,還好你不用這麼做了。
現在你應該知道ReactiveCocoa有多棒了吧!
最後一點,ReactiveCocoa讓使用Model View ViewModel,或者說MVVM設計模式成為可能。MVVM能讓應用邏輯和檢視邏輯更好地分離。如果你想了解更多的話,就來看下一篇教程吧。