ReactiveCocoa教程:下半部【譯】

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

ReactiveCocoa框架讓你可以在iOS應用中使用響應式函數編程(FRP)。在教程的上半部分你學會了如何用發送事件流的信號替換標準的動作和事件處理邏輯,還有如何對這些信號進行轉換、拆分和重組。

而在教程的下半部分,你將學到ReactiveCocoa更深層次的功能,如:

  • 另外兩種事件類型:errorcomplete
  • 限流
  • 多線程
  • 持續化
  • 等等……

事不宜遲,立馬開始吧!

推特即時搜索

在本教程中你將要開發的應用叫做推特即時搜索(模仿谷歌即時搜索的概念),一個在輸入時即時更新搜索記錄的推特搜索應用。

應用的初始項目包含了一些你開始時需要的基礎的界面和普通代碼。和教程的上半部分一樣,你需要使用CocoaPods獲取ReactiveCocoa框架并整合到你的項目中。初始項目已經包含了必須的Podfile文件,所以直接打開終端窗口和執行下列命令:

pod install

如果正確執行的話,你會看到相似輸出如下:

Analyzing dependencies
Downloading dependencies
Using ReactiveCocoa (2.1.8)
Generating Pods project
Integrating client project

這回生成一個Xcode workspace文件:TwitterInstant.xcworkspace。在Xcode中打開該文件,并確定里面包含了兩個項目:

  • TwitterInstant:應用邏輯所在
  • Pods:項目的外部引用,現在包含ReactiveCocoa框架

編譯運行,你會看到下圖的界面:


先花點時間熟悉一下應用的代碼。這是一個非常簡單基于拆分視圖控制器的app(split view controller-based app)。左邊的部分是RWSearchFormViewController,包含了一些通過storyboard添加的UI事件和一個外聯的搜索文本框。右邊的部分是RWSearchResultsViewController,暫時只是一個UITableViewController的子類。

打開RWSearchFormViewController.m文件你就能看到在viewDidLoad方法中定位了結果展示控制器,并將它指向resultsViewController私有屬性。由于這個應用最主要的邏輯就落在RWSearchFormViewController上,這個屬性將有助于為RWSearchResultsViewController提供搜索的結果。

校驗搜索文本

你首先要做的是驗證搜索文本,確保它的長度大于兩個字節。如果你完成了上半部教程的話,這對你來說應該是記憶猶新。在RWSearchFormViewController.mviewDidLoad方法下添加以下代碼:

- (BOOL)isValidSearchText:(NSString *)text {
  return text.length > 2;
}

這方法簡單地判斷搜索字符串是否長于兩個字節。這邏輯簡單得你可能會問:“為什么這都要單獨分離出一個方法呢?”

現在的邏輯的確相當的簡單,但如果將來這校驗需要變得更為復雜呢?在上面的例子中,你只需要在改變一個地方就可以了。不止如此,上面的實現讓你的代碼可讀性更好,指出了你判斷字符串長度的原因。想必我們都遵循著良好的編碼習慣對么?

在文件的頂部導入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;
  }];

不明白這都做了什么?上面的代碼實現了三件事:

  • 獲取了搜索文本框的文本信號
  • 把文本是否有效的校驗結果轉換成背景顏色
  • 然后在subscribeNext:的block中將上一步所得賦值給backgroundColor屬性

編譯運行后能看到當搜索文本過短時,文本框會判斷這為無效輸入,并把背景顏色變成黃色。

如果用圖表描述的話,這個簡單的響應式管道看起來是這樣的:

每當文本發生改變時,rac_textSignal就會發送包含當前文本內容的next事件。map方法把文本轉化成顏色,然后在subscribeNext:環節中獲取并賦值給文本框的背景顏色。

想必你還記得上半部中關于這一部分內容對嗎?如果不記得,你可能就需要先停下來,去回顧一下上半部的練習部分了。

而在添加推特的搜索邏輯前,這還有一些更加有趣的話題需要提及。

格式化管道

當你研究格式化ReactiveCocoa代碼時,慣例是一個操作對應一行,并垂直對齊每一個步驟。

在下圖中,你可以看到一個在更為復雜情況下的格式對齊,這是從上一個教程中截取出來的:

這讓你更容易看到管道的操作組成。同時這精簡了每個block中的代碼,任何超過兩行的代碼都應該封裝為一個私有方法。

但很不幸,Xcode并不是太喜歡這種格式化形式,所以你可能需要手動與它的自動縮格邏輯作斗爭!

內存管理

考慮一下你添加到TwitterInstantapp的代碼,你是否為你剛創建的管道是如何保持(retained)的感到疑惑?當然了,由于管道并沒有指向一個變量或者屬性,它的引用計數自然不會增加,那它隨后是否就會被直接銷毀呢?

匿名構造管道是ReactiveCocoa的其中一個設計理念。回顧至今為止你寫的所有響應式代碼,這應該是顯而易見的。

為了支持這種特性,ReactiveCocoa維系保持了它自己的全局信號集(global set of signals)。如果信號有一個或多個訂閱者的話,信號就會被激活。如果所有的訂閱者都給移除了,該信號就可以被回收。想知道更多關于ReactiveCocoa內管理這個過程的內容,你可以瀏覽Memory Management文檔(譯注:文檔已失效)。

這就剩下最后一個問題了:怎樣取消信號的訂閱呢?訂閱在接收到completedevent事件后,就會自動移除(你很快就會學到更多關于這部分的內容)。而要手動移除的話可以借助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];

可能在實際中你很少會這樣做,但知道這么一個可行操作還是很有價值的。

注意:相對應的,如果你創建了一個管道但不曾對其訂閱,這管道里的代碼,包括像doNext:這樣的副作用都永遠不會執行。

避免引用循環

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以獲取文本框的引用。block從會從封閉作用域中捕獲并持有了相關值,因而當self和信號中存在強引用時,就會導致引用循環。這會不會導致問題取決于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標記讓該引用變為弱引用。注意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宏創建了弱應用的影子變量(shadow variables)(如果你需要多個弱引用,你可以傳入多個變量),@strongify宏則使用先前傳到@weakify的變量創建強引用。

注意:如果你對@weakify@strongify的具體操作感到好奇,你可以在Xcode中選擇Product -> Perform Action -> Preprocess “RWSearchForViewController”。這會對視圖控制器進行預處理,展開所有的宏讓你看到最終的輸出。

最后需要注意的是,在block中使用實例變量時也要小心。這也會導致block對self進行強引用。你可以打開編譯器警告,當你的代碼導致這種問題時去提醒你。在項目的build settings中搜索retain,找到如下的設置:


好了,恭喜你終于熬過了理論知識!現在你已經為最有趣的部分做好了充足的準備:為應用添加真正的功能!

注意:看過上一個教程的敏銳讀者想必已經發現在這管道中可以使用RAC宏去替代subscribeNext:。如果你已經發現了,那就改改上面的代碼并獎勵自己一朵小紅花吧!

連接推特

你將要使用Social Framework在你的應用中搜索推特,使用Accounts Framework去獲取推特的授權。想要獲取更多關于Social Framework的信息,可以查看iOS 6 by Tutorials這篇介紹這個框架的文章。

在添加代碼前,你需要在運行本應用的模擬器或iPad上輸入你推特的用戶密碼。打開設置并選中推特選項,在屏幕的右方添加你的用戶密碼:


初始項目已經添加了所需框架,所以你只需要導入相關頭文件。在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];

以上代碼創建了賬號的庫(accounts store)和推特的賬號標識。

當應用請求連接一個社交賬戶時,用戶會看到一個彈窗。這是一個異步操作,所以最好將它用信號封裝起來,以便響應式使用。

繼續添加以下代碼:

- (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<RACSubscriber> 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. 通過賬戶庫鏈接到推特。此時,用戶會看到是否允許app連接到他們推特賬號的提示。
  4. 當用戶同意或拒絕了連接,信號事件就會發送。如果用戶同意連接,一個next事件和緊接一個completed事件就會被發送。如果用戶拒絕了連接一個error事件就會被發送。

回想一下上半部的教程,一個信號可以發送三種不同類型的事件:

  • Next
  • Completed
  • Error

在信號的整個生命周期,它可能不發送任何事件,也可能發送一個或多個next事件然后緊跟一個completed事件或者error事件。

最后為了使用這個信號,在viewDidLoad方法末端添加以下代碼:

[[self requestAccessToTwitterSignal]
  subscribeNext:^(id x) {
    NSLog(@"Access granted");
  } error:^(NSError *error) {
    NSLog(@"An error occurred: %@", error);
  }];

編譯運行,你就能看到下圖這樣的彈出框:


如果你點擊確定按鈕,控制臺就會答應subscribeNext:block中的記錄信息,相反,如果你點擊不允許,error里的代碼塊就會執行并打印響應的記錄。

賬戶管理框架會記住你的選擇。所以為了測試兩種情況,你需要在菜單中選擇重置模擬器: iOS Simulator -> Reset Contents and Settings …。這會有一點繁瑣,因為重置后你還需要重新輸入你的推特賬號密碼!

鏈接信號

當用戶成功連接到他們的推特賬號(希望如此!),應用就要繼續監聽搜索輸入框的改變來搜索推特。

應用需要等連接推特的信號發送completed事件,并傳遞給輸入框的信號。這種連續的信號鏈接是相當常見的問題,但是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事件被發送,然后轉訂閱參數代碼塊中返回的信號。這有效的將控制權從一個信號轉遞給下一個信號。

注意:你已經在上一個管道弱引用過self,所以不再需要在這個管道前添加@weakify(self)了。

編譯運行并允許連接,你會看到你在搜索文本框輸入的文本此時打印在了控制臺:

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!

接下來,為管道添加一個過濾操作,將無效的搜索字符串移除掉。在這個例子中,無效指的就是少于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事件通過過濾器最終到達訂閱的block。第一個環節中發送的error事件同樣能夠被同一個subscribeNext:error:方法捕獲到。

現在你已經有了一個發送搜索文本的信號,是時候使用來搜索推特了!你現在享受到其中的樂趣了么?想必是,畢竟你已經大展一番拳腳了!

推特搜索

Social Framework是使用推特搜索API的一種方式。但是,如你所想,Social Framework并不是響應式的!下一步要做的就是將需要的API方法封裝在一個信號中調用。 你這下應該搞明白了吧!
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標準創建了搜索推特的請求。上面的代碼使用q搜索參數用以搜索所有包含搜索關鍵字的推特。你可以在推特的接口文檔查看更多關于這個搜索接口信息,以及其他可以傳遞的有效參數列表。

下一步就是基于這個請求創建信號。在同一文件添加下列方法:

- (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<RACSubscriber> 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;
  }];
}

分解一下步驟:

  1. 剛開始,定義了兩種不同的錯誤,一個表示用戶尚未在設備中添加推特賬戶,另一個表示查詢過程中發生的錯誤。
  2. 像之前一樣,創建一個信號。
  3. 使用上一步你創建的方法根據提供的搜索關鍵字創建請求。
  4. 查詢賬號庫中第一個有效的推特賬戶。如果沒有任何賬戶返回,發送錯誤事件。
  5. 執行請求。
  6. 當成功返回時(HTTP返回編碼為200),轉換返回的JSON數據并伴隨next事件發送,緊跟發送一個completed事件。
  7. 當返回狀態為不成功時,發送一個error事件。

現在就能使用這個新的信號了!

在本教程的上半部分你學會了如何使用flattenMap去映射每一個next事件為一個全新的信號并接著訂閱它。現在就要再次運用這個方法。更新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);
  }];

