翻译自Don't let your UIViewController think for itself。是作者的这一系列三篇文章中的第二篇。
上篇文章,我展示了如何将协议(protocol
)从view controller
分离出来,以达到为胖controller瘦身的目的。在本系列三篇文章的第二篇,我将使用MVVM架构
来精简我们的View controller
。View controller
通常会包含非常多的业务逻辑,这会导致非常多的问题,比如:
- 代码将会难以阅读。让
View controller
负责外观,而将业务逻辑封装在单独对象里。 -
View controller
将会变得非常臃肿。然而业务逻辑本不应该放在View controller
里。
MVVM?
MVVM
是 Model View ViewModel 的缩写,是一种将界面、业务逻辑与数据分离的方法,并以一种相互独立的方式结合在一起。
这种方法的工作方式就像这张图展示的一样:
我认识的一个聪明家伙是这样描述它的:
“...数据从
Model
传递给ViewModel
,经过ViewModel
处理之后,再发送给View
。交互事件与数据修改从View
传递给ViewModel
,经过ViewModel
处理之后,再发送给Model
。”
我本来可以说出一大堆使用MVVM
的好处(比如测试、代码的可读性等等),但这已经超过了本文的探讨范畴。当然,如果你对此非常感兴趣,objc.io上的这篇非常棒的文章可以供你阅读。
特征蠕动
假设我们现在有了一个非常棒的view controller
,而且在一个完美的世界里我们不再需要修改它了。如果真这样的话,我们程序员可就都要失业了。
回归现实,产品经理刚刚给你提了一个新的需求。Joe Public非常想在点击列表或数据加载完成的时候修改导航栏的标题。
非常简单对不对?我们只需要把这段代码写在...view controller
里...?不!这是坏码农的做法!
一个好的码农,今天要做的就是把业务逻辑从view controller
中分离出来。那么业务逻辑应该放哪呢?我很高兴地告诉你:放在ViewModel
里。
ViewModel
如果你已经知道MVVM
或者看了之前提到的那篇文章,那么你应该已经知道什么是ViewModel
了。在MVVM
中ViewModel
是传递给view controller
的一个对象,内部实现了所有有关view controler
的业务逻辑和事件处理,是数据操作的大脑。
迁移到ViewModel里
OK,回到我们的工程里,首先我们需要创建一个名为VCTableModel的ViewModel
类,继承于NSObject
。在开始实现新功能之前,我们先把view controller
已经存在的业务逻辑放进我们新创建的ViewModel
中,看见没?就是MyAPI!代码如下:
//VCTableModel.h
@class MyAPI;
typedef void(^didReloadDataBlock)(NSArray *data);
typedef void(^didErrorBlock)(NSString *error);
@interface VCTableModel : NSObject
-(instancetype)initWithManager:(MyAPI *)api;
-(void)reloadData;
@property (nonatomic, copy) didReloadDataBlock didReloadData;
@property (nonatomic, copy) didErrorBlock didError;
@end
在这段代码中,你会发现我们是通过构造方法将MyAPI对象传给VCTableModel。这项技术被称为 依赖注入
,非常适合在 MVVM
中使用。如果你对依赖注入
,可以阅读这篇文章。
使用MVVM
的一个最大的好处就是,如果运用得当的话,可以使view controller
难以置信地随着数据的变化而自动变化。而这一神奇的特性是通过在view controller
与ViewModel
之间创建一种绑定关系实现的。
有很多工具可以实现这种绑定关系,比如ReactiveCocoa、ikevents,而在这里我仅仅通过block
来实现。可以发现我已经在** VCTableModel**中定义了两个事件, 一个用来处理出错,另一个用来处理数据更新。最后我们还有一个方法用来在view controller
中加载数据的。
接口已经定义好了,接下来该实现它们了,打开VCTableModel.m,为我们的MyAPI创建一个私有属性:
@property (nonatomic, strong) MyAPI *api;
添加构造方法:
-(instancetype)initWithManager:(MyAPI *)api {
if (!(self = [super init])) { return nil; }
self.api = api;
return self;
}
Next up let's add the methods that will 'push' our events and a small helper to change an NSError into an NSString
接下来添加一个传递事件的方法,而且在传递事件之前,我们会把NSError转化为NSString的形式:
-(NSString *)userMessageForError:(NSError *)error {
return [NSString stringWithFormat:@"An Error Occurred\n\n%@", error.localizedDescription];
}
-(void)sendError:(NSError *)error {
if (self.didError == nil) { return; }
dispatch_async(dispatch_get_main_queue(), ^{
self.didError([self userMessageForError:error]);
});
}
-(void)sendData:(NSArray *)data {
if (self.didReloadData == nil) { return; }
dispatch_async(dispatch_get_main_queue(), ^{
self.didReloadData(data);
});
}
干得漂亮!现在只剩下一件事要做了:将调用MyAPI的代码移植过来,因为MyAPI已经不再与界面直接藕合了。
-(void)reloadData {
[self.api getPhotos:^(NSArray *json) {
//Convert JSON items into our model objects
NSMutableArray *items = [NSMutableArray array];
for (NSDictionary *jsonItem in json) {
VCTableCellData *item = [[VCTableCellData alloc] initWithJSON:jsonItem];
[items addObject:item];
}
//Raise didReloadData
[self sendData:items];
} error:^(NSError *error) {
//Something went wrong, raise didError
[self sendError:error];
}];
}
好了,就是这样!现在功能都已经放在ViewModel
里了,随时可以传递给view controller
。
迁移View Controller
移除老的代码
既然我们已经创建了ViewModel
,接下来应该使用它来更新我们的view controller
了。首先我们在view controller
中把移入ViewModel
的代码删除。
执行以下步骤:
修改title的更新方式并且删除
self.api = [MyAPI new]
;删除
#import "MyAPI.h"
;删除私有属性
MyAPI *api
;-
使用以下代码替换现有的reloadData方法:
-(void)reloadData { [self.refresh beginRefreshing]; [self.model reloadData]; }
目前先暂时忽略编译错误,我们马上就会修复它。
引入新的代码
First thing we need to do is provide an instance of VCTableModel to our view controller via the constructor:
我们需要做的第一件事就是为我们的view controller
的构造方法添加一个VCTableModel参数:
//VCTable.h
@class VCTableModel;
@interface VCTable : UIViewController
-(instancetype)initWithModel:(VCTableModel *)model;
@end
接下来为view controller
添加一个私有的ViewModel
属性:
#import "VCTableModel.h"
@property (nonatomic, strong) VCTableModel *model;
新的构造方法实现如下:
-(instancetype)initWithModel:(VCTableModel *)model {
if (!(self = [super initWithNibName:NSStringFromClass([self class]) bundle:nil]))
{
return nil;
}
self.model = model;
return self;
}
差不多了!下一步我们添加一个方法用来将ViewModel
中的事件与view controller
绑定在一起:
-(void)bindToModel {
self.model.didError = [self modelDidError];
self.model.didReloadData = [self modelDidReloadData];
}
-(didErrorBlock)modelDidError {
return ^(NSString *error) {
[self endRefreshing];
[[[UIAlertView alloc]
initWithTitle:@"Oops..." message:error
delegate:nil cancelButtonTitle:@"OK" otherButtonTitles:nil]
show];
};
}
-(didReloadDataBlock)modelDidReloadData {
return ^(NSArray *data) {
[self endRefreshing];
[self.tableViewCoordinator reloadData:data];
};
}
注意:你可能不太熟悉这种处理事件的语法,但是
block
可以作为方法的返回值。这样做仅仅是为了提高代码的可阅读性。
最后务必在方法viewDidLoad中调用 [self bindToModel];
祝贺你! 你现在已经把你的 view controller
转换为MVVM
模式!
实现新功能!
现在可以开始添加我们新的功能了!第一步,我们应该把获取view controller
标题的方法放进我们的ViewModel
里,这样做可以让修改标题变得非常容易。比如,你有一个个人资料的界面,该界面与一个User对象关联,你就可以非常方便地将标题修改为用户(User)的名字,而不需要修改view controller
( 这是MVVM
的一个关键的原则)。
所以让我们再次更新ViewModel
:
//VCTableModel.h
@property (readonly) NSString *title;
//VCTableModel.m
-(NSString *)title {
return @"MVVM Table View Example";
}
并且修改view controller
的** viewDidLoad**方法:self.title = self.model.title;
我们知道有两种情况需要更新导航栏的标题,我们可以通过以下步骤来实现:
-
定义一个事件回调来:
//VCTableModel.h typedef void(^didUpdateNavigationTitleBlock)(NSString *title); @property (nonatomic, copy) didUpdateNavigationTitleBlock didUpdateNavigationTitle;
-
定义一个方法来触发这个事件回调:
//VCTableModel.m -(void)sendNavigationTitle:(NSString *)title { if (self.didUpdateNavigationTitle == nil) { return; } dispatch_async(dispatch_get_main_queue(), ^{ self.didUpdateNavigationTitle(title); }); }
-
我们还需要一个方法来处理列表的点击事件:
//VCTableModel.h @class VCTableCellData; -(void)userSelectedCell:(VCTableCellData *)cellData; //VCTableModel.m -(void)userSelectedCell:(VCTableCellData *)cellData { [self sendNavigationTitle:cellData.title]; }
ViewModel
最后需要修改的就是更新方法reloadData:在调用api之前添加[self sendNavigationTitle:self.title];
-
在我们的
view controller
中,添加一个处理事件回调的方法:-(didUpdateNavigationTitleBlock)modelDidUpdateNavigationTitle { return ^(NSString *title) { self.title = title; }; }
然后在方法bindToModel里添加self.model.didUpdateNavigationTitle = [self modelDidUpdateNavigationTitle];
- 最后在方法itemPressed添加:
Lastly add [self.model userSelectedCell:data];
, 这样我们的model就可以发送这些事件了,并且我们的新功能也完成了!
扫尾工作
最后,我喜欢写一个便利方法,可以非常方便地将view controller
、ViewModel
与依赖关系结合在一起。
创建** VCTable的一个category/extension,取名为VCTable+Factory**:
//VCTable+Factory.h
@interface VCTable (Factory)
+(instancetype)factoryInstance;
@end
//VCTable+Factory.m
#import "VCTableModel.h"
#import "MyAPI.h"
@implementation VCTable (Factory)
+(instancetype)factoryInstance {
VCTableModel *model = [[VCTableModel alloc] initWithManager:[MyAPI new]];
return [[VCTable alloc] initWithModel:model];
}
@end
在AppDelegate使用#import "VCTable+Factory.h"
替换原来的#import "VCTable.h"
并且更新方法**application:didFinishLaunchingWithOptions: **:
-(BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds];
VCTable *rootViewController = [VCTable factoryInstance];
self.navigationController = [[UINavigationController alloc] initWithRootViewController:rootViewController];
self.window.rootViewController = self.navigationController;
[self.window makeKeyAndVisible];
return YES;
}
现在你可以开始运行它了。
瘦而简单的view controller!
干得好!现在我们的view controller
只负责展示界面而不用关心其他事情了。
在这篇文章里,我向你展示了将业务逻辑从 view controller
中剥离出来是如此的简单,并且我希望这篇MVVM
入门的文章对我来说会非常简单。
Grab the source for our MVVM view controller here.
点此获取本篇相关源代码。