ReactiveCocoa学习笔记四--设计指导(译)

前言

原本这一篇应该在说完RACSignal之后再出的, 但是因为最近事情特别多, RACSignal就一直被耽搁下来了, 而这一篇又是对实际应用RAC框架的一直指导方针, 重要性还是不言而喻的, 因此, 就先留个印象, 看看官方推荐使用RAC框架的正确姿势.

另外, 想要在团队中推行RAC框架是很难的一件事情, 为了权衡, 但是有时候又想去使用一些函数式的代码, 因此, 推荐一下RXCollection这个库, github上这个库有一个比较复杂的版本, 但是已经说不用了, 我个人使用的是精简版的, 也就是只有map, filter, fold这些高阶函数(概念)的版本, 主要是事先转换一下自己的思维, 把用for循环写的代码, 改用map, fold等等.

正文

本文档包含了对于想要使用RAC框架的工程的一些设计指导, 文档内容主要受Rx设计指导的启发.

本文档假定你已基本熟悉RAC的特性. 如果没有, 那么框架总览则是一个更好的选择来快速起步学习RAC的特性.

RACSignal初探

RACSignal是一个推驱动的流, 通过订阅来集中处理异步的事件传递. 在框架总览中可以获取到更多相关信息.

序列化的信号事件

一个信号可能选择任意的线程来传递事件. 连续的事件甚至允许抵达不同的线程或者调度器, 除非显式地指定传递到特定的调度器.

然而, RAC保证不会有2个信号事件同时到达. 当一个事件正在被处理的时候, 没有其它的时间会被传递. 其余时间的发送方将会等待, 知道当前的时间已经被处理完毕.

这很显然地意味着, 传递给-subscribeNext:error:completed:的block不需要去互相同步, 因为它们绝对不同同步执行.

错误会立即传递

在RAC中, 错误事件有异常的语义(译注: 也就是意味着异常). 当一个错误事件被发送到信号中, 他将会理解被转发到所有的受依赖信号, 导致整个链路终止.

主要目的为改变错误事件处理行为的操作符-catch, -catchTo-materialize, 不受此规则约束.

每次订阅都发生副作用

对RACSignal的每一次新的订阅都会触发副作用. 这意味着, 任何副作用都会触发, 就像有多次订阅到信号本身一样. (译注: 应该是为了说明订阅几次就会触发几次副作用 )

考虑下面的例子:

__block int aNumber = 0;
// 含有将`aNumber`自增副作用block的 RACSignal
RACSignal *aSignal = [RACSignal createSignal:^ RACDisposable * (id<RACSubscriber> subscriber) {
   aNumber++;
    [subscriber sendNext:@(aNumber)];
    [subscriber sendCompleted];
    return nil;
}];

// 这里将会打印 "subscriber one: 1"
[aSignal subscribeNext:^(id x) {
    NSLog(@"subscriber one: %@", x);
}];

// 这里将会打印 "subscriber two: 2"
[aSignal subscribeNext:^(id x) {
    NSLog(@"subscriber two: %@", x);
}];

副作用会在每次订阅都重复. 这种行为应用于大多数操作符:

__block int missilesToLaunch = 0;

// 副作用为每次订阅都会改变`missToLaunch`的信号
RACSignal *processedSignal = [[RACSignal
    return:@"missiles"]
    map:^(id x) {
        missilesToLaunch++;
        return [NSString stringWithFormat:@"will launch %d %@", missilesToLaunch, x];
    }];

// 这里会打印 "First will launch 1 missiles"
[processedSignal subscribeNext:^(id x) {
    NSLog(@"First %@", x);
}];

// 这里会打印 "Second will launch 2 missiles"
[processedSignal subscribeNext:^(id x) {
    NSLog(@"Second %@", x);
}];

如果要抑制这种行为, 想要信号的副作用只执行一次, 发送时间给一个subject.

副作用可能潜伏很深, 不容易被诊断出来. 为此, 建议尽可能使副作用显性化

完成或者错误事件自动清除订阅

订阅者发送了一个completed或'error'事件, 相关联的订阅将会被立即清除, 此行为通常是为了杜绝手动清除订阅的需要.