編譯運行,在搜索框中輸入一些文本。當文本達到或超過3個字節時,你就能在控制臺中看到推特的搜索記錄。
下面節選了一段你會看到的數據樣式:

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 = "<null>";
            coordinates = "<null>";
            "created_at" = "Sun Jan 05 07:42:07 +0000 2014";
            entities =             {
                hashtags = ...

signalForSearchText:方法發送的error事件同樣能給subscribeNext:error:接收到。你可能已經記住了這點,但相比你更可能希望親手試驗一下!

在模擬器中打開設置并選中的的推特賬戶,然后點擊刪除賬戶按鈕:


重運行應用,應用仍然會獲取用戶推特賬號的授權,盡管已經現在已經沒有有效的賬號了。因此signalForSearchText方法會發送一個錯誤,在控制臺打印:

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事件的要點;當信號發出一個錯誤時,它會直接傳遞給處理錯誤的block。這是一個異常的處理流。

提醒:想試驗訪問推特失敗時的異常處理流的話,有一個小竅門,把請求入參改為無效數據就可以了!

多線程

相信你已經迫不及待要將搜索結果的JSON輸出轉化為UI了,但在那之前你還需要做一件事。而為了明確你要做的事,你還需要做一點探索。

subscribeNext:error:方法中如下的位置添加斷點:

重運行應用,如果有必要的話重新輸入你的推特賬號密碼,然后在搜索框中輸入一些內容。當執行到斷點時你會看到下圖相似的景象:


注意調試中的代碼并不是在主線程Thread 1中執行的。謹記你只能在主線程中更新UI界面;所以如果你希望在UI界面中更新UI的話,你需要切換執行線程。

這體現了ReactiveCocoa框架一個非常重要的特點。上面的操作是在信號開始發送信號的線程中執行的。在管道的其他環節添加斷點?,你可能會驚訝地發現他們并不在同一個線程中執行!

所以要如何更新UI界面呢?傳統的做法是使用操作隊列(更多的細節可以看本站的另一篇文章 How To Use NSOperations and 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);
  }];

現在重運行app,任意輸入一點內容讓app運行到斷點處。你會看到subscribeNext:error:中控制臺打印的代碼現在在主線程中執行了:


什么?這不過簡單的調整了一下代碼就能改變事件流的執行線程?這實在太棒了!你可以更新你的UI界面了!

注意:如果你關注一下RACScheduler類你就能看到其提供了相當多的選擇來實現不同的線程優先級和管道延遲處理。

現在是時候展現這些推特了!

更新UI界面

打開RWSearchResultsViewController.h你能看到里面已經定義displayTweets:了方法,用以為右手邊的視圖控制器渲染提供的推特列表。里面的實現非常簡單,只是標準的UITableView數據源處理。displayTweets:方法的唯一入參是一個裝載RWTweet實例的NSArray。初始項目也已經為你提供了RWTweet對象模型。

subscibeNext:error:中接收的是在signalForSearchWithText:方法中從JSON轉換成的NSDictionary類型數據。所以你怎樣才能知道字典中的內容呢?

閱讀推特的接口文檔你能看到接口的響應示例。所得的NSDictionary與這個結構相似,里面有個叫statuses的鍵對應值為裝載推特的NSArray,推特數據也是NSDictionary類型。

RWTweet已經包含一個類方法tweetWithStatus:,用以從給定格式的NSDictionary中提取數據。所以你需要做的只是編寫一個循環,并遍歷整個數組,為每條推特創建一個RWTweet的實例。

但是,別這樣做。之后又更好的解決方法呢。

這篇文章是關于ReactiveCocoa和函數式編程的。數據轉換時使用函數式的接口會顯得更加干練。你可以使用LinqToObjectiveC來完成這個任務。

