我是如何在Reactive Cocoa中写自定义信号的[译]

在学习Reactive Cocoa的过程中,发现这篇文章对于学习者来说非常棒,所以决定尝试去翻译一下。由于这是第一次翻译文章,所以很多地方估计翻译的难以理解希望见谅,并请指出其中的问题。

原文链接

说实话, 我是因为Reactive Cocoa比较时髦才开始使用它的。程序员一直在讨论这个框架,我很难想起一次没有提及Reactive Cocoa的iOS聚会。

当我开始学习Reactive Cocoa的时候, 我真不清楚它到底是什么。 "Reactive"听起来很酷, "functional" 听起来挺巧妙的。当我受不了Reactive Cocoa的诱惑开始学习使用之后,我很难想象我编码的时候没有它。

Reactive Cocoa是一个伟大的框架,它打开了一扇通往函数响应式编程的窗户。它使你从实际的项目获益FRP思想,即使你对FRP理论没有太深的了解。

我是通过在实际的项目中首先做一些简单的事情来解决两个问题,这个我会在文章中提及来学习Reactive Cocoa的。我会谈论"what to do" 而不是"how to do",所以你能够从中得到一些关于这个框架的实际的理解。

1. Bindings

Reactive Cocoa入门介绍都是从绑定开始的。毕竟这个是初学者最好学习的部分。

绑定是在Objective-C已存在的KVO机制上扩展的。 还有什么新的内容是Reactive Cocoa带给KVO的?当然一个更加友好的界面,一个明确的规则去绑定Model的状态和UI。

让我们看一下Cell的绑定的例子。

通常一个一个Cell连接了Model和Model的可视状态(或者MVVM中的view model的状态)。尽管ReactiveCocoa被认为是MVVM中的一个环节,反之亦然,不过那都无所谓。绑定机制仅仅是让你的编程过程更简单而已。

- (void)awakeFromNib {
 [super awakeFromNib];
 RAC(self, titleLabel.text) = RACObserve(self, model.title);
}

-awakeFromNib的描述: “我想让Label的text总是和model中的text一致”。 无论title或者model改变。

当我们通过底层查看它是如何工作的,我们发现RACObserver是一个宏定义可以通过你的keypath(我们例子中的"model.title")转换成一个RACSignalRACSignal是Reactive Cocoa框架中的一个对象用来接收未来的数据。 在我们的例子中, 它会在model或者title改变时接收"model.title"的数据。

因为在当前阶段没有必要过多的细节,我们会稍后再介绍信号。现在你可以在界面上添加model中的任意状态,并且享受这个便利的结果。

你经常需要在界面上展示model的数据之前对它进行转换。 这种情况下你可以使用操作符-map:

    RAC(self, titleLable.text) = [RACObserve(self, model.title) map:^id(NSString *text) {
 return [NSString stringWithFormat:@”title: %@”, text];
}]

所有的UI操作都必须在主线程上完成,但是title可以在后台线程中改变。 如果碰到需要在主线程中改变数据,可以使用以下的方式。

RAC(self, titleLabel.text) = [RACObserve(self, model.title) deliverOnMainThread];

RACObserver()是-rac_valuesForKeyPath:observer:的宏定义,但是这有一个陷阱:这个宏定义经常引用self作为观察者。所以如果你需要在block中使用RACObserver,需要保证不要造成循环引用,必要的时候需要使用若引用, Reactive Cocoa中可以使用@weakify@strongify

还有一个需要注意的就是如果你的model经常变动,并且你绑定在UI上,这可能会影响你的APP的性能。为了避免这种情况,你可以使用-throttle:方法,它的参数是一个NSTimeInterval, 只有当经过参数的时间之后才会发送下一次变动的信号。

2. Operations on collections(filter, map, reduce)

在Reactive Cocoa对集合类的操作是我们接下来要学习的。我们的大部分时间都是在和数组打交道,难道不是吗?当你的应用在运行的时候,从网络来的需要修改数据(input), 所以你可以通过一定的格式向用户展示(output)。

