链接:https://www.objc.io/issues/13-architecture/mvvm/
MVVM介绍
2011年,我在500px得到我的第一份工作。虽然在大学中我做了几年的iOS外包工作,但是这是我第一次真正的iOS开发工作。我是唯一的iOS开发者去制作漂亮设计的iOS应用。在短短的7周内,我们发布了1.0版本并持续迭代,添加了许多功能。从本质上来说,代码库也变得更复杂了。
有时候就像我不知道我做了什么。我清楚我的设计模式,就像其他优秀的开发一样,但我离我的产品太近了,导致我无法客观的衡量我的架构决策的功效。当团队中又来了一个一位开发者时,我才意识到我们遇到问题了。
大家是否听过MVC?有些人称之为Massive View Controller(巨型视图控制器)。这就是我当时的感受。我不想提起太多尴尬的细节,但这足以说明,如果不得不重新来一次,我会做出不同的决策。
我会修改其中一个关键的架构,并将其带入我从那时起开发的应用,也就是将Model-View-Controller改为Model-View-ViewModel。
所以到底什么是MVVM?先不管MVVM的来历,我们先看一个典型的iOS应用是什么样,并从这里开始了解MVVM:
一个典型的MVC架构是这样的:Models呈现数据,Views呈现用户交互,View Controller则调节前两者之间的交互。很棒的架构!
再仔细想想,尽管views和view controllers从技术上来说是完全不同的组件,但他们几乎总是成对出现。你什么时候能看到一个view可以与不同的view controller成对出现?或者反过来亦然?所以我们为什么不把他们连接在一起:
上图跟准确的描述了我们实际写出的MVC代码。但这对于解决日益增长的massive view controllers没有太大的帮助。在典型的的MVC应用中,大量的逻辑放在了view controllers中。其中有部分确实属于view controller,但是更多的所谓“表现逻辑”,在MVVM术语讲,就是将模型中的一些值,转换为可以在view中显示的内容,比如NSDate日期类型转换成格式化后的NSString类型。
在上面的图中缺失了一部分用来存放表现逻辑的代码。我们将之称为view model(视图模型),它在view/controller和model之间:
这样看起来好多了。上图准确描述了MVVM:一个升级版的MVC模式,其中将view和controller连接到一起,然后将表现逻辑从controller中移动到了一个新的对象中,也就是view model。MVVM模型听起来很复杂,但本质上就是一个对你所熟知的MVC架构进行了一个包装。
所以现在你知道了什么是MVVM,但是为什么我们要使用它呢?对于我来说,在iOS上使用MVVM的动机就是它可以减少视图控制器的复杂度,同时提高了表现了逻辑的可测试性。接下来用一些例子来看下如何完成上述的目标。
以下3点希望看完本文后可以学到的:
- MVVM可以兼容现有的MVC架构
- MVVM让应用更容易测试
- MVVM和绑定技术一起时最佳
正如前边所说,MVVM基本山是一个MVC的改进版本,所以我们很容易看到如何将MVVM整合到现有的使用典型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
中使用model
属性设置一些label。
- (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。现在我们看看如何使用view model改进下:
@interface PersonViewModel : NSObject
- (instancetype)initWithPerson:(Person *)person;
@property (nonatomic, readonly) Person *person;
@property (nonatomic, readonly) NSString *nameText;
@property (nonatomic, readonly) NSString *birthdateText;
@end
view model中的实现如下:
@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
移动到了view model中。此时viewDidLoad
方法就变得非常轻量:
- (void)viewDidLoad {
[super viewDidLoad];
self.nameLabel.text = self.viewModel.nameText;
self.birthdateLabel.text = self.viewModel.birthdateText;
}
可以看到,并没有对MVC架构做太多的改变。都是相同的代码,只是放到了不同的地方。MVVM兼容MVC,可以简化视图控制器,同时提高可测试性。
可测试性,嗯?是怎样的?众所周知,视图控制器是出了名的难以测试。在MVVM中,我们将尽可能多的代码迁移到view models中。测试视图控制器就变得容易多了,因为视图控制器不需要再做那么多工作,而且view models是非常容易测试的。我们看一下:
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
如果我们没有将表示逻辑移到view model中,就需要初始化整个视图控制器和相关的视图,并且比较view中label的值。这不仅仅变成了一个不便的间接层,而且会成为一个严重脆弱的测试。而使用MVVM后,我们可以自由的根据意愿修改视图的层级而不用担心打破我们的单元测试。使用MVVM对于测试的优点是显而易见的,哪怕仅仅是一个简单的例子,而且当表示逻辑越来越复杂时会更明显。
在上面的简单例子中,模型是不可变的,所以我们可以在初始化阶段将设置view model的属性。对于可变的model来说,我们需要使用一种绑定机制让view model在背后的model改变时,更新自己的属性。此外,当view model中的模型改变时,视图中的属性也需要更新。模型的更新需要级联向下通过view model进入view。
在OS X系统中,人们可以使用Cocoa绑定,但是在iOS上没有这么好的配置使用。此时就会想到键值绑定(KVO),KVO做了很多伟大的工作。然而,对于一个简单的绑定都需要许多样板,更不用说如果有许多属性需要绑定的时候了。相比KVO,我更喜欢使用ReactiveCocoa,但MVVM没有强制我们使用ReactiveCocoa。MVVM是一个伟大的典范,它本身完全独立,只是在有一个良好的绑定框架时会做的更好。
本文我们覆盖了不少内容:MVVM起源于MVC,看他们是如何相兼容的范式,从可测试性的角度看MVVM,并且知道了MVVM在有绑定技术下工作的更好。如果你对MVVM学习有兴趣,可以看下这篇博客,从更多的细节上解释了MVVM的好处,或者这篇文章,关于我们在最近的项目中如何使用MVVM来获取巨大的成功。我也有一个开源的基于MVVM的完整的可测试的应用C-41。去看看吧,如果有问题可联系我的twitter,让我知道。