查看文档内存管理来获取更多信号声明周期的信息.

清除会取消处理中的工作并清理资源

当一个订阅被手动或者自动清除, 任何关联这个订阅的处理中或者待处理工作都会被尽快优雅地取消掉, 任何管理这个订阅的资源都会被清理掉.

对信号清除订阅表现在文件上传上的例子为, 飞行模式下取消网络请求, 并从内存中释放文件数据.

最佳实践

接下来的建议意图使基于RAC的代码更加可预测, 可理解以及高效. 它们只是一些指导意见, 当要决定是否使用这里的建议到某些代码场景时, 还是要自己做最佳判断.

为返回信号的方法或者属性使用描述性声明

当一个方法或属性返回RACSignal时, 很难一眼就理解这个信号的具体语义.

下面有3个关键的问题可以细化声明:

  1. 是热信号(在返回给调用方时就已经激活)还是冷信号(订阅时激活)?
  2. 信号包含0个, 1个还是多个值?
  3. 信号是否有副作用?

无副作用的热信号应该是一个属性而不是方法. 属性的使用暗示了在订阅信号的事件前是不需要初始化的, 后续的订阅者也不会改变这一点. 信号属性应该被命名在事件之后(例如textChanged)

无副作用的冷信号应该由方法返回, 命名应该是类名词式的(例如: -currentText). 方法的声明暗示了信号可能被持有, 工作在订阅时执行. 如果信号发送多个值, 方法名应该变为复数(如: -currentModels).

有副作用的信号应该由动词式命名的方法返回(例如:-logIn). 动词暗示着此方法并不是幂等的(译注: 所谓幂等就是执行多次和执行一次是一样的结果)并且调用方需要小心调用, 只有当副作用是期望的才调用. 如果信号将会发送一个或多个值, 方法要含有一个名词(例如: -loadConfiguration, -fetchLatestEvents).

信号操作的不断缩进

这一段不翻译了, 主要是因为链式调用的原因, RAC代码很容易写的很密集, 因此为了方便理解, 官方给出的一链式调用的缩进例子

RACSignal *result = [[[RACSignal
    zip:@[ firstSignal, secondSignal ]
    reduce:^(NSNumber *first, NSNumber *second) {
        return @(first.integerValue + second.integerValue);
    }]
    filter:^ BOOL (NSNumber *value) {
        return value.integerValue >= 0;
    }]
    map:^(NSNumber *value) {
        return @(value.integerValue + 1);
    }];

以及:

[[signal
    then:^{
        @strongify(self);

        return [[self
            doSomethingElse]
            catch:^(NSError *error) {
                @strongify(self);
                [self presentError:error];

                return [RACSignal empty];
            }];
    }]
    subscribeCompleted:^{
        NSLog(@"All done.");
    }];

同一信号所有值类型统一

RACSignal本身允许信号存在多种类型的对象, 就像Cocoa的集合类一样. 然而, 在同一个信号中使用多种类型会使操作符的使用变得复杂, 并且增加使用者的负担, 他们必须要小心地调用对象方法.

因此, 尽可能保持信号只包含同样的类型.

只处理所需的信号

无谓地保持RACSignal订阅只会增加内存和CPU的消耗, 不需要的工作就不应该执行.

如果只有特性的值需要通过信号传递, -take:操作符可以用来取回这些值, 并在之后立即自动清除订阅.

类似-take:的操作符和-takeUntil:自动清理栈. 如果除了剩下的值并不需要其余的东西, 任何依赖也都将被终止, 潜移默化地节省了一大堆工作.

传递信号事件到已知调度器

当信号被方法返回, 或者与另一个信号结合, 会难以得知信号从哪个线程传递过来. 尽管保证事件是序列执行的, 有时候也会需要有更强力的保证, 比如要执行更新UI代码的时候(必须在主线程执行).

一旦这个保证很重要的时候, -deliverOn:操作符可以用来强制使信号的事件抵达到指定的调度器

尽量少切换调度器

尽管上面说了使事件在指定调度器传递是非常必要的, 但是切换调度器也会引入不必要的延迟以及导致CPU负载升高.

一般情况下, -deliverOn应该限制出现在信号链最后面, 理想位置应该在订阅前, 或者在值绑定到一个属性之前.

