从MVVM到ReactiveCocoa

翻译自 Bob SprynReactiveCocoa and MVVM, an Introduction
这是一篇很好的文章,从头到尾通读一遍会对MVVM模式以及ReactiveCocoa的使用有种豁然开朗的感觉。现有的译文中存在许多翻译不准确的地方,这里根据原文内容加上自己的理解重新翻译如下。

MVC

任何有经验的软件开发者都会熟悉 MVC 这个概念。它表示 Model View Controller ,是在复杂应用设计中一种久经考验的代码组织方式。在IOS开发中,MVC也被证实具有第二种含义:Massive View Controller (笨重的视图控制器) ,这让许多开发者困惑于如何优雅地对代码进行组织和解耦。IOS开发者需要给 view controller 瘦身,这是他们的共识。然而,怎么做呢?

MVVM

为了解决上面的问题,MVVM 应运而生。它表示 Model View View-Model ,它帮助我们创建更易管理、具有良好设计的代码。
某些情况下违背Apple建议的编码方式没有多大意义,我不是说不赞成这么做,而是觉得这么做弊大于利。比如我不建议你去实现一个 View Controller 基类并试图自己处理VIew的生命周期。
带着这种思想,我不禁提出这样一个问题:使用Apple推荐的MVC之外的设计模式是不明智的吗?
不!原因有两点:
Apple 没有真正给出解决 Massive View Controller 问题的任何指导,他们将更多空间留给我们。MVVM 就是一种很酷的解决方式。

MVVM 能够与 MVC 很好的兼容,并将 MVC 延展到另一个层次。

关于 MVC/MVVM 的历史这里不做介绍了,我会更关注它在 iOS 开发中的应用。

Defining MVVM

Model - 在 MVVM 中,model 的作用并没有什么特别变化,我们仅把它当做存放数据-模型对象信息的结构体,而在单独的管理类中保留创建/管理model的统一逻辑。

View - view中包含真正的UI本身(不管是 UIView 代码,还是 storyboard 和 Xib )、任何与视图有关的特定逻辑以及对用户输入的响应。这包括了许多由 UIViewController 负责处理的工作,不仅仅是UIView代码和文件。

View-Model - 这个术语本身就会给我们带来困惑,它由两个我们熟悉的术语组合而成,但完全是不同的东西。它不是传统意义上 data-model 结构中 model 的作用。它的职责之一是作为一个静态模型,为视图展示自身提供必要的数据,但它也有收集、解释、转换这些数据的责任。这留给 View(Controller) 一个更加清晰明确的任务:将 View-Model 提供的数据呈现出来。

More about the view-model

view-model 这个术语不足以描述我们的意图,一个更合适的名字可能是 “View Coordinator (视图协调器)”。它从资源(database,web service calls,etc)中收集原始数据,应用某种逻辑去处理修改造这些数据,加工成供 view(controller) 界面展示所需的数据。view-model (通常通过属性)仅仅暴露出来 view(controller) 显示所需的信息 (理想情况下不要暴露你的 data-model 对象)。它还负责处理上游数据的修改,比如更新模型/数据库, API POST 调用。

MVVM in a MVC world
在iOS开发中,我觉得 MVVM 这个首字母缩写像 view-model 一样词不达意,让我们再看下它是怎么适应MVC模式的。

Here is a simple mapping of how these two patterns fit together in iOS:

说明:

  • 使用图形块的大小粗略的表示它负责的工作量的多少

  • 注意看 view controller 部分有多大?

  • 巨大的 view controller 和 view-model 之间有大块工作上的重合

  • view controller 和 MVVM 中的 view 也有一大部分的工作是重合的

我们并不是要去除 view controller 这个概念,或者丢掉 “controller” 去匹配 MVVM,我们仅仅是将这部分重合的任务划分到 view-model 中,让 view controller 变得更加简单清晰。

最终得到的结果用图表示如下:

现在,view controller 仅涉及配置和管理各种视图,这些视图的数据都来自 view-model,view controller 也负责在用户有输入动作发生时通知 view-model ,让 view-model 去修改上游数据。view controller 不需要知道有关web service calls, Core Data, model objects 等的一些东西。

view-model 也是一个对象,它会以一个属性的方式存在于 view controller 中,视图控制器知道 view-model 和它的公有属性, 但是 view-model 对视图控制器一无所知。你或许已经感觉到这种设计好多了,因为在这里我们对相关工作做了很好的分离。

