ReactiveCocoa教程:下半部【译】
原文:ReactiveCocoa Tutorial – The Definitive Introduction: Part 2/2
上半部翻译:ReactiveCocoa教程:上半部【译】
ReactiveCocoa框架让你可以在iOS应用中使用响应式函数编程(FRP)。在教程的上半部分你学会了如何用发送事件流的信号替换标准的动作和事件处理逻辑,还有如何对这些信号进行转换、拆分和重组。
而在教程的下半部分,你将学到ReactiveCocoa更深层次的功能,如:
- 另外两种事件类型:
error
和complete
- 限流
- 多线程
- 持续化
- 等等……
事不宜迟,立马开始吧!
推特即时搜索
在本教程中你将要开发的应用叫做推特即时搜索(模仿谷歌即时搜索的概念),一个在输入时即时更新搜索记录的推特搜索应用。
应用的初始项目包含了一些你开始时需要的基础的界面和普通代码。和教程的上半部分一样,你需要使用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.m
的viewDidLoad
方法下添加以下代码:
- (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并不是太喜欢这种格式化形式,所以你可能需要手动与它的自动缩格逻辑作斗争!
内存管理
考虑一下你添加到TwitterInstant
app的代码,你是否为你刚创建的管道是如何保持(retained)的感到疑惑?当然了,由于管道并没有指向一个变量或者属性,它的引用计数自然不会增加,那它随后是否就会被直接销毁呢?
匿名构造管道是ReactiveCocoa的其中一个设计理念。回顾至今为止你写的所有响应式代码,这应该是显而易见的。
为了支持这种特性,ReactiveCocoa维系保持了它自己的全局信号集(global set of signals)。如果信号有一个或多个订阅者的话,信号就会被激活。如果所有的订阅者都给移除了,该信号就可以被回收。想知道更多关于ReactiveCocoa内管理这个过程的内容,你可以浏览Memory Management文档(译注:文档已失效)。
这就剩下最后一个问题了:怎样取消信号的订阅呢?订阅在接收到completed
或event
事件后,就会自动移除(你很快就会学到更多关于这部分的内容)。而要手动移除的话可以借助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;
}];
}
该方法做了以下操作:
- 定义了一个错误,当用户连接遭拒时发送。
- 像第一篇文章所说,类方法
createSignal
返回了一个RACSignal
的实例。 - 通过账户库链接到推特。此时,用户会看到是否允许app连接到他们推特账号的提示。
- 当用户同意或拒绝了连接,信号事件就会发送。如果用户同意连接,一个
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;
}];
}
分解一下步骤:
- 刚开始,定义了两种不同的错误,一个表示用户尚未在设备中添加推特账户,另一个表示查询过程中发生的错误。
- 像之前一样,创建一个信号。
- 使用上一步你创建的方法根据提供的搜索关键字创建请求。
- 查询账号库中第一个有效的推特账户。如果没有任何账户返回,发送错误事件。
- 执行请求。
- 当成功返回时(HTTP返回编码为200),转换返回的JSON数据并伴随
next
事件发送,紧跟发送一个completed
事件。 - 当返回状态为不成功时,发送一个
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首先获取了推特的NSArray
。linq_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的教程,有时间会继续进行翻译!)