转自:http://southpeak.github.io/blog/2014/08/02/reactivecocoazhi-nan-er-:twittersou-suo-shi-li/
原文由Colin Eberhardt发表于raywenderlich,ReactiveCocoa Tutorial – The Definitive Introduction: Part 2/2
第一部分我们学习了ReactiveCocoa处理信号的基本流程,如何发送流事件,以及分割及组合信号。在这一部分中,我们将继续学习ReactiveCocoa更多的特性,包括:
- error和completed事件类型
- 节流(Throttling)
- 线程
- 扩展
Twitter Instant
本部分我们将要开发的是一个称为Twitter Instant的程序,这是一个Twitter搜索应用,用于裡更新搜索结果。可以在这里下载初始程序,同时我们需要通过Cocoapods来下载依赖库,这个过程与第一部分相同。完成之后,运行程序,将得到下面的界面:
我们花点时间熟悉一下。这个程序很简单。左侧控制面板是RWSearchFormViewController,有一个搜索框。右侧是RWSearchResultsViewController。如果我们打开RWSearchFormViewController.m,我们可以看到viewDidLoad方法中指定了resultsViewController属性,这个程序的主要逻辑是在RWSearchFormViewController中,这个属性将搜索结果提供给RWSearchResultsViewController。
验证搜索框
首先我们来校验输入框的字符长度是否大于2。我们在RWSearchFormViewController.m的viewDidLoad方法下面添加以下代码:
- (BOOL)isValidSearchText:(NSString *)text { return text.length > 2; }
接下来,我们在RWSearchFormViewController.m中导入ReactiveCocoa
#import <ReactiveCocoa/ReactiveCocoa.h>
[[self.searchText.rac_textSignal map:^id(NSString *text) { return [self isValidSearchText:text] ? [UIColor whiteColor] : [UIColor yellowColor]; }] subscribeNext:^(UIColor *color) { self.searchText.backgroundColor = color; }];
这段代码通过信号来检测输入是否有效,并设置相应的输入框背影颜色值。运行后,可以看到如下效果:
其管道流程图如下所示:
rac_textSignal在每次输入时发出next事件,并包含当前输入框的文本。然后map操作将其转换为颜色值,最后subscribeNext:获取这个颜色值并用它来设置输入框的背景颜色。
在添加Twitter查找逻辑之前,我们先看看一些有趣的东西。
格式化管道代码
在调用信号的方法时,我们建议每个操作都新起一行,并排列所有的步骤。如下图所示,一个复杂的管道通过分行,看起来会更加清晰
内存管理
考虑下我们添加到TwitterInstant程序中的代码,想知道我们创建的管道是如何被保存的么?当然,因为它没有被指定给变量或属性,所以它没有增加引用计数,因此注定被销毁?ReactiveCocoa设计的目的之一是允许这样一种编程样式,即管道可以匿名创建。到目前为止,我们的管道都是这么处理的。为了支持这种模式,ReactiveCocoa维护了一个全局的信号集合。如果信号有一个或多个订阅者,它就是可用的。如果所有订阅者都被移除了,信号就被释放了。
剩下最后一个问题:如何取消对信号的订阅?在一个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 *subscripion = [backgroundColorSignal subscribeNext:^(UIColor *color) { self.searchText.backgroundColor = color; }]; // 在某个位置调用 [subscripion 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:块使用了self,以获取文本输入域。Block会捕获并保留闭包中的值,因此如果在self与信号之间有一个强引用,则会导致循环引用问题。这是不是问题取决于self对象的生命周期。如果self的生命周期是整个程序生存期,则没问题,好好用吧。但在大多数情况下,它确实是一个问题。
为了避循环引用,根据苹果的文档中推荐的捕获self的一个弱引用。如下代码所示:
__typeof(self) __weak weakSelf = self; [[self.searchText.rac_textSignal map:^id(NSString *text) { return [weakSelf isValidSearchText:text] ? [UIColor whiteColor] : [UIColor yellowColor]; }] subscribeNext:^(UIColor *color) { weakSelf.searchText.backgroundColor = color; }];
在上面的代码中weakSelf是self对象的一个弱引用。现在subscribeNext:中使用了这个变量。不过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的。
最后需要注意的是,当在block中使用实例变量时,block同样会捕获self的一个强引用。我们可以打开编译器警告,来提示我们这种情况。如下所求来处理
OK,内存问题说得差不多了,现在我们回到正题。
请求访问Twitter
我们将使用Social Framework以允许TwitterInstant程序搜索Tweets,同时使用Accounts Framework来获取对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";
然后我们RWSearchFormViewController()分类中添加以下代码:
@property (strong,nonatomic) ACAccountStore *accountStore; @property (strong,nonatomic) ACAccountType *twitterAccountType;
ACAccountsStore类提供了我们的设备可连接的多种社交账号,ACAccountType类表示账号的指定类型。
我们在viewDidLoad的结尾处添加以下代码,来创建账户存储及Twitter账户标识:
self.accountStore = [[ACAccountStore alloc] init]; self.twitterAccountType = [self.accountStore accountTypeWithAccountTypeIdentifier:ACAccountTypeIdentifierTwitter];
当账户请求社账号时,用户可以看到一个弹出框。这是一个异步操作,所以将其包装到一个信号中是很好的选择。
- (RACSignal *)requestAccessToTwitterSignal { // 定义一个错误,如果用户拒绝访问则发送 NSError *accessError = [NSError errorWithDomain:RWTwitterInstantDomain code:RWTwitterInstantErrorAccessDenied userInfo:nil]; // 创建并返回信号 @weakify(self) return [RACSignal createSignal:^RACDisposable *(id<RACSubscriber> subscriber) { // 请求访问twitter @strongify(self) [self.accountStore requestAccessToAccountsWithType:self.twitterAccountType options:nil completion:^(BOOL granted,NSError *error) { // 处理响应 if (!granted) { [subscriber sendError:accessError]; } else { [subscriber sendNext:nil]; [subscriber sendCompleted]; } }]; return nil; }]; }
一个信号可以发送三种事件类型:next,completed,error。
在信号的整个生命周期中,都可能不会发送事件,或者发送一个或多个next事件,其后跟着completed或error事件。
最后,为了使用这个信号,在viewDidLoad中添加以下代码:
[[self requestAccessToTwitterSignal] subscribeNext:^(id x) { NSLog(@"Access granted"); } error:^(NSError *error) { NSLog(@"An error occurred: %@",error); }];
运行程序,可以看到下面的提示
如果点击OK,subscribeNext:块中的日志会打印出来。如果点击Don’t allow,则会执行错误块并打印期望的信息。
链接信号
一旦用户获取了Twitter账户的访问权限,程序需要继续监听搜索框的输入,以查询twitter。程序需要等待请求访问Twitter的信号来发出完成事件,然后订阅广西输入框的信号。不同信号的顺序链接是一个问题,但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参数返回的信号。这有效地将控制从一个信号传递给下一个信号。运行程序,获取访问,然后在输入框输入,会在控制台看到以下输出:
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(@"%@",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!
当前管道如下图所示:
现在我们有一个发送搜索文本的信号了,是时候用它来搜索Twitter了。接下来才是正题。
搜索Twitter
Social Framework是访问Twitter搜索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创建了一个搜索Twitter的请求。关于这个API,可以在Twitter API docs中查看更多信息。
- (RACSignal *)signalForSearchWithText:(NSString *)text { // 定义错误 NSError *noAccountError = [NSError errorWithDomain:RWTwitterInstantDomain code:RWTwitterInstantErrorNoTwitterAccounts userInfo:nil]; NSError *invalidResponseError = [NSError errorWithDomain:RWTwitterInstantDomain code:RWTwitterInstantErrorInvalidResponse userInfo:nil]; // 创建信号block @weakify(self) return [RACSignal createSignal:^RACDisposable *(id<RACSubscriber> subscriber) { @strongify(self) // 创建请求 SLRequest *request = [self requestforTwitterSearchWithText:text]; // 提供Twitter账户 NSArray *twitterAccounts = [self.accountStore accountsWithAccountType:self.twitterAccountType]; if (twitterAccounts.count == 0) { [subscriber sendError:noAccountError]; } else { [request setAccount:[twitterAccounts lastObject]]; // 执行请求 [request performRequestWithHandler:^(NSData *responseData,NSHTTPURLResponse *urlResponse,NSError *error) { if (urlResponse.statusCode == 200) { // 成功,解析响应 NSDictionary *timelineData = [NSJSONSerialization JSONObjectWithData:responseData options:NSJSONReadingAllowFragments error:nil]; [subscriber sendNext:timelineData]; [subscriber sendCompleted]; } else { // 失败,发送一个错误 [subscriber sendError:invalidResponseError]; } }]; } return nil; }]; }
现在我们来使用这个新信号。
在第一部分中我们学习了如何使用flattenMap来将每个next事件映射到一个新的被订阅的信号。这里我们再次使用它们。在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(@"%@",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:块来处理。
线程
现在一定想把返回的JSON数据显示到UI上了吧,不过,在此之前我们还有一件事情需要处理。要了解这是什么,我们还需要探索一下。
在下图的subscribeNext:error:中打个断点:
重新运行程序,如果需要则再次输入Twitter账号密码,在搜索框中输入一些文本。当程序运行到断点位置时可以看到类似于下图的场景:
注意,从左侧的线程列表中我们可以看到debugger到的代码并没有运行在主线程,即线程Thread 1。记住,更新UI的操作一定得在主线程中操作;因此,如果要在UI上显示tweet列表,则必须切换线程。
这说明了ReactiveCocoa框架的一个重要点。上面显示的操作是在信号初始发出事件时的那个线程执行。尝试在管道的其它步骤添加断点,我们会很惊奇的发现它们会运行在多个不同的线程上。
因此,我们应该如何来更新UI呢?当然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(@"%@",error); }];
现在重新运行,此时我们可以看到subscribeNext:error:是运行在主线程了。
这样我们就可以安全地更新我们的UI了。
更新UI
打开RWSearchResultsViewController.h文件,我们可以看到displayTweets:方法,这个方法会让右侧的ViewController来渲染tweet数组。实现非常简单,它只是一个标准UITableView数据源。displayTweets:只需要一个包含RWTweet实例的数组作为参数。我们同样发现RWTweet实例是作为初始工程的一部分提供的。
在subscibeNext:error:步骤中获取到的数据现在是一个NSDictionary,它是在signalForSearchWithText:解析JSON数据时构造的。那么,我们如何处理这个字典的内容呢?
如果看一看Twitter API documentation,我们可以发现一个示例响应。在NSDictionary反映了这种结构,所以我们需要找到一个键名为statues的字典,其值为一个tweets数组。具体如何解析我们就不在此说明。这里给个更好的实现方式。
我们现在讲的是ReactiveCocoa及函数式编程。当我们使用函数式API时,数据从一种格式转换到另一种格式会变得更优雅。我们可以使用LinqToObjectiveC来执行这个任务。
我们需要使用Cocoapods来导入LinqToObjectiveC。在配置文件中加入以下代码:
pod 'LinqToObjectiveC','2.0.0'
关闭工程,在终端执行pod update命令,完成后在我们的Pods工程中就可以看到LinqToObjectiveC了。
打开RWSearchFormViewController.m并导入以下文件:
#import "RWTweet.h" #import "NSArray+LinqExtensions.h"
NSArray+LinqExtensions.h头文件来自于LinqToObjectiveC,并为NSArray添加了许多方法以允许我们使用一个流畅的API来转换、排序、分组及过滤数组的数据。
现在我们使用这些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:块首先获取twetts的NSArray对象。linq_select方法通过执行应用于每个数组元素的block来转换NSDictionary字典的数组,并生成一个RWTweet实例的数组。
一旦转换完成,tweets将结果发送给ViewController。
运行程序后我们可以看到以下UI:
异步加载图片
在上图中,我们可以看到每行数据前面有一片空白,这是用来显示用户头像的。RWTweet类已经有一个profileImageUrl属性,它是一个图片的URL地址。为了让UITableTable滑动得更平滑,我们需要让获取指定URL的图片的操作不运行在主线程中。这可以使用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]; }
现在我们应该熟悉这种模式了。以上的方法首先获取一个后台scheduler作为信号执行的线程,而不是主线程。接下来,创建一个下载图片数据的信号并在其有订阅者时创建一个UIImage。最后我们调用subscribeOn:,以确保信号在给定的scheduler上执行。
现在,我们可以更新tableView:cellForRowAtIndex:,在return之前添加以下代码:
cell.twitterAvatarView.image = nil; [[[self signalForLoadingImage:tweet.profileImageUrl] deliverOn:[RACScheduler mainThreadScheduler]] subscribeNext:^(UIImage *image) { cell.twitterAvatarView.image = image; }];
上面的代码首先重新设置图片,因为重用的单元格可能包含之前的数据。然后创建一个请求信号去获取数据,在deliverOn:中我们将后面的next事件运行在主线程,这样subscribeNext:可以安全运行。
运行后得到如下结果:
节流
你可能已经发现每次我们输入一个新的字符时,搜索操作都会立即执行。如果我们快速输入,可能会导致程序在一秒钟内执行了多次搜索操作。这当然是不好的,因为:
一个更好的方案是如果搜索文本在一个较短时间内没有改变时我们再去执行搜索操作,如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操作只有在两次next事件间隔指定的时间时才会发送第二个next事件。相当简单吧。运行程序看看效果吧。
小结
在庆祝胜利前,看看程序最终的管道是值得的。
这是一个相当复杂的数据流,但可以作为一个响应管道简洁地表示出来。看起来不错吧。如果使用非响应式技术,你会觉得这会有多复杂呢?在这样一个程序中,数据流的流动又会是多难以理解呢?听起来很麻烦吧。但有了ReactiveCocoa,我们不必再考虑这些了。现在我们知道ReactiveCocoa有多棒了吧。
最后,ReactiveCocoa让使用Model View viewmodel(MVVM)设计模式变成可能。如果有兴趣研究MVVM,可以去网上搜索相关的文章。