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

前一篇文章我们介绍了冷信号与热信号的概念,可能有同学会问了,为什么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.someLablel的text属性绑定。
  7. 把desc信号所有的错误转换为字符串@"Error"并且在没有获取值之前以字符串@"Loading..."占位,之后与self.originTextView的text属性绑定。
  8. 把renderedDesc信号所有的错误转换为属性字符串@"Error"并且在没有获取值之前以属性字符串@"Loading..."占位,之后与self.renderedTextView的text属性绑定。
  9. 订阅title、desc、renderedDesc这三个信号的任何错误,并且弹出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。后续的startWith、catchTo等都可以这样理解。

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

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

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

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

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

背景ReactiveCocoa(简称RAC)是最初由GitHub团队开发的一套基于Cocoa的FRP框架。FRP即Functional Reactive Programming(函数式响 ...