原始的网络数据需要转换才能到model和view model中, 过滤之后展示给用户。

在Reactive Cocoa中,RACSequence类代表集合类。Cocoa中的所有的集合类在Reactive Cocoa中都转换成相应的分类。 在转换之后, 你可一使用一些方法比如map,filter, reduce等。

以下是我们工程中的一个小的例子:

  RACSequence *sequence = [[[matchesViewModels rac_sequence]
                              filter:^BOOL(MatchViewModel *match) {
                                  return [match hasMessages];
                              }] map:^id(MatchViewModel *match) {
                            return match.chatViewModel;
                            }];

这段代码中,首先我们会把view model中已经有消息的过滤(- (BOOL)hasMessages)。 然后我们需要把他们转换成别的view models。

当你处理完你的sequence, 它应该被转换成为NSArray:

NSArray *chatsViewModels = [sequence array];

不知道你注意没有,我们又一次使用了-map:? 这一次它是用于RACSequence不是作为绑定用于RACSignal

RAC 架构最伟大的地方是它仅有两个主要的类RACSignalRACSequence, 它们两个有一个共同的父类RACStream。 所有的事都可以认为是流,但是信号是一个推送流(新值被推向订阅者不能拉取),然而序列是一个拉取驱动的流(当被要求值的时候提供值)。

重要的是我们如何组织所有的链式操作, 这是在RAC中的核心思想,在RACSignalRACSequence中一样适用。
可以参考: How to work with CloudKit

3. Networking

进行更深入了解这个框架,我们下一步将要说明网络的使用。当我说到绑定的时候,我说的是RACObserver的宏定义创造的代表即将接收的数据的信号。这个对象对于网络请求是完美的。

信号发送三种类型的事件:

  • next - 将来的值
  • error - 一个NSError表示这个信号不能成功完成
  • completed - 指示信号已经成功的完成

信号的生命周期包含数个next事件,之后是error或者完成事件(两者不可能同时出现)。

这个和我们使用blocks写网络请求非常相似。但是区别呢?为什么使用信号代替普通的blocks呢? 以下是一些原因:

1) 摆脱回调的地狱

当你有多个网络请求的时候,并且其中一个网络请求依赖另外一个网络请求的结果,这是代码中的噩梦。

2) 在同一个地方处理Eror

这是一个简短的例子:
假设你有两个信号--loginUserfetchUserInfo。让我们创造一个信号实现登录和获取用户信息。

RACSignal *signal = [[networkClient loginUser] flattenMap:^RACStream *(User *user) {
 return [networkClient fetchUserInfo:user];
}];

flattenMap block会在loginUser发送了next值成功之后获取用户的信息。在flattenMap中我们会返回从之前的信号获取的信息创造一个新的信号。现在让我们订阅这个信号:

 [signal subscribeError:^(NSError *error) {
 // 任何一个信号错误都会执行到此处
 } completed:^{
 //side effect goes here 这个block会在两个信号都执行成功后执行
 }];

需要注意的是subscriberError block会在任何一个信号执行失败的时候调用。 如果第一个信号调用失败, 第二个信号将不会执行。

3) 信号内置了取消(dispose)机制

很多用户会在一个网络请求正在加载的过程中离开这个页面。 这种情况下加载操作需要取消。 你没有必要存储一个加载操作的引用,可以直接继承以下的逻辑。例如,以下是我们加载用户数据的信号:

[[RACSignal createSignal:^RACDisposable *(id<RACSubscriber> subscriber) {
 __block NSURLSessionDataTask *task = [self GET:url parameters:parameters completion:^(id response, NSError *error) {
 if (!error) {
 [subscriber sendNext:response];
 [subscriber sendCompleted];
 } else {
 [subscriber sendError:error];
 }
 }];

 return [RACDisposable disposableWithBlock:^{
 [task cancel];
 }];
}]];

我只是简单的展示了这个想法, 但是在真实的代码中你不应该在订阅block中对self进行引用。

