[译]ReactiveCocoa基础:理解并使用RACCommand

本文英文出处:http://codeblog.shape.dk/blog/2013/12/05/reactivecocoa-essentials-understanding-and-using-raccommand/,由DEJAVU翻译。

本文讲解的范例源码在:https://github.com/olegam/RACCommandExample

什么是RACCommand?

RACCommand是ReactiveCocoa框架中一个很重要的部分,如果利用好了RACCommand,它将节省你很多的开发时间,并且让我们的应用更加健壮。

笔者遇到了很多刚接触ReactiveCocoa的开发者,他们一开始并不清楚RACCommand是如何工作的,并且不知道该怎么使用它。所以写一篇关于RACCommand简短的介绍会对大家有一点点帮助,在RAC的官方文档中,并没有太多提及RACCommand的用法,虽然头文件中的注释是一份不错的材料,但是毕竟还是对新手有一点吃力。

RACCommand代表着与交互后即将执行的一段流程。通常这个交互是UI层级的,比如你点击个Button。RACCommand可以方便的将Button与enable状态进行绑定,也就是当enable为NO的时候,这个RACCommand将不会执行。RACCommand还有一个常见的策略:allowsConcurrentExecution,默认为NO,也就是是当你这个command正在执行的话,你多次点击Button是没有用的。创建一个RACCommand的返回值是一个Signal,这个Signal会返回next或者complete或者error。接下来我们来看一个范例。

RACCommand范例

接下来我们将实现一个邮箱订阅的功能,只有一个输入框和一个订阅按钮,当用户在输入框输入正确的邮箱,点击订阅将向服务器发送订阅的邮箱号。虽然看起来是一个很简单的需求,但是我们需要处理的细节还是挺多的,比如用户快速的点击了两次订阅按钮、还有如何捕捉订阅失败、如果这个邮箱是非法的怎么办?如果我们用RACCommand来处理的话,其实是非常方便的。

另一方面,ReactiveCocoa也是实现iOS中MVVM模式的好框架。因此,在controller中我们来绑定view model。

1
2
3
4
5
6
7
- (void)bindWithViewModel {
RAC(self.viewModel, email) = self.emailTextField.rac_textSignal;

self.subscribeButton.rac_command = self.viewModel.subscribeCommand;

RAC(self.statusLabel, text) = RACObserve(self.viewModel, statusMessage);
}

我们在viewDidLoad方法中调用上面的方法,来进行view和view model的绑定。精华部分的实现都放在了view model中,我们先来看看view model的头文件:

1
2
3
4
5
6
7
8
9
10
11
@interface SubscribeViewModel : NSObject

@property(nonatomic, strong) RACCommand *subscribeCommand;

// write to this property
@property(nonatomic, strong) NSString *email;

// read from this property
@property(nonatomic, strong) NSString *statusMessage;

@end

其中两个属性已经被我们在controller中绑定了,剩下的subcribeCommand是我们接下来要重点讲解的,view model的实现文件如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
#import "SubscribeViewModel.h"
#import "AFHTTPRequestOperationManager+RACSupport.h"
#import "NSString+EmailAdditions.h"

static NSString *const kSubscribeURL = @"http://reactivetest.apiary.io/subscribers";

@interface SubscribeViewModel ()
@property(nonatomic, strong) RACSignal *emailValidSignal;
@end

@implementation SubscribeViewModel

- (id)init {
self = [super init];
if (self) {
[self mapSubscribeCommandStateToStatusMessage];
}
return self;
}

- (void)mapSubscribeCommandStateToStatusMessage {
RACSignal *startedMessageSource = [self.subscribeCommand.executionSignals map:^id(RACSignal *subscribeSignal) {
return NSLocalizedString(@"Sending request...", nil);
}];

RACSignal *completedMessageSource = [self.subscribeCommand.executionSignals flattenMap:^RACStream *(RACSignal *subscribeSignal) {
return [[[subscribeSignal materialize] filter:^BOOL(RACEvent *event) {
return event.eventType == RACEventTypeCompleted;
}] map:^id(id value) {
return NSLocalizedString(@"Thanks", nil);
}];
}];

RACSignal *failedMessageSource = [[self.subscribeCommand.errors subscribeOn:[RACScheduler mainThreadScheduler]] map:^id(NSError *error) {
return NSLocalizedString(@"Error :(", nil);
}];

RAC(self, statusMessage) = [RACSignal merge:@[startedMessageSource, completedMessageSource, failedMessageSource]];
}

- (RACCommand *)subscribeCommand {
if (!_subscribeCommand) {
NSString *email = self.email;
_subscribeCommand = [[RACCommand alloc] initWithEnabled:self.emailValidSignal signalBlock:^RACSignal *(id input) {
return [SubscribeViewModel postEmail:email];
}];
}
return _subscribeCommand;
}

+ (RACSignal *)postEmail:(NSString *)email {
AFHTTPRequestOperationManager *manager = [AFHTTPRequestOperationManager manager];
manager.requestSerializer = [AFJSONRequestSerializer new];
NSDictionary *body = @{@"email": email ?: @""};
return [[[manager rac_POST:kSubscribeURL parameters:body] logError] replayLazily];
}

- (RACSignal *)emailValidSignal {
if (!_emailValidSignal) {
_emailValidSignal = [RACObserve(self, email) map:^id(NSString *email) {
return @([email isValidEmail]);
}];
}
return _emailValidSignal;
}

@end

