ReactiveCocoa教程:上半部【译】

原文:ReactiveCocoa Tutorial – The Definitive Introduction: Part 1/2
下半部翻译:ReactiveCocoa教程:下半部【翻译】

译注:在学习ReactiveCocoa的过程中看了不少文章,但对我而言最适合入门的当数此系列。
虽然已经是14年的文章,但基本对于ReactiveCocoa在OC上的使用仍然基本符合。这上下两篇文章最好之处在于通过两个应用实例囊括了大部分ReactiveCocoa的常见用法,虽然没有提及RACCommand具体用法,也瑕不掩瑜。
为便于之后自己查看,同时加深理解,故对文章作简单翻译。因为第一次做翻译,如果翻译中有不妥之处,敬请谅解,亦欢迎大家交流:)

作为一个iOS开发人员,几乎你写的每一行代码都是为了处理各种事件:按钮的点击、网络的反馈、属性的变更(KVO)、位置的改变(CoreLocation)等等。但与此同时,这些事件相应又有着如action、delegate、KVO、callback等各种不一样的实现。ReactiveCocoa为此定义了一套接口与事件的标准,以便于用户凭借一套基础的工具对这些事件进行链接,过滤及重组。

看到这里,你是感到迷惑?兴奋?还是迫不及待?那就继续往下读吧 :]

ReactiveCocoa是如下两种编程风格的集合体:

  • 使用高阶函数的函数式编程,如:以某一函数作为另一函数的入参
  • 注重数据流及变更传递的响应式编程

故而ReactiveCocoa被称为响应式函数编程(Functional Reactive Programming 或FRP)框架。

读到这里你可能感到相当迷惑,但请放心,解惑这正是本教程的目的!虽然编程范式也是一个引人入胜的话题,但相比学术理论,本教程余下的部分,将希望通过完成一个实例让你更好的理解ReactiveCocoa。

响应式乐园

在本教程中,你将通过一个非常简单的应用实例学习响应式编程。在开始之前,作为准备工作,请先下载初始工程并编译运行。

ReactivePlayground是一个包含用户登录界面的简单应用。在用户名及密码校验成功后,你将看到一只非常可爱的小猫。


接下来我们最好花点时间去熟悉一下初始工程的源码。由于工程相当的简单,所以这不会花费太多的时间。

打开查看RWViewController.m文件。尝试回答以下问题:点击Sign In按钮会触发哪些事件?显示/隐藏登录失败的文本需要哪些条件?在这个简单例子中,你可能只需一两分钟就能解答这些问题。但如果情况复杂一点,你就可能需要花比较多的的时间去做相同的分析。

而使用ReactiveCocoa的话,你就能够更清晰地理解应用实际的意图了。事不宜迟,让我们现在立马开始吧!

添加 ReactiveCocoa 框架

往项目中添加ReactiveCocoa框架最简单的途径是使用CocoaPods。如果你之前从来没有使用过CocoaPods,你最好学习一下CocoaPods官网上的教程,或者至少跟着官网教程的初始步骤操作一遍,来保证你已经达到了安装的先决条件。

注意:如果基于某些原因你实在不想使用CocoaPods的话,只要你跟随ReactiveCocoa GitHub上文档进行导入操作,也同样可以使用ReactiveCocoa。

如果你还打开着ReactivePlayground项目的话,现在请先关闭。CocoaPods会创建一个Xcode workspace文件,用以替代原有的项目文件。

打开终端。跳转到你项目所在的文件夹,并输入以下指令:

touch Podfile
open -e Podfile

这创建了一个名为Podfile的文件并通过TextEdit打开。然后复制粘贴以下文本到TextEdit窗口中:(译注:使用Sublime等文本编辑器亦可,不过不要使用系统自带的文本编辑器)

platform :ios, '7.0'
 
pod 'ReactiveCocoa', '2.1.8'

上面文本规定了使用平台为iOS,最小的SDK版本号为7.0,并添加ReactiveCocoa框架作为依赖包。文档保存后,请返回终端窗口,并输入以下命令:(译注:ReactiveCocoa2.X为OC版本,之后包括最新的版本都基于Swift)

pod install

你将会会看到与下文相似的输出:

Analyzing dependencies
Downloading dependencies
Installing ReactiveCocoa (2.1.8)
Generating Pods project
Integrating client project
 
[!] From now on use `RWReactivePlayground.xcworkspace`.

这说明ReactiveCocoa已经成功下载,而且CocoaPods已经创建好了Xcode workspace文件用以整合相关的框架到你现有的应用中。打开新生成的workspace文件RWReactivePlayground.xcworkspace,浏览由CocoaPods在项目导航中生成的结构:

CocoaPods创建了一个新的工作空间,并在原始项目RWReactivePlayground外添加了包含有ReactiveCocoa 框架的Pods项目。CocoaPods就这样轻而易举地达到了管理依赖包的目的!

你想必已经注意到个项目叫做响应式乐园(ReactivePlayground),那么接下来,自然就是我们的游乐时间了……

游乐时间

正如在介绍中提到的,ReactiveCocoa提供了一套接口标准来处理应用中出现的各式事件流。这在ReactiveCocoa的术语中被称作信号,对应的是RACSignal类。

下面打开应用的初始视图控制器RWViewController.m,在文件开头添加以下代码来导入ReactiveCocoa的头文件:

#import <ReactiveCocoa/ReactiveCocoa.h>

你暂时还不需要替换现有的代码,现在要做的只是在原有的代码基础上丰富一下。先在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中的代码块就会执行。不需要target-action,不需要delegate——只凭借信号和block就能实现。这多棒!

ReactiveCocoa的信号(表现为RACSignal类)会向他的订阅者们发送事件流。发送的事件分为三种类型:nexterrorcompleted。一个信号在因为报错或完成的终止前可以发送若干个事件。在教程的上半部分将会把重点放在next事件上。你可以在教程的下半部学习有关errorcompleted的知识。

RACSignal提供了若干的方法用以订阅这些不同的事件类型。每个方法都会接受一个或多个block作为入参,当新的事件出现时,block中的逻辑代码就会执行。在这个例子中,你可以看到subscribeNext:方法就提供了一个block,在每个next事件到达时执行里面的代码。

ReactiveCocoa框架使用category为很多标准的UIKit控件添加了信号,借此你可以对它们的各种事件进行订阅。这正是文本输入框的rac_textSignal属性的由来。

理论学习就到此为止,现在是时候使用ReactiveCocoa来实现一些真正的功能了!

ReactiveCocoa有多个用于操作事件流的方法。举个例子,假如你仅仅关心长度超过3字节的用户名,你可以使用filter方法来达到这个目的。更新你刚刚添加到viewDidLoad方法的代码如下:

[[self.usernameTextField.rac_textSignal
  filter:^BOOL(id value) {
    NSString *text = value;
    return text.length > 3;
  }]
  subscribeNext:^(id x) {
    NSLog(@"%@", x);
  }];

编译运行,并往文本输入框中随意输入一些内容,此时控制台就只会打印文本长度超过3个字节的文本。

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?

你刚创建的是一个非常简单的管道(译注:pipeline,指一个信号被订阅的完整过程)。这正是响应式编程的的本质,通过传递一系列的数据流实现你应用中的功能。

下图中描绘了整个流程:


可以看到,rac_textSignal是这个事件的源头。之后数据流通过一个过滤器,只允许包含长度超过3的的字符串的事件通过。符合条件的事件最后来到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)的特性让你可以构造一个连续的管道而不必用本地变量分割每一步操作。

注意:ReactiveCocoa大量的使用了block。如果你对block并不熟悉,你可能需要阅读苹果官方的Blocks Programming Topics文档。如果你像我一样已经对block相当熟悉,但是难以记住相关的语法,这个有趣的网站f*****gblocksyntax.com应该能够帮到你!(避免冒犯,我们屏蔽了相关单词,但这个链接还是相当实用的。)

简单转换

如果你刚才把代码分解成数个RACSignal,现在就需要将它恢复成流式语法:

[[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);
  }];

编译运行,确保运行结果跟先前的一致。

什么是事件(Event)

目前为止本教程提到了几种不同的事件类型,但未曾详述这些事件的结构。而实际上,事件中可以包含任何类型的值!

为了证明这个观点,你需要往先前的管道中添加新的操作。更新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对事件中的数据进行了转换。其对每一个接收到的事件都通过提供的block进行处理,并将返回值作为next事件发送出去。在上面的代码中,map方法就得到了NSString类型的输入并获取到它的长度,之后转换为NSNumber类型返回给下一步。

我们可以结合下图进一步理解它是如何运作的:


如你所见,在map方法之后的所有环节现在都接收到NSNumber类型的实例。你可以使用map方法将接收到的数据转换成任何你想要的类型,只要转换的目标是一个对象。

注意:在上述例子中,text.length的类型是原始类型NSUInteger。为了将其作为事件的内容,必须对其进行封装(boxed)。幸而Objective-C 已经提供了一个语法糖去实现这一个功能——@(text.length)

游乐时间到此结束了!是时候运用你目前为止学到的概念对ReactivePlaygroundapp的代码进行更新。你可以先把你从开始学习本教程后添加的代码统统清除掉。

创建状态验证信号

你首先要做的是创建两个信号用以验证用户名跟密码是否有效。在RWViewController.mviewDidLoad方法末添加如下代码:

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的布尔值。