我们对这个信号做一个引用,可以制定它什么时候应该消失的规则:

[[networkClient loadChats] takeUntil:self.rac_willDeallocSignal];

或者

[[networkClient loadChats] takeUntil:[self.cancelButton rac_signalForControlEvents:UIControlEventTouchUpInside]];
//点击Cancel按钮的时候触发取消事件

这个方法的伟大之处在于它不需要存储操作的引用,不需要存储更多的中间变量,所以代码会看起来更加的简单明了。

当然你可以手动的取消信用 - 只需要保存RACDisposeabel对象(subscribeNext/Error/Completed方法的返回值)的引用,并且在你需要的时候直接调用-dispose方法。

使用信号来处理网络请求是一个相当广泛的讨论话题。 你可以看OCtoKit 一个教你如何使用Reactive Cocoa处理网络请求问题的伟大例子。Ash Furrow也在他的书《Functional Reactive Programming on iOS》 中覆盖了这个话题。

延伸阅读: 8 tips on CoreData migration

4. Signals in action

当解决一些变成的问题的时候,我们靖仇需要把应用不同部分的数据事件组合在一起。 这些数据可能在不同的线程异步出现或者修改。 如果我们迫切的需要解决它,我们需要尽力的考虑在代码中可能有什么额外的连接或者变量出现,更重要的是,及时的同步所有的数据。

当我们已经构想一个大概需要完成的事件链, 我们开始编码,一个类中不同部位的代码甚至多个类中的代码会被新的代码污染, if条件, 无用的状态,都将会在我们工程中的各个地方出现。

你知道如何去梳理这么复杂的代码!有时唯一的方法找到问题的原因需要一步一步的调试。

当使用Reactive Cocoa 一段时间之后, 我渐渐的发现解决以上提及的任务(绑定、集合类的操作、网络)的基础代表了一个app的生命周期的数据流。从用户的输入或者网络获取到的数据需要通过某种方式转变。这个证明你可以使用Reactive Cocoa用更简单的方式解决问题。

我会给你两个例子。

任务一

这个例子来自于我们最近完成的一个真实工程。

在app里我们有一个消息的功能,其中的一个任务就是在app的icon上展示合适的消息数量。 这是一个常用的功能。

我们有一个ChatViewModel类,其中有一个公开的未读状态。

@interface ChatViewModel : NSObject

@property(nonatomic, readonly) BOOL unread

// other public declarations

@end

在代码的另外一部分, 我们有一个dataSource的数组包含这些view models。

我们在这想做什么呢?我们想在每一个model的unread改变的时候,更新app icon上的未读消息数字。消息数量一定是所有的view model中unread为YES的值的总和。 让我们做一个这样的信号:

RACSignal *unreadStatusChanged = [[RACObserve(self, dataSource) map:^id(NSArray *models) {
 RACSequence *sequence = [[models rac_sequence] map:^id(ChatViewModel *model) {
 return RACObserve(model, unread);
 }];

 return [[RACSignal combineLatest:sequence] map:^id(RACTuple *unreadStatuses) {
 return [unreadStatuses.rac_sequence foldLeftWithStart:@0 reduce:^id(NSNumber *accumulator, NSNumber *unreadStatus) {
 return @(accumulator.integerValue + unreadStatus.integerValue);
 }];
 }];
}] switchToLatest];

这个可能对初学者来说比较困难, 但是也不是那么困难去理解。

首先我们监听了数组的变化:

RACObserve(self, dataSource)

这对于我们来说很重要, 因为这是基于可以添加新的聊天和删除旧的聊天的假设基础。因为RAC没有对可变集合的KVO操作, dataSource的设定是一个可以可以增加、移除的可变数组对象。RACObserver会在dataSource添加一个新的值的时候返回一个信号。

好吧,我们获取到了一个信号...但是这个信号不是我们想要的,所以我们需要对它改变一下。-map:操作在这种情况下非常适合。

