230 lines
14 KiB
Markdown
230 lines
14 KiB
Markdown
# 22 | 细说 iOS 响应式框架变迁,哪些思想可以为我所用?
|
||
|
||
你好,我是戴铭。
|
||
|
||
说到iOS 响应式框架,最开始被大家知道的是 ReactiveCocoa(简称RAC),后来比较流行的是 RxSwift。但据我了解,iOS原生开发使用 ReactiveCocoa框架的团队并不多,而前端在推出React.js 后,响应式思路遍地开花。
|
||
|
||
那么,**响应式框架到底是什么,为什么在iOS原生开发中没被广泛采用,却能在前端领域得到推广呢?**
|
||
|
||
我们先来看看响应式框架,它指的是能够支持响应式编程范式的框架。使用了响应式框架,你在编程时就可以使用数据流传播数据的变化,响应这个数据流的计算模型会自动计算出新的值,将新的值通过数据流传给下一个响应的计算模型,如此反复下去,直到没有响应者为止。
|
||
|
||
React.js框架的底层有个 Virtual DOM(虚拟文档对象模型),页面组件状态会和 Virtual DOM 绑定,用来和 DOM(文档对象模型)做映射与转换。当组件状态更新时,Virtual DOM 就会进行 Diff 计算,最终只将需要渲染的节点进行实际 DOM 的渲染。
|
||
|
||
JavaScript 每次操作 DOM 都会全部重新渲染,而Virtual DOM 相当于 JavaScript 和 DOM 之间的一个缓存,JavaScript 每次都是操作这个缓存,对其进行 Diff 和变更,最后才将整体变化对应到 DOM 进行最后的渲染,从而减少没必要的渲染。
|
||
|
||
React.js 的 Virtual DOM 映射和转换 DOM 的原理,如下图所示。我们一起通过原理,来分析一下它的性能提升。
|
||
|
||
![](https://static001.geekbang.org/resource/image/67/a2/672e07e4347b132701c37d21ac7a44a2.png)
|
||
可以看出,操作 Virtual DOM 时并不会直接进行 DOM 渲染,而是在完成了 Diff 计算得到所有实际变化的节点后才会进行一次 DOM 操作,然后整体渲染。而 DOM 只要有操作就会进行整体渲染。
|
||
|
||
直接在 DOM 上进行操作是非常昂贵的,所以视图组件会和 Virtual DOM 绑定,状态的改变直接更改 Virtual DOM。Virtual DOM 会检查两个状态之间的差异,进行最小的修改,所以 React.js 具有很好的性能。也正是因为性能良好,React.js才能够在前端圈流行起来。
|
||
|
||
而反观iOS,ReactiveCocoa框架的思路,其实与React.js中页面组件状态和 Virtual DOM 绑定、同步更新的思路是一致的。那**为什么 ReactiveCocoa 在iOS原生开发中就没流行起来呢?**
|
||
|
||
我觉得,主要原因是前端DOM 树的结构非常复杂,进行一次完整的 DOM 树变更,会带来严重的性能问题,而有了 Virtual DOM 之后,不直接操作 DOM 可以避免对整个 DOM 树进行变更,使得我们不用再担忧应用的性能问题。
|
||
|
||
但是,这种性能问题并不存在于iOS 原生开发。这,主要是得易于 Cocoa Touch 框架的界面节点树结构要比 DOM 树简单得多,没有前端那样的历史包袱。
|
||
|
||
与前端 DOM 渲染机制不同,Cocoa Touch 每次更新视图时不会立刻进行整个视图节点树的重新渲染,而是会通过 setNeedsLayout 方法先标记该视图需要重新布局,直到绘图循环到这个视图节点时才开始调用 layoutSubviews 方法进行重新布局,最后再渲染。
|
||
|
||
所以说,ReactiveCocoa框架并没有为 iOS 的 App 带来更好的性能。当一个框架可有可无,而且没有明显收益时,一般团队是没有理由去使用的。那么,像 ReactiveCocoa 这种响应式思想的框架在 iOS 里就没有可取之处了吗?
|
||
|
||
我觉得并不是。今天,我就来跟你分享下,**ReactiveCocoa 里有哪些思想可以为我所用,帮我们提高开发效率?**
|
||
|
||
ReactiveCocoa 是将函数式编程和响应式编程结合起来的库,通过函数式编程思想建立了数据流的通道,数据流动时会经过各种函数的处理最终到达和数据绑定的界面,由此实现了数据变化响应界面变化的效果。
|
||
|
||
## Monad
|
||
|
||
ReactiveCocoa 是采用号称纯函数式编程语言里的 Monad 设计模式搭建起来的,核心类是 RACStream。我们使用最多的 RACSignal(信号类,建立数据流通道的基本单元) ,就是继承自RACStream。RACStream 的定义如下:
|
||
|
||
```
|
||
typedef RACStream * (^RACStreamBindBlock)(id value, BOOL *stop);
|
||
|
||
/// An abstract class representing any stream of values.
|
||
///
|
||
/// This class represents a monad, upon which many stream-based operations can
|
||
/// be built.
|
||
///
|
||
/// When subclassing RACStream, only the methods in the main @interface body need
|
||
/// to be overridden.
|
||
@interface RACStream : NSObject
|
||
|
||
+ (instancetype)empty;
|
||
+ (instancetype)return:(id)value;
|
||
- (instancetype)bind:(RACStreamBindBlock (^)(void))block;
|
||
- (instancetype)concat:(RACStream *)stream;
|
||
- (instancetype)zipWith:(RACStream *)stream;
|
||
|
||
@end
|
||
|
||
```
|
||
|
||
通过定义的注释可以看出,RACStream的作者也很明确地写出了RACStream 类表示的是一个 Monad,所以我们在 RACStream 上可以构建许多基于数据流的操作;RACStreamBindBlock,就是用来处理 RACStream 接收到数据的函数。那么,**Monad 就一定是好的设计模式吗?**
|
||
|
||
**从代码视觉上看**,Monad 为了避免赋值语句做了很多数据传递的管道工作。这样的话,我们在分析问题时,就很容易从代码层面清晰地看出数据流向和变化。而如果是赋值语句,在分析数据时就需要考虑数据状态和生命周期,会增加调试定位的成本,强依赖调试工具去观察变量。
|
||
|
||
**从语言发展来看**,Monad 虽然可以让上层接口看起来很简洁,但底层的实现却犹如一团乱麻。为了达到“纯”函数效果,Monad底层将各种函数的参数和返回值封装在了类型里,将本来可以通过简单数据赋值给变量记录的方式复杂化了。
|
||
|
||
不过无论是赋值方式还是 Monad 方式,编译后生成的代码都是一样的。王垠在他的博文“[函数式语言的宗教](http://www.yinwang.org/blog-cn/2013/03/31/purely-functional)”里详细分析了 Monad,并且写了两段分别采用赋值和函数式的代码,编译后的机器码实际上是一样的。如果你感兴趣的话,可以看一下这篇文章。
|
||
|
||
所以,如果你不想引入 ReactiveCocoa 库,还想使用函数响应式编程思想来开发程序的话,完全不用去重新实现一个采用 Monad 模式的 RACStream,只要在上层按照函数式编程的思想来搭建数据流管道,在下层使用赋值方式来管理数据就可以了。并且,采用这种方式,可能会比 Monad 这种“纯”函数来得更加容易。
|
||
|
||
## 函数响应式编程例子
|
||
|
||
接下来,我通过一个具体的案例来和你说明下,如何搭建一个不采用 Monad 模式的函数响应式编程框架。
|
||
|
||
这个案例要完成的功能是:添加学生基本信息,添加完学生信息后,通过按钮点击累加学生分数,每次点击按钮分数加5;所得分数在30分内,颜色显示为灰色;分数在30到70分之间,颜色显示为紫色;分数在70分内,状态文本显示不合格;超过70分,分数颜色显示为红色,状态文本显示合格。初始态分数为0,状态文本显示未设置。
|
||
|
||
这个功能虽然不难完成,但是如果我们将这些逻辑都写在一起,那必然是条件里套条件,当要修改功能时,还需要从头到尾再捋一遍。
|
||
|
||
如果把逻辑拆分成小逻辑放到不同的方法里,当要修改功能时,查找起来也会跳来跳去,加上为了描述方法内逻辑,函数名和参数名也需要非常清晰。这,无疑加重了开发和维护成本,特别是函数里面的逻辑被修改了后,我们还要对应着修改方法名。否则,错误的方法名,将会误导后来的维护者。
|
||
|
||
那么,**使用函数响应式编程方式会不会好一些呢?**
|
||
|
||
这里,我给出了使用函数响应式编程方式的代码,你可以对比看看是不是比条件里套条件和方法里套方法的写法要好。
|
||
|
||
**首先,**创建一个学生的记录,在创建记录的链式调用里添加一个处理状态文本显示的逻辑。代码如下:
|
||
|
||
```
|
||
// 添加学生基本信息
|
||
self.student = [[[[[SMStudent create]
|
||
name:@"ming"]
|
||
gender:SMStudentGenderMale]
|
||
studentNumber:345]
|
||
filterIsASatisfyCredit:^BOOL(NSUInteger credit){
|
||
if (credit >= 70) {
|
||
// 分数大于等于 70 显示合格
|
||
self.isSatisfyLabel.text = @"合格";
|
||
self.isSatisfyLabel.textColor = [UIColor redColor];
|
||
return YES;
|
||
} else {
|
||
// 分数小于 70 不合格
|
||
self.isSatisfyLabel.text = @"不合格";
|
||
return NO;
|
||
}
|
||
}];
|
||
|
||
```
|
||
|
||
可以看出,当分数小于70时,状态文本会显示为“不合格”,大于等于70时会显示为“合格”。
|
||
|
||
**接下来,**针对分数,我再创建一个信号,当分数有变化时,信号会将分数传递给这个分数信号的两个订阅者。代码如下:
|
||
|
||
```
|
||
// 第一个订阅的credit处理
|
||
[self.student.creditSubject subscribeNext:^(NSUInteger credit) {
|
||
NSLog(@"第一个订阅的credit处理积分%lu",credit);
|
||
self.currentCreditLabel.text = [NSString stringWithFormat:@"%lu",credit];
|
||
if (credit < 30) {
|
||
self.currentCreditLabel.textColor = [UIColor lightGrayColor];
|
||
} else if(credit < 70) {
|
||
self.currentCreditLabel.textColor = [UIColor purpleColor];
|
||
} else {
|
||
self.currentCreditLabel.textColor = [UIColor redColor];
|
||
}
|
||
}];
|
||
|
||
// 第二个订阅的credit处理
|
||
[self.student.creditSubject subscribeNext:^(NSUInteger credit) {
|
||
NSLog(@"第二个订阅的credit处理积分%lu",credit);
|
||
if (!(credit > 0)) {
|
||
self.currentCreditLabel.text = @"0";
|
||
self.isSatisfyLabel.text = @"未设置";
|
||
}
|
||
}];
|
||
|
||
```
|
||
|
||
可以看出,这两个分数信号的订阅者分别处理了两个功能逻辑:
|
||
|
||
* 第一个处理的是分数颜色;
|
||
* 第二个处理的是初始状态下状态文本的显示逻辑。
|
||
|
||
整体看起来,所有的逻辑都围绕着分数这个数据的更新自动流动起来,也能够很灵活地通过信号订阅的方式进行归类处理。
|
||
|
||
采用这种编程方式,上层实现方式看起来类似于 ReactiveCocoa,而底层实现却非常简单,将信号订阅者直接使用赋值的方式赋值给一个集合进行维护,而没有使用 Monad 方式。底层对信号和订阅者的实现代码如下所示:
|
||
|
||
```
|
||
@interface SMCreditSubject : NSObject
|
||
|
||
typedef void(^SubscribeNextActionBlock)(NSUInteger credit);
|
||
|
||
+ (SMCreditSubject *)create;
|
||
|
||
// 发送信号
|
||
- (SMCreditSubject *)sendNext:(NSUInteger)credit;
|
||
// 接收信号
|
||
- (SMCreditSubject *)subscribeNext:(SubscribeNextActionBlock)block;
|
||
|
||
@end
|
||
|
||
@interface SMCreditSubject()
|
||
|
||
@property (nonatomic, assign) NSUInteger credit; // 积分
|
||
@property (nonatomic, strong) SubscribeNextActionBlock subscribeNextBlock; // 订阅信号事件
|
||
@property (nonatomic, strong) NSMutableArray *blockArray; // 订阅信号事件队列
|
||
|
||
@end
|
||
|
||
@implementation SMCreditSubject
|
||
|
||
// 创建信号
|
||
+ (SMCreditSubject *)create {
|
||
SMCreditSubject *subject = [[self alloc] init];
|
||
return subject;
|
||
}
|
||
|
||
// 发送信号
|
||
- (SMCreditSubject *)sendNext:(NSUInteger)credit {
|
||
self.credit = credit;
|
||
if (self.blockArray.count > 0) {
|
||
for (SubscribeNextActionBlock block in self.blockArray) {
|
||
block(self.credit);
|
||
}
|
||
}
|
||
return self;
|
||
}
|
||
|
||
// 订阅信号
|
||
- (SMCreditSubject *)subscribeNext:(SubscribeNextActionBlock)block {
|
||
if (block) {
|
||
block(self.credit);
|
||
}
|
||
[self.blockArray addObject:block];
|
||
return self;
|
||
}
|
||
|
||
#pragma mark - Getter
|
||
- (NSMutableArray *)blockArray {
|
||
if (!_blockArray) {
|
||
_blockArray = [NSMutableArray array];
|
||
}
|
||
return _blockArray;
|
||
}
|
||
|
||
```
|
||
|
||
如上面代码所示,订阅者都会记录到 blockArray 里,block 的类型是 SubscribeNextActionBlock。
|
||
|
||
最终,我们使用函数式编程的思想,简单、高效地实现了这个功能。这个例子完整代码,你可以点击[这个链接](https://github.com/ming1016/RACStudy)查看。
|
||
|
||
## 小结
|
||
|
||
今天这篇文章,我和你分享了ReactiveCocoa 这种响应式编程框架难以在 iOS 原生开发中流行开的原因。
|
||
|
||
从本质上看,响应式编程没能提高App的性能,是其没能流行起来的主要原因。
|
||
|
||
在调试上,由于 ReactiveCocoa框架采用了 Monad 模式,导致其底层实现过于复杂,从而在方法调用堆栈里很难去定位到问题。这,也是ReactiveCocoa没能流行起来的一个原因。
|
||
|
||
但, ReactiveCocoa的上层接口设计思想,可以用来提高代码维护的效率,还是可以引入到 iOS 开发中的。
|
||
|
||
ReactiveCocoa里面还有很多值得我们学习的地方,比如说宏的运用。对此感兴趣的话,你可以看看sunnyxx的那篇[《Reactive Cocoa Tutorial \[1\] = 神奇的Macros》。](http://blog.sunnyxx.com/2014/03/06/rac_1_macros/)
|
||
|
||
对于 iOS 开发来说,响应式编程还有一个很重要的技术是 KVO,使用 KVO 来实现响应式开发的范例可以参考[我以前的一个 demo](https://github.com/ming1016/DecoupleDemo)。如果你有关于KVO的问题,也欢迎在评论区给我留言。
|
||
|
||
## 课后作业
|
||
|
||
在今天这篇文章里面,我和你聊了Monad 的很多缺点,不知道你是如何看待Monad的,在评论区给我留言分享下你的观点吧。
|
||
|
||
感谢你的收听,欢迎你在评论区给我留言分享你的观点,也欢迎把它分享给更多的朋友一起阅读。
|
||
|