看起来很多比较头大,我们现在分成一个个小的部分来讲解下,其中关于RACCommand最有趣的是创建的时候:

1
2
3
4
5
6
7
8
9
- (RACCommand *)subscribeCommand {
if (!_subscribeCommand) {
NSString *email = self.email;
_subscribeCommand = [[RACCommand alloc] initWithEnabled:self.emailValidSignal signalBlock:^RACSignal *(id input) {
return [SubscribeViewModel postEmail:email];
}];
}
return _subscribeCommand;
}

初始化的时候我们传入了一个enabledSignal参数,这个参数决定了command什么时候可以执行。在这个范例中,表示的是当我们输入的邮箱地址合法的时候才能执行。self.emailValidSignal是一个返回YES或者NO的Signal。

signalBlock参数将在每次我们需要执行command的时候调用,这个block返回一个Signal,这个Signal代表了之前所说的执行流程。我们之前保持了默认的allowsConcurrentExecution属性为NO,这就保证了我们在完成执行block之前不会再次执行这个block。

因为在ReactiveCocoa中,UIButton的属性rac_command定义在了一个UIButtton+RACCommandSupport类别,UIButton的enable状态是与command的执行过程相关联绑定的。

因此当按钮被点击的时候command将会自动执行。当然,这些都是RACCommand帮我们自动完成的。当你需要手动调用这个command的时候,可以调用-[RACCommand execute:]方法,传入的参数是可选的,我们在这个例子中将传入nil(其实是button把自己当做参数传入了-execute:方法),另外,这个方法也是一个监视执行流程的一个好地方,比如我们可以这么做:

1
2
3
[[self.viewModel.subscribeCommand execute:nil] subscribeCompleted:^{
NSLog(@"The command executed")
;

}];

在我们的范例中,按钮自动为我们做了这个操作,我们没有调用-execute:,因此当command执行的时候我们得从其他的属性来获得执行的状态,以便于我们更新UI。executionSignalsRACCommand的一个Signal属性,当每次command开始执行的时候,这个Signal就会发送next:next:发送的参数就是初始化RACCommandsignalBlock,也就是说:它是Signal中的Signal。接下来在mapSubscribeCommandStateToStatusMessage方法中我们初始化一个Signal来表示每当command开始执行的时候返回一个表示开始的字符串:

1
2
3
RACSignal *startedMessageSource = [self.subscribeCommand.executionSignals map:^id(RACSignal *subscribeSignal) {
return NSLocalizedString(@"Sending request...", nil);
}];

然后再实现一个类似的Signal来表示每当command执行完毕时候转换返回一个字符串:

1
2
3
4
5
6
7
RACSignal *completedMessageSource = [self.subscribeCommand.executionSignals flattenMap:^RACStream *(RACSignal *subscribeSignal) {
return [[[subscribeSignal materialize] filter:^BOOL(RACEvent *event) {
return event.eventType == RACEventTypeCompleted;
}] map:^id(id value) {
return NSLocalizedString(@"Thanks", nil);
}];
}];

flattenMap方法返回一个新的Signal,并且这个新的Signal它的返回值将传递到最终的Signal中,materialize操作符允许我们将Signal转换成RACEvent,接下来我们就可以过滤这些事件,只允许成功事件通过,并且将成功事件转换成一个代表成功的字符串。如果大家对这步有不清楚的地方,可以去看看官方flattemMapmaterialize的用法。

其实我们还可以换一个更简单的方式来实现:

1
2
3
4
5
6
7
@weakify(self);
[self.subscribeCommand.executionSignals subscribeNext:^(RACSignal *subscribeSignal) {
[subscribeSignal subscribeCompleted:^{
@strongify(self);
self.statusMessage = @"Thanks";
}];
}];

然而,我并不喜欢上面的实现方式,不仅是因为有副作用,而且对self的引用也很不方便,我们不得不使用@weakify@strongify来避免循环引用。

这儿还有一个关于executionSignals属性比较重要的知识点,它并不包含error事件,因此有一个专门的errors属性的Signal,这个Signal会在执行command的任何阶段调用next:发送错误信息,它并不会发送error:,因为error:会终止信号。因此我们可以轻松的转换这个错误信息:

1
2
3
RACSignal *failedMessageSource = [[self.subscribeCommand.errors subscribeOn:[RACScheduler mainThreadScheduler]] map:^id(NSError *error) {
return NSLocalizedString(@"Error :(", nil);
}];

到现在为止,我们已经有了三个带有返回信息的Signal,因此我们将它们合并到一个新的Signal,并且绑定到view model的statusMessage属性:

1
RAC(self, statusMessage) = [RACSignal merge:@[startedMessageSource, completedMessageSource, failedMessageSource]];

到这儿,整个RACCommand的流程就差不多结束了,我认为这种实现方式有很多的优势比起在view controller中使用UITextFieldDelegate和保存过多的变量或属性。

关于RACCommand的其他兴趣点

RACCommand有一个executingSignal属性,当execute:调用的时候它会发送YES,而当command终止的时候它会发送NO。如果你只是想得到当前的值可以这么做:

1
BOOL commandIsExecuting = [[command.executing first] boolValue];

如果你在command enabled状态为NO的时候手动调用了-execute:,那么它会立刻发送一个错误,但是这个错误并不会发送到errorsSignal。

-execute:方法会自动订阅Signal并且多播它,也就是说你不用订阅返回的Signal,但是如果你订阅的话也不用担心会产生副作用,也就是执行两次。