下图展示了这种 MVVM 模式下新的应用设计结构:

这张图或许能更好的帮助你理解。

View-Model and View Controller, together but separate

举个栗子:为了情节简单, 让我们构建一个简化的twitter客户端,任何使用推特的用户,只要输入用户名,就可以查阅最近的回复。 我们的例子交互和界面如下:

  • 有一个 UITextField,让用户可以输入名字,一个 “Go” UIbutton
  • 有一个 UIImageView和一个 UILabel ,用于显示当前被查看的用户的头像和姓名
  • 下面有一个 UITableView,显示最近的推文回复。
  • 允许无限滚动

The Example View-Model

view-model 的头文件如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
@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 conreoller 不关心 view-model 是怎么得到这些信息的。

view-model 不做的事情

  • 通过任何形式直接作用于 view controller,或者直接通知控制器关于自己的一些变化。

The Example View Controller

视图控制器使用从 view-model 获取的数据去做:

  • usernameValid值变化时,触发“Go”按钮的enabled属性
  • usernameValid 等于 NO 时调整按钮的 alpha 值为0. 5(等于 YES 时设为1.0)
  • 使用 userFullName更新 UILabel 的文本内容
  • 使用 userAvatarImage更新 UIImageView 的 image
  • 使用数组 tweets配置 table view 的 cells
  • 当滑动到 table view 的底部时,如果 allTweetsLoaded为 NO,提供一个显示“loading”的 cell

View Controller将通过如下方式作用于 view-model :

  • 每当 UITextField 中的文本发生变化, 更新 view-model 上仅有的 readwrite 属性 username
  • 当 “Go” 按钮被按下时,调用 view-model 上的 getTweetsForCurrentUsername 方法
  • 当到达表格中的 “loading” cell 时,调用 view-model 上的 loadMoreTweets 方法

view controller 不做的事情

  • 发起网络服务调用
  • 管理 tweets 数组
  • 判定 username 内容是否有效
  • 将用户的姓和名格式化为全名
  • 下载用户头像并转成 UIImage
  • 挥洒汗水

再次注意,视图控制器的总责任是如何处理 view-model 中的变化

#####Child View-Models
上面提到,我们使用 view-model 的 tweets 数组配置表格中的cell。通常你期望用来展示 tweets 的是这些 data-model 对象。但是上面提到,MVVM 模式下,不会暴露 data-model 对象,这时候你正感受到深深的恶意。。。

不需要仅使用一个 view-model 代表屏幕上展示的所有东西! 你可以使用 child view-model 表示更小的、潜在的更具封装性的部分:如果某一小块视图(比如 cell)在你的app中可以复用,或者它表示多个 data-model 对象,这么做将会十分有益。

你并不总是需要 child view-models。比如,我可以使用一个 table header view 来渲染我们的app “tweetboat plus”顶部部分,它不是一个可复用组件,所以我仅需要传入 view controller 使用的那个相同的 view-model 给这个自定义 header view 就可以了。它从那个 veiw-model 中获取自己需要的信息而忽略其他的。这是一个让你的子视图保持同步的特别棒的方法,因为它们都可以有效地使用相同的信息上下文,并观察与更新相同的属性。

在我们示例app中,tweets数组内放置的是 子view-model,大概长这样:

1
2
3
4
5
6
7
@interface MYTweetCellViewModel: NSObject

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

@end

你可能会觉得,这个子view-model也太像通常意义的 data-mode 对象了吧?为什么要把它转换成 view-model ? 尽管很相似,但是 view-model 让我们能够限制信息,仅暴露出我们需要的部分;提供可能转换数据的其他属性;或者为特定视图计算数据 (再说下,一种很好的设计方式是尽可能不要暴露可变的 data-model 对象,因为我们希望 view-model 自己负责修改更新他们,而不是 view 或者 view controler)。

View-Model 从哪来?

那么 view-model 是何时何处被创建的呢?视图控制器创建它们自己的 view-model 么?

View-Model 产生 View-Model

严格来讲,你应该在 app delegate 中为顶级视图控制器创建一个 view-model。当展示一个新的 view controller 或者一个很小的视图(这个小的视图使用 view-model 表示)时,要让当前的这个 view-model 为你创建需要的 child view-model 。

假如我们想要在用户点击应用顶部的头像时,添加一个资料视图控制器,我们可以为当前主 view-model 添加一个方法:

