ReactiveCocoa(RAC)框架

以下是RAC的Github主页:ReactiveCocoa
以及官方给出的用法链接

本文转载于:http://yimouleng.com/2015/12/20/ios-ReactiveCocoa/

第一部分 简单使用

文本框事件
原来我们在使用textFiled的时候我们需要写到

[textField addTarget:self action:@selector(textChanged:) forControlEvents:UIControlEventEditingChanged];

然后实现textChanged:方法,在RAC中,对于文本框的监听,是非常简单的一件事情,看如下代码:

UITextField * textField = ({
       UITextField * textField = [[UITextField alloc]init];
       textField.backgroundColor = [UIColor cyanColor];
       
       textField;
   });
  [self.view addSubview:textField];
   
   @weakify(self); //  __weak __typeof__(self) self_weak_ = self;
   
   [textField mas_makeConstraints:^(MASConstraintMaker *make) {
       
       @strongify(self);    // __strong __typeof__(self) self = self_weak_;
       make.size.mas_equalTo(CGSizeMake(180, 40));
       make.center.equalTo(self.view);
   }];
   
   [[textField rac_signalForControlEvents:UIControlEventEditingChanged]
    subscribeNext:^(id x) {
        LxDBAnyVar(x);
    }];
   [textField.rac_textSignal subscribeNext:^(NSString *x) {
       LxDBAnyVar(x);
   }];

打印结果:

📍__31-[ViewController textFiledTest]_block_invoke_2 + 215🎈 x = 12
📍__31-[ViewController textFiledTest]_block_invoke241 + 211🎈 x = <UITextField: 0x7fe810c51a90; frame = (97.5 313.5; 180 40); text = '123'; clipsToBounds = YES; opaque = NO; gestureRecognizers = <NSArray: 0x7fe810f58fb0>; layer = <CALayer: 0x7fe810c51600>>
📍__31-[ViewController textFiledTest]_block_invoke_2 + 215🎈 x = 123
📍__31-[ViewController textFiledTest]_block_invoke241 + 211🎈 x = <UITextField: 0x7fe810c51a90; frame = (97.5 313.5; 180 40); text = '1231'; clipsToBounds = YES; opaque = NO; gestureRecognizers = <NSArray: 0x7fe810f58fb0>; layer = <CALayer: 0x7fe810c51600>>
📍__31-[ViewController textFiledTest]_block_invoke_2 + 215🎈 x = 1231
📍__31-[ViewController textFiledTest]_block_invoke241 + 211🎈 x = <UITextField: 0x7fe810c51a90; frame = (97.5 313.5; 180 40); text = '12312'; clipsToBounds = YES; opaque = NO; gestureRecognizers = <NSArray: 0x7fe810f58fb0>; layer = <CALayer: 0x7fe810c51600>>
📍__31-[ViewController textFiledTest]_block_invoke_2 + 215🎈 x = 12312
📍__31-[ViewController textFiledTest]_block_invoke241 + 211🎈 x = <UITextField: 0x7fe810c51a90; frame = (97.5 313.5; 180 40); text = '123123'; clipsToBounds = YES; opaque = NO; gestureRecognizers = <NSArray: 0x7fe810f58fb0>; layer = <CALayer: 0x7fe810c51600>>
📍__31-[ViewController textFiledTest]_block_invoke_2 + 215🎈 x = 123123

我们很容易的监听到textFiled中发生的变化,其中x的类型默认为id类型, 我们已知它的类型的时候我们可以将其改变,就像上面代码,将id改成了NSString类型。

手势

self.view.userInteractionEnabled = YES;
    UITapGestureRecognizer * tap = [[UITapGestureRecognizer alloc]init];
    [[tap rac_gestureSignal] subscribeNext:^(UITapGestureRecognizer * tap) {
        LxDBAnyVar(tap);
    }];
    [self.view addGestureRecognizer:tap];

