[译]ReactiveCocoa and MVVM, an Introduction

原文链接:http://www.sprynthesis.com/2014/12/06/reactivecocoa-mvvm-introduction/

MVC

每一个进行过一段时间软件开发的人员都会熟悉MVC。它代表Model View Controller,是一种在复杂的软件应用设计中有效的编码模式。然而,在iOS开发中,它似乎有第二种含义:Massive View Controller。它让许多iOS软件开发者纠结于如何保持代码整洁清晰,开发者们意识到,必须为他们的view controller瘦身。但,怎么做呢?

MVVM

这就是MVVM出现的原因——它代表Model View View-Model,它能够帮我们写出更加可控,结构更加合理的代码。
有时,编写app的时候不遵守Apple的推荐规范并不是一个好主意。我并不是不赞成,我只是说,这有可能得不偿失。比如,我不会推荐你去编写你自己的view controller基类,并且自己去管理视图的生命周期。
因此,这里我需要回答一个问题:用一个并非Apple推荐的设计模式(MVC)是否是不明智的呢?
!原因有两点:

  1. Apple并没有指导我们如何去解决Massive View Controller的问题。它让我们自己去解决,去为我们的代码添加更有效的修改(原注1:今年的WWDC上,一些Apple的示例代码中也出现了view model)。MVVM是一个很好的解决的途径。
  2. MVVM,或者说我接下来将要展示的MVVM的编码模式,是很符合MVC模式规范的,就好像是我们将MVC向前自然地推进了一步。

MVVM的定义

  1. Model——MVVM中的model的含义并没有变化。你的model有可能包含一些业务逻辑,这取决于你自己[1]。我倾向于用它来作为一个保存数据模型对象的结构[2],而将创建或管理model的逻辑放到一个单独的manager类型的类中。
  2. View——view包含了UI(不管是UIView代码,storyboard还是xibs),view的逻辑,以及用户输入响应。在iOS开发中,这其中很多是UIViewController所做的事情,而不仅仅是UIView
  3. View-Model——这个词组本身会使人误解,虽然它是由两个我们已经了解的词语构成,但却代表了完全不同的一种东西。它不是传统数据模型结构意义上的model(再次表明,这也只是我的个人倾向)。它的职能之一是作为一个静态模型,代表view展示所需要的数据。但此外,它也负责收集、解释、转换这些数据。这就使得view (controller)有了更清晰专一的职责:将view-model提供的数据展示出来。

更多关于view-model

view-model这个词组的确不能表达我们的意思。一个更好的表述应当是"view coordinator(视图协调器)"。你可以把它想象成电视新闻节目幕后的调查者和撰稿人。它从信息源搜集原始数据(可能是数据库,网络协议,等等),进行逻辑演绎,将数据转换成view (controller)可展示的数据。它仅仅暴露(往往通过属性property)view controller展示view所必需的信息(理想情况下你不应该暴露你的数据模型对象)。它也负责对上游数据进行修改(例如,更新model/数据库,POST数据等等)。

MVVM in a MVC world

正如词组view-model一样,我觉得MVVM这个缩写词一定程度上也不能清楚代表我们在iOS开发中的使用方法。让我们再看一下这个缩写词,看看它是怎么融入MVC的。
为了画出示意图,让我们把MVC中的V和C调换一下,这样得到的缩写词,MCV,能够更准确反映各个部分之间的关系。对于MVVM,我们采取一样的做法,把V(View)移动到VM的右边,得到MVMV(我确定最初不采取这种更直观的命名是有原因的)。
下图描述了这两种模式在iOS中是如何融为一体的:


  • 我尽量将各个方块的大小与各部分所承担的任务量对应起来。
  • 注意到,view controller的方块有多大!
  • 可以看到,庞大的view controller与view-model之间有很大一部分工作是重叠的。
  • 你也可以看到,view controller的一部分与MVVM中的view是重合的。

你也许会感到宽慰的是:实际上我们并没有抛弃view controller的概念。我们只是将其中那一大块重叠的部分放进view-model中,让view controller更轻松。
最终,我们得到的其实是MVMCVModel View-Model Controller View。


我们得到的结果是:

现在,view controller唯一要做的就是用来自view-model的数据来调度,管理不同的view,并在用户输入需要改变上游数据的时候告诉view-model。view controller并不需要知道网络请求,core data,model对象(原注3.1:但实践中,有时候通过view-model的头文件来暴露一些model是很有效的方法,而不是再去复制大量的属性,稍后详谈)[3],等等。
view-model将作为view controller的属性property存在。view controller了解view-model及其公共属性,但view-model对view controller毫不知情。你应该已经感觉到这种分离的好处了。
另一种帮助你理解这些组成部分之间的关系,以及各部分的职能的方法就是,看下面这张新的应用层级结构图:

