[翻译]本文翻译自objc.io官网iOS大神Ash Furrow的文章, 原文可查看Introduction to MVVM。
——————————————我是分割线——————————————
2011年我在500px公司获得第一份iOS工作。虽然大学期间我一直有承包一些iOS小项目,但这份工作才堪称我第一次真正的个人show。作为唯一被聘请的iOS开发人员,我的工作是开发一款设计精美的iPad应用。在仅仅七周的时间里,我们就发布了一个1.0版本。在持续迭代的过程中我们增加了更多功能,本质上也增加了代码的复杂性。
有时我觉得不知道自己在做什么。像任何优秀的技术人员一样,我知道自己的设计模式。但由于我实在过于贴近产品端,导致自己无法正确衡量技术架构并作出客观决策。直到团队中的另一位开发人员入伙,我才意识到我们遇上麻烦了。
听说过MVC架构吗?有人也称它为巨型视图控制器模式。而这绝对就是我当年的真正感受。我不打算描述自己令人尴尬的技术细节,但假如我不得不再做一次技术决策,我一定不会选择MVC架构。
在这一工作经验后的再开发其他app时, 我都会做出一个关键的架构决策,即使用一种称为Model-View-ViewModel(MVVM)的架构来替代Model-View-Controller(MVC)。
那么MVVM究竟是什么?我们不要关注MVVM的历史背景,我们直接来看看一个典型的iOS应用是什么样子,它又是怎么派生出MVVM的:
这里我们看到一个典型的MVC架构。 Model(模型)表示数据,View(视图)表示用户界面,ViewController(控制器)负责协调这两者的交互。
虽然视图和视图控制器在技术上是不同的组件,但它们几乎总是配对着携手并进。尝试回忆一下,你什么时候见过一个视图可以与各种不同的视图控制器配对? 或者反过来? 那么我们为什么不直接确定他们两者的联系性?
上图更准确地描述了你可能已经写过的MVC代码。 但是这对于解决iOS应用中的巨型视图控制器问题并没有太大的帮助。 在典型的MVC应用程序中,很多逻辑被放置在视图控制器中。 当然,部分逻辑是属于视图控制器的,但其中很多都是被称为“表示逻辑”的东西(在MVVM术语中),比如将模型中的值转换为视图上可以呈现的东西的逻辑,比如获取一个NSDate并将其转换为一个格式化的NSString。
在上图中我们缺失了一个可以放置所有表示逻辑的东西。 我们把它称为"View Model"(视图模型),它将位于视图、控制器和模型之间:
该图精确地描述了什么是MVVM:我们把视图和控制器视为同一层,并将表示逻辑从控制器中移到视图模型这个新对象中。MVVM听起来很复杂,但它本质上就是你已熟悉的MVC架构的一个进化版。
知道MVVM是什么后,我们再来思考为什么要使用它?对我来说,使用MVVM好处是它能降低视图控制器的复杂性,并使得表示逻辑更易于测试。后面我们将会通过一些实例看看它是如何实现这些目标的。
我希望你从这篇文章中了解三个非常重要的观点:
- MVVM与你现有的MVC架构是兼容的。
- MVVM能使您的应用程序更具可测试性。
- MVVM配合绑定机制的使用能更好地发挥功效。
正如前文所述,MVVM本质上只是MVC的一个升级版,所以它能很容易被整合到使用了MVC架构的已有应用程序中。我们来看一个简单的Person模型和相应的视图控制器:
@interface Person : NSObject
- (instancetype)initwithSalutation:(NSString *)salutation
firstName:(NSString *)firstName
lastName:(NSString *)lastName
birthdate:(NSDate *)birthdate;
@property (nonatomic, readonly) NSString *salutation;
@property (nonatomic, readonly) NSString *firstName;
@property (nonatomic, readonly) NSString *lastName;
@property (nonatomic, readonly) NSDate *birthdate;
@end
现在我们假设我们有一个PersonViewController,它在viewDidLoad中根据模型属性设置一些标签:
- (void)viewDidLoad {
[super viewDidLoad];
if (self.model.salutation.length > 0) {
self.nameLabel.text = [NSString stringWithFormat:@"%@ %@ %@", self.model.salutation, self.model.firstName, self.model.lastName];
} else {
self.nameLabel.text = [NSString stringWithFormat:@"%@ %@", self.model.firstName, self.model.lastName];
}
NSDateFormatter *dateFormatter = [[NSDateFormatter alloc] init];
[dateFormatter setDateFormat:@"EEEE MMMM d, yyyy"];
self.birthdateLabel.text = [dateFormatter stringFromDate:model.birthdate];
}
这一切通过MVC实现都相当简单。 现在我们看看如何用视图模型来改良它:
@interface PersonViewModel : NSObject
- (instancetype)initWithPerson:(Person *)person;
@property (nonatomic, readonly) Person *person;
@property (nonatomic, readonly) NSString *nameText;
@property (nonatomic, readonly) NSString *birthdateText;
@end
视图模型PersonViewModel内部的实现如下所示:
@implementation PersonViewModel
- (instancetype)initWithPerson:(Person *)person {
self = [super init];
if (!self) return nil;
_person = person;
if (person.salutation.length > 0) {
_nameText = [NSString stringWithFormat:@"%@ %@ %@", self.person.salutation, self.person.firstName, self.person.lastName];
} else {
_nameText = [NSString stringWithFormat:@"%@ %@", self.person.firstName, self.person.lastName];
}
NSDateFormatter *dateFormatter = [[NSDateFormatter alloc] init];
[dateFormatter setDateFormat:@"EEEE MMMM d, yyyy"];
_birthdateText = [dateFormatter stringFromDate:person.birthdate];
return self;
}
@end
我们已经将viewDidLoad中的表示逻辑转移到视图模型中,新的viewDidLoad方法现在非常轻巧:
- (void)viewDidLoad {
[super viewDidLoad];
self.nameLabel.text = self.viewModel.nameText;
self.birthdateLabel.text = self.viewModel.birthdateText;
}
正如你所看到的,原来的MVC架构并没有太多变化。 代码是相同的,它们只是被移动了。 它与MVC兼容,可令视图控制器更轻,并且更具可测试性。
可测试性又是怎么做到的? 我们知道视图控制器是非常难以测试,因为它们做了太多工作。 在MVVM中,我们会将尽可能多的代码移动到视图模型中。 因为视图控制器工作减少了,测试它将变得容易得多,视图模型同样也很容易测试。 我们来看看:
SpecBegin(Person)
NSString *salutation = @"Dr.";
NSString *firstName = @"first";
NSString *lastName = @"last";
NSDate *birthdate = [NSDate dateWithTimeIntervalSince1970:0];
it (@"should use the salutation available. ", ^{
Person *person = [[Person alloc] initWithSalutation:salutation firstName:firstName lastName:lastName birthdate:birthdate];
PersonViewModel *viewModel = [[PersonViewModel alloc] initWithPerson:person];
expect(viewModel.nameText).to.equal(@"Dr. first last");
});
it (@"should not use an unavailable salutation. ", ^{
Person *person = [[Person alloc] initWithSalutation:nil firstName:firstName lastName:lastName birthdate:birthdate];
PersonViewModel *viewModel = [[PersonViewModel alloc] initWithPerson:person];
expect(viewModel.nameText).to.equal(@"first last");
});
it (@"should use the correct date format. ", ^{
Person *person = [[Person alloc] initWithSalutation:nil firstName:firstName lastName:lastName birthdate:birthdate];
PersonViewModel *viewModel = [[PersonViewModel alloc] initWithPerson:person];
expect(viewModel.birthdateText).to.equal(@"Thursday January 1, 1970");
});
SpecEnd
如果我们没有将这个逻辑转移到视图模型中,我们将不得不实例化一个完整的视图控制器和相应的视图,并一一比较视图标签中的值。这不仅不方便测试,同时这会是一个严重脆弱的测试。现在我们可以随意修改我们的视图层次结构,而不用担心破坏我们的单元测试。即使是这个如此简单的例子,使用MVVM的测试优势也很明显,在更复杂的逻辑中它的可测试性优势会更加明显。
请注意,在这个简单的示例中模型是不可变的,所以我们可以在初始化时指定视图模型的属性值。而对于可变模型我们需要使用某种绑定机制,以便模型改变时视图模型可以及时更新其属性。此外,一旦视图模型上的模型发生变化,视图的属性也需要更新。模型变化后新数据的流动应该从模型上流动到视图模型中,再流动到视图中。
在OS X上我们可以使用Cocoa实现绑定,但在iOS上我们没有这种特权。KVO是一种不错的选择。但是如果要绑定很多属性的话,使用KVO会显得很冗赘。我个人喜欢使用ReactiveCocoa,但是我没有意思要强迫大家都去使用ReactiveCocoa。 MVVM是一个很好的架构,虽然它可以独立运行,但只有通过一个优秀的绑定框架才能实现更好的效果。
我们已经介绍了很多:MVC如何派生出MVVM并互相兼容;MVVM的可测试性;MVVM在与绑定机制配合使用时效果最佳。如果您有兴趣了解更多关于MVVM的知识,可以查看这篇博客,它更详细地解释MVVM的好处,或者关于我们如何在最近的项目中使用MVVM并取得巨大成功的文章。我还有一个全面测试过的基于MVVM的开源应用,名为C-41。如果您有任何问题,欢迎联系我。