为了方便,我们直接添加到self.view上,点击屏幕,得到打印结果:

📍__29-[ViewController gestureTest]_block_invoke + 184🎈 tap = <UITapGestureRecognizer: 0x7fa2e3e1f9f0; state = Ended; view = <UIView 0x7fa2e3e20b70>; target= <(action=sendNext:, target=<RACPassthroughSubscriber 0x7fa2e3c064f0>)>>

通知

[[[NSNotificationCenter defaultCenter] rac_addObserverForName:UIApplicationDidEnterBackgroundNotification object:nil] subscribeNext:^(NSNotification * notification) {
       
       LxDBAnyVar(notification);
   }];

我们建立了一个通知,叫做进入后台, 当程序进入后台的时候通知相应,当我们用RAC写通知的时候,我们有一个好处,就是不用removeObserver通知,因为RAC通知的监听者师RAC自己,它会帮你管理释放方法。可以看方法实现如下:

- (RACSignal *)rac_addObserverForName:(NSString *)notificationName object:(id)object {
    @unsafeify(object);
    return [[RACSignal createSignal:^(id<RACSubscriber> subscriber) {
        @strongify(object);
        id observer = [self addObserverForName:notificationName object:object queue:nil usingBlock:^(NSNotification *note) {
            [subscriber sendNext:note];
        }];

        return [RACDisposable disposableWithBlock:^{
            [self removeObserver:observer];
        }];
    }] setNameWithFormat:@"-rac_addObserverForName: %@ object: <%@: %p>", notificationName, [object class], object];

定时器

//1. 延迟某个时间后再做某件事
[[RACScheduler mainThreadScheduler]afterDelay:2 schedule:^{
    LxPrintAnything(rac);
}];

//2. 每间隔多长时间做一件事
[[RACSignal interval:1 onScheduler:[RACScheduler mainThreadScheduler]]subscribeNext:^(NSDate * date) {
    
    LxDBAnyVar(date);
}];

这是定时器最常用的两种写法,第一种方法,延迟时间去做某件事,更改afterDelay的属性。
第二种方法,每间隔多长时间做一件事,更改interval属性。

代理

UIAlertView * alertView = [[UIAlertView alloc]initWithTitle:@"RAC" message:@"ReactiveCocoa" delegate:self cancelButtonTitle:@"Cancel" otherButtonTitles:@"Ensure", nil];
   
   [[self rac_signalForSelector:@selector(alertView:clickedButtonAtIndex:) fromProtocol:@protocol(UIAlertViewDelegate)] subscribeNext:^(RACTuple * tuple) {
       
       LxDBAnyVar(tuple);
       
       LxDBAnyVar(tuple.first);
       LxDBAnyVar(tuple.second);
       LxDBAnyVar(tuple.third);
   }];
   [alertView show];
   //   更简单的方式:
   [[alertView rac_buttonClickedSignal]subscribeNext:^(id x) {
       
       LxDBAnyVar(x);
   }];

用RAC去写代理的时候,会有局限,只能取代没有返回值的代理方法,什么是没有返回值的代理呢?比如说tableView的代理方法:

- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath

- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath

这两个方法一个返回的是CGFloat,一个是void,RAC只能取代void的代理。

KVO

UIScrollView * scrollView = [[UIScrollView alloc]init];
scrollView.delegate = (id<UIScrollViewDelegate>)self;
[self.view addSubview:scrollView];

UIView * scrollViewContentView = [[UIView alloc]init];
scrollViewContentView.backgroundColor = [UIColor yellowColor];
[scrollView addSubview:scrollViewContentView];

@weakify(self);

[scrollView mas_makeConstraints:^(MASConstraintMaker *make) {
    
    @strongify(self);
    make.edges.equalTo(self.view).insets(UIEdgeInsetsMake(80, 80, 80, 80));
}];

[scrollViewContentView mas_makeConstraints:^(MASConstraintMaker *make) {
    
    @strongify(self);
    make.edges.equalTo(scrollView);
    make.size.mas_equalTo(CGSizeMake(CGRectGetWidth(self.view.frame), CGRectGetHeight(self.view.frame)));
}];

[RACObserve(scrollView, contentOffset) subscribeNext:^(id x) {
   
    LxDBAnyVar(x);
}];

用RAC写KVO的好处就是方法简单,keypath有代码提示。

第二部分 进阶

信号

- (RACSignal *)loginSignal
  {
      return [RACSignal createSignal:^RACDisposable *(id<RACSubscriber> subscriber) {
          
          RACDisposable * schedulerDisposable = [[RACScheduler mainThreadScheduler]afterDelay:2 schedule:^{
             
              if (arc4random()%10 > 1) {
                  
                  [subscriber sendNext:@"Login response"];
                  [subscriber sendCompleted];
              }
              else {
                  
                  [subscriber sendError:[NSError errorWithDomain:@"LOGIN_ERROR_DOMAIN" code:444 userInfo:@{}]];
              }
          }];
          
          return [RACDisposable disposableWithBlock:^{
              
              [schedulerDisposable dispose];
          }];
      }];
  }

RAC的核心就是RACSignal,也就是信号,我们可以直接创建信号createSignal,并发送它sendNext,当信号完成后我们同时用dispose方法销毁它。发送信号,我们同时也要订阅信号,订阅信号代码如下:

[signal subscribeNext:^(id x) {
     
     LxDBAnyVar(x);
 } error:^(NSError *error) {
     
     LxDBAnyVar(error);
 } completed:^{
     
     LxPrintAnything(completed);
 }];

在信号发送的时候, 错误的时候,以及完成的时候,我们都可以得到相应。

信号的处理

map (映射)

UITextField * textField = ({
        UITextField * textField = [[UITextField alloc]init];
        textField.backgroundColor = [UIColor cyanColor];
        
        textField;
    });
    [self.view addSubview:textField];
    
    @weakify(self); //  __weak __typeof__(self) self_weak_ = self;
    
    [textField mas_makeConstraints:^(MASConstraintMaker *make) {
        
        @strongify(self);    // __strong __typeof__(self) self = self_weak_;
        make.size.mas_equalTo(CGSizeMake(180, 40));
        make.center.equalTo(self.view);
    }];

    [[textField.rac_textSignal map:^id(NSString *text) {
        
       LxDBAnyVar(text);
        
        return @(text.length);
        
    }] subscribeNext:^(id x) {
         LxDBAnyVar(x);
    }];

map这个函数,在这里不是地图的意思,代表映射。map能做的事情就是把监听的rac_textSignal所返回的值,替换成别的就像上面代码中的text的长度。

filter

为了方便演示,我就不再赋值创建textField的代码了,请到Demo中查看

[[[textField.rac_textSignal map:^id(NSString *text) {
        
       LxDBAnyVar(text);
        
        return @(text.length);
        
    }]filter:^BOOL(NSNumber *value) {
        
        return value.integerValue > 3;
        
    }] subscribeNext:^(id x) {
         LxDBAnyVar(x);
    }];

filter是个BOOL值,它代表的是一个条件,当这个条件发生的时候才会作出相应,比如上面代码中,当长度大于3的时候,才会打印x的值。

delay

//创建信号
    RACSignal * signal = [[RACSignal createSignal:^RACDisposable *(id<RACSubscriber> subscriber) {
        [subscriber sendNext:@"rac"];
        [subscriber sendCompleted];
        return nil;
    }]delay:2];
    LxPrintAnything(start);
    //创建订阅者
    [signal subscribeNext:^(id x) {
        LxDBAnyVar(x);
    }];

delay的作用就是延迟,或者说等待,如上,等待2秒之后打印了x。

startWith

RACSignal * signal = [[RACSignal createSignal:^RACDisposable *(id<RACSubscriber> subscriber) {
        
//        [subscriber sendNext:@"123"];//startWith:@"123"等同于这句话 也就是第一个发送,主要是位置
        [subscriber sendNext:@"rac"];
        [subscriber sendCompleted];
        return nil;
    }]startWith:@"123"];
    LxPrintAnything(start);
    //创建订阅者
    [signal subscribeNext:^(id x) {
        LxDBAnyVar(x);
    }];

startWith也就是最开始的意思,看以上代码 startWith:@"123"等同于[subscriber sendNext:@"123"] 也就是第一个发送,主要是位置.

timeOut

[[[RACSignal createSignal:^RACDisposable *(id<RACSubscriber> subscriber) {
        
        [[RACScheduler mainThreadScheduler]afterDelay:3 schedule:^{
            
            [subscriber sendNext:@"rac"];
            [subscriber sendCompleted];
        }];
        
        return nil;
    }] timeout:2 onScheduler:[RACScheduler mainThreadScheduler]]
     subscribeNext:^(id x) {
         
         LxDBAnyVar(x);
     } error:^(NSError *error) {
         
         LxDBAnyVar(error);
     } completed:^{
         
         LxPrintAnything(completed);
     }];
     ```

上面代码的意思就是,我设置了超时限制为(timeout)2秒钟,但是我代码延迟3秒钟发送,超时了,所以这条信息发生错误,会走error的方法。 这种情况可以用在封装http client中,当然你可能遇到别的需求,也需要它。

###take – skip

RACSignal * signal = [[RACSignal createSignal:RACDisposable *(id subscriber) {
[subscriber sendNext:@"rac1"];
[subscriber sendNext:@"rac2"];
[subscriber sendNext:@"rac3"];
[subscriber sendNext:@"rac4"];
[subscriber sendCompleted];
return nil;
}]take:2];//Skip

[signal subscribeNext:id x {
LxDBAnyVar(x);
}];
```

比如说我们发送了很多次请求

take表示我们只取前两次
skip表示跳过前两次
takeLast表示倒数的前两次
takeUntil这个值比较特殊,他后面的参数是个信号,它的意思是,当takeUntil发送这个信号的时候,上面的发送信号就会停止发送。

接下来是几个block回调方法

takeWhileBlock BOOL值,意思是当返回YES的时候,订阅者才能收到信号
skipWhileBlock BOOL值,意思是当返回YES的时候,订阅者就会跳过信号,NO的时候才接受
skipUntilBlock BOOL值,意思是 返回NO的时候,不会收到消息, 直到返回YES的时候才开始收消息。

即时搜索优化 (throttle,distinctUntilChanged,ignore)

UITextField * textField = [[UITextField alloc]init];
   textField.backgroundColor = [UIColor cyanColor];
   [self.view addSubview:textField];
   
   @weakify(self);
   
   [textField mas_makeConstraints:^(MASConstraintMaker *make) {
       
       @strongify(self);
       make.size.mas_equalTo(CGSizeMake(180, 40));
       make.center.equalTo(self.view);
   }];
   //throttle 后面是个时间 表示rac_textSignal发送消息,0.3秒内没有再次发送就会相应,若是0.3内又发送消息了,便会在新的信息处重新计时
   //distinctUntilChanged 表示两个消息相同的时候,只会发送一个请求
   //ignore 表示如果消息和ignore后面的消息相同,则会忽略掉这条消息,不让其发送
   [[[[[[textField.rac_textSignal throttle:0.3] distinctUntilChanged] ignore:@""] map:^id(id value) {
       
       return [RACSignal createSignal:^RACDisposable *(id<RACSubscriber> subscriber) {
           
           //  network request
           [subscriber sendNext:value];
           [subscriber sendCompleted];
           
           return [RACDisposable disposableWithBlock:^{
               
               //  cancel request
           }];
       }];
   }]switchToLatest] subscribeNext:^(id x) {
       
       LxDBAnyVar(x);
   }];

以上代码,是用textField模拟一个即时搜索优化的功能,其中参数如下:

throttle 后面是个时间 表示rac_textSignal发送消息,0.3秒内没有再次发送就会相应,若是0.3内又发送消息了,便会在新的信息处重新计时
distinctUntilChanged 表示两个消息相同的时候,只会发送一个请求
ignore 表示如果消息和ignore后面的消息相同,则会忽略掉这条消息,不让其发送

这样做,是不是给服务器减小了很多的压力,更是节省了我们大量的代码。 其中我们用map建立了一个新的信号,我们知道textField的改变是一个信号, map就是在这个信号上,又加了一个信号,即signal of signals。
订阅者所打印的消息x则是,map发出的信号。我们可以再map中发送新的信号,以及取消信号disposable.
当我们用map发送信号的时候,我们则需要使用 switchToLatest这个参数来获取最后一个信号,也就是我们最后所打印的x,就是map最后发错的这个信号。

repeat

[[[[[RACSignal createSignal:^RACDisposable *(id<RACSubscriber> subscriber) {
       
       [subscriber sendNext:@"rac"];
       [subscriber sendCompleted];
       
       return nil;
   }]delay:1]repeat]take:3] subscribeNext:^(id x) {
       
       LxDBAnyVar(x);
   } completed:^{
       
       LxPrintAnything(completed);
   }];

repeat,顾名思义,就是重复发送这条消息,当我们在后面添加了delay和take的时候,意思就是每隔1秒发送一次这条消息,发送3次后停止。

merge – concat – zipWith

RACSignal * signalA = [RACSignal createSignal:^RACDisposable *(id<RACSubscriber> subscriber) {
       
       dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
           LxPrintAnything(a);
           [subscriber sendNext:@"a"];
           [subscriber sendCompleted];
       });
       
       return nil;
   }];
   
   RACSignal * signalB = [RACSignal createSignal:^RACDisposable *(id<RACSubscriber> subscriber) {
       
       dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(3 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
           LxPrintAnything(b);
           [subscriber sendNext:@"b"];
           [subscriber sendCompleted];
       });
       
       return nil;
   }];
   
   [[RACSignal merge:@[signalA, signalB]]subscribeNext:^(id x) {
       
       LxDBAnyVar(x);
   }];

我们创建了两个请求,A和B,用GCD的方法A延迟两秒钟,B延迟了3秒钟,我们用merge方法合并了A和B,打印结果为

📍__23-[ViewController merge]_block_invoke_2 + 66🎈 a
📍__23-[ViewController merge]_block_invoke29 + 87🎈 x = a
📍__23-[ViewController merge]_block_invoke_215 + 77🎈 b
📍__23-[ViewController merge]_block_invoke29 + 87🎈 x = b

也就是A和B不管谁发送都会打印x,简单的说就是A和B的打印方法用的是同一个。他们之间关系是独立的,如果A发送失败,B依然会执行。
当我们用concat方法链接A和B之后,意思就是当A执行完了之后才会执行B,他们之间是依赖的关系,如果A发送失败,B也不会执行。
请注意合并(merge)和链接(concat)的区别。
zipWith,当用zipWith链接A和B的时候,只有在A.B每隔都至少发送过一次消息的时候才会执行zipWith的方法,它的返回值是一个集合,也就是数组,同时包含了A和B的打印结果。
zipWith的写法等同于 :

[[RACSignal combineLatestWith:@[signalA, signalB]subscribeNext:^(id x) {
        
        LxDBAnyVar(x);
    }];

亦或者

[[RACSignal combineLatest:@[signalA, signalB]]subscribeNext:^(id x) {
        
        LxDBAnyVar(x);
    }];

但是使用combineLatest,可以再后面添加更多的信号.

RAC(<#TARGET, …#>) 宏

//button setBackgroundColor:forState:
    
    UIButton * button = [UIButton buttonWithType:UIButtonTypeCustom];
    [self.view addSubview:button];
    
    @weakify(self);
    
    [button mas_makeConstraints:^(MASConstraintMaker *make) {
        
        @strongify(self);
        make.size.mas_equalTo(CGSizeMake(180, 40));
        make.center.equalTo(self.view);
    }];
    
    RAC(button, backgroundColor) = [RACObserve(button, selected) map:^UIColor *(NSNumber * selected) {
        
        return [selected boolValue] ? [UIColor redColor] : [UIColor greenColor];
    }];
    
    [[button rac_signalForControlEvents:UIControlEventTouchUpInside]subscribeNext:^(UIButton * btn) {
        
        btn.selected = !btn.selected;
    }];

比如Btn的设置背景颜色的属性,OC中并没有button setBackgroundColor:forState:这种方法,我们不能直接设置其选中后的颜色。在RAC中,则可以很简单的改变BTN的背景颜色。不得不说RAC的简单和强大。

做一个秒表

UILabel * label = ({
      
       UILabel * label = [[UILabel alloc]init];
       label.backgroundColor = [UIColor cyanColor];
       label;
   });
   [self.view addSubview:label];
   
   @weakify(self);
   
   [label mas_makeConstraints:^(MASConstraintMaker *make) {
       @strongify(self);
       
       make.size.mas_equalTo(CGSizeMake(240, 40));
       make.center.equalTo(self.view);
       
   }];
   
   RAC(label, text) = [[RACSignal interval:1 onScheduler:[RACScheduler mainThreadScheduler]] map:^NSString *(NSDate * date) {
       
       return date.description;
   }];

只有这么多代码,我们便可以完美的做一个秒表,是否很cool?

当我们大量使用RAC写代码的时候,会把一个个事件封装成一个个信号,通过触发信号,订阅这个信号来返回各种信息。RAC使我们的代码耦合性根底,聚合性更高。
若有不懂得地方可以留言,若有写错的地方,请及时与我联系,可以留言或者Email等。
文本所用的Demo,下载地址 戳这里.

2017/4/7 posted in  ReactiveCocoa

ReactiveCocoa入门教程 — 第二部分

2015/11/12 posted in  ReactiveCocoa

ReactiveCocoa入门教程 - 第一部分

本文翻译自RayWenderlich ReactiveCocoa Tutorial – The Definitive Introduction: Part 1/2

GitHub:https://github.com/ReactiveCocoa/ReactiveCocoa

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

如果你对上面说的还比较疑惑,那还是继续往下看吧。

ReactiveCocoa结合了几种编程风格:

  1. 函数式编程(Functional Programming):使用高阶函数,例如函数用其他函数作为参数。
  2. 响应式编程(Reactive Programming):关注于数据流和变化传播。

所以,你可能听说过ReactiveCocoa被描述为函数响应式编程(FRP)框架。

这就是这篇教程要讲的内容。编程范式是个不错的主题,但是本篇教程的其余部分将会通过一个例子来实践。

Reactive Playground

通过这篇教程,一个简单的范例应用Reactive Playground ,你将会了解到响应式编程。下载初始工程,然后编译运行一下确保你已经把一切都设置正确了。

Reactive Playground是一个非常简单的应用,它为用户展示了一个登录页。在用户名框输入user,在密码框输入password,然后你就能看到有一只可爱小猫咪的欢迎页了。

ReactivePlaygroundStarte

现在可以花一些时间来看一下初始工程的代码。很简单,用不了多少时间。

打开RWViewController.m看一下。你多快能找到控制登录按钮是否可用的条件?判断显示/隐藏登录失败label的条件是什么?在这个相对简单的例子里,可能只用一两分钟就能回答这些问题。但是对于更复杂的例子,这些所花的时间可能就比较多了。

使用ReactiveCocoa,可以使应用的基本逻辑变得相当简洁。是时候开始啦。

添加ReactiveCocoa框架

添加ReactiveCocoa框架最简单的方法就是用CocoaPods。如果你从没用过CocoaPods,那还是先去看看CocoaPods简介这篇教程吧。请至少看完教程中初始化的步骤,这样你才能安装框架。

开动

就像在介绍中提到的,RAC为应用中发生的不同事件流提供了一个标准接口。在ReactiveCocoa术语中这个叫做信号(signal),由RACSignal类表示。

打开应用的初始view controller,RWViewController.m ,引入ReactiveCocoa的头文件。

#import <ReactiveCocoa/ReactiveCocoa.h> 

不要替换已有的代码,将下面的代码添加到viewDidLoad方法的最后:

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

编译运行,在用户名输入框中输几个字。注意console的输出应该和下面的类似。

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,只有signal和block。令人激动不是吗?

ReactiveCocoa signal(RACSignal)发送事件流给它的subscriber。目前总共有三种类型的事件:next、error、completed。一个signal在因error终止或者完成前,可以发送任意数量的next事件。在本教程的第一部分,我们将会关注next事件。在第二部分,将会学习error和completed事件。

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

ReactiveCocoa框架使用category来为很多基本UIKit控件添加signal。这样你就能给控件添加订阅了,text field的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);
  }];

编译运行,在text field只能怪输入几个字,你会发现只有当输入超过3个字符时才会有log。

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是起始事件。然后数据通过一个filter,如果这个事件包含一个长度超过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新手,你可能想看看Apple官方的block编程指南。如果你熟悉block,但是觉得block的语法有些奇怪和难记,你可能会想看看这个有趣又实用的网页f*****gblocksyntax.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的值肯定是个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);
  }];
  

编译运行,你会发现log输出变成了文本的长度而不是内容。

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改变了事件的数据。map从上一个next事件接收数据,通过执行block把返回值传给下一个next事件。在上面的代码中,map以NSString为输入,取字符串的长度,返回一个NSNumber。

来看下面的图片:

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

注意:在上面的例子中text.length返回一个NSUInteger,是一个基本类型。为了将它作为事件的内容,NSUInteger必须被封装。幸运的是Objective-C literal syntax提供了一种简单的方法来封装——@ (text.length)。

现在差不多是时候用所学的内容来更新一下ReactivePlayground应用了。你可以把之前的添加代码都删除了。。。。。。

创建有效状态信号

首先要做的就是创建一些信号,来表示用户名和密码输入框中的输入内容是否有效。把下面的代码添加到RWViewController.m中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]);
 }];

可以看到,上面的代码对每个输入框的rac_textSignal应用了一个map转换。输出是一个用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宏允许直接把信号的输出应用到对象的属性上。RAC宏有两个参数,第一个是需要设置属性值的对象,第二个是属性名。每次信号产生一个next事件,传递过来的值都会应用到该属性上。

你不觉得这种方法很好吗?

在编译运行之前,找到updateUIState方法,把头两行删掉。

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

这样就把不相关的代码删掉了。

编译运行,可以发现当输入内容无效时,输入框看起来高亮了,有效时又透明了。

现在的逻辑用图形来表示就是下面这样的。能看到有两条简单的管道,两个文本信号,经过一个map转为表示是否有效的布尔值,再经过一个map转为UIColor,而这个UIColor已经和输入框的背景颜色绑定了。

你是否好奇为什么要创建两个分开的validPasswordSignalvalidUsernameSignal呢,而不是每个输入框一个单独的管道呢?(?)稍安勿躁,答案就在下面。

原文:Are you wondering why you created separate validPasswordSignal and validUsernameSignal signals, as opposed to a single fluent pipeline for each text field? Patience dear reader, the method behind this madness will become clear shortly!

聚合信号

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

现在的代码中已经有可以产生用户名和密码输入框是否有效的信号了——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的实现中有很多隐藏的技巧,值得你去看看。

现在已经有了合适的信号,把下面的代码添加到viewDidLoad的末尾。这会把信号和按钮的enabled属性绑定。

[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、usernameTextFieldChanged和passwordTextFieldChanged方法删掉。

最后确保把viewDidLoad中updateUIState的调用删掉。

编译运行,看看登录按钮。当用户名和密码输入有效时,按钮就是可用的,和以前一样。

现在应用的逻辑就是下面这样的:

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

  • 分割——信号可以有很多subscriber,也就是作为很多后续步骤的源。注意上图中那个用来表示用户名和密码有效性的布尔信号,它被分割成多个,用于不同的地方。
  • 聚合——多个信号可以聚合成一个新的信号,在上面的例子中,两个布尔信号聚合成了一个。实际上你可以聚合并产生任何类型的信号。

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

响应式的登录

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

在storyboard中,登录按钮的Touch Up Inside事件和RWViewController.m中的signInButtonTouched方法是绑定的。下面会用响应的方法替换,所以首先要做的就是断开当前的storyboard action。

打开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事件创建了一个信号,然后添加了一个订阅,在每次事件发生时都会输出log。

编译运行,确保的确有log输出。按钮只在用户名和密码框输入有效时可用,所以在点击按钮前需要在两个文本框中输入一些内容。

可以看到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

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

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

创建信号

幸运的是,把已有的异步API用信号的方式来表示相当简单。首先把RWViewController.m中的signInButtonTouched:删掉。你会用响应式的的方法来替换这段逻辑。

还是在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;
}];
}

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

