ReactiveCocoa入门教程——第二部分(转)
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能让应用逻辑和视图逻辑更好地分离。如果你想了解更多的话,就来看下一篇教程吧。