ReactiveCocoa入門-part2

ReactiveCocoa是一個框架,它能讓你在iOS應用中使用函數響應式編程(FRP)技術。在本系列教程的第一部分中,你學到了如何將標準的動做與事件處理邏輯替換爲發送事件流的信號。你還學到了如何轉換、分割和聚合這些信號。html

 

在本系列教程的第二部分,你將會學到一些ReactiveCocoa的高級功能,包括:react

  • 另外兩個事件類型:error 和 completed
  • 節流
  • 線程
  • 延伸
  • 其餘

 

是時候深刻研究一下了。ios

 

Twitter Instant

在本教程中你將要開發的應用叫Twitter Instant(基於Google Instant的概念),這個應用能搜索Twitter上的內容,並根據輸入實時更新搜索結果。git

 

這個應用的初始工程包括一些基本的UI和必須的代碼。和第一部分同樣,你須要使用CocoaPods來獲取ReactiveCocoa框架,並集成到項目中。github

 

驗證搜索文本的有效性

首先要作的就是驗證搜索文本,來確保文本長度大於2個字符。若是你完成了本系列教程的第一部分,那這個應該很熟悉。編程

 

RWSearchFormViewController.m中的viewDidLoad 下面添加下面的方法:json

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

這個方法就只是確保要搜索的字符串長度大於2個字符。這個邏輯很簡單,你可能會問「爲何要在工程文件中寫這麼一個單獨的方法呢?」。api

 目前驗證輸入有效性的邏輯的確很簡單,但若是未來邏輯須要變得更復雜呢?若是是像上面的例子中那樣,那你就只須要修改一個地方。並且這樣寫能讓你代碼的可讀性更高,代碼自己就說明了你爲何要檢查字符串的長度。數組

把下面的代碼加到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中的文字每次發生變化時,rac_textSignal都會發送一個next 事件,事件包含當前text field中的文字。map這一步將文本值轉換成了顏色值,因此subscribeNext:這一步會拿到這個顏色值,並應用在text field的背景色上。

 

避免循環引用

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對象的強引用。

 

就在引用的下面,添加下面的枚舉和常量:

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; 
    }]; 
}​

這個方法作了下面幾件事:

  1. 定義了一個error,當用戶拒絕訪問時發送。
  2. 和第一部分同樣,類方法createSignal返回一個RACSignal實例。
  3. 經過account store請求訪問Twitter。此時用戶會看到一個彈框來詢問是否容許訪問Twitter帳戶。
  4. 在用戶容許或拒絕訪問以後,會發送signal事件。若是用戶容許訪問,會發送一個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); 
    }];​

 

連接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傳遞給下一個。

 

接下來,在管道中添加一個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); 
    }];​

如今用圖形來表示管道,就和下圖相似:

管道從requestAccessToTwitterSignal 開始,而後轉換爲rac_textSignal。同時,next事件經過一個filter,最終到達訂閱者的block。你還能看到第一步發送的error事件也是由subscribeNext:error:block來處理的。

 

搜索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; 
    }];
}

分別講一下每一個步驟:

  1. 首先須要定義2個不一樣的錯誤,一個表示用戶尚未添加任何Twitter帳號,另外一個表示在請求過程當中發生了錯誤。
  2. 和以前的同樣,建立一個signal。
  3. 用你以前寫的方法,給須要搜索的文本建立一個請求。
  4. 查詢account store來找到可用的Twitter帳號。若是沒有帳號的話,發送一個error事件。
  5. 執行請求。
  6. 在請求成功的事件裏(http響應碼200),發送一個next事件,返回解析好的JSON數據,而後再發送一個completed事件。
  7. 在請求失敗的事件裏,發送一個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 = ...​

 

線程

我相信你已經想把搜索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

若是你打開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來完成這個任務。

 

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的數組。

 

異步加載圖片

你可能注意到了每條微博的左側有一段空隙,這是用來顯示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就能安全執行了。

 

節流

你可能注意到了,每次輸入一個字,搜索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毫秒後,纔會開始搜索。感受比以前好一些吧?你的用戶也會這麼想的。

PS:本文翻譯自RayWenderlich  ReactiveCocoa Tutorial – The Definitive Introduction: Part 2/2

相關文章
相關標籤/搜索