1
- (MYTwitterUserProfileViewModel *) viewModelForCurrentUser;

在我们的主控制器中,可以像这样使用它:

1
2
3
4
5
6
7
8
- (IBAction) didTapPrimaryUserAvatar
{
MYTwitterUserProfileViewModel *userProfileViewModel = [self.viewModel viewModelForCurrentUser];

MYTwitterUserProfileViewController *profileViewController =
[[MYTwitterUserProfileViewController alloc] initWithViewModel: userProfileViewModel];
[self.navigationController pushViewController: profileViewController animated:YES];
}

在这个例子中,我想弹出一个用户资料视图控制器,但是这个控制器需要一个 view-model。我的主控制器并不知道关于这个用户的数据信息,无法创建这个view-model(也不应该要它创建),所以,我的主控制器让它的 view-model 去做这项苦差事。

view-model 列表

回到推特例子中 table view 的 cell,当数据通过网络请求被拿到后,我会特意提前将对应cell的所有view-model创建好。所以在我们这里,主view-model的tweets数组内是MYTweetCellViewModel对象。在 table view 的 cellForRowAtIndexPath方法中,我会简单地在正确的索引位置从tweets数组中抓取子view-model,将它赋值给 cell 的 view-model 属性。

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

Functional Core(函数式内核)

view-model 就是我们的函数式内核 “functional core””,尽管实际上在 iOS/Objective-C 中达到纯函数级别是很棘手的(Swift 提供了一些附加的函数性, 这会让我们更接近)。一般的想法是让 view-model 尽可能少地依赖和影响应用程序的其它部分。这是什么意思?回想一下你刚开始学编程时遇到的简单的函数,它们接受一两个输入参数,并输出一个结果值。Data in, data out。也许这个函数做了一些数学计算或者字符串拼接。不管应用程序中发生了什么,相同的输入,就会得到相同的输出。这就是 函数式

我们使用 view-model ,就是想得到函数式结果。view-model 内部包含逻辑与功能,将数据转换并存储在它的属性中。理想情况下,相同的输入将导致相同的输出结果。这意味着可以尽可能多的消除应用程序的其它部分对输出结果的影响,比如使用大量的状态值。我们要做的第一步就是在你的view-model的头文件中不要包含UIKit.h(这是一个很好的原则,但也有一些灰色区域:比如,你可能会将UIImage看作数据,而不是视图(我喜欢这样)。在这种情况下,你需要UIKit.h来获得UIImage类)。UIKit 的性质就决定了它会严重影响应用程序的许多地方,它含有很多副作用,更改一个值或调用一个方法将触发许多间接(甚至不可知)的更改。

update: 需要理解你的 view-model 仍然是一个 object,并且的确需要维持一些状态(否则它对你的视图来说就不是一个非常有用的模型了)。但你仍然应该努力将尽可能多的逻辑转移到无状态函数的“值”中(swift 在这方面比 Objective-C 更可行)。

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

我们将 view-model 数据转换成屏幕所显示的东西,需要做一系列工作,比如所有的状态改变,应用内其它部分的改变,命令式外壳就是我们做这些脏活儿累活儿的地方。这就是我们的 view (controller),我们处理 UIKit 的地方。我依然特别注意尽可能的减少状态变量,将这一系列工作用声明式的方式完成,例如使用ReactiveCocoa。但本质上,iOS和UIKit是命令式的。

Testable Core

iOS 的单元测试是个脏, 苦, 乱的活儿. 至少我去做的时候得出的是这么个结论. 就这方面我还出读过一两本书, 但当开始做视图控制器的 mocking 和 swizzling 使其一些逻辑可测试时, 我目光呆滞. 我最终把单元测试归入模型和任何同类别模型管理类中. (译者注: mock 是测试常用的手段, 而 method swizzling 是基于 Objective-C Runtime 交换方法实现的黑魔法)

这个函数式核心一样的 view-model 的最大优点, 除了 bug 数量随着状态数递减之外, 就是变得非常能够进行单元测试. 如果你有那种每次输入相同而产生的输出也相同的方法, 那就非常适合单元测试的世界. 现在我们将数据的获取/逻辑/转换从 view controller 中提取出来, 避免了视图控制器的复杂性. 那意味着测试时不需要用疯狂的 mock 对象, method swizzling, 或其他疯癫的变通方法(希望能有)了。

Connecting Everything