View-Model和View Controller:和而不同

让我们看一个简单的view-model头文件来更深入了解我们的新模式长啥样。简单起见,让我们编写一个假冒的twitter客户端,它能让我们查找任何twitter用户最近的回复,只需要输入用户名,然后点击"Go"。我们的界面长这样:

  • 有一个UITextField用来输入用户名,一个“Go”按钮UIButton
  • 有一个UIImageView和一个UILable,展示当前查找的用户的头像和名字。
  • 下方有一个UITableView用来展示最近的回复(推特)。
  • 可以无限滚动。

示例View-Model

我们view-model的头文件可能会长这样:

@interface MYTwitterLookupViewModel: NSObject

@property (nonatomic, assign, readonly, getter=isUsernameValid) BOOL usernameValid;

@property (nonatomic, strong, readonly) NSString *userFullName;

@property (nonatomic, strong, readonly) UIImage *userAvatarImage;

@property (nonatomic, strong, readonly) NSArray *tweets;

@property (nonatomic, assign, readonly) BOOL allTweetsLoaded;

@property (nonatomic, strong, readwrite) NSString *username;

- (void) getTweetsForCurrentUsername;

- (void) loadMoreTweets;

十分明了清晰。注意到这些壮观的readonly属性了吗?View-model会暴露尽量少的信息给view controller,view controller也不关心view-model是怎么获得这些信息的(现在我们也不需要关心,只需要想像成我们常用的网络请求得到的数据,伪造的数据,持久化存储的数据等等)。

view-model不会做的事情:

  • 以任何方式直接操作view controller,或有变化时直接通知到view controller。

View controller

View controller会使用从view-model获得的数据来:

  • 根据usernameValid属性的变化来改变“GO”按钮的enabled属性
  • usernameValid为NO时将按钮的alpha值设为.5(当usernameValid为YES时为1.0)
  • userFullName里的字符串来更新UILabel的text属性
  • 使用userAvatarImage的值来更新UIImageView的Image
  • 使用tweets里的对象来设置tableview cell
  • 当tableview 滑到底时,如果allTweetsLoaded的值为NO,需要添加一个"loading" cell

View controller可能以下列方式来操作view-model:

  • 当UITextField里的text改变时,更新我们view-model中唯一的readwrite属性,username
  • 当用户点击"Go"按钮时,调用view-model的getTweetsForCurrentUsername方法
  • 当tableview 滑动到“loading cell”时,调用view-model的loadMoreTweets方法

View controller不做的事情:

  • 发起网络请求
  • 管理tweets数组
  • 判断username是否是有效名字
  • 将用户的first name和last name拼成full name
  • 下载用户头像,并将其转换成UIImage(原注4:如果你习惯使用一些UIImageView的category来加载网络图片的话,你可以不暴露一个UIImage,而是暴露一个URL,这确实能够将view-model和UIKit分隔得更清楚。但就我自己而言,我更将UIIMage看作一种数据,而不是用来表现数据的视图。这里并没有明显的界线)
  • 做许多费力的活儿

再一次,注意到对于view-model的变化的作出响应的责任在view controller中。

Child View-Model

之前提到,使用tweets里的对象来设置tableview cell。通常你希望这些对象是代表一条条推特的数据模型对象。你可能会感到疑惑:诶不是说MVVM中我们尽量不暴露数据模型的吗?(原注3)

并不是一个view-model代表了屏幕上所有的东西。我们可以使用child view-model来代表屏幕上更小的,更模块化的元素。当这种元素可以被重用(比如tableview cell),或者代表了多个data-model对象的时候,这种方法尤其有效。

我们并不总是需要child view-model。例如,我们可以使用一个table header view来实现我们的“tweetboat plus” APP的顶部。这部分是不可重用的,所以我们可以直接传我们在view controller中所使用的view-model到这个自定义的header view。Header view从view-model中挑选自己需要的信息,忽略其他的信息。这也很有利于保持各个subview的同步,因为它们都是使用的同一套信息,并监听同样的属性变化。

在我们的demo中,tweets数组会装满child view-model,child view-model也许长这样:

@interface MYTweetCellViewModel: NSObject

@property (nonatomic, strong, readonly) NSString *tweetAuthorFullName;

@property (nonatomic, strong, readonly) UIImage *tweetAuthorAvatarImage;

@property (nonatomic, strong, readonly) NSString *tweetContent;

