本文翻译自 http://www.raywenderlich.com/62699/reactivecocoa-tutorial-pt1
译者翻译时使用的Xcode 7
作为一个iOS开发者,我们几乎每行代码都在响应一些事件:一个按钮的点击,接受到网络的回调消息,属性的改变(通过KVO)或者位置的改变等。然而,这些事件以不同的形式处理着,比如action, delegates, KVO, callbacks等。ReactiveCocoa为这些事件定义了一个标准的接口,所以它们能轻易地用些基本的工具来连接、过滤和组合。
ReactiveCocoa包含了两种编程方式:
- Functional Programming 使用高阶函数并且把函数作为参数
- Reactive Programming 注重数据流本身以及变化传播
所以,你可能听说ReactiveCocoa是一个Function Reactive Programming(或者FRP)框架。
这就是本教程的内容,编程范式是一个很好的主题,但是本教程的剩余部分会着重通过例子而非理论。
The Reactive Playground
本教程通过一个简单的例子来介绍响应式编程,下载starter project,编译运行,会发现一切正常。
现在花些时间来看看代码,很简单,不会很久。
打开RWViewController.m,你能多快找出控制Sign In按钮是否enable的条件?显示/隐藏
signInFailure
label的规则?在这个简单例子中,可能需要1-2分钟的时间来找到答案。在一个更为复杂的项目中的话,你可能会花上更久的时间来分析。使用ReactiveCocoa后,应用的逻辑会变得相当清晰,是时候开始了!
加入ReactiveCocoa框架
最简单的方式是通过CocoaPods,打开终端,到项目所在目录,输入:
pod init
open Podfile -a Xcode
这会使用Xcode打开Podfile,然后粘贴如下代码替换Podfile的内容:
platform :ios, '7.0'
target 'RWReactivePlayground' do
pod 'ReactiveCocoa', '2.1.8'
end
然后保存文件,回到终端输入:
pod install
集成之后通过RWReactivePlayground.xcworkspace打开项目,会看到目录结构是这样的:
Time to play
上面提到过,ReactiveCocoa提供了一系列标准接口来处理应用中的不同事件流,在ReactiveCocoa中,这些事件流都叫做信号,用RACSignal
类来代表。
打开RWViewController.m中,引入头文件:
#import <ReactiveCocoa/ReactiveCocoa.h>
你暂时不用替换任何代码,目前只是随便玩一玩,在viewDidLoad
中加入如下代码:
[self.usernameTextField.rac_textSignal subscribeNext:^(id x) {
NSLog(@"%@", x);
}];
编译运行,在username text field中输入一些文本,盯住控制台会看到类似如下的输出:
2013-12-24 14:48:50.359 RWReactivePlayground[9193:a0b] i
2013-12-24 14:48:50.436 RWReactivePlayground[9193:a0b] is
2013-12-24 14:48:50.541 RWReactivePlayground[9193:a0b] is
2013-12-24 14:48:50.695 RWReactivePlayground[9193:a0b] is t
2013-12-24 14:48:50.831 RWReactivePlayground[9193:a0b] is th
2013-12-24 14:48:50.878 RWReactivePlayground[9193:a0b] is thi
2013-12-24 14:48:50.901 RWReactivePlayground[9193:a0b] is this
2013-12-24 14:48:51.009 RWReactivePlayground[9193:a0b] is this
2013-12-24 14:48:51.142 RWReactivePlayground[9193:a0b] is this m
2013-12-24 14:48:51.236 RWReactivePlayground[9193:a0b] is this ma
2013-12-24 14:48:51.335 RWReactivePlayground[9193:a0b] is this mag
2013-12-24 14:48:51.439 RWReactivePlayground[9193:a0b] is this magi
2013-12-24 14:48:51.535 RWReactivePlayground[9193:a0b] is this magic
2013-12-24 14:48:51.774 RWReactivePlayground[9193:a0b] is this magic?
你可以看到每次输入,block中的代码都会被执行,没有target-action,没有delegates,只是signals和blocks,多么令人激动!
ReactiveCocoa signals(RACSignal
代表)会发送一个事件流给订阅者,有三种事件类型:next, error, completed,一个signal可以发送若干个next事件,之后一定要学习part 2的部分,那里会涉及到error和completed事件。
RACSignal
有发送不同类型事件的方法,每个方法都带有一个或多个block,当一个事件发生时,这些block会被执行。在这个例子中,你会看到subscribeNext:
方法,它会在每次next事件发生时执行。
ReactiveCocoa框架使用了categories的方式给很多UIKit标准控件加入了信号概念,所以我们能直接使用类似UITextField的rac_textSignal
这样的属性来给他加入订阅者。
理论上的东西够多了,我们来实践下!
ReactiveCocoa有大量的操作能对事件流进行一些控制,比如,假设你希望用户名的长度是一定会大于三个字符的,你可以使用filter
来进行操作:
[[self.usernameTextField.rac_textSignal
filter:^BOOL(id value) {
NSString *text = value;
return text.length > 3;
}]
subscribeNext:^(id x) {
NSLog(@"%@", x);
}];
编译运行,然后输入一些文本,你会看到只有大于三个字符的输入才会打印出来:
2013-12-26 08:17:51.335 RWReactivePlayground[9654:a0b] is t
2013-12-26 08:17:51.478 RWReactivePlayground[9654:a0b] is th
2013-12-26 08:17:51.526 RWReactivePlayground[9654:a0b] is thi
2013-12-26 08:17:51.548 RWReactivePlayground[9654:a0b] is this
2013-12-26 08:17:51.676 RWReactivePlayground[9654:a0b] is this
2013-12-26 08:17:51.798 RWReactivePlayground[9654:a0b] is this m
2013-12-26 08:17:51.926 RWReactivePlayground[9654:a0b] is this ma
2013-12-26 08:17:51.987 RWReactivePlayground[9654:a0b] is this mag
2013-12-26 08:17:52.141 RWReactivePlayground[9654:a0b] is this magi
2013-12-26 08:17:52.229 RWReactivePlayground[9654:a0b] is this magic
2013-12-26 08:17:52.486 RWReactivePlayground[9654:a0b] is this magic?
你在这里所创建的实际上是一个很简单的管道,这是响应式编程的本质,用数据流来表达功能。
可以用这张图来表达上述过程:
在上面的图中,你看到rac_textSignal
是初始的事件,数据流通过一个filter
只允许超过三个字符的数据通过,管道的最后是subscribeNext:
,这就是block打印每个数据的地方。
在这里值得注意的是filter
的输出仍然是一个RACSignal
,你可以把上面的管道分开写成这样:
RACSignal *usernameSourceSignal = self.usernameTextField.rac_textSignal;
RACSignal *filteredUsername = [usernameSourceSignal
filter:^BOOL(id value) {
NSString *text = value;
return text.length > 3;
}];
[filteredUsername subscribeNext:^(id x) {
NSLog(@"%@", x);
}];
因为每个RACSignal
操作都会返回一个RACSignal
,这个术语叫做fluent interface,这个特性使你能避免使用局部变量来构造管道。
类型转换
现在我们还是把代码恢复成熟悉的语法上来:
[[self.usernameTextField.rac_textSignal
filter:^BOOL(id value) {
NSString *text = value; // implicit cast
return text.length > 3;
}]
subscribeNext:^(id x) {
NSLog(@"%@", x);
}];
这个隐式的转换把id
转成NSString
,这看起来不是很优雅,幸运的是,因为block传过来的值始终是NSString
,你就可以改变参数本身的类型了:
[[self.usernameTextField.rac_textSignal
filter:^BOOL(NSString *text) {
return text.length > 3;
}]
subscribeNext:^(id x) {
NSLog(@"%@", x);
}];
编译运行,确认还像之前一样正确地工作。
什么是事件
教程到现在描述了不同的事件类型,但没有具体介绍事件的结构。一个事件可以包含任意的东西!
为了解释,你需要在管道中加入另一种操作,更新你在viewDidLoad
的代码:
[[[self.usernameTextField.rac_textSignal
map:^id(NSString *text) {
return @(text.length);
}] filter:^BOOL(NSNumber *length) {
return [length integerValue] > 3;
}]
subscribeNext:^(id x) {
NSLog(@"%@", x);
}];
编译运行,会发现控制台的输出如下:
2013-12-26 12:06:54.566 RWReactivePlayground[10079:a0b] 4
2013-12-26 12:06:54.725 RWReactivePlayground[10079:a0b] 5
2013-12-26 12:06:54.853 RWReactivePlayground[10079:a0b] 6
2013-12-26 12:06:55.061 RWReactivePlayground[10079:a0b] 7
2013-12-26 12:06:55.197 RWReactivePlayground[10079:a0b] 8
2013-12-26 12:06:55.300 RWReactivePlayground[10079:a0b] 9
2013-12-26 12:06:55.462 RWReactivePlayground[10079:a0b] 10
2013-12-26 12:06:55.558 RWReactivePlayground[10079:a0b] 11
2013-12-26 12:06:55.646 RWReactivePlayground[10079:a0b] 12
新加的map操作利用提供的block把事件数据转换了,它对于任何一个next事件,它通过给定的block来返回一个next event,在上述代码中,map通过NSString
的输入,来返回了一个NSNumber
的输出。
用图片来描述的话是这样的:
就像你看到的,所有
map
之后的操作都会接受NSNumber
的类型,你可以使用map
操作来转换任何数据,只要它是一个对象。我们玩得够多啦!是时候更新ReactivePlayground来使用这些概念啦。你可以删除之前加入的所有代码。
创建一个有效的状态信号
第一件事是创建一些信号来表示用户名和密码的输入是有效的,加入如下代码到viewDidLoad
:
RACSignal *validUsernameSignal =
[self.usernameTextField.rac_textSignal
map:^id(NSString *text) {
return @([self isValidUsername:text]);
}];
RACSignal *validPasswordSignal =
[self.passwordTextField.rac_textSignal
map:^id(NSString *text) {
return @([self isValidPassword:text]);
}];
上述代码利用map
来把rac_textSignal
转换成NSNumber
。
下一步则是把这些信号转化成每个text field的背景色。一个可用的实现方式是:
[[validPasswordSignal
map:^id(NSNumber *passwordValid) {
return [passwordValid boolValue] ? [UIColor clearColor] : [UIColor yellowColor];
}]
subscribeNext:^(UIColor *color) {
self.passwordTextField.backgroundColor = color;
}];
(不要加入这个代码,会有一个更优雅的解决方法)
从概念上来讲,就是把之前信号的输出应用到输入框的backgroundColor
属性上,但是上面的用法不是很好。
幸运的是,ReactiveCocoa提供了一个宏,允许我们使用优雅的方式来完成这样的事情,直接加入如下代码到viewDidLoad:
中:
RAC(self.passwordTextField, backgroundColor) =
[validPasswordSignal
map:^id(NSNumber *passwordValid) {
return [passwordValid boolValue] ? [UIColor clearColor] : [UIColor yellowColor];
}];
RAC(self.usernameTextField, backgroundColor) =
[validUsernameSignal
map:^id(NSNumber *passwordValid) {
return [passwordValid boolValue] ? [UIColor clearColor] : [UIColor yellowColor];
}];
RAC
宏允许我们使用信号的输出来给属性赋值,它带有两个参数,第一个参数是持有该属性的对象,第二个是属性名称,每次信号发送next事件时,其输出的数据就会被赋予给该属性。
在编译之前注意把updateUIState
里的两行代码给删掉:
self.usernameTextField.backgroundColor = self.usernameIsValid ? [UIColor clearColor] : [UIColor yellowColor];
self.passwordTextField.backgroundColor = self.passwordIsValid ? [UIColor clearColor] : [UIColor yellowColor];
这是清除掉非响应式的代码。
编译运行程序,你会发现text fields无效时会高亮,有效时会透明。
视觉上的表示是很好的,所以这边也能把上述的逻辑视觉化,这里我们能看到两个简单的管道,初始信号为text signals,之后map成booleans值,再map成UIColor
值,这个颜色就是绑定在了text field的背景色。
你是否好奇为什么要分开创建validPasswordSignal
和validUsernameSignal
呢,而不是每个输入框一个管道呢?稍安勿躁,答案马上就会明了!
合并信号
在目前的应用中,Sign In按钮只有在当用户名和密码都有效的时候才能点击,是时候来做响应式的转化了!
目前代码中有两个信号代码用户名和密码的有效性:validUsernameSignal
和validPasswordSignal
,你的任务就是合并这两个信号来决定是否开启Sign In按钮。
在viewDidLoad
中加入:
RACSignal *signUpActiveSignal =
[RACSignal combineLatest:@[validUsernameSignal, validPasswordSignal]
reduce:^id(NSNumber *usernameValid, NSNumber *passwordValid) {
return @([usernameValid boolValue] && [passwordValid boolValue]);
}];
上述代码使用了combineLatest:reduce:
的方法来合并validUsernameSignal
和validPasswordSignal
最新的值到一个新的信号中。每次这两个信号有新的变化时,reduce block就会执行,这个返回值就是新的合并信号的next value。
注意:
RACSignal
的combine方法可以合并任意多个信号,reduce block中的参数相应的符合源信号。ReactiveCocoa有个很精巧的工具类RACBlockTrampoline
,它用来依次处理reduce block中的多个变量。事实上,ReactiveCocoa的实现中有很多这种小技巧,值得去研究研究。
现在我们有了合适的信号,下面就是改变属性了:
[signUpActiveSignal subscribeNext:^(NSNumber *signupActive) {
self.signInButton.enabled = [signupActive boolValue];
}];
并且删除之前老的属性声明:
@property (nonatomic) BOOL passwordIsValid;
@property (nonatomic) BOOL usernameIsValid;
以及viewDidLoad
中的代码:
// handle text changes for both text fields
[self.usernameTextField addTarget:self action:@selector(usernameTextFieldChanged) forControlEvents:UIControlEventEditingChanged];
[self.passwordTextField addTarget:self action:@selector(passwordTextFieldChanged) forControlEvents:UIControlEventEditingChanged];
另外别忘了删除updateUIState
,usernameTextFieldChanged
和passwordFieldChanged
方法。
最后,保证删除viewDidLoad
中的updateUIState
的调用。
如果你编译运行,注意Sign In按钮,它应该在用户名和密码都是有效的情况下才能点击。
我们再更新下逻辑图:
上面解释了一些ReactiveCocoa的一些重要概念:
- Splitting - 信号可以有多个订阅者并且能为多个连续管道服务,在上面的图中,注意用户名和密码有效性的bool类型的信号就被split出来达到不同的目的。
- Combining - 多个信号也可以合并成一个新的信号,在这个例子中,两个boolean的信号就被合并了。其实你可以合并任何信号并且发出任何类型的数据。
结果就是这个项目中不再需要那些表示状态的属性了,这是你采用响应式编程的一个重大风格改变----你不需要实例变量来追踪状态了。
对Sign In进行响应式改变
项目目前采用了这些响应式的管道来管理text field和button的状态,但是,按钮的按下事件仍然是用的actions,所以下一步就是替换现有的逻辑来来让应用全部采用响应式的编程风格。
Sign In按钮的Touch Up Inside事件的响应是在RWViewController.m
的signInButtonTouched
,并且通过storyboard的action来绑定的,所以我们首先需要取消storyboard的连线。
打开Main.storyboard,定位到Sign In按钮,右键点击弹出outlet/action连线菜单,点击x来取消连线。如果你觉得有些懵逼,这里有图:
你已经加入了ReactiveCocoa框架到项目中,目前为止你使用了
rac_textSignal
,它每当文本改变时就会发出事件流,为了处理事件,你需要另一个ReactiveCocoa加入到UIKit的当中的:rac_signalForControlEvents
。回到
RWViewController.m
,加入如下代码到viewDidLoad
:
[[self.signInButton rac_signalForControlEvents:UIControlEventTouchUpInside]
subscribeNext:^(id x) {
NSLog(@"button clicked");
}];
上述代码从按钮的UIControlEventTouchUpInside
事件中创建了一个信号,并且添加了一个订阅,每次触发都会打印日志。
编译运行确保日志能正确输出,按钮只在用户名和密码都有效时才可用,所以在点击按钮前需要在两个文本框中输入一些内容。
在Xcode控制台中会看到类似如下的输出:
2013-12-28 08:05:10.816 RWReactivePlayground[18203:a0b] button clicked
2013-12-28 08:05:11.675 RWReactivePlayground[18203:a0b] button clicked
2013-12-28 08:05:12.605 RWReactivePlayground[18203:a0b] button clicked
2013-12-28 08:05:12.766 RWReactivePlayground[18203:a0b] button clicked
2013-12-28 08:05:12.917 RWReactivePlayground[18203:a0b] button clicked
现在按钮也有了一个touch事件,下一步就是和登录本身连接起来。
打开RWDummySignInService.h,看一看这个接口:
typedef void (^RWSignInResponse)(BOOL);
@interface RWDummySignInService : NSObject
- (void)signInWithUsername:(NSString *)username password:(NSString *)password complete:(RWSignInResponse)completeBlock;
@end
这个接口需要一个用户名、密码和完成的block作为参数,这个block在登录成功或者失败后会调用。你可以直接在点击事件的subscribeNext:
block里直接调用这个方法,但是为什么要这么做呢?这是一个关于异步、基于事件行为的ReactiveCocoa的初次尝试。
注意:这个dummy service在这个教程中非常简单,所以你不需要依赖外部的API,但是,如果你遇到一个真正的这样的问题,该如何使用没有包装成信号的API呢?
创建信号
幸运的是,用已经存在的异步API能很简单的包装成信号,首先,移除掉RWViewController.m中的signInButtonTouched:
方法,你不需要这个逻辑,因为这会被响应式的方式替换。
在RWViewController.m加入如下方法:
-(RACSignal *)signInSignal {
return [RACSignal createSignal:^RACDisposable *(id<RACSubscriber> subscriber) {
[self.signInService signInWithUsername:self.usernameTextField.text password:self.passwordTextField.text
complete:^(BOOL success) {
[subscriber sendNext:@(success)];
[subscriber sendCompleted]; }];
return nil;
}];
}
上述方法用现有的用户名和密码创建了一个信号,现在分解来看。
上面用了RACSignal
的createSignal:
方法来创建一个信号,方法的参数是一个block,这个bock描述了这个信号,当这个信号有订阅者时,block就会执行。
block本身的参数是一个subscriber
实例,它实现了RACSubscriber
的协议,表示它有方法来发送事件流,你可以发送任意多的next事件,也可以用error和complete事件来终止事件传播。在这个例子中,它发送了一个next事件后还发送了complete表示登录是否成功。
block的返回值是一个RACDisposable
对象,它允许你在一个订阅被取消时进行一些清理工作,这个例子不需要清理,所以返回nil
。
你可以看到,封装一个异步API成一个信号是多么简单!
现在我们就用这个新的信号,在viewDidLoad
中更新代码:
[[[self.signInButton rac_signalForControlEvents:UIControlEventTouchUpInside]
map:^id(id x) {
return [self signInSignal];
}]
subscribeNext:^(id x) {
NSLog(@"Sign in result: %@", x);
}];
上述代码用了map
方法来转换button touch signal为sign-in signal,订阅者打印结果。
如果你编译运行,点击Sign In按钮,会发现控制台的输出有点奇怪。。。
2014-01-08 21:00:25.919 RWReactivePlayground[33818:a0b] Sign in result:
<RACDynamicSignal: 0xa068a00> name: +createSignal:
subscribeNext:
block确实正确传入了一个信号,但这不是sign-in信号的数据结果,而是sign-in数据本身。
rac_signalForControlEvents
发送了next事件(UIButton为事件数据),map操作把它转化为了sign-in信号,意味着下一个管道步骤就会接受到一个RACSignal
,这就是我们用subscribeNext:
步骤的结果。这个情形我们叫做信号中的信号,换句话说就是一个外部信号中包含了一个内部信号,如果你想,可以在外部信号的
subscribeNext:
block中订阅内部信号。但是这嵌套会非常乱,这是一个很常见的问题,ReactiveCocoa已经有了解决方法。
信号中的信号
这个问题的解决方法很直接,只需要把map
换成flattenMap
就可以了:
[[[self.signInButton rac_signalForControlEvents:UIControlEventTouchUpInside]
flattenMap:^id(id x) {
return [self signInSignal];
}]
subscribeNext:^(id x) {
NSLog(@"Sign in result: %@", x);
}];
这和之前一样将button touch事件转化成sign-in信号,同时把事件从内部信号发送到外部信号。
编译运行,看看控制台:
2013-12-28 18:20:08.156 RWReactivePlayground[22993:a0b] Sign in result: 0
2013-12-28 18:25:50.927 RWReactivePlayground[22993:a0b] Sign in result: 1
现在管道已经做了你想让它做的了,最后一步就是在subscribeNext
中添加逻辑代码。替换管道代码如下:
[[[self.signInButton rac_signalForControlEvents:UIControlEventTouchUpInside]
flattenMap:^id(id x) {
return [self signInSignal];
}]
subscribeNext:^(NSNumber *signedIn) {
BOOL success = [signedIn boolValue];
self.signInFailureText.hidden = success;
if (success) {
[self performSegueWithIdentifier:@"signInSuccess" sender:self];
}
}];
subscribeNext:
是登录信号的结果,更新signInFailureText
,并且执行segue,再编译运行一遍!
但是你注意到还有一点用户体验的问题存在吗?当sign-in service正在验证身份时,应该让Sign In按钮不可用,这可以防止用户不停点击按钮,并且,当按钮被按下时,报错信息也应该隐藏掉。
但是如何加入这段逻辑到现在的管道中呢?改变按钮的状态不是一个信号转换、过滤或之前我们遇到的任何一个概念。而是一个附加操作,换句话说就是在一个next事件发生时执行的逻辑,但并不改变事件本身。
添加附加操作
替换代码如下:
[[[[self.signInButton rac_signalForControlEvents:UIControlEventTouchUpInside]
doNext:^(id x) {
self.signInButton.enabled = NO;
self.signInFailureText.hidden = YES;
}] flattenMap:^id(id x) {
return [self signInSignal];
}]
subscribeNext:^(NSNumber *signedIn) {
self.signInButton.enabled = YES;
BOOL success = [signedIn boolValue];
self.signInFailureText.hidden = success;
if (success) {
[self performSegueWithIdentifier:@"signInSuccess" sender:self];
}
}];
你可以看到上面添加了一个doNext:
步骤,注意这个doNext:
block没有返回数据,因为这只是附加操作,不改变事件。
doNext:
block让button的enabled属性为NO
,然后隐藏报错信息,而subscribeNext:
block重新启用按钮,并且根据登录结果来显示或者隐藏报错信息。
来看看附加操作的图:
编译运行,确认Sign In按钮enable和disable是正常工作的。
所有工作都已做完,现在的项目完全是响应式的了!
在第二章你会学到更高级的技巧,比如错误处理和在不同线程管理执行代码!