那么,当 view-model 上的公开属性值变化时,我们怎么更新视图呢?

大多时候,我们使用相应的 view-model 去初始化 view controller,类似在上文见到的,比如:

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

有时你无法在初始化时将 view-model 传入, 比如在 storyboard segue 或 cell dequeuing 的情况下. 这时你应该在该 view (controller) 中暴露一个公有可写的 view-model 属性.

1
2
3
4
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];

有时候可以在钩子程序执行前传入 view-model,比如 init或者 viewDidLoad,这样,我们可以使用 view-model 提供的属性值初始化所有UI部件的状态:

1
2
3
4
5
6
7
8
9
10
11
12
- (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 中的数据变化时,怎么更新UI部件的状态?怎么将按钮变为不可用?我们的用户名label和头像将如何被网络请求的结果所填充?

将 viewcontroller 设置为 view-model 的代理?数据变化时,view-model 发送通知? 不不不。。

我们的视图控制器能够知道一些变化的发生。可以使用 UITextfield 的代理方法,通过每次有输入字符变动时检查 view-model ,来更新button的状态。

1
2
3
4
5
6
7
- (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;
}

这种方法解决的场景是在只有再文本框发生变化时才会影响 view-model 中的 isUsernameValid 值. 假使还有其他变量/动作改变 isUsernameValid 的状态将会怎么样? 对于 view-model 中的网络请求又如何呢?或许我们该为 view-model 中的方法加一个完成后回调处理, 这样我们在那个节点就可以更新 有关UI 的一切东西了?使用古老而笨重的KVO方法?

最终,我们可以使用我们所熟悉的各种机制来连接 view-model 和 view (controller) 中的所有接触点,但是你知道标题上可不是这么写的。这种方式在代码中创建了大量的入口点,就算是简单的UI更新,也必须完全重新创建应用程序状态的上下文。

Enter ReactiveCocoa

ReactiveCocoa (RAC) 是来拯救我们的。让我们看看它是怎么做的。

考虑通过一个新的用户界面来控制信息的流动:该界面在表单有效时更新提交按钮的状态。以下是你目前的工作方式:

最终,通过使用状态,谨小慎微地将自己简单的逻辑穿插在众多不同且无关的代码上下文中。看一下信息流中所有不同的入口点,是不是感觉乱糟糟的?(这还仅仅是一个UI元素的逻辑线) 。 我们在编程中使用的这些抽象还不够聪明,不能追踪所有这些事情的联系,所以最终还得自己做这些事儿。

让我们看下“陈述式”的版本:

这张图谱记录了我们应用程序的流程。通过这种陈述式编程,我们使用了一种更高级别的抽象,它让我们在实际的编程中,能够更接近我们自己脑海中的思维流。我们让电脑做更多的工作。现在实际的代码与这张图谱显得很接近了。

RACSignal

ReactiveCocoa核心就是 RACSignal。RACSignal (信号)对于 RAC 来说是构造单元。它是一个我们最终将会接收到的承载着信息的对象。当你有了一个在某个时间点将会收到的信息的具体表示形式时,那就开干吧!运用必要逻辑并预先构建你的信息流(声明式),而不是必须等到事件发生时才这么做(响应式)。

信号会捕获所有的异步方法(委托, 回调 block, 通知, KVO, target/action事件观察者,etc)来控制通过应用程序的信息流,并将他们统一在一个接口下。不仅如此,它还能够让你轻松的转换/分解/合并/过滤 流经你app的信息。

那么什么是信号呢,这就是一个信号:

信号是一个发送一连串值的物体。但我们这里的信号什么都没做,因为它还没有任何订阅者。一个RAC信号仅当有订阅者去监听它时,它才会发出信息。它将向订阅者发送0或者载有数值的“next”事件,后面紧跟着一个 “complete” 事件或者一个 “error” 事件。信号不仅限于一次只向它的订阅者发送一个返回值。

就像我前面提到的,如果需要的话你可以过滤, 转换, 分解,合并那些值。不同的订阅者可能需要由信号发出的这些数值的不同形式。

信号从哪里得到它们发送的值?

Signals 是一些等待事件发生的异步代码,当事件发生时就向它们的订阅者发送结果值。你可以使用 RACSignal 类中的类方法
createSignal: 手动创建这些信号:

1
2
3
4
5
6
7
8
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];
}];