你可能会觉得,这长得很像我们正常使用的“Tweet”数据模型啊。那为什么要把它转换成一个view-model呢?因为即使很相似,view-model能让我们将对外暴露的信息尽量压缩到我们需要的范围,并提供一些可能是经过加工的属性,或者计算一些为这个视图所独有的数据。(再次声明,尽量不暴露可变的数据模型对象,因为我们希望是由view-model自身来改变这些数据模型对象,而不是view或view controller)

那,在哪里创建view-model呢

我们的View-Model会在何时何地被创建呢?是由view controller自己创建自己的view-model吗?

View-Model 创造 View-Model

严格来讲,你应当在app delegate中为你的top view controller创建一个view-model。当present一个新的view controller,或者一个新的由view-model代表的view的时候,你请求当前的view-model来创建一个child view-model。


例如,我们想增加一个资料页(profile view controller),用以当用户点击APP顶部区域中的头像的时候跳转。我们可以为我们的主view-model增加一个方法:
- (MYTwitterUserProfileViewModel *) viewModelForCurrentUser;
在我们的主view controller中这样调用它:

- (IBAction) didTapPrimaryUserAvatar

{

MYTwitterUserProfileViewModel *userProfileViewModel = [self.viewModel viewModelForCurrentUser];

MYTwitterUserProfileViewController *profileViewController =

[[MYTwitterUserProfileViewController alloc] initWithViewModel: userProfileViewModel];

[self.navigationController pushViewController: profileViewController animated:YES];

}

在这个例子中,我想要present一个当前用户的profile view controller,但这个profile view controller需要一个view-model。我们这里的view controller并不知道创建这个profile view controller所需要的全部信息(当然,它也不应该知道),因此,view controller会让自己的view-model来做这个工作。

View-Models 列表

在这个demo中,当我们收到数据的时候(比如可能是通过一个网络请求)我们会提前创建好所有cell对应的child view-model。在这种情况下,主view-model中的tweets数组中会装满了MYTweetCellViewModel对象。在我们的tableview的cellForRowAtIndexPath中,我们只需要找到index对应的那个view-model,然后把它赋给cell。

Functional Core, Imperative Shell (函数式内核,命令式外壳)

这种view-model的软件设计方式可以看成一种近似于由Gary Bernhardt所提出的 “Functional Core, Imperative Shell”软件设计模式

Functional Core(函数式内核)

View-Model 是我们的"函数式内核",尽管在iOS/OC中,很难达到纯函数式的程度(Swift给我们提供了更多的函数性)。通常的思想是,让我们的view-models尽可能少地依赖于/影响到应用的其它部分。什么意思呢?回想一下刚开始学习编程的时候写过的最简单的函数。它们可能会接受1到2个参数,然后输出一个结果。Data in, Data out。函数中或许进行了一些简单的计算,或者诸如拼合first name和last name之类。不管程序其它部分怎么运作,相同的输入总是会产生相同的输出。这就是函数的思想。

这正是我们使用view-models所要取得的结果。View-model内部包含了转换数据的逻辑,并将结果作为property保存下来。理想情况下,相同的输入(例如网络响应)总会产生相同的输出(property的值)。这就是说,会尽可能消除外部对于结果的影响,比如 使用大量的状态值我们要做的第一步就是在你的view-model的头文件中不要包含UIKit.h(原注6:这是一个很好的原则,但也有一些灰色区域:比如,你可能会将UIImage看作数据,而不是视图(我喜欢这样)。在这种情况下,你需要UIKit.h来获得UIImage类)UIKit天生就会影响到APP的很多地方,它包含了许多副作用,因此改变一个值,或者调用某个方法都可能会产生不直接的变化。
更新:刚刚看了Andy在Functional Swift Conference上的另一个很棒的演讲,因此对此有了更多的思考。我们的view-model说到底还是一个对象,还是需要保持一些状态变量(否则不会成为一个很实用的对象)。但我们仍然需要把尽可能多的逻辑写到无状态的函数中。在这方面Swift又一次做的比OC更好。

Imperative (Declarative?) Shell(命令式(声明式?)外壳)[4]

我们将view-model数据转换成屏幕所显示的东西,需要做一系列工作,比如所有的状态改变,应用内其它部分的改变,命令式外壳就是我们做这些脏活儿累活儿的地方。这就是我们的view (controller),我们处理UIKit的地方。我依然特别注意尽可能的减少状态变量,将这一系列工作用声明式的方式完成,例如使用ReactiveCocoa。但本质上,iOS和UIKit是命令式的。(原注7:table data source是一个很好的示例:它的这种代理模式会使得delegate使用状态变量来在tableview请求数据的时候提供信息。事实上,一般情况下代理模式都会使用大量的状态变量)

可测试的内核