使信号的副作用显性化

如果可以的话, 尽量不要让RACSignal有副作用, 因为订阅者可能会发现副作用的行为并不是想要的.

然而, 因为Cocoa主要是命令式编程, 有时候在信号事件发生时需要做点什么. 虽然大多数操作符接受任意block(这里可能包含副作用), 使用-doNext:, -doError:-doCompleted:将使副作用更加显性化和自文档化:

NSMutableArray *nexts = [NSMutableArray array];
__block NSError *receivedError = nil;
__block BOOL success = NO;

RACSignal *bookkeepingSignal = [[[valueSignal
    doNext:^(id x) {
        [nexts addObject:x];
    }]
    doError:^(NSError *error) {
        receivedError = error;
    }]
    doCompleted:^{
        success = YES;
    }];

RAC(self, value) = bookkeepingSignal;

用主题(Subject)共享信号的副作用

默认每次订阅都会发生副作用, 但是某些情况下, 副作用只需要发生一次即可, 例如一次典型的网络请求不应该在新订阅者加进来后再重复一次.

与其订阅一个信号多次, 不如转发信号的事件到RACSubject, 这一可以想订阅几次就订阅几次:

// 这个信号在每次订阅都会开启一个新请求
RACSignal *networkRequest = [RACSignal createSignal:^(id<RACSubscriber> subscriber) {
    AFHTTPRequestOperation *operation = [client
        HTTPRequestOperationWithRequest:request
        success:^(AFHTTPRequestOperation *operation, id response) {
            [subscriber sendNext:response];
            [subscriber sendCompleted];
        }
        failure:^(AFHTTPRequestOperation *operation, NSError *error) {
            [subscriber sendError:error];
        }];

    [client enqueueHTTPRequestOperation:operation];
    return [RACDisposable disposableWithBlock:^{
        [operation cancel];
    }];
}];

// 这个主题会分发`networkRequest`信号的事件
RACSubject *results = [RACSubject subject];

// 设置一堆订阅者给主题
[results subscribeNext:^(id response) {
    NSLog(@"subscriber one: %@", response);
}];

[results subscribeNext:^(id response) {
    NSLog(@"subscriber two: %@", response);
}];

// 然后, 真正地开始请求(通过订阅一次请求的信号), 并且2个订阅者都能接受到一样的事件
[networkRequest subscribe:results];

给定信号名字来调试

每个信号都一个name属性来辅助调试. 信号的-description包含了它的名字, 并且所有的RAC操作符都自动会添加名字. 一般情况下, 默认的名字就基本可以鉴别信号了.

例如:

RACSignal *signal = [[[RACObserve(self, username) 
    distinctUntilChanged] 
    take:3] 
    filter:^(NSString *newUsername) {
        return [newUsername isEqualToString:@"joshaber"];
    }];

NSLog(@"%@", signal);

将会打印出[[[RACObserve(self, username)] -distinctUntilChanged] -take: 3] -filter:.
名字也可以用-setNameWithFormat:来手动设定.

RACSignal也提供了-logNext, -logError, -logCompleted-logAll这些方法, 可以在事件发生时自动打印信号日志. 这可以很方便地实时审查信号.

避免显式地订阅和解除

虽然-subscribeNext:error:completed:与其变形方法是最基本的处理信号的方式, 但是直接使用会使得代码变得复杂, 因为缺少陈述声明, 副作用的滥用, 以及潜在的内置功能.

