使用RAC的基本操作

2017/10/19 posted in  iOS

作为一个iOS开发者,你写的每一行代码几乎都是在相应某个事件,例如按钮的点击,收到网络消息,属性的变化(通过KVO)或者用户位置的变化(通过CoreLocation)。但是这些事件都用不同的方式来处理,比如action、delegate、KVO、callback等。ReactiveCocoa为事件定义了一个标准接口,从而可以使用一些基本工具来更容易的连接、过滤和组合。

这里我们来创建一个简单的登录功能界面,当输入的namepassword符合我们的预期就输出success

开始

首先我们创建一个工程起名为FirstRAC,并在里面创建podfile文件:

source 'https://github.com/CocoaPods/Specs.git'

platform :ios, ‘8.0’
use_frameworks!

target 'FirstRAC’ do

    pod 'Masonry'
    pod 'ReactiveObjC', '~> 3.0.0'

end

首先我们创建一个button,两个LabeltextField如下图所示:

这里我们的界面布局使用Masonry来进行约束。

界面元素的属性声明为:

@property(nonatomic,strong) UIView *login;
@property(nonatomic,strong) UILabel *nameLabel;
@property(nonatomic,strong) UILabel *passLabel;
@property(nonatomic,strong) UITextField *name;
@property(nonatomic,strong) UITextField *pass;
@property(nonatomic,strong) UIButton *sign;

元素的约束以及创建为:

self.login = [[UIView alloc] initWithFrame:CGRectZero];
    
    _name = [[UITextField alloc] init];
    _name.borderStyle = UIFontWeightBold;
    _name.font = [UIFont systemFontOfSize:15];
    _name.placeholder = @"Enter Name";
    [_login addSubview:_name];
    
    _pass = [[UITextField alloc] init];
    _pass.borderStyle = UIFontWeightBold;
    _pass.font = [UIFont systemFontOfSize:15];
    _pass.placeholder = @"Enter Pass";
    [_login addSubview:_pass];
    
    _nameLabel = [[UILabel alloc] init];
    _nameLabel.backgroundColor = UIColor.whiteColor;
    _nameLabel.font = [UIFont systemFontOfSize:14.0];
    _nameLabel.lineBreakMode = NSLineBreakByTruncatingTail;
    _nameLabel.text = @"Name";
    [_login addSubview:_nameLabel];
    
    _passLabel = [[UILabel alloc] init];
    _passLabel.backgroundColor = UIColor.whiteColor;
    _passLabel.font = [UIFont systemFontOfSize:14.0];
    _passLabel.lineBreakMode = NSLineBreakByTruncatingTail;
    _passLabel.text = @"Pass";
    [_login addSubview:_passLabel];
    
    _sign = [UIButton buttonWithType:UIButtonTypeRoundedRect];
    _sign.layer.borderWidth = 3;
//    _sign.layer.borderColor = [UIColor blueColor].CGColor;
    _sign.titleLabel.textColor = [UIColor redColor];
//    _sign.backgroundColor = [UIColor greenColor];
    [_sign setTitle:@"Sign" forState:UIControlStateNormal];
    [_sign setTitle:@"Push" forState:UIControlStateHighlighted];
    [_sign setTitleColor:UIColor.blackColor forState:UIControlStateNormal];
    _sign.showsTouchWhenHighlighted = YES;
//    [_sign addTarget:self action:@selector(buttonClick:) forControlEvents:UIControlEventTouchUpInside];
    [_login addSubview:_sign];
    [self.view addSubview:_login];

    [_login mas_makeConstraints:^(MASConstraintMaker *make){
        make.left.right.and.bottom.equalTo(self.view);
        make.top.equalTo(self.mas_topLayoutGuide).offset(@50);
    }];
    [_nameLabel mas_makeConstraints:^(MASConstraintMaker *make){
        make.top.equalTo(_login.top);
        make.height.equalTo(@20);
        make.left.equalTo(_login.left).with.offset(20);
    }];
    [_passLabel mas_makeConstraints:^(MASConstraintMaker *make){
        make.top.equalTo(_nameLabel.bottom).with.offset(10);
        make.centerX.equalTo(_nameLabel.centerX);
        make.height.equalTo(@20);
    }];
    [_name mas_makeConstraints:^(MASConstraintMaker *make){
        make.left.equalTo(_nameLabel.right).with.offset(5);
        make.centerY.equalTo(_nameLabel.centerY);
        make.height.equalTo(@20);
    }];
    [_pass mas_makeConstraints:^(MASConstraintMaker *make){
        make.left.equalTo(_passLabel.right).with.offset(5);
        make.centerY.equalTo(_passLabel.centerY);
        make.height.equalTo(@20);
    }];
    [_sign mas_makeConstraints:^(MASConstraintMaker *make){
        make.height.equalTo(@50);
        make.width.equalTo(@100);
        make.top.equalTo(_pass.bottom).offset(@50);
        make.centerX.equalTo(self.view.centerX);
    }];

