ReactiveCocoa P1

作为一个 iOS 开发者,几乎你写的每一个行代码都是针对某些事件的响应。点击一个按钮、接受一个网络消息、改变一个属性(通过键值观察)或者通过 CoreLocation 改变用户位置都是很好的例子。然而这些事件都是以不同的形式编码,例如动作、委托、KVO、回调等等。ReactiveCocoa 给事件定义了一个标准的接口,所以它们可以更容易地使用一系列基本工具来连接,过滤和合成。

ReacticeCocoa 结合了好几种编码风格:
- 函数式编程 — 利用高阶函数,即函数将其它函数作为其参数
- 响应式编程 — 注重于数据流和变化传播

由于这个原因,你可能会听说过 ReactiveCocoa 被描述成增强型的响应式编程(或 FRP)框架。

毋庸置疑,这就是本教程预期想要描述的学术知识。编程范式是一个吸引人的话题,但本 ReactiveCocoa 教程的其余部分完全集中于实用价值,用的是实际工作的例子而不是学术理论。

开始

假如我们有个视图控制器,它有一个用户名和密码文本框,将以下代码添加到 viewDidLoad 方法的末尾:

[self.usernameTextField.rac_textSignal subscribeNext:^(id x) {
    NSLog(@"%@", x);
}];

运行这个应用程序然后在输入用户名的文本框键入一些文本。观察控制台并寻找类似于下面的输出:

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。这太令人兴奋了!

ReactiveCocoa 信号(由 RACSignal 类表示)发送一连串的事件给它们的订阅者。这里有三种事件类型需要了解:nexterrorcompeleted。一个信号可以在它由于发生 errorcomplete 之前发送任意数量的 next 事件。在这部分教程中你将会专注于 next 事件。当你浏览第二部分的时候就会学到 errorcomplete 事件了。

RACSignal 有许多方法可以让你来订阅这些不同的事件类型。每个方法都会有一个或更多的 block 语句,当事件发生的时候 block 语句中的逻辑就会执行。在这种情况下,你会看到在每个 next 事件中 subscribeNext: 方法会用来供给 block 语句。

ReactiveCocoa 框架使用类别来给许多标准的 UIKit 控件添加信号,因此你可以订阅它们的事件,这就是文本框中 rac_textSignal 属性的由来。

ReactiveCocoa 有大量的操作符可以让你用来操纵事件流。比如,你只对超过三个字符长度的用户名感兴趣。你可以通过使用 filter 操作符来实现这一目标。修改在你先前 viewDidLoad 中的内容上添加如下代码:

[[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 是事件的初始来源。数据流会经过一个只允许字符串长度大于三的事件通过的过滤器。管道的最后一步就是 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 它称得上一个连贯的接口。这个特性允许你构建管道时不需要每一步都使用局部变量。

注意:ReactiveCocoa 中大量使用 block 语句。如果你是这方面的新手,你可能想去阅读 Apple’s Blocks Programming Topics。如果你像我一样熟悉 block 语句,但发现语法有点混乱,很难记住。你会发现这个有趣的标题 f*gblocksyntax.com 是非常有用的!(我们删除那些不和谐的词语你懂的。。。[原网址是:fuckinggblocksyntax.com])

转换

[[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 的值总是 NSSting,那我们可以直接把它的参数类型改成 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 事件。在以上的代码中,map 映射需要 NSString 的输入和它的长度,然后返回一个 NSNumber。

下面的图表形象的描述了整个过程:

正如你所见的,执行完 map 操作后就得到了一个 NSNumber 实例。你可以使用 map 操作来把收到的数据转换成任何你喜欢的,只要它是一个对象。

注意:上面的例子中的 text.length 属性返回一个 NSUInteger,这是个原始类型。为了在事件内容中使用它,它必须被经过封装。幸运的是 Objective-C 语法提供一个相当简单的方式去实现它 - @(text.length)

创建信号

添加如下的代码到 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 事件,传递的值就会分配给指定属性。

合并信号

在 viewDidLoad 的末尾加上下面代码:

RACSignal *signUpActiveSignal = [RACSignal combineLatest:@[validUsernameSignal, validPasswordSignal] reduce:^id(NSNumber *usernameValid, NSNumber *passwordValid) {  
    return @([usernameValid boolValue] && [passwordValid boolValue]);
}];

上面的代码用到了 combineLatest:reduce: 方法来合并由 validUsernameSignalvalidPasswordSignal 发出的最新的值为一个新的信号。只要每次这两个源信号发出其中一个发出新值,reduce block 就会执行,然后它的返回值就会作为合并后信号的 next 值发出。

注意:RACSignal 的 combine 方法可以合并任意数量的信号,reduce block 的参数与每一个源信号相对应。ReactiveCocoa 有一个很巧妙的工具类,RACBlockTrampoline 会在内部处理 reduce block 变量参数列表。事实上,有很多的巧妙的技巧隐藏在 ReactiveCocoa 的实现文件中,所以它值得我们去深入研究。

添加下面的代码到 viewDidLoad 的结尾处。这将把信号与按钮的启动属性连接起来。

[signUpActiveSignal subscribeNext:^(NSNumber *signupActive) {
    self.signInButton.enabled = [signupActive boolValue];
}];

新的应用程序逻辑图如下:

以上说明了两个重要的概念, 使用 ReactiveCocoa 能执行一些非常强大的任务。

分离 — 信号可以有多个订阅者并作为后续多个管道的源。上面的图表中,请注意表示用户名和密码有效性的布尔值信号是分离并用于一对不同的目的。

合并 — 多个信号可以合并成一个新的信号。在这种情况下,两个布尔值的信号被合并了。然而,你可以合并发出的任何类型的信号。

这些改变的结果就是应用不再需要私有属性来表示当前两个文本框的有效状态。当你遵循响应式风格的时候你会发现这是一个关键的区别 - 你不再需要使用实例变量来跟踪瞬间的状态。

控件事件

现在介绍一个新的属性 rac_signalForControlEvents

[[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  

创建信号

-(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 显示它只需要一个参数。当这个信号有一个订阅者的时候,block 中的代码就会执行。

block 传入一个遵循 RACSubscriber 协议的订阅者实例,你可以调用这个协议的方法来发出事件。你也可以发出任意数量的 next 事件,在遇到 error 或者 complete 事件时终止。在这种情况下它发出一个 next 事件来表明是否登录成功,紧跟其后的是一个 complete 事件。

这个 block 的返回类型是一个 RACDisposable 对象,它能让你在当一个订阅被取消或者捣毁时执行任何必须的清理工作。上面的信号不需要做任何清理工作,所以返回 nil。

正如你所见,在一个信号里封装一个异步 API 是如此的简单。

[[[self.signInButton rac_signalForControlEvents:UIControlEventTouchUpInside] map:^id(id x) {
    return [self signInSignal];
}] subscribeNext:^(id x) {
    NSLog(@"Sign in result: %@", x);
}];

上面的代码使用了 map 方法来把按钮触摸信号转换为登录信号。

运行后你会发现结果完全不是你所想要的:

2014-01-08 21:00:25.919 RWReactivePlayground[33818:a0b] Sign in result:  
<RACDynamicSignal: 0xa068a00> name: +createSignal:  

subscribeNext: 已经正确的传入了一个信号,但是不是登录信号。

现在来说明这个管道,你就知道发生了什么

当你点击按钮时 rac_signalForControlEvents 发出 next 事件(用源 UIButton 作为它的事件数据)。映射阶段创建并返回了一个登录信号,这意味着接下来的管道阶段收到了一个 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);
}];

这段代码像之前一样将按钮触摸事件映射为登录信号,但也通过从内部信号发送事件到外部信号来使之扁平化。

构建并运行,查看控制台。它应该正确记录登录是否成功或者失败。

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。

添加附带效果

[[[[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: 不会返回任何值,因为它只是附带效果,它不会改变事件本生。

doNext: block 块设置按钮的 enabled 属性为 NO,并隐藏失败文本。同时 subscribeNext: block 语句会重新开启按钮的 enabled 属性,并根据登录的结果来显示或隐藏失败文本。

这是这段代码的图表:

构建和运行应用程序来确认登录按钮启用和禁用如逾期一样。

总结

希望这个教程给你一个良好的基础,能够帮助你在你自己的应用程序中开始使用 ReactiveCocoa。可能这需要一点练习来适应这些概念,但是任何语言或者程序,一旦你熟悉了它其实还是很简单的。ReactiveCocoa 的核心是就是信号,这只不过是一些事件流。还有比这更简单的么。

值得深思的是 ReactiveCocoa 的主要目标是让你的代码更加整洁和通俗易懂。我个人认为如果应用程序的逻辑是被一个清晰的管道以及使用流畅的语法表达,我们将更加容易理解一个应用程序到底在做什么。