[RACObserve(self, dataSource) map:^id(NSArray *models) {

}]

我们在map的block中获取到models的数组。 因为我们想知道数组中的每一个model的unread属性的改变,好像我们需要另外一个信号甚至一个数组的信号包含没一个model的信号:

 RACSequence *sequence = [[models rac_sequence] map:^id(ChatViewModel *model) {
 return RACObserve(model, unread);
 }];

在这里没有什么新的东西 RACSequence, map, RACObserver
备注: 在这种情况下,我们把sequence中的values映射为信号

实际上我们并不需要这么多信号, 我们只需要一个有实际意义的,因为我们的不断改变的数据最终还是要在一起处理。在RAC中有多种方式可以合并信号, 你可以选择一种满足你的需求。

+merge: 从会我们的信号中传递值到一个新的信号流中。因为它只有最后一个value,所以不是太满足我们的需求。

因为我们想要所有的values(计算所有的和), 让我们使用
combineLatest: 它会监视所有的信号的改变, 当有一个信号发生改变,所有的信号会发送最后的值。 在next的block中我们可以看到我们所有的未读的值的快照。

[RACSignal combineLatest:sequence];

现在我们可以在每一个value发生改变的时候获取一个最新数据的数组。这已经快结束了!唯一的任务就是统计有多少个YES存在于这个数组中。 我们可以使用一个简单的循环, 但是让我们使用功能性到底并且使用reduce。Reduce是一个在函数式编程中熟知的功能,它实现了由预先的规则把集合类转变为一个原子值的功能。在RAC中这个函数式-foldLeftWithStart:reduce: (or -foldLeftWithStart:reduce:)

[unreadStatuses.rac_sequence foldLeftWithStart:@0 reduce:^id(NSNumber *accumulator, NSNumber *unreadStatus) {
 return @(accumulator.integerValue + unreadStatus.integerValue);
}];

最后的不清楚的就是我们为什么会使用switchToLatest?

没有它我们会获取到信号的信号(因为我们映射一个数组到一个信号),如果你订阅了unreadStatusChanged,你会在next block中获取到一个信号并不是一个值。我们可以使用flatten或者-switchToLatest(两者都是实现了扁平化,又有所区别)去解决这个问题。

flatten意味着订阅这个信号的订阅者会把从map返回的信号的值进行扁平化。 -flatten会把每一个信号返回的值进行处理,-switchToLatest做同样的事情,不过它只处理最新的信号发送的值。

对于我们来说我们不需要保存旧版本dataSource数组的改变的数据只需要结果才更好一点。这个信号已经好了我们可以尝试一下, 让我们做最后的绑定:

RAC([UIApplication sharedApplication], applicationIconBadgeNumber) = unreadStatusChanged;

你有没有注意到我们是怎么一步步到解决这个问题的构想的?我们只需要明确的写出我们想要做什么。 我们不需要保存中间状态。

有的时候你必须深入的研究框架的文档才能找到一个更合适的操作去构建你的自定义的信号,不过这个时间花费是值得的。

任务二

这是另外一个任务来阐述这个框架的各种可能性。在我们的app中我们有一个聊天列表页面,需要做的就是当其中的一个聊天收到第一条消息的时候进入这个界面。以下是我们最新的信号:

RACSignal *chatReceivedFirstMessage = [[RACObserve(self, dataSource) map:^id(NSArray *chats) {
 RACSequence *sequence = [[[chats rac_sequence] filter:^BOOL(ChatViewModel *chat) {
 return ![chat hasMessages];
 }] map:^id(ChatViewModel *chat) {
 return [[RACObserve(chat, lastMessage) ignore:nil] take:1];
 }] ;

 return [RACSignal merge:sequence];
}] switchToLatest];

让我们看看它是如何构成的。

这个例子和之前的例子是非常的相似。 我们监听一个数组, 映射值到信号,并且扁平化最后的信号获取到结果。

最初的时候我们过滤掉我们的dataSource数组是因为我们队已经有消息的聊天不感兴趣。