之后我们运用RAC为界面的元素添加各种事件,首先我们可以简单的为nametextField来订阅事件:

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

该事件对输入的文本进行过滤,将输入文本的长度大于3的文本转化成信号,传递给下一个订阅事件;下一个订阅者输出文本内容。

RACSignal有很多方法可以来订阅不同的事件类型。每个方法都需要至少一个block,当事件发生时就会执行block中的逻辑。在上面的例子中可以看到每次next事件发生时,
subscribeNext:方法提供的block都会执行。

ReactiveCocoa框架使用category来为很多基本UIKit控件添加signal。这样你就能给控件添加订阅了,text field的rac_textSignal就是这么来的。

类型转换

我们运行下面代码就会发现:

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

编译运行,你会发现log输出变成了文本的长度而不是内容。这是因为:新加的map操作通过block改变了事件的数据。map从上一个next事件接收数据,通过执行block把返回值传给下一个next事件。在上面的代码中,map以NSString为输入,取字符串的长度,返回一个NSNumber。

如下图所示:

能看到map操作之后的步骤收到的都是NSNumber实例。你可以使用map操作来把接收的数据转换成想要的类型,只要它是个对象。

创建有效的状态信号

首先我们创建两个信号;

RACSignal *validUsernameSignal =[self.name.rac_textSignal
                                     map:^id(NSString *text){
                                         return @([self isValidUsername:text]);
                                     }];
RACSignal *validPasswordSignal = [self.pass.rac_textSignal
                                      map:^id(NSString *text){
                                          return @([self isValidPassword:text]);
                                      }];

- (BOOL)isValidUsername:(NSString *)username {
    return username.length > 3;
}

- (BOOL)isValidPassword:(NSString *)password {
    return password.length > 3;
}

可以看到,上面的代码对每个输入框的rac_textSignal应用了一个map转换。输出是一个用NSNumber封装的布尔值。

下一步是转换这些信号,从而能为输入框设置不同的背景颜色。基本上就是,你订阅这些信号,然后用接收到的值来更新输入框的背景颜色。下面有一种方法很方便:

    RAC(self.name , backgroundColor) = [validUsernameSignal
                                        map:^id(NSNumber *nameValid){
                                            return [nameValid boolValue] ? [UIColor clearColor]:[UIColor yellowColor];
                                        }];
    RAC(self.pass , backgroundColor) = [validPasswordSignal
                                        map:^id(NSNumber *nameValid){
                                            return [nameValid boolValue] ? [UIColor clearColor]:[UIColor yellowColor];
                                        }];

RAC宏允许直接把信号的输出应用到对象的属性上。RAC宏有两个参数,第一个是需要设置属性值的对象,第二个是属性名。每次信号产生一个next事件,传递过来的值都会应用到该属性上。

聚合信号

目前在应用中,登录按钮只有当用户名和密码输入框的输入都有效时才工作。现在要把这里改成响应式的。

现在的代码中已经有可以产生用户名和密码输入框是否有效的信号了——validUsernameSignalvalidPasswordSignal了。现在需要做的就是聚合这两个信号来决定登录按钮是否可用。

把下面的代码添加到viewDidLoad的末尾:

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

上面的代码使用combineLatest:reduce:方法把validUsernameSignal和validPasswordSignal产生的最新的值聚合在一起,并生成一个新的信号。每次这两个源信号的任何一个产生新值时,reduce block都会执行,block的返回值会发给下一个信号。

注意:RACsignal的这个方法可以聚合任意数量的信号,reduce block的参数和每个源信号相关。ReactiveCocoa有一个工具类RACBlockTrampoline,它在内部处理reduce block的可变参数。实际上在ReactiveCocoa的实现中有很多隐藏的技巧,值得你去看看。

现在我们的逻辑变为:

上图展示了一些重要的概念,你可以使用ReactiveCocoa来完成一些重量级的任务。

  • 分割——信号可以有很多subscriber,也就是作为很多后续步骤的源。注意上图中那个用来表示用户名和密码有效性的布尔信号,它被分割成多个,用于不同的地方。

  • 聚合——多个信号可以聚合成一个新的信号,在上面的例子中,两个布尔信号聚合成了一个。实际上你可以聚合并产生任何类型的信号。

这些改动的结果就是,代码中没有用来表示两个输入框有效状态的私有属性了。这就是用响应式编程的一个关键区别,你不需要使用实例变量来追踪瞬时状态。

响应式的登录

应用目前使用上面图中展示的响应式管道来管理输入框和按钮的状态。但是按钮按下的处理用的还是action,所以下一步就是把剩下的逻辑都替换成响应式的。

你已经知道了ReactiveCocoa框架是如何给基本UIKit控件添加属性和方法的了。目前你已经使用了rac_textSignal,它会在文本发生变化时产生信号。为了处理按钮的事件,现在需要用到ReactiveCocoaUIKit添加的另一个方法,rac_signalForControlEvents

现在回到ViewController.m中,把下面的代码添加到viewDidLoad的末尾:

[[self.signInButton
   rac_signalForControlEvents:UIControlEventTouchUpInside]
   subscribeNext:^(id x) {
     NSLog(@"button clicked");
   }];

上面的代码从按钮的UIControlEventTouchUpInside事件创建了一个信号,然后添加了一个订阅,在每次事件发生时都会输出log
你就会发现每次点击按钮后,就会响应输出事件,输出button clicked

现在按钮有了点击事件的信号,下一步就是把它和登录流程连接起来。。那么问题就来了,打开RWSignInService.h,看一下接口:

//RWSignInService.h
#import <Foundation/Foundation.h>

typedef void (^RWSignInResponse)(BOOL);

@interface RWSignInService : NSObject
- (void)signInWithUsername:(NSString *)username
                 password:(NSString *)password
                 complete:(RWSignInResponse)completeBlock;
@end

//RWSignInService.m
#import "RWSignInService.h"

@implementation RWSignInService

- (void)signInWithUsername:(NSString *)username password:(NSString *)password complete:(RWSignInResponse)completeBlock {
    
    [[UIApplication sharedApplication] setNetworkActivityIndicatorVisible:YES];
    double delayInSeconds = 2.0;
    dispatch_time_t popTime = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(delayInSeconds * NSEC_PER_SEC));
    dispatch_after(popTime, dispatch_get_main_queue(), ^(void){
        [[UIApplication sharedApplication] setNetworkActivityIndicatorVisible:NO];
        BOOL success = [username isEqualToString:@"user"] && [password isEqualToString:@"password"];
        completeBlock(success);
    });
}

@end

这个service有3个参数,用户名、密码和一个完成回调block。这个block会在登录成功或失败时执行。你可以在按钮点击事件的subscribeNext: blcok里直接调用这个方法。

注意:本教程为了简便使用了一个假的service,所以它不依赖任何外部API。但你现在的确遇到了一个问题,如何使用这些不是用信号表示的API呢?

创建信号

幸运的是,把已有的异步API用信号的方式来表示相当简单。,还是在ViewController.m中,添加下面的方法:

-(RACSignal *)signInSignal{
    return [RACSignal createSignal:^RACDisposable *(id subscriber){
        [self.signInService signInWithUsername:self.name.text password:self.pass.text complete:^(BOOL success){
            [subscriber sendNext:@(success)];
            [subscriber sendCompleted];
        }];
        return nil;
    }];
}

上面的方法创建了一个信号,使用用户名和密码登录。现在分解来看一下。

上面的代码使用RACSignalcreateSignal:方法来创建信号。方法的入参是一个block,这个block描述了这个信号。当这个信号有subscriber时,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方法,把按钮点击信号转换成了登录信号。subscriber输出log.

编译运行,点击登录按钮,查看Xcode的控制台后发现输出的是一个信号实例,没错,你已经给subscribeNext:block传入了一个信号,但传入的不是登录结果的信号。

当点击按钮时,rac_signalForControlEvents发送了一个next事件(事件的data是UIButton)。map操作创建并返回了登录信号,这意味着后续步骤都会收到一个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);
   }];

这个操作把按钮点击事件转换为登录信号,同时还从内部信号发送事件到外部信号。

到这里我们的大部分内容就结束了。最后就是在subscribeNext步骤里添加登录成功后的逻辑。把代码更新成下面的:

[[[self.sign rac_signalForControlEvents:UIControlEventTouchUpInside]
     flattenMap:^id(id x){
         return [self signInSignal];
     }]
     subscribeNext:^(NSNumber*signedIn){
         BOOL success =[signedIn boolValue];
         if(success){
             NSLog(@"Success!!!");
         }
     }];

但是,你注意到这个应用现在有一些用户体验上的小问题了吗?当登录service正在校验用户名和密码时,登录按钮应该是不可点击的。这会防止用户多次执行登录操作。

这个逻辑应该怎么添加呢?改变按钮的可用状态并不是转换(map)、过滤(filter)或者其他已经学过的概念。其实这个就叫做“副作用”,换句话说就是在一个next事件发生时执行的逻辑,而该逻辑并不改变事件本身。

添加附加操作(Adding side-effects)

把代码更新成下面的:

[[[[self.sign rac_signalForControlEvents:UIControlEventTouchUpInside]
       doNext:^(id x){
           self.sign.enabled = NO;
       }]
     flattenMap:^id(id x){
         return [self signInSignal];
     }]
     subscribeNext:^(NSNumber*signedIn){
         BOOL success =[signedIn boolValue];
         if(success){
             NSLog(@"Success!!!");
         }
     }];

你可以看到doNext:是直接跟在按钮点击事件的后面。而且doNext: block并没有返回值。因为它是附加操作,并不改变事件本身。

上面的doNext: block把按钮置为不可点击,隐藏登录失败提示。然后在subscribeNext: block里重新把按钮置为可点击,并根据登录结果来决定是否显示失败提示。

之前的管道图就更新成下面这样的:

现在所有的工作都已经完成了,这个应用已经是响应式的啦!!