从 C-41 看 MVVM 和 ReactiveCocoa
基本概念
C-41 是一个关于 MVVM
和 ReactiveCocoa
的开源程序,我是通过 objc.io 上的一篇文章知道它的,相关地址:
MVVM
(Model-View-ViewModel
) 和 RAC
(ReactiveCocoa
) 都有不错的介绍文章,前面提到的是一篇,其他的附在文章结尾介绍给大家。
阅读这篇文章是需要一点 MVVM 和 RAC 的基础的,完全不知道什么是 MVVM 或 RAC 的同学请先了解它们。
据我观察,MVVM 基本上是这么用的:一个 View/ViewController 对应一个 ViewModel,一个 ViewModel 通常只对应一个 Model,不过也可能聚合多个 Model(在这个程序中未出现)。如果一个 View/ViewController 想要对应不只一个 ViewModel,那就说明这个 View/ViewController 需要拆分成更细的部分,由更细的部分各自持有更细的 ViewModel。
文章差不多是按照我的代码阅读顺序写的,不过按照对 RAC
的使用深度稍微调整了一下。
启动流程
ASHAppDelegate
中,初始化了自定义的 CoreData 栈 ASHCoreDataStack
,并为 ASHMasterViewController
设置了 ViewModel。
这个程序中的 Model 全部都是依托于 CoreData 的数据类型,其实就两个 ASHRecipe
和 ASHStep
。
ASHMasterViewController
的 ViewModel 作为 ASHMasterViewModel
的实例,继承自 RVMViewModel
,这是一个第三方为 RAC(ReactiveCocoa
)提供的 ViewModel 基类,可以使用 CocoaPods 集成到项目里。 RVMViewModel
假定一个 ViewModel 只对应一个 Model。
然后程序就进入 ASHMasterViewController
的控制范围。
ASHMasterViewController
和 ASHMasterViewModel
这个 ViewController 持有一个作为 Public 属性的 ViewModel, ASHMasterViewModel
。
我们看到,ViewController 里要显示什么数据,都是直接从 self.viewModel
里直接取,并没有做额外的处理,这使得 ViewController 瘦了很多,专注于处理 View 层的事情(输入相应、界面布局和动画等等)。
值得一提的是,在 ViewDidLoad 里,绑定了 ViewModel 的 updatedContentSignal 到一个 Block,@weakify
和 @strongify
来自 libextobjc
,用于解决 Block 引用的内存泄露问题,RAC 已经自带这个 Pod。至于这两个宏具体生成什么代码,可以看文末附注。
@weakify(self);
[self.viewModel.updatedContentSignal subscribeNext:^(id x) {
@strongify(self);
[self.tableView reloadData];
}];
另外这几行代码的意思是如果信号 self.viewModel.updatedContentSignal
触发 next
事件并返回值,那么执行 subscribeNext
对应的 Block 代码。
而 ViewModel 的 updatedContentSignal
是我们在 ASHMasterViewModel
中自定义的信号:
@property (nonatomic, strong) RACSubject *updatedContentSignal;
我们在代码里手动触发这个信号的 next
事件:
[(RACSubject *)self.updatedContentSignal sendNext:nil];
基本上这是一个比较标准的 TableViewController 子类,没有太多额外的内容。
接下来有几种方式跳转到其他 ViewController:
- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath
- (void)prepareForSegue:(UIStoryboardSegue *)segue sender:(id)sender
无一例外,都是初始化了对应的 ViewController,然后设置它的 ViewModel。不过这里值得注意的是,下一层级的 ViewController 的 ViewModel,是由这一层级的 ViewController 的 self.viewModel
获取的。
ASHEditRecipeViewController
和 ASHEditRecipeViewModel
ASHEditRecipeViewController
又是一个 TableViewController,在 viewDidLoad
里有这么一句:
// ReactiveCocoa Bindings
RAC(self, title) = RACObserve(self.viewModel, name);
这就是为什么 MVVM 经常和 ReactiveCocoa 一起用的原因之一了,View 通常需要观察 ViewModel 的变化,在 ViewModel 变化的时候,自动更改 View 里的对应部分。这里就是让 self.titile
自动反应 self.viewModel.name
的变化。
另外在 -(void)configureTitleCell:(ASHTextFieldCell *)cell forIndexPath:(NSIndexPath *)indexPath
里有这么一句:
RAC(self.viewModel, name) = [cell.textField.rac_textSignal takeUntil:cell.rac_prepareForReuseSignal];
我们发现赋值等号的右边不是用 RACObserve
创建的Signal,而是使用 ReactiveCocoa
对 textField
做的扩展 rac_textSignal
, 它实际上是创建了一个监听 textField
的 UIControlEventEditingChanged
事件的信号。 takeUntil:cell.rac_prepareForReuseSignal
则是指只有当 cell
的 -prepareForReuse
被调用时才触发这个信号的 next
或 completed
事件。
ViewController 的其他部分一切如常,接下来我们看看 ASHEditRecipeViewModel
。
-(instancetype)initWithModel:(id)model
这个方法里有个RACChannelTo,这是干什么的呢?
RACChannelTo(self, name) = RACChannelTo(self.model, name);
RACChannelTo(self, blurb) = RACChannelTo(self.model, blurb);
RACChannelTo(self, filmType, @(ASHRecipeFilmTypeColourNegative)) = RACChannelTo(self.model, filmType, @(ASHRecipeFilmTypeColourNegative));
RACChannelTo(self, name) = RACChannelTo(self.model, name);
这种写法是个双向绑定,也就是 self.name
改变,self.model.name
会改变;反之 self.model.name
改变的话,self.name
也会改变。
RACChannelTo(self, filmType, @(ASHRecipeFilmTypeColourNegative))
里面第三个参数是指,如果值的变化中出现 nil,那么就会使用这个值来代替,相当于一个默认值。
这是为什么 MVVM 通常会依赖 ReactiveCocoa
的原因之二,即 ViewModel 和 Model 的改变通常是需要双向同步的。
ASHDetailViewController
和 ASHDetailViewModel
ASHDetailViewController
没什么好说的,我们看 ASHDetailViewModel
。
RAC(self, canStartTimer) = [RACObserve(self.model, steps) map:^id(NSOrderedSet *value) {
return @([value count] > 0);
}];
这里出现了 map
,对一个信号执行 map
其实就是通过映射改变了它信号流下一步的值,即不再是原来 Observe 到的值。这里原先 Observe 到的值是 self.model.steps
,是一个 NSOrderedSet
,现在经过map,信号流的下一步收到的输入就是一个封装成 NSNumber
的 BOOL 值,于是就和 self.canStartTimer
对应起来了。这里信号流的概念就和 Unix 管道比较像,这一点应该在其他介绍 RAC
或 响应式编程
的文章中有所提及。
ASHTimerViewController
和 ASHTimerViewModel
ASHTimerViewController
同样没什么好看的,我们看 ASHTimerViewModel
:
RAC(self, nextStepString) = [RACSignal combineLatest:@[RACObserve(self.model, steps), RACObserve(self, currentStepIndex)]
reduce:^id(NSOrderedSet *steps, NSNumber *currentStepIndexNumber) {
NSInteger nextStepIndex = [currentStepIndexNumber integerValue] + 1;
if (nextStepIndex >= 0 && nextStepIndex < steps.count) {
return [[steps objectAtIndex:nextStepIndex] name];
} else {
return @"";
}
}];
我们发现一个属性不仅仅只能绑定由单个值改变触发的信号,还可以绑定由多个值改变触发的聚合信号。通过 combineLatest:reduce:
我们可以聚合多个信号成一个信号,让属性的改变是依赖多个值的变化的。
结尾
看到这里就差不多了,RAC
有很多高级的特性,MVVM
也有一些更复杂的实现方式,而这个程序仅使用了比较基本的 MVVM
结构和 RAC
特性来构建,对于刚刚接触 MVVM
和 RAC
的 iOS 开发者来说,已经是一个上乘的例子,在很多地方都有提及。
我们回顾一下:在这个程序里,一个 ViewController(View层) 持有一个 ViewModel,一个 ViewModel 对应一个 Model。ViewController(View层) 对于 ViewModel 使用单向绑定,将 ViewModel 的变化反应到 ViewController(View层);ViewModel 对于 Model 使用双向绑定,不论修改 ViewModel 或是 Model 都会实现数据的同步更新。
于是我们把很多原本放在 ViewController 里的逻辑独立了出来,让属于 View层 的 ViewController 去做 View层 应该做的事情,而不要关心原本不属于它的事情。当然我们也没有把独立出来的这部分事情放在 Model 里,并不污染真正属于数据存储部分的逻辑。于是其实我们独立出来的这个部分,就成了 ViewModel。
其他参考文章
- 唐巧的技术博客: ReactiveCocoa - iOS开发的新框架
- iOS应用架构谈(二):View层的组织和调用方案(中)
- Raywenderlich.com 上关于
MVVM
和ReactiveCocoa
的文章翻译(翻译文章包含原文链接)
附注
@weakify(self);
宏实际上生成的代码是:
@autoreleasepool {} __attribute__((objc_ownership(weak))) __typeof__(self) self_weak_ = (self);;
@strongify(self);
宏实际上生成的代码是:
@autoreleasepool {} __attribute__((objc_ownership(strong))) __typeof__(self) self = self_weak_;