前言
FRP 是一门学习曲线比较陡峭的技术,回想自己以前的学习过程,也是反反复复好几次,而且总是挫败感很强。不过还好坚持了下来,现在也算是用着比较顺手了。
关于 FRP, 最容易被吐槽的地方就是没有好的学习资料和文档。一开始我也是这种感觉,后来在反复尝试的过程中,发现其实真的不是文档的问题。先说我的结论 —- 不要指望脱离代码能够把 FRP 的原理讲清楚,这是 FRP 和其他编程技术的一个明显差异,这就类似于很难用一段文字把一个数学公式描述清楚一样。而且,即便是开始看用 FRP 编写的各种代码了,还是会觉得太抽象了,仍然需要大量的时间体会代码,或者说,『悟』出其中的一些基本门道。
关于入门学习,没有捷径,最好的办法就是通过代码来学习,下面是我觉得比较好的一些入门学习资料
- The introduction to Reactive Programming you’ve been missing
- ReactiveCocoa Documentation 我本人主要是做 iOS 开发,目前使用的是 RAC 这个库,所以它的官方文档也是一个学习途径。另外,本文中的代码也是使用 RAC 进行编写
- ReactiveCocoa Tutorial – The Definitive Introduction: Part 1/2
- ReactiveCocoa Tutorial – The Definitive Introduction: Part 2/2
- Interactive diagrams of Rx Observables 这个是一组动态效果图,用可视化的效果演示了一些 FRP 里常用操作(当然,其实还是很抽象的)
之所以说 FRP 的学习曲线很陡峭,不仅仅是指它的入门学习比较耗时费脑,当入了门或者稍微找到一些感觉之后,紧接着就会面对第二个问题:FRP 里面提供的都是一些比较抽象的函数操作,怎样才能用这些基本函数来解决各种各样的业务问题?尤其是那些很抽象的操作,怎样才能用起来?
这个系列的文章,主要就是针对后面这第二个问题,做的一些 demo 演示。
可以把 FRP 看成是一种更高级的 Pipeline 编程范式,Pipeline 的一个精髓,就是可以灵活的组合,虽然 FRP 里常用的操作也就那么几十个,但是一旦像搭积木那样对它们进行了组装之后,FRP 的强大之处一下子就展现了出来。
FRP 通常是以库或框架的形式提供给使用者,目前已经有很多常见编程语言的具体实现。在这个系列文章中,将使用 RAC 2 (ReactiveCocoa 的 Objective-C 版本) 进行编写。但是 FRP 本质上是一种编程范式,从 Pipeline 的角度来看,它的侧重点在于如何组装出不同形状的 Pipeline,而不太在乎 Pipeline 的具体构成材料(编程语言),从框架的角度来看,虽然有不同语言版本的实现,但是每个版本里,提供的诸如 map、flattenMap、reduce 等基础操作,在概念上和行为模式上,又都是一样的。所以,FRP 也是一门 “Learn once, write anywhere” 的技术。
FRP 有几个明显的好处,比如可以减少中间状态变量的使用,可以编写紧凑的代码,可以用同步风格编写异步运行的代码,在本系列文章中,也会尽量体现出这些特点。
处理键盘的弹出和隐藏
这个业务其实是非常简单的,就是在某个 UIViewController 里面,当检测到键盘弹出的时候,为了避免键盘遮挡住某个 UIView,需要根据键盘的高度重新对 view 进行 layout,用 RAC 写出来的代码是下面这个样子:
1 | //1 |
用数字标注的地方,是比较关键的点:
- 很多时候,Pipeline 都是只需要构建一次的,如果是针对 UIViewController,通常都是在 viewDidLoad 方法里调用 [self initPipeline],如果是针对 UIView,则很有可能是在 awakeFromNib 方法里进行调用,这里遵循的一个策略是,在模块『活』起来之后,应该尽快的构造所有的 Pipeline,如果是 model 或 service 类型的模块,则很可能是在 init 完成后,就调用 initPipeline,但是对于 UI 性质的模块,因为有 iOS 平台相关的 view 加载策略,而且 Pipeline 通常又是和 UI 交互有关,所以通常是需要在 view 生命期相关的方法中才构造 Pipeline。
- 通过 map 操作,把 UIKeyboardWillShowNotification 转换成一个 CGRect(包装在 NSValue 里面)。map 操作是 FRP 里面最核心的一个基本操作,也是最体现函数式编程(FP)哲学的一个操作,所谓的这个哲学,用通俗的话来描述,就是『把复杂的业务拆分成一个一个的小任务,每一个小任务,都需要一个输入值,并且会给出一个输出值(当然也会反馈错误信息),而且每个小任务都只专心的做一件事情』。如果第一个小任务的输出值,是第二个小任务的输入值,那么,就可以用 map 操作把这两个小任务串联在一起。在接收到 UIKeyboardWillShowNotification 消息通知的时候,这个小任务的输入值就是 NSNotification,输出值是键盘尺寸对应的 CGRect,小任务本身做的事情,就是从 NSNotification 里面取出包装着这个 CGRect 的 NSValue。
- 当接收到 UIKeyboardWillHideNotification 消息通知的时候,这个小任务要做的事情,和 2 里面的小任务是类似的,只不过这一次,NSNotification 并没有包含键盘的尺寸,那我们自己用 CGRectZero 构造一个就行了。
- 终于到了这段代码的重点了,merge 操作在这里的使用效果,相当于把 2 和 3 里面的两个小任务的输出值作为自己的输入值,按照时间先后顺序排列起来,然后作为自己这个小任务的输出值,返回给 Pipeline 中的下一个环节。
这样描述还是很抽象,看不懂,是吧?没关系,早就说过用语言很难描述了。把代码运行起来,通过 NSLog(@"Keyboard size is: %@", value) 这句代码的输出信息体会一下 merge 的实际效果。
- 这里才是真正的实现业务想要的效果,根据前一个小任务的输出值(键盘尺寸 CGRect)来计算 layout 的尺寸。
- takeUntil 是一个难点,如果没有这一句代码调用,运行代码后会发现,前面 5 里面的业务还是正常执行了,但是当 self 被 dealloc 后(比如 pop UIViewController 后),NSLog(@”Keyboard size is: %@”, value) 这句代码还是会被执行到(因为已经处理过 retain cycle,所以此时 self 是 nil),这是因为当 self 被 dealloc 后,这个 Pipeline 并没有被释放,Pipeline 里面还是有数据在继续流动。这个话题牵扯到 RAC 框架中的内存管理策略,很重要,后面的内容中还会讲到这个话题。这里暂时只需要知道可以借助 takeUntil:self.rac_willDeallocSignal 这样的一行代码方便的解决问题就行了。
Singal上的 next、complete、error
在学习的过程中,发现有一个问题很容易被忽略掉,那就是 Signal 的 next、complete、error 这 3 种数据,会在什么时候被发送出来,针对这个问题做过一个总结,放在了 这篇文档 中,主要目的是使用一种简单易懂的格式把 Signal 的关键信息描述出来,这里简单摘录一下。
基础格式
1 | HotSignal<T, E> // or ColdSignal<T, E> |
关键字解释
- HotSignal And ColdSignal:
HotSignal
: Signal 已经处于活动状态(activated);ColdSignal
: Signal 需要订阅(subscribed)才会活动(activate);
- T: Signal sendNext 的类型, 可以下面几种情况:
T
: 表示只会发送 1 次 next 事件, 内容是类型T
的实例;T?
: 表示只会发送 1 次 next 事件, 内容是类型T
的实例或者nil
;[T]
: 表示会发送 0 到 n 次 next 事件, 内容是类型T
的实例;[T?]
: 表示会发送 0 到 n 次 next 事件, 内容是类型T
的实例或者nil
;None
: 表示不会发送 next 事件;
- E: Signal sendError 的类型, 通常是
NSError
或NoError
;NoError
表示 Signal 不会 sendError; - Completion: 描述什么情况 sendCompleted;
- 如果 next 事件的发送次数是
无穷多次
,相当于使用者永远也接收不到 Completed 事件,所以这一行可以不写;
- 如果 next 事件的发送次数是
- Error: 描述什么情况 sendError;
如果 Signal 不会 sendError, 这一行可以不写; - Scheduler: Signal 所在的线程,通常是
main
specified
current
, 默认是current
- main 模块内部的pipeline有切换不同的scheduler,所以模块内部有责任确保最终的signal始终是在main schedular上的
- specified 模块内部自定义了一个任务队列,模块会确保最终返回的signal都在这个特定的schedular中(或者是使用全局默认的后台schedular)
- current 模块内部pipeline没有做任何scheduler的切换,且不指定特定的schedular,所以最终返回的signal和外部调用者的线程保持一致
- Multicast: 是否广播,通常是
YES
NO
, 默认是NO
所有可能出现的有意义的非嵌套 Signal
1 | * HotSignal<T, NoError> |
发送验证码的倒计时按钮
如上图,这里的需求是,点击右上角的按钮后,该按钮不可以使用,同时在按钮上显示一个倒计时时间,当达到倒计时时间后,按钮恢复可用状态。这个需求并不难,相信大家都可以写出来,但是,每个人写出来的代码,风格肯定千差万别,而且,免不了会需要一些状态变量来记录一些信息,比如定时器对象和倒计时的时间等等。如果换用 RAC,则可以在一段连续的代码中,满足所有的需求,代码如下:
1 | //1 |
对关键代码的描述如下:
- 设计一个 RACSignal,这个 Signal 每次发送的 Next 数据里面包含的就是按钮上要显示的文本信息和按钮的可用状态。从模块的角度来看,这个 Signal 的内部细节(倒计时逻辑),外部使用者是不需要知道的,所以后面我们会先看外层 Pipeline 的实现代码,然后再倒回来看这个 Signal 的内部逻辑。
- 每当 retryButtton 被点击的时候,要重新启动一个定时器,所以在这个 map 操作里面,调用 [self retryButtonTitleAndEnable] 得到一个 Signal,将这个 Signal 作为这个小任务的输出值。注意,因为这里 map 操作返回的是一个 Signal,形成了一个 Pipeline 的嵌套,所以可以预见到,在外层 Pipeline 的后续操作中,肯定是需要把这个内嵌的 Pipeline flatten 出来的。
- 在业务需求中,点击这个 retryButtton 后,要请求服务器发送一个验证码(省略了这部分的代码,如果要用 RAC 实现的话,是比较容易的),同时,当每次进入这个 UI 页面的时候,不需要用户主动点击这个 retryButtton 按钮,首先就要自动的请求服务器发送一个验证码,这种情况下,也要求 retryButtton 开始进入倒计时的模式,所以,用 startWith 操作,在外层 Pipeline 中先插入第一个 Next 数据,因为是同样的倒计时逻辑,所以这里也是调用 [self retryButtonTitleAndEnable] 得到内嵌的 Pipeline。
- 前面已经提过了,既然形成了 Pipeline 的嵌套,那肯定是要把这种嵌套解出来的,这里使用 switchToLatest 更合适。要注意区分一下和 flattenMap 的差异。
- Pipeline 的生命期控制,前面的例子中已经讲过这种技巧了,但是,这是写上这句,只是一个双保险。复杂的地方在于外层 Pipeline 有 switchToLatest 操作,这个 switchToLatest 后的 Signal 什么时候才会 Completed,请继续看至后面 13 中的解释。
- 这里是更新 retryButtton 的 title 和状态。
- 现在开始回到内层 Pipeline 的逻辑中去。用 Pipeline 的方式实现一个定时器,借助 RAC 提供的 interval 操作就行。每隔一秒都会在主线程上发送一个 Next。
- 7 里面的定时器上的 Next 数据,是当前的系统时间值,我们的需求里面并不需要这个时间值,所以这里直接 map 成 nil。
- RACSignal interval 要隔一秒后才会发出第一次,需要用 startWith 立刻发送一个,代表倒计时的初始值。
- 把倒计时要用到的数字放到一个数组里面,然后通过 numbers.rac_sequence.signal 语句转换成一个 Signal。
- 把前面 10 中得到的 Signal 和 9 中得到的 timer Signal,用 zipWith 组装起来。注意一点,这个通过 zipWith 组装出来的 Signal,会在 numbers.rac_sequence.signal Completed 的时候 Completed (这句话有点绕,需要结合 zipWith 的定义仔细体会一下)。
- 根据倒计时的数值,计算按钮上需要显示的 title 信息和按钮的状态。
- 前面 11 里面的 zipWith 操作,可以确保倒计时结束时,会触发 Completed,但是万一在倒计时的过程中,用户离开了当前页面,这个时候就需要通过 takeUntil 来触发 Completed。之所以在这里这么注重 Completed,是因为前面的 5 里面的 switchToLatest 操作,会
sends completed when both the receiver and the last sent signal complete
。 - 通过 setNameWithFormat 和 logCompleted 打印一些 log 信息,方便调试,注意观察一下 Signal 的 Completed。
内存管理,自动释放 Pipeline
从前面的 code 中可以看到,好几个地方都在强调要触发 Completed,这完全就是为了正确的进行内存管理,避免内存泄露,避免手动的调用 disposal。takeUntil:self.rac_willDeallocSignal 是一种常用的手段。
还有一种典型的场景,也可以通过 takeUntil 操作来触发 Completed,代码如下:
1 | - (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath { |
这段代码也很简单,唯一需要特别注意的就是 takeUntil:[cell rac_prepareForReuseSignal] 这一句,因为 UICollectionViewCell 本身是有一套复用机制的,每个 cell 上的 Pipeline 的生命期和 cell 本身的生命期并不一致,所以不能依赖于 cell.rac_willDeallocSignal,而应该使用 [cell rac_prepareForReuseSignal] 这个更准确的 Signal。
讨论到这里,还可以得到一个结论,在设计 Signal 的时候,要尽量的让这个 Signal 能够发送 Completed 事件,这样才能够充分的利用 Pipeline 的自动释放功能,保持代码的简洁。RAC 框架里,有一些很常用的 Signal,其实它们的内部实现也是用类似 takeUntil 的操作做了这种处理,比如下面这些 Signal:1
2
3
4
5
6
7
8
9@interface UIControl (RACSignalSupport)
- (RACSignal *)rac_signalForControlEvents:(UIControlEvents)controlEvents;
@end
@interface UIGestureRecognizer (RACSignalSupport)
- (RACSignal *)rac_gestureSignal;
@end
RACObserve 宏定义
下面这个 Signal,则是没有 Completed 事件的,要求它的使用者来决定什么时候释放对应的 Pipeline:1
2
3@interface NSNotificationCenter (RACSupport)
- (RACSignal *)rac_addObserverForName:(NSString *)notificationName object:(id)object;
@end