ReactiveObjC基本概念与简单使用

前几个月一直在学习RxSwift,确实相当酷的一个开源库,受益匪浅。在未来学习swift版本(ReactiveSwift)RAC(ReactiveCocoa)之前特意花了3天回顾了一下OC版本(ReactiveObjC)。而之所以愿意写下本篇,是因为这3天中有1天半是在仔细阅读官方文档。官方文档理解之后再去看之前看过的一些他人写的博客,发现质量良莠不齐,真正值得一读的屈指可数。不禁想到原来居然走了那么多弯路。万维刚说:只有学习了“学习的方法”之后才能快速进步。所以学会了哪个开源库不重要,重要的是怎么学会的。越是复杂的开源库,越是要仔细阅读官方文档,之后遇到困惑的地方再找博客对比查证一番,事半功倍。

言归正传,本篇文章主要分为三个部分:ReactiveObjC简介,ReactiveObjC中的基本概念与简单使用,ReactiveObjC中丰富而神奇的操作符。

按照惯例,先来一张图镇帖。


继续阅读之前,强烈建议读者先去了解或者重温一遍官方文档Introduction 和Documentation ,对于接下来的理解会很有帮助。

ReactiveObjC简介

ReactiveCocoa-简称为RAC,现在可分为OC版本-ReactiveObjC和swift版本-ReactiveSwift。本篇文章仅介绍ReactiveObjC,之后会有介绍ReactiveSwift的。

RAC是一个将函数响应式编程范式带入iOS的开源库,其兼具函数式与响应式的特性。它是由Josh Abernathy和Justin Spahr-Summers当初在开发GitHub for Mac 过程中创造的,灵感来源于Functional Reactive Programming 。所以,这么一个神奇伟大的库,竟然是个副产物!而这个副产物比孕育它的产品出名的多,不得不说很有意思。

那么问题来了,什么是函数响应式编程-简称为FRP 呢?一言以蔽之,FRP是基于异步事件流进行编程的一种编程范式。针对离散事件序列进行有效的封装,利用函数式编程的思想,满足响应式编程的需要。

网上资料一大堆,这里就不多介绍了,重点说一下我个人的理解。

函数式编程

举一个简单的🌰:

已知:`f(x) = 2sin(x + π/2) + 3`, 求 `f(π/2)`的值。

怎么做呢,把 `x = π/2` 就可以得出答案,so easy。

那如果是函数式做法呢?

首先定义如下几个函数:

`f1(x) = x` ;

`f2(x) = x + π/2` ;

`f3(x) = sin(x)` ;

`f4(x) = 2x` ;

`f5(x) = x + 3` ;

然后将最初的`f(x)`改写成`f(x) = f5(f4(f3(f2(f1(x)))))`。

也就是说,将每一个复杂的问题都设计成一个高阶函数,其中的参数又是一个新的函数,以此类推。有点类似陈凯歌电影《无极》里面的 “圆环套圆环”。

其中每一个函数都是稳定无副作用的,表现在:在任意时刻输入相同的值,内部经过运算后都会输出相同的值,不会对外界产生任何影响。

上个月看了几页王东岳的《物演通论》,惊为天书,虽然几乎没看懂神马,但是也不是一无所获。“尺度” 是一个看待问题非常重要的点。同一个问题应用不同尺度可能得出的结论天壤之别。

从时间角度看,朝夕是一种尺度,一万年是另一种尺度,几亿年是第三种尺度;从空间角度看,微观是一种尺度,宏观是另一种尺度。

程序员追求将代码写得结构清晰,逻辑合理,很大一部分原因是为了能够高效“复用”。面向对象编程可复用的“尺度”是“类”级别的,而函数式编程可复用的尺度是“函数”级别的。

响应式编程

响应式编程 是一种面向数据流和变化传播的编程范式。这意味着可以在编程语言中很方便地表达静态或动态的数据流,而相关的计算模型会自动将变化的值通过数据流进行传播。

来看下面一小段代码:

NSInteger a = 3;

NSInteger b = 4;

NSInteger c = a + b;

NSLog(@"c is %ld",c);


a = 5;

b = 6;

NSLog(@"c is %ld",c);

初始化c时其值等于a和b的总和,当a或者b或者a与b同时改变时,若想让c的值仍然等于a和b的总和,若是命令式代码需要重写一遍`c = a + b`;而若是响应式则完全不需要。

iOS中其实也有响应式编程的典型例子:Autolayout。

举个实际些的🌰:

为了实现在注册页面注册按钮`enable`状态由几个`textField`的文本内容决定这么一个小需求。

应用命令式写法类似如下:

- (void)dealloc {

    [[NSNotificationCenter defaultCenter] removeObserver:self];

}

- (void)viewDidLoad {

    [super viewDidLoad];

    [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(changeRegisterButtonEnableState) name:UITextFieldTextDidChangeNotification object:nil];

}

- (void)changeRegisterButtonEnableState {

    self.registerButton.enabled = [self isInfomationValid];

}

- (BOOL)isInfomationValid {

    return self.usernameTextField.text.length > 0 && self.passwordTextField.text.length > 0 && [self.passwordTextField.text isEqualToString:self.confirmTextField.text];

}

可以看出,此时一个完整的逻辑会被分散到多个方法中,散乱的分布在各个位置。

应用RAC响应式写法类似如下:

- (void)viewDidLoad {

    [super viewDidLoad];

    RACSignal *validSignal = [RACSignal

    combineLatest:@[self.usernameTextField.rac_textSignal,

                    self.passwordTextField.rac_textSignal,

                    self.confirmTextField.rac_textSignal]

    reduce:^(NSString *username, NSString *password, NSString *confirm){

        return @(username.length > 0 && password.length > 0 && [password isEqualToString:confirm]);

    }];

    RAC(self.registerButton, enabled) = validSignal;

}

可以看出,此时一个完整的逻辑会被聚合在一块儿,非常清晰,可读性也非常强。

至此,如果以上这些内容对你并没有什么吸引力,可以不继续往下看了。

ReactiveObjC中的基本概念与简单使用

如果让我用一句话总结RAC到底能干嘛?那就是:统一所有异步事件的回调方式。

大一统

作为iOS开发者,我们写的绝大部分代码其实都是为了响应事件发生或者状态变化:当一个按钮被点击时,需要写一个`@IBAction`方法来响应;当需要监听键盘是否弹出的状态时,需要注册一个`Notification`来响应;当使用`NSURLSession`做网络请求时需要提供一个`block`来响应;当想要监听一个属性值的变化时,需要使用`KVO`来响应;当一个`scrollView`滑动时,需要写`Delegate`方法来响应。

为了响应这些事件发生或者状态变化,系统提供了多种方式: `Delegate`, `KVO`, `Block`, `Notification`, `Target-Action`。而这也就是问题的根源,写法不统一最终一定会导致代码异常复杂与混乱。如果用一种新的方式将上述五种方式合而为一,会不会很大程度提高代码可读性?哇咔咔,那绝对是当然的。

首先就来看一下如何将五种方式回调写法统一。

//T-A

    [[self.button rac_signalForControlEvents:UIControlEventTouchUpInside] subscribeNext:^(id x) {

        // 按钮被点击回调

    }];

    //Notification

    [[[NSNotificationCenter defaultCenter] rac_addObserverForName:UIKeyboardWillShowNotification object:nil] subscribeNext:^(id x) {

        // 键盘弹出回调

    }];

    //Block

    [[self asyncDataRequest] subscribeNext:^(id x) {

        // 请求成功回调

    } error:^(NSError *error) {

        // 请求错误回调

    }];

    //KVO

    [RACObserve(self, name) subscribeNext:^(id x) {

        // 属性值变化回调

    }];

    //Delegate

    [[self rac_signalForSelector:@selector(scrollViewDidScroll:) fromProtocol:@protocol(UIScrollViewDelegate)] subscribeNext:^(id x) {

        // scrollView滑动回调

    }];


不光如此,对于任意方法也可以应用同样的回调方式。

// Method

    [[self rac_signalForSelector:@selector(viewWillAppear:)] subscribeNext:^(id x) {

        // viewWillAppear方法被调用

    }];

每当相应的事件触发或者状态改变时,`block`中的代码都会执行。没有`Target-Action`,没有`Delegate`,没有`KVO`,没有`Notification`,只有`block`。厉害了有木有?

观察上面代码可以发现:代码高度聚合,无需跨方法调用和传值。这样优点有:

1. 能够减少方法数量

2. 减少很多表示状态的中间变量

3. 拥有足够的上下文环境,减少对其他对象的引用

理论上来说,通过上面这种方式一个类中只要有一个方法就够了,虽然现实中没有人会这样。

基本概念

RACEvent

上面提到,响应式编程可以将变化的值通过数据流进行传播。为此,RAC中定义了一个事件的概念,即:`RACEvent`。

事件分三种类型:Next类型,Completed类型和Error类型。其中Next类型和Error类型事件内部可以承载数据,而Completed类型并不。

RACSignal

这是RAC中最基本的一个概念,中文名叫做“信号”,搞懂了这个类,就可以用RAC去玩耍了。

信号代表的是一个随时间而改变的值流。作为一个流,可以将不断变化的值(或者说数据)向外进行传播。想获取一个信号中的数据,需要订阅它。什么是订阅呢?和订阅博客,订报纸,订牛奶一个意思。但前提是这个信号是存在的,所以想要订阅必先创建。反过来说,创建了一个信号但是并没有订阅它,也获取不到其内部的数据。(这种情况下RACSignal信号根本就不会向外发送数据,下一篇中会详细介绍,暂时忽略)。当一个订阅过程结束时,如有必要去做一些清理工作(当然为了回收资源需要将信号销毁,但RAC内部会自动处理,使用者无需关心)。综上,一个信号完整的使用过程应该是创建,订阅,清理。

信号被订阅了之后,可以认为在信号源和订阅者之间建立起了一座桥梁,通过它信号源源不断的向订阅者发送最新数据,直到桥被销毁。但是要注意,这是一条很窄而且承重很差的桥,以至于一次只能通过一条数据。如果将一条数据理解成一个人,那么通俗的说就是只有一个人通过了另一个人才能继续过,而绝不能同时两个人走上桥。

信号向外传播数据的载体就是事件。其中Next类型事件可以承载任意类型数据-即id,甚至可以是nil。但一般不用来承载错误类型数据,因为有Error类型事件单独做这件事。Completed类型事件仅作为一个正常完成的标志,不承载任何数据。

信号被订阅了之后,可以发送任意多个Next事件(当然可以是0),直到发送了一个Completed事件或者一个Error事件,这两个事件都标志着结束,区别在于Completed事件表示正常结束,而Error事件表示因为某种错误而结束。只要两者之一被发送了,整个订阅过程就结束了。

根据是否会发送一个表示结束的事件信号其实可以分两类。举2个例子,网络请求可作为一个信号,去订阅它,调用API得到结果后,若成功则将获得的数据通过Next事件发出,然后跟一个Completed事件;反之则将错误原因通过一个Error事件发出。此时这个API都调用完成,整个订阅过程结束。按钮可作为另一个信号,去订阅它,之后每当按钮被点击时都会发出一个Next事件,但是在它的整个生命周期中,都不会发送Completed事件或Error事件。

看一下下面的简单示意图:

--1--2--3--4--5--6--|----> // "|" = 正常结束:发送一个Completed事件

--a--b--c--d--e--f--X----> // "X" = 异常结束:发送一个Error事件

--tap--tap----------tap--> // "|" = 一直发送Next事件

三种类型事件关系如下图:


RACSubscriber

信号可以被订阅,订阅了信号的对象就是(外部)订阅者。注意,信号`RACSignal`本身是没有发送事件能力的。为了实现向外发送事件,内部还需要一个发送者。为此,RAC首先定义了一个`RACSubscriber`协议,此协议中定义了发送不同事件类型的方法。然后又定义了一个实现了此协议并与其同名的类`RACSubscriber`。这个类中文可翻译成(内部)订阅者。当一个对象订阅了某个信号后,就会产生一次订阅行为,对应的英文是subscription。此时,RAC内部会创建一个`RACSubscriber`类的实例充当内部订阅者,负责发送事件。综上,虽然有两个订阅者,但是两者并不一样,不要懵逼。官方文档或者其他博客中提到的订阅者均是此处所说的内部订阅者,而本篇文章中提到的订阅者均是此处所说的外部订阅者。

举个🌰:

当你订阅了每天一次的牛奶时,这个订阅行为叫做:subscription;此时你是一个(外部)订阅者;而每天负责向你送牛奶的送奶工,就是一个(内部)订阅者:subscriber。

RACDisposable

前面提到,当一个信号的订阅过程结束时,如有必要去做一些清理工作,为此,RAC定义了一个`RACDisposable`类,中文可以叫做“清理者”。

综上,RAC最核心最基本的概念就是这些,四个名词类:RACEvent(事件),RACSignal(信号),RACSubscriber(发送者),RACDisposable(清理者);三个动词:创建(create),订阅(subscribe),清理(dispose)。

简单使用

创建

创建一个信号非常简单,`RACSignal`定义了一个类方法如下:

+ (RACSignal *)createSignal:(RACDisposable * (^)(id subscriber))didSubscribe;

调用这个方法就会成功创建一个signal对象,并且其内部会自动创建一个实现`RACSubscriber`协议的对象`subscriber`,负责对外发送事件。

不难猜出,`RACSubscriber`协议中定义的发送三种不同事件类型的方法分别如下:

- (void)sendNext:(id)value;

- (void)sendError:(NSError *)error;

- (void)sendCompleted;

举个🌰:

RACSignal *sourceSignal = [RACSignal createSignal:^RACDisposable *(id subscriber) {

        [subscriber sendNext:@"👦🏻"];

        [subscriber sendCompleted];

        return [RACDisposable disposableWithBlock:^{

        }];

    }];

订阅

信号有了,那如何订阅呢?仍然非常简单,`RACSignal`中定义了一些方法如下:

仅订阅next类型事件:

- (RACDisposable *)subscribeNext:(void (^)(id x))nextBlock;

仅订阅next和error类型事件:

- (RACDisposable *)subscribeNext:(void (^)(id x))nextBlock error:(void (^)(NSError *error))errorBlock;

同时订阅三种类型事件:

- (RACDisposable *)subscribeNext:(void (^)(id x))nextBlock error:(void (^)(NSError *error))errorBlock completed:(void (^)(void))completedBlock;

以及:

- (RACDisposable *)subscribeError:(void (^)(NSError *error))errorBlock;

- (RACDisposable *)subscribeCompleted:(void (^)(void))completedBlock;

- (RACDisposable *)subscribeNext:(void (^)(id x))nextBlock completed:(void (^)(void))completedBlock;

- (RACDisposable *)subscribeError:(void (^)(NSError *error))errorBlock completed:(void (^)(void))completedBlock;

根据实际想订阅内容的不同可以有选择性的使用不同的方法,通常来说上面三个比较常用。

举个🌰:

[sourceSignal subscribeNext:^(id x) {

        NSLog(@"接收到next类型事件:%@",x);

    } error:^(NSError *error) {

        NSLog(@"接收到error类型事件:%@",error);

    } completed:^{

        NSLog(@"接收到completed类型事件,不包含任何数据");

    }];

此时,`sourceSignal`发送任何数据都能被接收到。

清理

注意上面`createSignal`方法中的`block`返回的是一个`RACDisposable`类型对象,`subscribe`方法返回的也是一个`RACDisposable`类型对象。下面就来说说这个类怎么用。

`RACDisposable`类有一个类方法:

+ (instancetype)disposableWithBlock:(void (^)(void))block;

就像上面展示过的,使用它就创建了一个`disposable`对象。`block`中可以写一些资源回收和垃圾清理的代码。比如如果是一个网络请求就取消这个请求,如果是打开一个文件就关闭这个文件等。

当信号的订阅过程结束时,`block`中的代码会自动执行。

当然,有时不需要任何清理,那么`block`中就是空的。这种情况也可以不返回一个`RACDisposable`类型对象而是直接返回一个`nil`。

举个🌰:

RACSignal *sourceSignal = [RACSignal createSignal:^RACDisposable *(id subscriber) {

        [subscriber sendNext:@"👦🏻"];

        [subscriber sendCompleted];

        return nil;

    }];

那信号的订阅过程如何结束呢?这里分两种情况,分别是订阅正常完成而结束和订阅中途被取消而结束。

订阅正常完成是指当信号源发送完所有的next事件后,发送一个completed或error事件。订阅中途被取消是指信号源还没有发送结束事件时订阅者就不再继续订阅了。

RAC中并没有明确定义一个取消订阅的方法,但是`RACDisposable`类中定义了如下一个方法:

- (void)dispose;

调用这个方法就表示不再订阅(也就是取消订阅)可以直接去清理资源了。

举个🌰:

RACDisposable *disposable = [sourceSignal subscribeNext:^(id x) {

NSLog(@"接收到next类型事件:%@",x);

}];

[disposable dispose];

通常来说应用这种方式的情况非常少。

综上,订阅过程如下图:


下面来看一个完整的流程:

发送next类型事件以completed结束时:

// 1 信号未被创建 RACSignal *sourceSignal = [RACSignal createSignal:^RACDisposable *(id subscriber) {

        // 3 信号被激活,开始发送事件

        [subscriber sendNext:@"👦🏻"];

        [subscriber sendCompleted];

        return [RACDisposable disposableWithBlock:^{

            // 6 订阅流程结束,可清理资源

        }];

    }];

    // 2 信号已被创建,未被订阅(未激活)

    [sourceSignal subscribeNext:^(id x) {

        // 4 信号已被订阅,可接收next类型事件

        NSLog(@"接收到next类型事件:%@",x);

    } error:^(NSError *error) {

        // 发送next与completed类型事件时,此处不会走到

        NSLog(@"接收到error类型事件:%@",error);

    } completed:^{

        // 5 信号已被订阅,可接收completed类型事件

        NSLog(@"接收到completed类型事件");

    }];

结果是:

接收到next类型事件:👦🏻

接收到completed类型事件 

未发送next类型事件以error结束时:

// 1 信号未被创建 RACSignal *sourceSignal = [RACSignal createSignal:^RACDisposable *(id subscriber) {

        // 3 信号被激活,开始发送事件

        [subscriber sendError:[NSError errorWithDomain:@"www.reactivecocoademo.com" code:202 userInfo:nil]];

        return [RACDisposable disposableWithBlock:^{

            // 5 订阅流程结束,可清理资源

        }];

    }];

    // 2 信号已被创建,未被订阅(未激活)

    [sourceSignal subscribeNext:^(id x) {

        // 仅发送error类型事件时,此处不会走到

        NSLog(@"接收到next类型事件:%@",x);

    } error:^(NSError *error) {

        // 4 信号已被订阅,可接收error类型事件

        NSLog(@"接收到error类型事件:%@",error);

    } completed:^{

        // 发送error类型事件时,此处不会走到

        NSLog(@"接收到completed类型事件");

    }];

结果是:

接收到error类型事件:Error Domain=www.reactivecocoademo.com Code=202 "(null)"

其中注释前面的数字表示代码执行的先后顺序。

除此之外,在信号发送的数据被订阅者接收到之前还可以拦截到而添加一些附加操作,有点面向切片编程的意思。

接收next类型事件以及completed事件之前做些事情:

// 1 信号未被创建 RACSignal *sourceSignal = [[[[RACSignal createSignal:^RACDisposable *(id subscriber) {

        // 3 信号被激活,开始发送事件

        [subscriber sendNext:@"👧🏼"];

        [subscriber sendCompleted];

        return [RACDisposable disposableWithBlock:^{

            // 8 订阅流程结束,可清理资源

        }];

    }] doNext:^(id x) {

        // 4 信号被激活,next类型事件已发送,before接收到

        NSLog(@"before 接收到next类型事件:%@",x);

    }] doError:^(NSError *error) {

        // 发送next与completed类型事件时,此处不会走到

    }] doCompleted:^{

        // 6 信号被激活,before发送completed类型事件

    }];

    // 2 信号已被创建,未被订阅(未激活)

    [sourceSignal subscribeNext:^(id x) {

        // 5 信号已被订阅,可接收next类型事件

        NSLog(@"接收到next类型事件:%@",x);

    } error:^(NSError *error) {

        // 发送next与completed类型事件时,此处不会走到

    } completed:^{

        // 7 信号已被订阅,可接收completed类型事件

        NSLog(@"接收到completed类型事件");

    }];

结果是:

before 接收到next类型事件:👧🏼

接收到next类型事件:👧🏼

接收到completed类型事件

接收error类型事件之前做些事情:

// 1 信号未被创建 RACSignal *sourceSignal = [[[[RACSignal createSignal:^RACDisposable *(id subscriber) {

        // 3 信号被激活,开始发送事件

        [subscriber sendError:[NSError errorWithDomain:@"www.reactivecocoademo.com" code:202 userInfo:nil]];

        return [RACDisposable disposableWithBlock:^{

            // 6 订阅流程结束,可清理资源

        }];

    }] doNext:^(id x) {

        // 仅发送error类型事件时,此处不会走到

    }] doError:^(NSError *error) {

        // 4 信号被激活,error类型事件已发送,before接收到

        NSLog(@"before 接收到error类型事件:%@",error);

    }] doCompleted:^{

        // 发送error类型事件时,此处不会走到

    }];

    // 2 信号已被创建,未被订阅(未激活)

    [sourceSignal subscribeNext:^(id x) {

        // 仅发送error类型事件时,此处不会走到

    } error:^(NSError *error) {

        // 5 信号已被订阅,可接收error类型事件

        NSLog(@"接收到error类型事件:%@",error);

    } completed:^{

        // 发送error类型事件时,此处不会走到

    }];

结果是:

before 接收到error类型事件:Error Domain=www.reactivecocoademo.com Code=202 "(null)"

接收到error类型事件:Error Domain=www.reactivecocoademo.com Code=202 "(null)"

See? 就是这么简单。


最后,再来看一眼文章开头出现过的图片,有没有感觉豁然开朗?



参考链接

官方文档:

ReactiveObjCIntroduction

ReactiveObjCDocumentation

宏观介绍:

Reactive​Cocoa

入门经典:

ReactiveCocoa Tutorial – The Definitive Introduction: Part 1/2

ReactiveCocoa Tutorial – The Definitive Introduction: Part 2/2

国人写的高质量文章:

iOS开发下的函数响应式编程

ReactiveCocoa v2.5 源码解析之架构总览

我之前写的:

ReactiveCocoaDemo

RxSwift基本概念与使用

推荐阅读更多精彩内容

  • RAC使用测试Demo下载:github.com/FuWees/WPRACTestDemo 1.ReactiveC...
    FuWees阅读 5,353评论 3 10
  • 前言 不同于前面的几篇文章,在这个系列里,我会通过我以前的学习介绍一些常用的基本API的作用,集团的项目写的比较快...
    Link913阅读 2,561评论 1 11
  • 标签: iOS RAC 概述 ReactiveCocoa是一个函数响应式编程框架,它能让我们脱离Cocoa AP...
    GodyZ阅读 7,203评论 16 97
  • RACSignal: 一:创建方法: + (RACSignal *)createSignal:(RACDispos...
    飞翔的青蛙王子阅读 699评论 0 2
  • 1.ReactiveCocoa常见操作方法介绍。 1.1 ReactiveCocoa操作须知 所有的信号(RACS...
    萌芽的冬天阅读 852评论 0 5
  • 勤能补拙? 勤奋的唯一意义是生存,也仅仅是生存 天才和勤奋的天才才有发展 好马拉车尽管悲催,遇到伯乐都会被挑走 再...
    lemonsauce阅读 819评论 0 0
  • 这学期转到产品运营组,开始接触微信公众号推文撰写,这个过程中参阅了很多优秀的推文。上星期被要求给师弟师妹们做...
    小昀阅读 1,328评论 0 5
  • 记不清有多少个 这样的夜晚 我在泪水中 咀嚼那些痛心的过往 包括爱情的背叛 亲情的冷漠 有时候心痛得无法喘气 只好...
    海梦夜眼阅读 117评论 0 1
  • 早上睡醒看见导师传唤,意料之中。午饭后到学校去,听了半小时毕设的思路,帮导师装了steam,买了csgo,等下载的...
    AJI米阅读 70评论 0 0
  • 没有坚持锻炼之前,我基本是三天一感冒,几乎都在吃药,有时候吃得特别烦躁。做什么事情都提不起劲,有时候看着镜子里的自...
    行走的鱼阅读 269评论 0 0