转载

细说ReactiveCocoa的冷信号与热信号(二):为什么要区分冷热信号

细说ReactiveCocoa的冷信号与热信号(二):为什么要区分冷热信号

William Zang 2015-09-28 14:00

前一篇文章我们介绍了冷信号与热信号的概念,可能有同学会问了,为什么RAC要搞得如此复杂呢,只用一种信号不就行了么?要解释这个问题,需要绕一些圈子。

前面可能比较难懂,如果不能很好理解,请仔细阅读相关文档。

最前面提到了RAC是一套基于Cocoa的FRP框架,那就来说说FRP吧。FRP的全称是Functional Reactive Programming,中文译作函数式响应式编程,是RP(Reactive Programm,响应式编程)的FP(Functional Programming,函数式编程)实现。说起来很拗口。太多的细节不多讨论,我们着重关注下FRP的FP特征。

FP有个很重要的概念是和我们的主题相关的,那就是纯函数。

纯函数 就是返回值只由输入值决定、而且没有可见 副作用 )的函数或者表达式。这和数学中的函数是一样的,比如:

f(x) = 5x + 1

这个函数在调用的过程中除了返回值以外的没有任何对外界的影响,除了入参x以外也不受任何其他外界因素的影响。

那么副作用都有哪些呢?我来列举以下几个情况:

  • 函数的处理过程中,修改了外部的变量,例如全局变量。一个特殊点的例子,就是如果把OC的一个方法看做一个函数,所有的成员变量的赋值都是对外部变量的修改。是的,从FP的角度看OOP是充满副作用的。
  • 函数的处理过程中,触发了一些额外的动作,例如发送了一个全局的Notification,在console里面输出了一行信息,保存了文件,触发了网络,更新了屏幕等。
  • 函数的处理过程中,受到外部变量的影响,例如全局变量,方法里面用到的成员变量。注意block中捕获的外部变量也算副作用。
  • 函数的处理过程中,受到线程锁的影响算副作用。

由此我们可以看出,在目前的iOS编程中,我们是很难摆脱副作用的。甚至可以这么说,我们iOS编程的目的其实就是产生各种副作用。(基于用户触摸的外界因素,最终反馈到网络变化和屏幕变化上。)

接下来我们来分析副作用与冷热信号的关系。既然iOS编程中少不了副作用,那么RAC在实际的使用中也不可避免地要接触副作用。下面通过一个业务场景,来看看冷信号中副作用的坑:

 self.sessionManager = [[AFHTTPSessionManager alloc] initWithBaseURL:[NSURL URLWithString:@"http://api.xxxx.com"]]; self.sessionManager.requestSerializer = [AFJSONRequestSerializer serializer]; self.sessionManager.responseSerializer = [AFJSONResponseSerializer serializer]; @weakify(self) RACSignal *fetchData = [RACSignal createSignal:^RACDisposable *(id<RACSubscriber> subscriber) {  @strongify(self)  NSURLSessionDataTask *task = [self.sessionManager GET:@"fetchData" parameters:@{@"someParameter": @"someValue"} success:^(NSURLSessionDataTask *task, id responseObject) {   [subscriber sendNext:responseObject];   [subscriber sendCompleted];  } failure:^(NSURLSessionDataTask *task, NSError *error) {   [subscriber sendError:error];  }];  return [RACDisposable disposableWithBlock:^{   if (task.state != NSURLSessionTaskStateCompleted) {    [task cancel];   }  }]; }]; RACSignal *title = [fetchData flattenMap:^RACSignal *(NSDictionary *value) {  if ([value[@"title"] isKindOfClass:[NSString class]]) {   return [RACSignal return:value[@"title"]];  } else {   return [RACSignal error:[NSError errorWithDomain:@"some error" code:400 userInfo:@{@"originData": value}]];  } }]; RACSignal *desc = [fetchData flattenMap:^RACSignal *(NSDictionary *value) {  if ([value[@"desc"] isKindOfClass:[NSString class]]) {   return [RACSignal return:value[@"desc"]];  } else {   return [RACSignal error:[NSError errorWithDomain:@"some error" code:400 userInfo:@{@"originData": value}]];  } }]; RACSignal *renderedDesc = [desc flattenMap:^RACStream *(NSString *value) {  NSError *error = nil;  RenderManager *renderManager = [[RenderManager alloc] init];  NSAttributedString *rendered = [renderManager renderText:value error:&error];  if (error) {   return [RACSignal error:error];  } else {   return [RACSignal return:rendered];  } }]; RAC(self.someLablel, text) = [[title catchTo:[RACSignal return:@"Error"]]  startWith:@"Loading..."]; RAC(self.originTextView, text) = [[desc catchTo:[RACSignal return:@"Error"]] startWith:@"Loading..."]; RAC(self.renderedTextView, attributedText) = [[renderedDesc catchTo:[RACSignal return:[[NSAttributedString alloc] initWithString:@"Error"]]] startWith:[[NSAttributedString alloc] initWithString:@"Loading..."]]; [[RACSignal merge:@[title, desc, renderedDesc]] subscribeError:^(NSError *error) {  UIAlertView *alertView = [[UIAlertView alloc] initWithTitle:@"Error" message:error.domain delegate:nil cancelButtonTitle:@"OK" otherButtonTitles:nil];  [alertView show]; }]; 

