ZhouJiatao's Blog

MVVM for iOS

如果你接触过移动应用开发,那么你很可能已经听说过 Model-View-Controller(简称MVC)。
MVC是一种相当传统、经典的开发模式,Apple官方也推荐使用这种模式开发iOS应用。
在MVC下,所有对象都被分为3大类:

  1. 持有数据的Model;
  2. 显示用户界面的View;
  3. View和Model通讯的桥梁:controller;
    mvc diagram

View和Model之间不会直接的相互引用,它们之间的沟通是通过controller来协调的。用户对界面View进行了操作,View就会通知Controller更新Model的状态;Model的状态改变,会通知controller去更新界面View。

在iOS中哪些组件分别与M、V、C对应?

Model: 通常非常轻量简单,是你自己定义的类或者结构体,比如一个拥有姓名和电话号码等数据的Person类。

View: UIKit中的组件,或者是你自定义的继承于UIView的类,比如UIButton,UILabel。你可能会把它们放在 .xib 或者 Storyboard 文件中。

controller: 几乎等同于视图控制器(View Controller),通常是UIViewController或者它的子类。UIviewController就像一个繁忙的工场。拥有生命周期的它不仅负责Model和View的交互,还负责了视图的loading,appearing,disappearing等。

model-view-viewcontroller

Massive View Controller

一个View Controller,往往包含了许多的IBOutlet属性,并且实现了多个protocol的响应方法,这足以让View Controller变得臃肿。雪上加霜的是,在实际项目中,你还能发现业务逻辑和model层的数据处理逻辑混杂其中。
如果有人告诉你,他在一个View Controller中看到了上千行的代码,我想你并不会为此感到惊讶。
因此有人调侃iOS架构中的MVC是Massive View Controller的意思。

一个体积庞大的视图控制器,它所带来的弊端是显而易见的,就是难以维护,也难以写单元测试。相信有不少人已经放弃对View Controller写单元测试了。

在iOS开发中,我们都在尽可能的将代码从View Controller中分离出来,以保持它的简洁。有时候,代码是少了,但仍不能使得View Controller的结构清晰。受限于生命周期和Views的回调方法,有时候同一个操作在好几个地方都需要调用。

比如,当我们对一个属性(name)赋值时,我们需要让View Controller更新对应的视图,因此我们在该属性(name)的set方法中调用更新视图的方法updateUI()。

1
2
3
4
- (void)setName:(NSString *)name {
_name = name;
[self updateUI];
}

但如果赋值操作是在视图加载完成之前进行的呢?最后,我们不得不这么做,在set方法中判断视图加载完毕的情况下,才调用updateUI(),接着在viewDidLoad方法内也补充调用updateUI()。

1
2
3
4
5
6
7
8
9
10
11
- (void)viewDidLoad {
[super viewDidLoad];
[self updateUI];
}
- (void)setName:(NSString *)name {
_name = name;
if (self.view.window) {
[self updateUI];
}
}

MVC带来的好处已经非常多了,但我们不满足于此。

Model-View-ViewModel

在实践中发现,Views和它对应的View Controller总是成对出现,如此紧密的耦合,我们不妨将它们看成同一个组件。
view_connected_controller

所以,一个想法诞生了😄…

mvvm

(MVVM是微软的一位架构师John Gossman提出的,具体历史请参阅这里)
在MVVM下,View Controller不再引用Model,而是引用到View Model。

像有些代码,是为了把Model的数据转换成View能展示的值,我们称这些代码为“展示逻辑”。
View Model就是存放”展示逻辑”的好地方。验证用户输入、网络请求 等都可以放在View Model层。
但关键的一点是,不要引用任何View。严格来说,在View Model中不该 #import UIKit.h

让我们看一下代码示例(From Ash Furrow):

Model层有个Person:

1
2
3
4
5
6
7
8
9
10
@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。

1
2
3
4
5
6
7
8
9
10
11
12
13
- (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后会如何:

1
2
3
4
5
6
7
8
9
10
@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的内部实现是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@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非常简洁😁:

1
2
3
4
5
6
- (void)viewDidLoad {
[super viewDidLoad];
self.nameLabel.text = self.viewModel.nameText;
self.birthdateLabel.text = self.viewModel.birthdateText;
}

正如你所看到的,相对于原来的MVC并没有大变化,还是原来的代码,只是展示逻辑被放置到View Model。
MVVM与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的代价是我们的代码量会轻微增加,但好处是降低了程序的复杂性。这是一笔很划算的交易。

另外,可以使用开源项目ReactiveCocoa或者RxSwift来协助你实践MVVM模式,它们的绑定机制能带给你很多的便利(学习曲线比较陡)。当然,使用ReactiveCocoa或者RxSwift都不是必须的,你完全可以不用任何框架来实践MVVM。

参考链接: