ReactiveCocoa入门教程:Part 1/2

本文翻译自 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,编译运行,会发现一切正常。

ReactiveCocoa入门教程:Part 1/2_第1张图片

现在花些时间来看看代码,很简单,不会很久。
打开 RWViewController.m,你能多快找出控制 Sign In按钮是否enable的条件?显示/隐藏 signInFailurelabel的规则?在这个简单例子中,可能需要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打开项目,会看到目录结构是这样的:

ReactiveCocoa入门教程:Part 1/2_第2张图片

Time to play

上面提到过,ReactiveCocoa提供了一系列标准接口来处理应用中的不同事件流,在ReactiveCocoa中,这些事件流都叫做信号,用RACSignal类来代表。
打开RWViewController.m中,引入头文件:

#import 

你暂时不用替换任何代码,目前只是随便玩一玩,在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?

你在这里所创建的实际上是一个很简单的管道,这是响应式编程的本质,用数据流来表达功能。
可以用这张图来表达上述过程:


ReactiveCocoa入门教程:Part 1/2_第3张图片

在上面的图中,你看到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的输出。
用图片来描述的话是这样的:

ReactiveCocoa入门教程:Part 1/2_第4张图片

就像你看到的,所有 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的背景色。

ReactiveCocoa入门教程:Part 1/2_第5张图片

你是否好奇为什么要分开创建validPasswordSignalvalidUsernameSignal呢,而不是每个输入框一个管道呢?稍安勿躁,答案马上就会明了!

合并信号

在目前的应用中,Sign In按钮只有在当用户名和密码都有效的时候才能点击,是时候来做响应式的转化了!
目前代码中有两个信号代码用户名和密码的有效性:validUsernameSignalvalidPasswordSignal,你的任务就是合并这两个信号来决定是否开启Sign In按钮。
viewDidLoad中加入:

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

上述代码使用了combineLatest:reduce:的方法来合并validUsernameSignalvalidPasswordSignal最新的值到一个新的信号中。每次这两个信号有新的变化时,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,usernameTextFieldChangedpasswordFieldChanged方法。
最后,保证删除viewDidLoad中的updateUIState的调用。
如果你编译运行,注意Sign In按钮,它应该在用户名和密码都是有效的情况下才能点击。
我们再更新下逻辑图:

ReactiveCocoa入门教程:Part 1/2_第6张图片

上面解释了一些ReactiveCocoa的一些重要概念:

  • Splitting - 信号可以有多个订阅者并且能为多个连续管道服务,在上面的图中,注意用户名和密码有效性的bool类型的信号就被split出来达到不同的目的。
  • Combining - 多个信号也可以合并成一个新的信号,在这个例子中,两个boolean的信号就被合并了。其实你可以合并任何信号并且发出任何类型的数据。

结果就是这个项目中不再需要那些表示状态的属性了,这是你采用响应式编程的一个重大风格改变----你不需要实例变量来追踪状态了。

对Sign In进行响应式改变

项目目前采用了这些响应式的管道来管理text field和button的状态,但是,按钮的按下事件仍然是用的actions,所以下一步就是替换现有的逻辑来来让应用全部采用响应式的编程风格。
Sign In按钮的Touch Up Inside事件的响应是在RWViewController.msignInButtonTouched,并且通过storyboard的action来绑定的,所以我们首先需要取消storyboard的连线。
打开Main.storyboard,定位到Sign In按钮,右键点击弹出outlet/action连线菜单,点击x来取消连线。如果你觉得有些懵逼,这里有图:

ReactiveCocoa入门教程:Part 1/2_第7张图片

你已经加入了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 subscriber) { 
        [self.signInService signInWithUsername:self.usernameTextField.text password:self.passwordTextField.text 
        complete:^(BOOL success) { 
            [subscriber sendNext:@(success)]; 
            [subscriber sendCompleted]; }]; 
        return nil; 
    }];
}

上述方法用现有的用户名和密码创建了一个信号,现在分解来看。
上面用了RACSignalcreateSignal:方法来创建一个信号,方法的参数是一个block,这个bock描述了这个信号,当这个信号有订阅者时,block就会执行。
block本身的参数是一个subscriber实例,它实现了RACSubscriber的协议,表示它有方法来发送事件流,你可以发送任意多的next事件,也可以用errorcomplete事件来终止事件传播。在这个例子中,它发送了一个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:
                                    name: +createSignal:

subscribeNext:block确实正确传入了一个信号,但这不是sign-in信号的数据结果,而是sign-in数据本身。

ReactiveCocoa入门教程:Part 1/2_第8张图片

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,再编译运行一遍!

ReactiveCocoa入门教程:Part 1/2_第9张图片

但是你注意到还有一点用户体验的问题存在吗?当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重新启用按钮,并且根据登录结果来显示或者隐藏报错信息。
来看看附加操作的图:

ReactiveCocoa入门教程:Part 1/2_第10张图片

编译运行,确认 Sign In按钮enable和disable是正常工作的。
所有工作都已做完,现在的项目完全是响应式的了!
在第二章你会学到更高级的技巧,比如错误处理和在不同线程管理执行代码!

你可能感兴趣的:(ReactiveCocoa入门教程:Part 1/2)