iOS中的单元测试是一项糟糕的,变态的,令人讨厌的工作(。。。)至少这是我开始接触做这些时的感想。我甚至读过一两本这方面的书,但当他们开始伪造(mocking),偷换(swizzling) view controllers来使得其中一些逻辑可以测试的时候,我就开始打瞌睡。最终,我选择了退而求其次,只对models和一些相关的model manager类进行单元测试。

除了因为减少状态变量而减少的bug之外,View-model这种函数式内核最大的优点之一就是,它能够很好地支持单元测试。如果对于一些方法,相同的输入总会产生相同的输出,那么这些方法就很适合做单元测试。我们现在将数据的收集/逻辑/转化,与复杂的view controller分离开了,这就意味着不需要伪造,偷换等等疯狂的举动就可以写出很好的测试了。

连接一切

那么,当view-model中的公共属性发生变化的时候,我们怎么去更新view呢?

大部分时候,我们会用相应的view-model来初始化view controller,正如我们上面所看到的:

MYTwitterUserProfileViewController *profileViewController = [[MYTwitterUserProfileViewController alloc] initWithViewModel: userProfileViewModel];

有时,在初始化的时候不能传入view-model,比如使用storyboard segue或者cell重用的时候。这时,可以让view (controller)对外暴露一个readwrite的view-model属性。

MYTwitterUserCell *cell = [self.tableView dequeueReusableCellWithIdentifier:@"MYTwitterUserCell" forIndexPath:indexPath];// grab the cell view-model from the vc view-model and assign it 
cell.viewModel = self.viewModel.tweets[indexPath.row];

若我们可以在init或者viewDidLoad之前传入view-model的话,我们就可以利用view-model的属性来初始化一些UI元素的状态。

- (id) initWithViewModel:(MYTwitterLookupViewModel *) viewModel {

self = [super init];

if (!self) return nil;

_viewModel = viewModel;

return self;

}

- (void) viewDidLoad {

[super viewDidLoad];

_goButton.enabled = viewModel.isUsernameValid;

_goButton.alpha = viewModel.isUsernameValid ? 1 : 0.5;

// etc

}

太棒了!我们搞定了我们的初始化。但若view-model中的值发生变化呢?GO按钮怎么才能变成enabled的呢?我们的user label和头像怎么才能利用网络请求响应来填充呢?

我们可以将view controller暴露给view-model,这样view-model在相关数据发生变化的时候,就能调用view controller的“updateUI”方法(千万别这么做我说着玩的)。将view controller作为view-model的一个delegate?当view-model中属性发生变化的时候抛出一个通知?Noooooooooo!
我们的view controller确实会知道view-model中的一些变化。我们可以利用UITextField代理方法,每当text发生变化的时候,检查view-model来更新按钮的状态。

- (void)textFieldDidChange:(UITextField *)sender {

// update the view-model

self.viewModel.username = sender.text;

// check if things are now valid

self.goButton.enabled = self.viewModel.isUsernameValid;

self.goButton.alpha = self.viewModel.isUsernameValid ? 1.0 : 0.5;

}

如果UITextField的text变化是导致view-model的isUsernameValid属性变化的唯一原因的话,这样确实可以解决问题。但如果还有其它变量/方法会改变isUsernameValid的值呢?如果view-model内部的网络请求会改变这个值呢?也许我们可以为view-model中的方法添加completion handlers来更新UI?或许我们可以使用郑重其事,繁琐笨重的KVO?

也许,不管怎么样我们最终都可以利用我们熟悉的种种机制将view-model和view controller的接触点连接起来。但你已经知道了,这不是我们写这篇文章的原因。这些方法都为我们的代码增加了大量分散的修改UI逻辑的入口。

进入ReactiveCocoa的世界

ReactiveCocoa (RAC)为我们提供了一种清楚的解决方案。现在让我们看看吧!
若有一个表单,当表单判断为有效时,更新一个提交按钮的状态。考虑如何控制这些信息的流动。现在的你可能是这么做的:


最终,你小心地使用这些状态变量,在代码里各个不相关的地方处理这个简单的逻辑。看到这个信息流的许多不同入口了吗?(这还只是一个UI元素的一套逻辑而已!)我们所使用的这种抽象并不聪明,不能为我们追踪这些事情之间的关系,所以我们只好可怜兮兮地自己来做这些事情。

让我们来看看声明式的做法


这看起来有点像以前学校里CS课上使用的APPLICATION流程图。利用声明式编程方法,我们使用一种更高的抽象,使得我们编程的方法更接近我们脑海中设计应用流程图的过程。我们把更多的事情丢给计算机去做。实际的代码现在更接近这张图了。