上面我创建了一个信号,我使用 RACSignal 提供的 subscriber 对象调用 sendNext:sendCompleted: 方法(请求成功时),或者调用 sendError:(请求失败时)。现在我可以订阅这个信号并在网络请求返回时接收到 json 值或是 error。

RAC 为我们提供了丰富的机制来从我们常用的现有异步模式中提取信号。如果你有一个异步任务没有覆盖在内置的信号中,你可以很容易地用 createSignal: 或类似方法来创建信号。

RAC提供的一个机制就是使用宏 RACObserve(),这个宏是对 KVO 中那些糟透的 API 的替代。你只需要传入一个对象以及在这个对象中你想要监听的属性的名称。给出这些参数后,RACObserve ()会生成一个信号,并立即向它的监听者发送这个属性的当前值,以及未来关于这个属性的任何变化。

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

上面仅是创建信号的一种方式,下面有几种现成的从控制流机制中获得信号的方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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" }

你也能轻松创建自己的信号, 包括替代那些没有内置支持的其他委托。我们现在可以将所有这些断了联系的异步/控制流工具中获得信号,将也可以将它们组合在一起,想想这是多么酷的事情!这些信号会成为上面看到的陈述式图谱中的nodes节点,开心吧。

什么是订阅者?

简言之, 订阅者就是一段代码, 它等待信号给它发送一些值, 然后订阅者就能处理这些值了(它也可以作用于 “complete” 和 “error” 事件)。
再次注意,订阅者就是一段代码,而不是一个具体的对象。

下面是一个简单的 subscriber,通过向信号的实例方法 subscribeNext : 传入一个 block 创建的。这里我们正在通过 RACObserve() 宏创建的这个信号,观察一个对象的某个属性的当前值, 并把这个属性值赋给一个内部属性。