不知道大家有没有被这么一大段的代码吓到,我想要表达的是,在真正的工程中,我们的业务逻辑是很复杂的,而一些坑就隐藏在如此看似复杂但是又很合理的代码之下。所以我尽量模拟了一些需求,使得代码看起来更丰富。下面我们还是来仔细看下这段代码的逻辑吧:

  1. 创建了一个 AFHTTPSessionManager 用来做网络接口的数据获取。
  2. 创建了一个名为 fetchData 的信号来通过网络获取信息。
  3. 创建一个名为 title 的信号从获取的 data 中取得 title 字段,如果没有该字段则反馈一个错误。
  4. 创建一个名为 desc 的信号从获取的 data 中取得 desc 字段,如果没有该字段则反馈一个错误。
  5. 针对 desc 这个信号做一个渲染,得到一个名为 renderedDesc 的新信号,该信号会在渲染失败的时候反馈一个错误。
  6. title 信号所有的错误转换为字符串 @"Error" 并且在没有获取值之前以字符串 @"Loading..." 占位,之后与 self.someLableltext 属性绑定。
  7. desc 信号所有的错误转换为字符串 @"Error" 并且在没有获取值之前以字符串 @"Loading..." 占位,之后与 self.originTextViewtext 属性绑定。
  8. renderedDesc 信号所有的错误转换为属性字符串 @"Error" 并且在没有获取值之前以属性字符串 @"Loading..." 占位,之后与 self.renderedTextViewtext 属性绑定。
  9. 订阅 titledescrenderedDesc 这三个信号的任何错误,并且弹出 UIAlertView

这些代码体现了RAC的一些优势,例如良好的错误处理和各种链式处理。很不错,对不对?但是很遗憾的告诉大家,这段代码其实有很严重的错误。

如果你去尝试运行这段代码,并且打开Charles查看,你会惊奇的发现,这个网络请求发送了6次。没错,是6次请求。我们也可以想象到类似的代码存在其他副作用的问题,重新刷新了6次屏幕,写入6次文件,发了6个全局通知。

下面来分析,为什么是6次网络请求呢?首先根据上面的知识,可以推断出名为 fetchData 信号是一个冷信号。那么这个信号在订阅的时候就会执行里面的过程。那这个信号是在什么时候被订阅了呢?仔细回看了代码,我们发现并没有订阅这个信号,只是调用这个信号的 flattenMap 产生了两个新的信号。

这里有一个很重要的概念,就是任何的信号转换即是对原有的信号进行订阅从而产生新的信号。由此我们可以写出flattenMap的伪代码如下:

 - (instancetype)flattenMap_:(RACStream * (^)(id value))block { {  return [RACSignal createSignal:^RACDisposable *(id<RACSubscriber> subscriber) {     return [self subscribeNext:^(id x) {      RACSignal *signal = (RACSignal *)block(x);      [signal subscribeNext:^(id x) {       [subscriber sendNext:x];      } error:^(NSError *error) {       [subscriber sendError:error];      } completed:^{       [subscriber sendCompleted];      }];     } error:^(NSError *error) {      [subscriber sendError:error];     } completed:^{      [subscriber sendCompleted];     }];  }]; } 

除了没有高度复用和缺少一些disposable的处理以外,上述代码大致可以比较直观地说明flattenMap的机制。观察会发现其实是在调用这个方法的时候,生成了一个新的信号,并在这个新信号的执行过程中对 self 进行的了 订阅 。还需要注意一个细节,就是这个返回信号在未来订阅的时候,才会间接的订阅 self 。后续的 startWithcatchTo 等都可以这样理解。

回到我们的问题,那就是说,在 fetchDataflattenMap 之后,它就会因为名为 titledesc 信号的订阅而订阅。而后续对 desc 也会进行 flattenMap ,得到了 renderedDesc ,因此未来 renderedDesc 被订阅的时候, fetchData 也会被间接订阅。这就解释了,为什么后续我们用 RAC 宏进行绑定的时候, fetchData 会订阅 3次 。由于 fetchData 是冷信号,所以3次订阅意味着它的过程被执行了3次,也就是有3次网络请求。

另外的3次订阅来自 RACSignal 类的 merge 方法。根据上述的描述,我们也可以猜测 merge 方法也一定是创建了一个新的信号,在这个信号被订阅的时候,把它包含的所有信号订阅。所以我们又得到了额外的3次网络请求。

由此可以看到,不熟悉冷热信号对业务造成的影响。我们可以想象对用户流量的影响,对服务器负载的影响,对统计的影响,如果这是一个点赞的接口,会不会造成多次点赞?后果不堪设想啊。而这些都可以通过将 fetchData 转换为热信号来解决。

接下来也许你会问,如果我的整个计算过程中都没有副作用,是否就不会有这个问题?答案是肯定的。试想下刚才那段代码如果没有网络请求,换成一些标准化的计算会怎样。虽然可以肯定它不会出现bug,但是不要忽视其中的运算也会执行多次。纯函数还有一个概念就是 引用透明 )。在纯函数式语言(例如 Haskell )中对此可以进行一定的优化, 也就是说纯函数的调用在相同参数下的返回值第二次不需要计算 ,所以在纯函数式语言里面的FRP并没有冷信号的担忧。然而Objective-C语言中并没有这种纯函数优化,因此有大规模运算的冷信号对性能是有一定影响的。

从上文内容可以看出,如果我们想更好地掌握RAC这个框架,区分冷信号与热信号是十分重要的。接下来的系列第三篇文章,我会揭示冷信号与热信号的本质,帮助大家正确的理解冷信号与热信号。

正文到此结束
Loading...