RACSignal

RACSignal(signal,信号量)是RAC所有内容的基石。它是一个代表我们最终会收到的信息的对象。当你能够将在未来某个时刻接收到的信息用一个具体的对象来表示的时候,你就可以提前写好所有的逻辑,并提前建立起完整的信息流(声明式),而不是等那个事件发生的时候再去做这些(命令式)。

一个信号量将app中所有控制这个信息流动的异步的方法(delegates, callback blocks, notifications, KVO, target/action event observers, 等等)整合在一个地方。这是很有意义的一件事。此外,它还能让你在该信息流动的过程中轻松地转换/分离/整合/过滤信息。


那么,什么是signal信号量呢?这是一个信号量:



一个信号量是一个会输出一连串值流的对象。但此处的这个信号量并没有任何作用,因为它没有任何订阅者(subscriber)。一个信号量只有在拥有订阅者聆听它的时候才会传出信息。信号量会向它的订阅者传出大于等于0个"next"事件,其中包含了所需要的值。随后,它会再传出一个"complete"事件或是一个"error"事件。一个信号量有点类似于其它语言或框架中的"promise",但信号量的作用更强大,不仅仅是只传递一次返回值而已。


A signal with a subscriber
A signal with a subscriber

上文提过,我们可以根据需要对信号量传递出的值进行过滤,转换,分离,整合等。不同的订阅者可能会以不同的方式来使用信号量传出的这些值。


A signal with two subscribers
A signal with two subscribers

信号量是从哪儿获得它们所传递的这些值的呢?

信号量是一段异步的代码,等待某些事情发生之后,将结果值传给它的订阅者。你可以使用RACSignal类的类方法createSignal:手动创建一个信号量:

RACSignal *networkSignal = [RACSignal createSignal:^RACDisposable *(id<RACSubscriber> subscriber) {

NetworkOperation *operation = [NetworkOperation getJSONOperationForURL:@"http://someurl"];

[operation setCompletionBlockWithSuccess:^(NetworkOperation *theOperation, id *result) {

[subscriber sendNext:result];

[subscriber sendCompleted];

} failure:^(NetworkOperation *theOperation, NSError *error) {

[subscriber sendError:error];

}];

这里我在一个(假的)带有success block 和 failure block的网络请求操作中创建了一个信号量(原注8:如果想等到有订阅者的时候才真正地发起网络请求,可以使用RACSignal类的类方法defer。在success block中,我对参数subscriber对象调用了sendNext:方法和sendCompleted方法,在failure block中,则调用了sendError:方法。现在,我可以订阅这个信号量了,每当有网络请求响应返回的时候,我都能收到一个json值或是一个error。

幸运的是,RAC框架的创造者们在实际项目中也运用着该框架(我猜的),因此,他们很清楚我们的代码中最需要的是什么。他们提供给我们一套很好的机制,来将我们以前习惯使用的异步模式转换成信号量。只是,万一你有一个异步的任务,用内置的信号量类型无法完成的时候,不要忘了还可以使用createSignal:或类似方法方便地自己创建一个。

他们所提供的机制之一就是RACObserve()宏(如果你不喜欢用宏,你可以很容易地使用更底层的方法。这会稍微繁琐点儿,但依旧很好。对于swift,这里也有一份教程 using the RAC library with swift,以及swift版本 swifty replacement)。RACObserver()宏是RAC对复杂的KVO的替代方案。你只需要将所观察的对象以及想要观察的该对象的属性的keypath作为参数传入,RACObserver会生成一个信号量,并立刻将该属性当前的值传出(如果有订阅者的话),并且将来该属性的值变化的话,该变化值也会由此传出。

RACSignal *usernameValidSignal = RACObserve(self.viewModel, usernameIsValid);
A signal created with RACObserve
A signal created with RACObserve

这仅仅是RAC所提供的一种创建信号量的方式。还有其它多种创建信号量的方式:

RACSignal *controlUpdate = [myButton rac_signalForControlEvents:UIControlEventTouchUpInside];

// signals for UIControl events send the control event value (UITextField, UIButton, UISlider, etc)

// subscribeNext:^(UIButton *button) { NSLog(@"%@", button); // UIButton instance }

RACSignal *textChange = [myTextField rac_textSignal];

// some special methods are provided for commonly needed control event values off certain controls

// subscribeNext:^(UITextField *textfield) { NSLog(@"%@", textfield.text); // "Hello!" }

RACSignal *alertButtonClicked = [myAlertView rac_buttonClickedSignal];

// signals for some delegate methods send the delegate params as the value

// e.g. UIAlertView, UIActionSheet, UIImagePickerControl, etc

// (limited to methods that return void)

// subscribeNext:^(NSNumber *buttonIndex) { NSLog(@"%@", buttonIndex); // "1" }

RACSignal *viewAppeared = [self rac_signalForSelector:@selector(viewDidAppear:)];

// signals for arbitrary selectors that return void, send the method params as the value

// works for built in or your own methods

// subscribeNext:^(NSNumber *animated) { NSLog(@"viewDidAppear %@", animated); // "viewDidAppear 1" }

要记住,你也可以轻松创建自己的信号量,包括替换其他未被支持的代理模式。想象一下吧!我们现在能从这所有不相关的信息流动的异步事件中抽取出信号量,并将它们全整合起来!酷!它们会成为上文声明式流程图中的节点。这是多么让人鸡冻啊!

什么是订阅者(subscriber)?

简单来说,一个订阅者就是一段代码,它等待信号量传来的值,并用这些值来做一些事情(当然,也可以用“complete”和"error"来做一些事情)。

这里,通过将一个block作为参数传到一个信号量的subscribeNext实例方法中,我们就创建了一个简单的订阅者。此时,我们通过RACObserve()宏创建了一个信号量,并以此来监听一个对象的一个属性,并将其值赋给本身的一个属性。

- (void) viewDidLoad {

// ...

// create and get a reference to the signal

RACSignal *usernameValidSignal = RACObserve(self.viewModel, isUsernameValid);

// update the local property when this value changes

[usernameValidSignal subscribeNext:^(NSNumber *isValidNumber) {

self.usernameIsValid = isValidNumber.boolValue

}];

}

注意到,RAC处理的都是对象,不是如BOOL的原始值类型。不过不用担心,RAC大部分情况下都会为你自动转换好。

更棒的是,RAC的创造者认为,像这种将一个属性的值绑定到另一个属性上,并监听其变化的行为是一种很常见的需求,因此他们提供了另一个宏RAC()。类似于RACObserve(),你只要传入监听的对象,以及你想要绑定的参数,剩下的工作(创建一个订阅者,更新参数等)就交给底层去做吧!这样,我们上面的例子就变成了这样:

- (void) viewDidLoad { 
        //... 
        RAC(self, usernameIsValid) = RACObserve(self.viewModel, isUsernameValid)
;}

但这里,我们的目的并不在于此。我们并不想用另一个属性来保存信号量传过来的值(因为这样又会产生状态变量了)。我们真正想做的事情是利用信号量传过来的信息来更新UI。

转化接收到的值流

现在我们来看看,RAC提供给我们什么方法来转化接收到的值流的。这里我们要使用的是RACSignal的实例方法map

- (void) viewDidLoad {

//...

RACSignal *usernameIsValidSignal = RACObserve(self.viewModel, isUsernameValid);

RAC(self.goButton, enabled) = usernameIsValidSignal;

RAC(self.goButton, alpha) = [usernameIsValidSignal

map:^id(NSNumber *usernameIsValid) {

return usernameIsValid.boolValue ? @1.0 : @0.5;

}];

}

现在,我们将view-model的isUsernameValid属性的变化直接与goButton按钮的enabled绑定起来啦!这是多棒!与alpha值的绑定就更酷了,我们将BOOL值通过map方法转化成了alpha属性的数据类型(注意我们这里返回的是一个NSNumber类型,而不是普通数据类型)。

多个订阅者,副作用,以及复杂的操作

当订阅一个信号量链的时候,有一点很重要的事情要注意:每次有一个新的值通过信号量链[5]传递的时候,每有一个订阅者,值就会被传递一次,这些值不会在任何地方被存储起来(除了RAC内部实现的时候)。当一个信号量想传递出去一个新值的时候,它会遍历其所有订阅者,对每一个订阅者都传一次。(这是信号量链的一个简化了的解释,但基本思想是对的)

了解这点为什么重要呢?这意味着,在这一信号量链中某处产生的副作用,任何影响应用的转换,都会重复发生多次。这通常是刚接触RAC的使用者所不希望的。(这也违背了函数式data in, data out的原则)

举一个略显刻意的例子:有一个按钮点击事件的信号量,在信号量链中的某处,会增加self的counter属性。如果有多个订阅者订阅了这个信号量链,counter属性会比你预期的增加得更多。你必须尽量消除信号量链中产生的副作用。如果实在不能避免中间的副作用,你也可以使用一些方法来防止副作用的影响[6]。我将在另一篇文章中讲解。

除了副作用,你也要当心那些包含耗时操作或可变数据的信号量链。网络请求包含了以上所说的三个注意点:

  1. 网络请求影响了你app的网络层(副作用)
  2. 网络请求为你的信号量链引入了可变数据(两个完全相同的网络请求可能会返回不同的数据)
  3. 网络请求速度较慢。

举例:我们有一个信号量,每当一个按钮被点击的时候,会传出一个值。我们想将这个值通过网络请求转换为另一个结果。如果这个信号量链有多个订阅者要使用最后的值,这中间就会产生多次的网络请求。


A signal with side effects happening twice
A signal with side effects happening twice

显然,网络请求是一个很常见的需求。正如你预料,RAC为这些情况提供了解决方案,即RACCommand和multicasting。我将在我的下篇文章中详细讲解。

Tweetboat Plus

好了,经过了简单的介绍(哈?),让我们看一下,如何利用ReactiveCocoa连接我们的view model和view controller。

//

// View Controller

//

- (void) viewDidLoad {

        [super viewDidLoad];

        RAC(self.viewModel, username) = [myTextfield rac_textSignal];

        RACSignal *usernameIsValidSignal = RACObserve(self.viewModel, usernameValid);

        RAC(self.goButton, alpha) = [usernameIsValidSignal

                map: ^(NSNumber *valid) {

                        return valid.boolValue ? @1 : @0.5;

        }];

        RAC(self.goButton, enabled) = usernameIsValidSignal;

        RAC(self.avatarImageView, image) = RACObserve(self.viewModel, userAvatarImage);

        RAC(self.userNameLabel, text) = RACObserve(self.viewModel, userFullName);

        @weakify(self);

        [[[RACSignal merge:@[RACObserve(self.viewModel, tweets),

                RACObserve(self.viewModel, allTweetsLoaded)]]

                bufferWithTime:0 onScheduler:[RACScheduler mainThreadScheduler]]

                subscribeNext:^(id value) {
                
                        @strongify(self);

                        [self.tableView reloadData];

        }];

        [[self.goButton rac_signalForControlEvents:UIControlEventTouchUpInside]

                subscribeNext: ^(id value) {

                @strongify(self);

                [self.viewModel getTweetsForCurrentUsername];

        }];

}

-(UITableViewCell*)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {

// if table section is the tweets section

        if (indexPath.section == 0) {

                MYTwitterUserCell *cell =

                [self.tableView dequeueReusableCellWithIdentifier:@"MYTwitterUserCell" forIndexPath:indexPath];

// grab the cell view model from the vc view model and assign it

                cell.viewModel = self.viewModel.tweets[indexPath.row];

                return cell;

        } else {

// else if the section is our loading cell

                MYLoadingCell *cell =

                [self.tableView dequeueReusableCellWithIdentifier:@"MYLoadingCell" forIndexPath:indexPath];

                [self.viewModel loadMoreTweets];

                return cell;

        }

}

//

// MYTwitterUserCell

//

// this could also be in cell init

- (void) awakeFromNib {

        [super awakeFromNib];

        RAC(self.avatarImageView, image) = RACObserve(self, viewModel.tweetAuthorAvatarImage);

        RAC(self.userNameLabel, text) = RACObserve(self, viewModel.tweetAuthorFullName);

        RAC(self.tweetTextLabel, text) = RACObserve(self, viewModel.tweetContent);

}

让我们审视一下这段代码。
RAC(self.viewModel, username) = [myTextfield rac_textSignal];
这里我们用RAC的库方法从UITextField中抽取出一个信号量,这一行代码将view-model的readwrite属性username与用户产生输入时textfield的更新绑定起来。

RACSignal *usernameIsValidSignal = RACObserve(self.viewModel, usernameValid);
RAC(self.goButton, alpha) = [usernameIsValidSignal 
  map: ^(NSNumber *valid) { 
    return valid.boolValue ? @1 : @0.5; 
  }
];
RAC(self.goButton, enabled) = usernameIsValidSignal;

这里我们使用RACObserve在view-model的usernameValid属性上创建了一个信号量usernameIsValidSignal。每当这个属性发生变化的时候,该信号量会传出一个@YES@NO的值。我们将这个值与goButton的两个属性绑定起来。首先,我们根据值是YES或NO,分别将alpha设置成1或者0.5(记住我们要传的是一个NSNumber类型)。接着我们将该值直接与enabled属性绑定起来,因为该属性刚好是一个BOOL类型,所以不需要做任何转换。

RAC(self.avatarImageView, image) = RACObserve(self.viewModel, userAvatarImage);
RAC(self.userNameLabel, text) = RACObserve(self.viewModel, userFullName);

接下来我们还是用宏RACObserve,将imageView和Label分别绑定到view-model对应的属性上去。

@weakify(self);
[[[RACSignal merge:@[RACObserve(self.viewModel, tweets), RACObserve(self.viewModel, allTweetsLoaded)]] 
      bufferWithTime:0 onScheduler:[RACScheduler mainThreadScheduler]] 
      subscribeNext:^(id value) { 
          @strongify(self);
         [self.tableView reloadData]; 
}];

这一段代码可能有点复杂。我们希望当view-model的tweets数组和allTweetsLoaded属性改变时更新tableview(在这个例子中,我们简单地更新了整个tableview)。所以我们用RACObserve创建了这两个属性的信号量,并合并成一个更大的信号量:当这两个属性任意一个发生变化的时候,合并后的信号量会传出一个值(通常你会希望一个信号量传出的值都是同一类型的,而不是像这个例子中混合的类型。这个用RAC Swift是会强迫保证的。但这里我们并不关心实际传出的值,我们只是用它来触发tableview的刷新)。

这里看起来有点复杂的是接在后面的bufferWithTime:onScheduler:方法。这是为了解决UIKit中的一个问题。我们需要追踪这两个属性,tweetsallTweetsLoaded的变化,并在其中任意一个发生变化时刷新tableview。有时,这两个属性会在同一时间发生变化,这意味着,合并的信号量中的两个单独的信号量会同时传出一个值,reloadData方法会在同一个run loop中调用两次。UIKit并不允许这样的做法。bufferWithTime:方法将一定时间内所有待传递的值存起来,并在这段时间之后打包发送给订阅者。如果传入的参数为0,bufferWithTime:将会保存我们合并后的信号量在一个特定run loop中传递的所有的值,然后将它们一并发出(原注10:NSTimer的工作方法是相同的。这也不是巧合啦哈哈,因为bufferWithTime:就是用NSTimer实现的)。现在不用去想scheduler,就把它想象成指定了这些值必须是在主线程传递。现在,我们保证了reloadData方法每一个run loop都只执行一次。

注意我这里使用的strong weak dance,就是@weakify/@strongify这些宏。当我们使用这些block的时候,这是非常重要的!当在RAC block中使用self的时候,如果不仔细,很容易会使得self被block所持有,从而产生循环引用。

[[self.goButton rac_signalForControlEvents:UIControlEventTouchUpInside] 
      subscribeNext: ^(id value) { 
        @strongify(self); 
        [self.viewModel getTweetsForCurrentUsername]; 
}];

这是我的下一篇文章中会讲到的RACCommand使用的地方。但这里,我们只是当按钮被点击时,手动调用了view-model的getTweetsForCurrentUsername方法。

我们已经讲过了cellForRowAtIndexPath的第一个部分,现在看一下loading cell的部分:

MYLoadingCell *cell = [self.tableView dequeueReusableCellWithIdentifier:@"MYLoadingCell" forIndexPath:indexPath];
[self.tableView loadMoreTweets];return cell;

这是另一个将来会用RACCommand的地方,不过现在我们也是手动调用了view-model的loadMoreTweets方法。我们默认在cell重用的时候,view-model内部会有机制防止该方法重复调用。

- (void) awakeFromNib { 
  [super awakeFromNib]; RAC(self.avatarImageView, image) = RACObserve(self, viewModel.userAvatarImage);
   RAC(self.userNameLabel, text) = RACObserve(self, viewModel.tweetAuthorFullName);
   RAC(self.tweetTextLabel, text) = RACObserve(self, viewModel.tweetContent);
}

这段代码意思很明确,但我还要指出一点:我们将一个image和一些string绑定在UI的相关属性上。但注意到,viewModel是处于RACObserve()宏的右边参数位置。这些cell将会被重用,新的view-model会被赋给它们。如果将viewModel放在左边参数的位置,就相当于监听viewModel属性的变化,并每次都重新进行绑定;相反,将viewModel放在右边参数的位置,RACObserve会为我们做好这些工作。因此,我们只需要做一次这些绑定的工作,剩下的工作交给Reactive Cocoa吧!在绑定cell的时候要记住这一点。实际使用中我从没碰到过坑,即使是在大量cell复用的时候。

译者注:


  1. 胖model和瘦model

  2. 是否是说,从原始数据转化成的model(也许item更合适)称为数据模型,model保存了若干这样的数据模型,这种结构

  3. 什么情况下呢?

  4. 参考Imperative and Declarative Programming一文:http://theproactiveprogrammer.com/design/imperative-and-declarative-programming/?utm_source=tuicool&utm_medium=referral

  5. 个人理解,即由一个信号量出发,经过一系列整合,分离,转化的过程,最后止于订阅者的一条链

  6. RACMulticastConnection

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

推荐阅读更多精彩内容