1
2
3
4
5
6
7
8
9
- (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() 类似,你提供一个对象和这个对象的属性名参数,传入的值就会绑定到这个对象的这个参数上。这个宏定义在内部就是做了上面 viewDidLoad 方法中的工作:创建订阅者,更新属性值。

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

但考虑到我们的目的,这样做有点傻冒。我们并不真的需要将信号中的值存储到一个属性中(也会因此创建状态),我们真正想做的是用从这个值中收集的信息来更新UI。

转换数据流

现在我们开始看 RAC 为我们提供的转换数据流的值的方法。我们会用到 RACSignal 类提供的 map 实例方法。

1
2
3
4
5
6
7
8
9
- (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属性上。绑定 goButton 按钮的 alpha 属性显得更让人兴奋,因为我们使用 map 方法将信号的值转换成 alpha 属性的值。

多个订阅者, 副作用, 昂贵的操作

在订阅信号链时,你应当认识到这样一件非常重要的事,每当一个新值通过该信号链发送时,它实际上是每一个订阅者都会发送一次。 比如新增了一个订阅者去监听一个信号,那么信号会立即向订阅者发送信息,注意是所有订阅者!而不仅仅是你刚才新增的那个。信号发送出的信息(值)不会存储在任何地方(除了RAC的内部实现部分),认识到这一点对我们来说是有意义的。当信号需要发送一个新的值时,它会遍历所有的订阅者,并给每个订阅者发送那个值。

这就意味着在你信号链的某处产生的任何副作用,任何影响应用程序世界的转换,都会多次发生。对于刚开始使用RAC的用户来说,这是意料之外的(这也违背了“函数式”思想—相同的输入,产生相同的输出)。

举个蹩脚的例子:有一个按钮点击事件信号,它会在信号链的某个地方更新一个计数属性,如果有多个订阅者监听了这个信号链,这个计数属性的增长比你想象的还要多。你需要从信号链中尽可能的剔除副作用,当副作用不可避免时, 你可以使用一些恰当的预防机制,我将会在另一篇文章中讨论。

除了副作用以外,你需要特别注意带有代价昂贵的操作和可变数据的信号链。网络请求是一个兼有以下三点的例子:

  1. 网络请求影响你的 app 的网络层(副作用).
  2. 网络请求给信号链带来了可变数据. (两个完全一样请求可能返回了不同的数据)
  3. 网络请求反应慢

例如,你有一个信号,每次点击按钮,信号就会发送一个值,你想转换这个值,并用转换结果进行网络请求从而得到请求结果。如果有多个订阅者要处理这个信号链返回的值,你将会发起多次网络请求。

显然网络请求是经常需要的,如你所想,RAC 为这种情况提供了解决方案:RACCommand 和多点广播。我将在下一篇文章中深入讨论。

Tweetboat Plus

简短的介绍之后,现在我们着手怎么将 view-model 和 view controller 使用 ReactiveCocoa 联系起来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
//
// 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);
}

让我们来分析下上面的这个例子:

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

这里使用RAC提供的方法,从 UITextField 中得到一个信号。上面这行代码将 view-model 的可读写属性 username 绑定到 textfield 的任何更新。

1
2
3
4
5
6
7
8
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,不管什么时候只要这个属性值有变化,这个信号就会沿着管道(pipe)发送一个新的 @YES 或 @NO 值。我们将这个值绑定到 goButton 的两个属性上。

接着,通过使用宏 RACObserve 在对应的 view-model 属性上创建信号,为 table view 表头的 image view 和 user label 创建绑定:

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

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

下面这段代码看起来有点棘手,所以让我们多花点时间在这里。

1
2
3
4
5
6
7
8
@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 属性发生变化时就能立刻更新 table view。所以我们将观察这两个属性的信号两个信号合并成一个“大信号”,这样,当这两个属性有任何一个变化时,这个合并后的大信号就会发送一个值 (通常你希望信号的值是同类型的,而不是像这个信号那样混合着其他类型值。这可能会在 RAC swift 强制执行,但这里我们不关心发送的实际值,我们只是用它来触发 table view 的重新加载)。

所以这里有点吓人的部分可能是 bufferWithTime:onScheduler: 方法,这么做是为了解决 UIKit 中的一个问题。我们需要同时追踪 tweetsallTweetsLoaded 这两个属性,目的是为了防止其中一个发生变化而另一个没有变(有一个属性变化,就需要更新 table view)。问题是,有时这两个属性碰巧会在同一时间点发生变化,这意味着,合并产生的大信号中的两个小信号都将发送一个值,那么reloadData 将在同一个 run loop 中被连续调用两次。UIKit 不喜欢这样。bufferWithTime: 捕获任何在给定时间内发送过来的下一个值,当这段时间过去以后,再将这些值一并发送给订阅者。通过传入参数 0 ,bufferWithTime:方法将捕获在一个 run-loop 时间内由“大信号”发出的所有值,然后再将这些值一起发出去。别担心,就把它当做需要将这些值必须在主线程上传递。现在我们能够确保 reloadData 方法在每个run-loop 内只执行一次。
注意:我们用到了 @weakify/@strongify 宏,这对打破循block引起的环引用非常重要。

下面这段代码展示出 RACCommand 将会发挥作用的地方,将在下一篇文章中介绍。就目前来说,当按钮被点击时,我们只是手动调用 view-model 的 getTweetsForCurrentUsername 方法:

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

我们已经介绍了cellForRowAtIndexPath 的第一部分,所以这里只说下 loading cell:

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

这是将来我们使用 RACCommand 的另一个领域。现在我们只是调用 view-model 中的 loadMoreTweets方法。我们相信,如果 cell 隐藏并显示了多次,view-model 可以在内部避免多次调用。

1
2
3
4
5
6
7
- (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);
}

This should be fairly straightforward now, aside from one thing I want to point out. We are binding an image and strings to the appropriate properties on our UI, but note that viewModel is on the right side of the comma in the RACObserve macro. These cells will end up getting reused and new view-models will be assigned. Instead of listening for the viewModel property to change and then re-setting up our bindings everytime, if we put viewModel on the right side of the comma, RACObserve is going to take care of that for us. So we only set up this binding ONCE and let Reactive Cocoa do the rest. This is a good thing to keep in mind for performance with bindings on table cells. In practice I’ve had no issues even with lots of table cells screaming around.

额外的好处——消除更多的状态

有时候你在 view-model 中暴露出 RACSignal 而不是一些属性值会帮你消除 view-model 上更多的状态。这样,你的 view (controller) 就可以直接使用这些信号,而不必使用 RACObserve创建它自己的信号了。注意:如果在 UI 订阅/绑定这个信号之前,它就已经发送了一个值,那么你就会错过这个“初始”的值。

结论

这是一种不同的编程风格,它为你提供了另一种与“命令式”完全不同的思路。即使你一开始并不会经常使用这种方式,但它仍然告诉你,有这样一位姑娘(RAC) 可以用她特有的方式为你解决困惑。