同样地, 显式使用RACDisposables类可能是迅速导致资源管理和清理的代码的源头.(译注: rat's nest翻译为了源头)

这里有一些更加高阶的模式来替代手动订阅和解除:

  • RAC()RACChannelTo宏可以用来绑定信号到属性上, 而不是在改变发生时手动地更新.

  • -rac_liftSelector:withSignals:方法可以用来自动执行某个方法当一个或者多个信号触发后.

  • 类似-takeUntil:可以来用在事件发生时自动解除订阅(例如"取消"按钮被按下了)

通常, 相比于在订阅的回调中复制同样的行为, 使用内置的操作符可以使代码更加简洁和更加健壮.

避免直接操作主题

主题是桥接指令式代码到信号的世界中和共享副作用的强大工具, 但是, 作为RAC中的"可变量", 主题很容易因为滥用而导致复杂度提升.

因为主题可以在任何地方任何时候操作, 经常会打破线性流处理和使得逻辑很难追踪. 同时主题还不支持有意义的解除, 这样会导致不必要的工作.

在下列情况下, 主题可以被替换成其它的RAC模式:

然而, 主题在共享信号的副作用上还是很必要的. 在这种情况下, 使用-Subscribe:并且避免调用-sendNext:, -sendError-sendCompleted来直接操作主题.

实现新的操作符

RAC提供了一大串的内置操作符来覆盖RACSignal的大多数应用场景, 然而, RAC并不是封闭的系统. 针对特定使用场景实现额外的操作符来是完全有效的, 甚至可以针对RAC本身来实现.

实现新的操作符需要小心细节, 并且专注于简洁, 避免引入bug到调用代码中.

下面的指导覆盖了一些常见的陷阱, 且有助于保护期望的API的规约.
(译注: 大多数情况下我们不会去自定义新的操作符, 所以这节就简单翻译要点, 如果有需要查看完整内容的, 还是查看源文档更好)

  • 尽量组合现有的操作符: 这些操作符已经经过了严格的测试和实际工程检验了.
  • 避免引入并发: 并发是很常见的bug之源, 代码的并发应该交由调用方来决定.
  • 在解除订阅后取消工作和清理资源
  • 不要在操作符中阻塞: 信号操作符应该理解返回一个新的信号, 操作符需要做的任何工作都应该成为订阅一个新信号的一部分, 而不是本身的.
// WRONG!
- (RACSignal *)map:(id (^)(id))block {
    RACSignal *result = [RACSignal empty];
    for (id obj in self) {
        id mappedObj = block(obj);
        result = [result concat:[RACSignal return:mappedObj]];
    }

    return result;
}

// Right!
- (RACSignal *)map:(id (^)(id))block {
    return [self flattenMap:^(id obj) {
        id mappedObj = block(obj);
        return [RACSignal return:mappedObj];
    }];
}
  • 避免深度递归造成栈溢出: 可能出现无限递归的地方都应该使用RACScheduler的-scheduleRecursiveBlock:方法.
    错误姿势:
- (RACSignal *)repeat {
    return [RACSignal createSignal:^(id<RACSubscriber> subscriber) {
        RACCompoundDisposable *compoundDisposable = [RACCompoundDisposable compoundDisposable];

        __block void (^resubscribe)(void) = ^{
            RACDisposable *disposable = [self subscribeNext:^(id x) {
                [subscriber sendNext:x];
            } error:^(NSError *error) {
                [subscriber sendError:error];
            } completed:^{
                resubscribe();
            }];

            [compoundDisposable addDisposable:disposable];
        };

        return compoundDisposable;
    }];
}

正确姿势:

  -(RACSignal *)repeat {
    return [RACSignal createSignal:^(id<RACSubscriber> subscriber) {
        RACCompoundDisposable *compoundDisposable = [RACCompoundDisposable compoundDisposable];

        RACScheduler *scheduler = RACScheduler.currentScheduler ?: [RACScheduler scheduler];
        RACDisposable *disposable = [scheduler scheduleRecursiveBlock:^(void (^reschedule)(void)) {
            RACDisposable *disposable = [self subscribeNext:^(id x) {
                [subscriber sendNext:x];
            } error:^(NSError *error) {
                [subscriber sendError:error];
            } completed:^{
                reschedule();
            }];

            [compoundDisposable addDisposable:disposable];
        }];

        [compoundDisposable addDisposable:disposable];
        return compoundDisposable;
      }];
      }

后记

限于水平, 文中难免出现错漏, 如果你发现了, 麻烦评论指出.

同时, 通篇看完后, 还是要啰嗦一句, 如果真打算在实际工程中使用RAC, 这篇是必不可少的, 剩下的一篇比较关键的内存管理其实结论还是比较简单的, 找机会直接在一篇文章中插入即可.

最后, 希望对函数响应式编程感兴趣的同学一起进步.

推荐阅读更多精彩内容