關閉項目,并打開你在第一個教程中使用TextEdit創建的Podfile文件(譯注:這里作者混亂了,指的是本項目中的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文件并確認新的框架已經如下圖一樣成功引入:


打開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首先獲取了推特的NSArraylinq_select方法將裝有NSDictionary實例的數組通過提供的block處理轉換成新的數組元素,最后返回一個裝有RWTweet實例的數組。

轉換成功后,相關推特就會被發送到結果視圖控制器。最后編譯運行,你就能看到推特展示在UI界面中:


注意:ReactiveCocoa和LinqToObjectiveC有著相似的靈感來源。ReactiveCocoa是模仿微軟的Reactive Extensions框架,LinqToObjectiveC則是模仿它們的語言集成查詢接口(Language Integrated Query APIs),或稱作LINQ,特別是用于對象的LINQ

異步加載圖片

你可能已經發現在每一條推特的左邊有一塊間隙。那個位置是用來展示推特用戶的頭像的。

RWTweet類已經有了一個profileImageUrl屬性以記錄獲取這張圖片的URL。為了使列表平滑地滾動,你需要確保從提供的URL中獲取圖片的代碼不在主線程中執行。這可以使用Grand Central Dispatch(GCD)或者NSOperationQueue實現。但是為什么不直接使用ReactiveCocoa呢?

打開RWSearchResultsViewController.m并在文件最后添加如下方法:

-(RACSignal *)signalForLoadingImage:(NSString *)imageUrl {
 
  RACScheduler *scheduler = [RACScheduler
                         schedulerWithPriority:RACSchedulerPriorityBackground];
 
  return [[RACSignal createSignal:^RACDisposable *(id<RACSubscriber> subscriber) {
    NSData *data = [NSData dataWithContentsOfURL:[NSURL URLWithString:imageUrl]];
    UIImage *image = [UIImage imageWithData:data];
    [subscriber sendNext:image];
    [subscriber sendCompleted];
    return nil;
  }] subscribeOn:scheduler];
 
}

你現在應該相當熟悉這種模式了!

由于你希望這個信號不在主線程中執行,上面的方法先獲取了一個后臺調度器。然后創建一個信號,該信號在有訂閱者時下載圖像數據并生成UIImage。最后一步就是使用subscribeOn:,以保證信號在提供的調度器中執行。

搞定!

現在,在同一個文件中更新tableView:cellForRowAtIndex:方法,在方法返回前添加以下代碼:

cell.twitterAvatarView.image = nil;
 
[[[self signalForLoadingImage:tweet.profileImageUrl]
  deliverOn:[RACScheduler mainThreadScheduler]]
  subscribeNext:^(UIImage *image) {
   cell.twitterAvatarView.image = image;
  }];

在上面的代碼中,由于這些單元(cell)會重復使用并可能包含先前遺留的數據,因而首先重置了圖片。然后創建用以獲取圖片數據的信號。而接下來你先前遇到過的deliverOn:方法將next事件調整到了主線程上,以便安全地執行subscribeNext:中的block。

多么簡單有效!

編譯運行后就能看到頭像現在都已經正確顯示了:


節流

你可能已經發現,每當你輸入一個新的字符,就會立馬執行一次新的推特搜索。如果你是一個熟練的打字員(或者只是按緊刪格鍵),這會導致應用在一秒內作出多個搜索請求。這種實現并不理想,原因有二:第一,這在對推特的搜索接口造成沖擊的同時舍棄了大部分返回的結果;第二,不斷的更新結果會擾亂用戶的注意力!

更好的實現應該是當搜索文本在一個短時間內,比如說500毫秒,沒有改變的話再執行搜索。正如你可能猜到的那樣,ReactiveCocoa還是很容易就能實現這一點!

打開RWSearchFormViewController.m,更新viewDidLoad末端的管道,在過濾后新增節流操作:

[[[[[[[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操作只有在時間間隔內沒有接收到新的next事件時才會發送next事件給下一環節。這是不是相當簡單!

編譯運行,這時搜索結果只在停止輸入超過500毫秒時才會更新。這感覺好多了對嗎?你的用戶也會這么想的。

并且……隨著最后一步的完成,你的推特即時搜索應用已經完成了。給自己一點掌聲并跳支舞放松一下吧!

如果你在教程的過程中感到迷惑的話,你可以下載瀏覽最終的項目(當然別忘了在打開前在項目所在目錄運行pod instal命令),你也可以在GitHub找到這個項目,那里有對應教程中每一步操作的提交記錄。

總結

在結束教程并給自己泡上一杯咖啡慶祝之前,非常值得欣賞一下項目最終搭建的管道。

這是一個相當復雜的數據流,但所有都簡明的表達在了一個響應式管道中。這是多么迷人的景象啊!你可以想象如果使用非響應式技術的話來時實現這些功能的話,應用該變得多么復雜嗎?而且要理清楚數據的流向將變得多么困難?聽著就覺得夠麻煩的了,而你現在已經不再需要重蹈覆轍了!

現在你體會ReactiveCocoa是多么了不起了吧!

最后一點,ReactiveCocoa讓使用又稱為MVVM的Model View ViewModel設計模式變為可能,其更有效的分離了應用邏輯和視圖邏輯。如果有人對后續關于用ReactiveCocoa實現MVVM的文章感興趣的話,請在評論中告訴我。我非常希望聽到能夠你的想法和經驗!(譯注:后續也有作者關于MVVM的教程,有時間會繼續進行翻譯!)

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

推薦閱讀更多精彩內容