下一步要做的是将这些信号继续转换,为文本输入框提供一个合适的背景色。基本上来说,你可以订阅这个信号并将结果直接应用到文本输入框上更新背景色。其中一种可行的实现如下:

[[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宏将一个信号的输出和一个对象的属性绑定起来。宏接受两个参数,第一个是包含改变属性的对象,第二个为属性的名字。每次当信号发出一个新的事件,事件的值就会传递给绑定的属性。

这难道不是一个相当优雅的解决方案么?

在编译运行前,找到updateUIState方法并移除头两行代码:

self.usernameTextField.backgroundColor = self.usernameIsValid ? [UIColor clearColor] : [UIColor yellowColor];
self.passwordTextField.backgroundColor = self.passwordIsValid ? [UIColor clearColor] : [UIColor yellowColor];

去掉原来的非响应式代码。

这时再编译运行。文本输入框就会在内容无效的情况下高亮,在有效时底色又变回透明。

由于图像更有助理解,我们把现在的逻辑图像化。在下图你可以看到两个获取文本信号的管道,先把他们映射为检验有效性的布尔值,然后再映射为与文本输入框的底色属性绑定的UIColor


你是否对单独创建validPasswordSignalvalidUsernameSignal信号感到不解?为什么不直接为文本输入框创建一个连续流畅的管道呢?亲爱的读者耐心点,这疯狂举动背后的真正目的将立马揭晓!

信号合成

在当前App,登录按钮设定只有在用户名和密码都有效输入时才能使用。我们现在要做的就是用响应式来重实现这个功能!

现在的代码已经实现了两个发送布尔值的信号validUsernameSignalvalidPasswordSignal来对用户名和密码的输入进行验证。接下来的任务就是合成这两个信号,用以共同决定登录按钮是否可用。

viewDidLoad的末端添加如下代码:

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

上面的代码使用combineLatest:reduce:方法获取validUsernameSignalvalidPasswordSignal的最近一个信号值并组合成一个全新的信号。每当两个源信号的其中一个发送新值,reduce里的block代码块就会执行,其返回的值会作为合成信号的值发送出去。

注意:RACSignal合成方法可以合成任意数量的信号,而reduce block的入参和源信号一一对应。ReactiveCocoa有一个巧妙的工具类RACBlockTrampoline,用以内部处理reduce block的可变入参列表。实际上,ReactiveCocoa的实现上还隐藏着很多精妙的技巧,非常值得我们深入探究!

现在你已经有一个合适的信号了,在viewDidLoad的末端添加以下代码。把信号与按钮的可用属性关联起来。

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

同时也去除updateUIStateusernameTextFieldChangedpasswordTextFieldChanged`方法。哇!你刚刚可是删除了一大堆非响应式的代码啊!你之后绝对会庆幸你做的一切的。

最后,也别忘了从viewDidLoad中移除updateUIState的调用。

此时如果你编译运行,并留意一下登录按钮。就会发现它像原来一样只有在用户名和密码有效的情况下有效。

更新应用的逻辑图如下:


这体现了两个非常重要的概念,你可以凭借这两点用ReactiveCocoa实现一些相当强大的功能。

  • 拆分:信号可以有多个订阅者和作为多个管道后续环节的来源。在上图中,验证账号和密码的布尔值信号就被单独拆分,并用于两个不同的方面。
  • 组合:多个信号可以组合为全新的信号。在这个例子中,两个布尔值信号被组合到了一起。但不局限于此,实际上你可以组合任意值类型的信号。

这些改动让应用不再需要保留用以记录两个文本输入框是否有效的私有属性。这是你选用响应式编程的其中一个关键差异——你不再需要使用实例变量去记录这些瞬时状态。

响应式登录

应用现在已经如上图说明那样的响应式管道去管理文本输入框和按钮的状态。但是,按钮的点击事件任然是使用action机制,所以下一步我们就把剩下的应用逻辑完全用响应式操作替换掉!

登录按钮的触摸事件(Touch Up Inside event)是在storyboard中与RWViewController.msignInButtonTouched方法进行绑定的。由于你接下来需要使用响应式的等效实现对其进行替换,所以你首先要做的就是断开storyboard现有的绑定。

打开Main.storyboard,按着ctrl同时点击登录按钮,唤起outlet / action关联视图并点击x删除关联。如果你感到迷惑,下图为你标记了删除按钮所在:


你已经见识过ReactiveCocoa框架是怎样在UIKit控件中添加属性和方法的了。至今为止你使用过当文本改变时发送事件的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

现在登录按钮已经有了一个点击事件的信号,下一步就是将它与登录过程关联起来。那么问题来了——但这是好事,你也无惧任何困难对吗?打开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来说简直是小菜一碟!

注意:为了简化,这个教程中使用了一个假的服务,所以你不需要依赖于任何外部的接口。那么问题来了,怎样在不使用信号的情况下使用API呢?(译注:原文为“However, you’ve now run up against a very real problem, how do you use APIs not expressed in terms of signals?”语义貌似与前后文不符,翻译时相当有些困惑,欢迎大家指教)

创建信号

很幸运,现有的异步API很容易就能改造成信号的形式。首先,从RWViewController.m文件移除signInButtonTouched: method方法。因为之后会有等效的响应式实现,所以这不再需要了。

继续在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;
  }];
}

上面的方法用用户名和密码创建了一个信号。现在先来分析一下它的组成部分。

上面的代码使用RACSignalcreateSignal:方法创建信号。描述这个信号的block是这个方法唯一的入参。当这个信号有订阅者的时候,block中的代码就会执行。

这个block传入了一个遵守RACSubscriber协议的subscriber实例,实例中包含发送事件的方法;你可以发送任意数量的事件到下一环节,也可以通过error或者complete事件终止信号。在这个例子中,subscriber实例发送了表示登录结果的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方法将按钮点击信号转换为登录信号。然后订阅者简单地打印了结果。

如果编译运行并点击登录事件,你就会在Xcode的控制台看到上面代码的运行结果……
跟你想象中的大相径庭!

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

subscribeNext的block的确已经接收了一个信号,但是并不是登录信号的结果!

是时候分析一下这个管道好让你明白中间发生了什么了:


当你点击按钮的时候,rac_signalForControlEvents发送了一个next事件(源按钮作为事件的值)。之后在map方法中创建并返回了登录信号,意味着接下来的管道环节现在接受到的是一个RACSignal。也就是你在subscribeNext:环节所获取到的。

上面的状况有时被称为信号里的信号(signal of signals);换句话说就是一个外部的信号包含着一个内部的信号。如果你真的想这么做的话,你可以在外部信号的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:block中获取了登录信号的结果,根据结果更新signInFailureText文本框的内容,同时按需导航到下一页面。编译运行,再次欣赏一下这可爱的猫咪吧!

你是否注意到现在的应用有一个小小的用户习惯问题?当登录服务验证时,登录按钮应当是不可用的。这能够避免用户重复登录。而且,如果登录失败了,当用户再次尝试登录时错误信息应当隐藏起来。

但是怎样添加这些逻辑到现有的管道中呢?影响按钮可用状态的跟改变,过滤或者其他你现今遇到过的概念挂不上勾。相对的,这其实是一种副作用,或者说是当新的事件发生时你希望管道执行的逻辑,而那并不改变对应事件的本质。

添加副作用

替换现有代码如下:

[[[[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中把按钮的的可用属性设为NO,同时隐藏了失败文本。直到subscribeNext:的block中按钮才再次变为可用,并根据登录的结果决定显示或隐藏失败文本。

更新包含了副作用的管道图表如下。


编译运行应用,确保登录按钮像预想一样切换可用与不可用的状态。

而到了这里,你的工作已经完成了——应用已经完全实现了响应式重构。

如果你在过程中感到疑惑,你可以下载最终的项目(包含了框架引用),你也可以从GitHub上获取代码,那里有对应教程中每一步操作的提交记录。

注意:在异步操作时禁用按钮也是一个很常见的问题,ReactiveCocoa同样给出了解决方案。RACCommand封装了这个概念,通过一个可用信号去关联按钮的可用属性。你回头也可以试试使用这个类。

最后

希望这篇教程能够为你在自己的项目上使用ReactiveCocoa建立了一个良好的基础。要熟练运用这些概念还需要一定的练习,但如同其他编程语言一样,一旦你掌握了,也不过如此。ReactiveCocoa最为核心的就是信号,说白了也就是事件流。还有比这个更加简单的么?

同时我发现在ReactiveCocoa中有趣的是,你能使用好几种方式去解决同一个问题。你可以试试在这个应用中实践一下,自己调整信号与管道的拆分与组合来达到相同的效果。

谨记使用ReactiveCocoa最重要的目的是使你的代码更加整洁和易懂。就我个人而言,使用清晰的管道和流式语法能让我更容易理解应用的工作流程。

在本系列教程的下半部,你会学到一些更深层的主题,比如错误的处理和在其他线程中执行代码。而在那之前,就先愉快地实践一下暂时所学吧!

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,560评论 4 361
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,104评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,297评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,869评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,275评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,563评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,833评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,543评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,245评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,512评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,011评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,359评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,006评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,062评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,825评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,590评论 2 273
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,501评论 2 268

推荐阅读更多精彩内容