ReactiveCocoa入门教程——第二部分
ReactiveCocoa iOS 翻译 2015-05-20 16:37:16 4710 1 1
本文翻译自RayWenderlich ReactiveCocoa Tutorial – The Definitive Introduction: Part 2/2
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搜索逻辑之前,还有一些有意思的话题要说说。
格式化代码
当你在探索如何格式化ReactiveCocoa的代码时,惯例是每个操作新起一行,垂直对齐每个步骤。
在下图中你能看到比较复杂的代码是如何对齐的,这是第一部分教程中的代码。
这样对齐能让你很容易的看到每一步的操作。同时你还应该减少每个block中的代码量,如果block中的代码超过几行时,就应该新写一个私有方法。
很不幸的是,Xcode不是很喜欢这种风格的格式化,所以你会发现Xcode的自动缩进逻辑总是和你过不去。
内存管理
看一下你添加到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;
}];
}?
这个方法做了下面几件事:
1定义了一个error,当用户拒绝访问时发送。
2和第一部分一样,类方法createSignal返回一个RACSignal实例。
3通过account store请求访问Twitter。此时用户会看到一个弹框来询问是否允许访问Twitter账户。
4在用户允许或拒绝访问之后,会发送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 send