上面的代码使用RACSignal的createSignal:方法来创建信号。方法的入参是一个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的控制台,等等,输出的这是个什么鬼?

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

没错,你已经给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);
   }];

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

编译运行,注意控制台,现在应该输出登录是否成功了。

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是否可见。如果登录成功执行导航跳转。

编译运行,应该就能再看到可爱的小猫啦!喵~

你注意到这个应用现在有一些用户体验上的小问题了吗?当登录service正在校验用户名和密码时,登录按钮应该是不可点击的。这会防止用户多次执行登录操作。还有,如果登录失败了,用户再次尝试登录时,应该隐藏错误信息。

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

添加附加操作(Adding side-effects
把代码更新成下面的:

[[[[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把按钮置为不可点击,隐藏登录失败提示。然后在subscribeNext: block里重新把按钮置为可点击,并根据登录结果来决定是否显示失败提示。

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

编译运行,确保登录按钮的可点击状态和预期的一样。

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

如果你中途哪里出了问题,可以下载最终的工程(依赖库都有),或者在Github上找到这份代码,教程中的每一次编译运行都有对应的commit。

注意:在异步操作执行的过程中禁用按钮是一个常见的问题,ReactiveCocoa也能很好的解决。RACCommand就包含这个概念,它有一个enabled信号,能让你把按钮的enabled属性和信号绑定起来。你也许想试试这个类。

总结

希望本教程为你今后在自己的应用中使用ReactiveCocoa打下了一个好的基础。你可能需要一些练习来熟悉这些概念,但就像是语言或者编程,一旦你夯实基础,用起来也就很简单了。ReactiveCocoa的核心就是信号,而它不过就是事件流。还能再更简单点吗?

在使用ReactiveCocoa后,我发现了一个有趣的事情,那就是你可以用很多种不同的方法来解决同一个问题。你可以用教程中的例子试试,调整一下信号,改改信号的分割和聚合。

ReactiveCocoa的主旨是让你的代码更简洁易懂,这值得多想想。我个人认为,如果逻辑可以用清晰的管道、流式语法来表示,那就很好理解这个应用到底干了什么了。

在本系列教程的第二部分,你将会学到诸如错误处理、在不同线程中执行代码等高级用法。

2015/11/12 posted in  ReactiveCocoa