然后我们映射聊天值到信号, 然后使用 RACObserver

return [[RACObserve(chat, lastMessage) ignore:nil] take:1];

因为由RACObserver产生的信号会产生一个初始值为nil的值, 我们应该忽略掉nil,我们应该使用-ignore操作。

这个任务的第二部分是只处理第一次进来的消息。take这个操作会在收到第一次消息的时候完成(dispose)。

让事情更加明了。我们在这段代码中创建了3个新的信号。第一个是RACObserver宏定义创建的,第二个是z在第一个信号的基础上通过调用-ignore``获取的`, 第三个是在第二个的基础上调用-take:```获取的。

就像第一个例子一样,我们只需要一个信号,在别的所有信号的基础上。我们使用-merge:合并一个新的流,因为我们不关心像前一个例子的values。

是时候side effet

 [chatReceivedFirstMessage subscribeNext:^(id x) {
 // switching to chat screen
 }];

备注: 我们不关心从信号获取的值,但是x会包含一个获取的值。

这个教程基本上快结束了。现在我们就谈一下我对Reactive Cocoa的印象吧。

What I really love about Reactive Cocoa

  1. 非常容易起步。这个框架的文档是非常糟糕。 在github上有很多的例子,对于每一个类和方法都有详细的介绍,网上有太多的文章,幻灯片还有视频教程。
  2. 你不需要彻底的改变你的编码风格。 最初的时候你可以使用现有的解决方案去解决问题,例如绑定、网络封装等。然后一步一步的,抓住所有的机会去使用Reactive Cocoa的方法。
  3. 它真的改变你编程的方式。现在函数式编程在iOS开发社区越来越火,不过很难让很多基础的开发者改变他们的想问题的方法。Reactive Cocoa帮助去改变, 因为它有很多工具去帮助你交流怎么去做而不是如何去做。

What I don't like about Reactive Cocoa

  1. 广泛的时候宏定义(RAC, RACObserver())
  2. 有的时候导致调试的困难程度,因为信号导致堆栈变深
  3. 没有安全类型(你很难知道subscribeNext中的类型是什么)。最好的做法就是在接口中做详细文档,比如
/**
* Returns a signal which will send a User and complete or error.
*/
-(RACSignal *)loginUser;

Conclusion

函数响应式方法可以简化你做日常任务的方法。可能在最开始的时候,RAC的思想可能太复杂,它的解决办法太笨重而且许多操作可能会使你迷惑。 但是之后,你就会清晰的认识到所有的背后都有一个简单的思想。

你用数据流的方式展示你的数据。数据流就像是一个事件管道(data,error,complete)。信号和序列都是流。信号是一个推送流:当它有什么东西的时候他推给你比如说网络,用户的输入,从硬盘异步读取的数据。Sequence是一个拉取流:当你需要什么的时候,你可以从sequence拉取比如集合数据。其它的操作都是用来转换和合并流的。

你也需要订阅者订阅的事件都是在他们所在的线程上的。如果你需要指定线程,使用RACScheduler(类似于GCD但是可以取消)。

通常你需要明确的指定[RACScheduler mainThreadScheduler]在更新UI的时候,但是你也可以继承你自己的RACScheduler当你处理特殊的事件,例如CoreData的contexts。

Sources:

  1. Reactive Programming on Objective-C --我们的文章,在这里你可以找到一些主要的操作可视化的描述
  2. Reactive Cocoa Turorial Ray Wenderlich上的两篇详细教程。对于初学者非常好。
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 157,298评论 4 360
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,701评论 1 290
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 107,078评论 0 237
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,687评论 0 202
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,018评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,410评论 1 211
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,729评论 2 310
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,412评论 0 194
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,124评论 1 239
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,379评论 2 242
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,903评论 1 257
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,268评论 2 251
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,894评论 3 233
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,014评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,770评论 0 192
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,435评论 2 269
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,312评论 2 260

推荐阅读更多精彩内容