翻譯自:http://www.raywenderlich.com/62796/reactivecocoa-tutorial-pt2
ReactiveCocoa 是一個框架,它允許你在你的iOS程序中使用函數響應式(FRP)技術。加上第一部分的講解,你將會學會如何使用信號量(對事件發出數據流)如何替代標准的動作和事件處理邏輯。你也會學到如何轉換、分離和組合這些信號量。
在這裡,也就是第二部分裡,你將會學到更多先進的ReactiveCocoa特性,包括:
1、另外兩個事件類型:error和completed
2、Throttling(節流)
3、Threading
4、Continuations
5、更多。。。
是時候開始了。
Twitter Instant
這裡我們要使用的貫穿整個教程的程序是叫做Twitter Instant的程序,該程序可以在你輸入的時候實時更新搜索到的結果。
該應用包括一些基本的用戶交互界面和一些平凡的代碼,了解之後就可以開始了。在第一部分裡面,你使用Cocoapods來把CocoaPods加載到你的工程裡面,這裡的工程裡面就已經包含了Podfile文件,你只需要pod install一下即可。
然後重新打開工程即可。(這個時候打開TwitterInstant.xcworkspace):
1、TwitterInstant:這是你的程序邏輯
2、Pods:裡面是包括的三方類庫
運行一下程序,你會看到如下的頁面:
花費一會時間讓你自己熟悉一下整個工程。它就是一個簡單的split viewController app.左邊的是RWSearchFormViewController,右邊的是:RWSearchResultsViewController。
自己說:原文簡單介紹了一下該工程,就不在介紹看一下就可以了。
驗證搜索文本
你第一件要做的事情就是去驗證一下搜索文本,讓它確保大於兩個字符串。如果你看了第一篇文章,這個將會很簡單。
在RWSearchFormViewController.m中添加方法如下:
- (BOOL)isValidSearchText:(NSString *)text { return text.length > 2; }
這就簡單的保證了搜索的字符串大於兩個字符。寫這個很簡單的邏輯你可能會問:為什麼要分開該方法到工程文件裡面呢?
當前的邏輯很簡單,但是如果後面這個會更復雜呢?在上面的例子中,你只需要修改一個地方。此外,上面的寫法讓你的代碼更有表現力,它告訴你為什麼要檢查string的長度。我們應該遵守好的編碼習慣,不是麼?
然後,我們導入頭文件:
#import
然後在導入該頭文件的文件裡面的viewDidLoad後面寫上如下代碼:
[[self.searchText.rac_textSignal map:^id(NSString *text) { return [self isValidSearchText:text] ? [UIColor whiteColor] : [UIColor yellowColor]; }] subscribeNext:^(UIColor *color) { self.searchText.backgroundColor = color; }];
想想這是做什麼呢?上面的代碼:
1、取走搜索文本框的信號量
2、把它轉換一下:用背景色來預示內容是否可用。
3、然後設置backgroundColor屬性在subscribeNext:的block裡面。
Build然後運行我們就會發現當搜索有效的時候就會是白色,搜索字符串無效的時候就是黃色。
下面是圖解,這個簡單的反應傳輸看起來如下:
ran_textSignal發出包含當前文本框每次改變內容的next事件。map那個步驟轉換text value,將其轉換成了color,subscribeNext那一步將這個value提供給了textField的background。
當然了,你從第一個教程一定記得這些,對吧?如果你不記得的話,你也許想在這裡停止閱讀,至少讀了整個測試工程。
在添加Twitter 搜索邏輯之前 ,這裡有一些更有趣的話題。
Formatting of Pipelines
當你正在鑽研格式化的ReactiveCocoa代碼的時候,普遍接受的慣例就是:每一個操作在一個新行,和所有步驟垂直對齊的。
在下面的圖片,你會看到更復雜的對齊方式,從上一個教程拿來的圖片:
這樣你會更容易看到該組成管道的操作。另外,在每個block中用最少的代碼任何超過幾行的都應該拆分出一個私有的方法。
不幸的是,Xcode真的不喜歡這種格式類型的代碼,因此你可能需要找到自己調整。
Memory Management
思考一下你剛才加入到TwitterInstant的代碼。你是否想過你剛才創建的管道式如何保留的呢?無疑地,是否是它沒有賦值為一個變量或者屬性他就不會有自己的引用計數,注定會消亡呢?
其中一個設計目標就是ReactiveCocoa允許這種類型的編程,這裡管道可以匿名形式。所有你寫過的響應式代碼都應該看起來比較直觀。
為了支持這種模型,ReactiveCocoa維持和保留自己全局的信號。如果它有一個或者多個subscribers(訂閱者),信號就會活躍。如果所有的訂閱者都移除掉了,信號就會被釋放。想了解更多關於ReactiveCocoa管理進程,可以參看Memory Management 文檔。
這就剩下了最後的問題:你如何從一個信號取消訂閱?當一個completed或者error事件之後,訂閱會自動的移除(一會就會學到)。手工的移除將會通過RACDisposable.
所有RACSignal的訂閱方法都會返回一個RACDisposable實例,它允許你通過處置方法手動的移除訂閱。下面是一個使用當前管道的快速的例子。
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];
你不會經常做這些,但是你必須知道可能性的存在。
Note:作為這些的一個推論,如果你創建了一個管道,但是你不給他訂閱,這個管道將不會執行,這些包括任何側面的影響,例如doNext:blocks。
Avoiding Retain Cycles
當ReactiveCocoa在場景背後做了好多聰明的事情—這就意味著你不必要擔心太多關於信號量的內存管理——這裡有一個很重要的內存喜愛那個管的問你你需要考慮。
如果你看到下面的響應式代碼你僅僅加入:
[[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來獲得一個textField的引用,Blocks在封閉返回內捕獲並且持有了值。因此在self和這個信號量之間造成了強引用,造成了循環引用。這取決於對象的生命周期,如果他的生命周期是應用程序的生命周期,那這樣是沒關系的,但是在更復雜的應用中就不行了。
為了避免這種潛在的循環引用,蘋果官方文檔: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; }];
在上面的代碼中,bself就是self標記為__weak(使用它可以make一個弱引用)的引用,現在可以看到使用textField的時候使用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傳遞的強引用變量。
Note:如果你對@weakify和@strongify感興趣,可以進入RACEXTSCope.h中查看其實現。
最後一個提醒,當在Blocks使用實例變量的時候要小心,這樣也會導致block捕獲一個self的強引用。你可以打開編譯警告去告訴你你的代碼有這個問題。
好了,你從理論中幸存出來了,恭喜。現在你變得更加明智,准備移步到有趣的環節:添加一些真實的函數到你的工程裡面。
Requesting Access to Twitter
為了在TwitterInstant 應用中去搜索Tweets,你將會用到社交框架(Social Framework)。為了訪問Twitter你需要使用Accounts Framework。
在你添加代碼之前,你需要到模擬器中輸入你的賬號:
設置好賬號之後,然後你只需要在RWSearchFormViewController.m中導入以下文件即可:
#import #import
然後在引入的頭文件下面寫如下的代碼:
typedef NS_ENUM(NSInteger, RWTwitterInstantError) { RWTwitterInstantErrorAccessDenied, RWTwitterInstantErrorNoTwitterAccounts, RWTwitterInstantErrorInvalidResponse}; static NSString * const RWTwitterInstantDomain = @"TwitterInstant";
你將會使用這些簡單地鑒定錯誤。然後在interface和end之間聲明兩個屬性:
@property (strong, nonatomic) ACAccountStore *accountStore; @property (strong, nonatomic) ACAccountType *twitterAccountType;
ACAccountsStore類提供訪問你當前設備有的social賬號,ACAccountType類代表指定類型的賬戶。
然後在viewDidLoad裡面加入以下代碼:
self.accountStore = [[ACAccountStore alloc] init]; self.twitterAccountType = [self.accountStore accountTypeWithAccountTypeIdentifier:ACAccountTypeIdentifierTwitter];
這些代碼創建了賬戶存儲和Twitter賬號標示。在.m中添加如下方法:
- (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; }]; }
這個方法的作用是:
1、定義了如果用戶拒絕訪問的錯誤
2、根據第一個入門教程,類方法createSignal返回了一個RACSignal的實例。
3、通過賬戶存儲請求訪問Twitter。在這一點上,用戶將看到一個提示,要求他們給予這個程序訪問Twitter賬戶的彈框。
4、當用戶同意或者拒絕訪問,信號事件就會觸發。如果用戶同意訪問,next事件將會緊隨而來,然後是completed發送,如果用戶拒絕訪問,error事件會觸發。
如果你回想其第一個入門教程,一個信號可以以三種不同的事件發出:
1、next
2、completed
3、error
超過了signal的生命周期,它將不會發出任何信號事件。
最後,為了充分利用信號,在viewDidLoad後面添加如下代碼;
[[self requestAccessToTwitterSignal] subscribeNext:^(id x) { NSLog(@"Access granted"); } error:^(NSError *error) { NSLog(@"An error occurred: %@", error); }];
如果你運行程序,將會看到一個彈出框:
提示是否允許訪問權限,如果ok,則打印出來Access granted ,否則將會走error。
Accounts Framework會記住你的決定,因此如果想再次測試,你需要針對模擬機進行:Reset Contents and Settings。
Chaining Signals
一旦用戶允許訪問Twitter賬戶,為了執行twitter,程序將會不斷監聽搜索內容textField的變化.
程序需要等待信號,它請求訪問Twitter去發出completed事件,然後訂閱textField的信號。不同信號連續的鏈是一個共有的問題,但是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事件發出,然後訂閱者通過自己的block參數返回,這有效地將控制從一個信號傳遞給下一個。
Note:上面已經寫過了@weakly(self);所以這裡就不用再寫了。
then方法傳遞error事件。因此最後的subscribeNext:error: block還接收初始的訪問請求錯誤。
當你運行的時候,然後允許訪問,你應該可以在控制台看到打印出來的你輸入的東西。
然後,添加filter操作到管道去移除無效的搜索字符串。在這個實例中,他們是不到三個字符的string:
[[[[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); }];
運行就可以在控制台看到只有三個以上的才能輸出。
圖解一下上邊的管道:
程序管道從requestAccessToTwitterSignal信號開始,然後轉換到tac_textSignal。同事next事件通過filter,最後到達訂閱block.你也可以看到任何通過第一步的error事件。
現在你有一個發出搜索text的信號,它可以用來搜索Twitter了。很有趣吧。
Searching Twitter
Social Framework是一個訪問Twitter 搜索API的選項。然而,它並無法響應搜索,下一步就是給信號包括API請求方法。在當前的控制器中,添加如下方法:
- (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; }
這創建了一個請求:搜索Twitter(V.1.1REST API)。這個是調用Twitter的api。
下一步就是創建一個基於request的信號量。添加如下方法:
- (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; }];}
然後在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]; }] subscribeNext:^(id x) { NSLog(@"%@", x); } error:^(NSError *error) { NSLog(@"An error occurred: %@", error); }];
運行:
即可在控制台裡面打印出來篩選的數據。
Threading
我很確信你這會亟待把JSON數據放到UI裡面,但是在放到UI裡面之前你需要做最後一件事:找到他是什麼,你需要做一些探索!
添加一個端點到subscribeNext:error:那個步,然後我們會看到Xcode左側的Thread,我們發現如果想加載圖片的話必須在主線程裡面,但是他不在主線程中,所以我們就可以做如下操作:
[[[[[[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); }];
這樣就會在主線程中運行。也就是更新了管道:添加了deliverOn:操作。
然後再次運行我們就會發現他是在主線程上執行了。這樣你就可以更新UI了。
Updating the UI
這裡用到了另一個庫:LinqToObjectiveC。安裝方式就不說了和ReactiveCocoa一樣
我們在RWSearchFormViewController中導入:
#import "RWTweet.h" #import "NSArray+LinqExtensions.h"
然後在輸出json數據的地方修改如下:
[[[[[[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); }];
運行:
就可以看到右側的詳情頁面加載到數據了。剛引入的類庫其實就是將json數據轉換成了model.加載數據的效果如下:
Asynchronous Loading of Images
現在內容都加載出來了,就差圖片了。在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]; }
這會你一ing該就會很熟悉這種模式了。然後在tableview:cellForRowAtIndex:方法裡面添加:
cell.twitterAvatarView.image = nil; [[[self signalForLoadingImage:tweet.profileImageUrl] deliverOn:[RACScheduler mainThreadScheduler]] subscribeNext:^(UIImage *image) { cell.twitterAvatarView.image = image; }];
再次運行就可以出來效果了:
Throttling(限流)
你可能注意到這個問題:每次輸入一個字符串都會立即執行然後導致刷新太快 ,導致每秒會顯示幾次搜索結果。這不是理想的狀態。
一個好的解決方式就是如果搜索內容不變之後的時間間隔後在搜索比如500毫秒。
而ReactiveCocoa是這個工作變的如此簡單。
打開RWSearchFormViewController.m然後更新管道,調整如下:
[[[[[[[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); }];
你會發現這樣就可以了。throttle操作只是發送一個操作,這個操作在時間到之後繼續進行。
Wrap Up
現在我們知道ReactiveCocoa是多麼的優雅。
最後一點:ReactiveCocoa使使用MVVM成為可能。
附:最終代碼