iOS VIPER实现。展示使用协议发现模块和依赖注入。
两个iOS VIPER架构实现,关注于模块化和基于接口的依赖注入。
VIPER的全称是View-Interactor-Presenter-Entity-Router
。示意图如下:
与MVX架构相比,VIPER增加了两个元素:Interactor(交互器)和Router(路由器)。
各部分职责如下:
VIPER将MVC中的Controller进一步拆分为表示器、路由器和交互器。与MVP中负责业务逻辑的表示器不同,VIPER的表示器的主要工作是在视图和交互器之间传递事件,并管理一些视图的展示逻辑,主要的业务逻辑实现代码都放在交互器里。交互器的设计中提出了"用例"的概念,即把每个业务流程封装好,这样可测试性会大大提高。而路由器进一步解决了不同模块之间的耦合。因此,与上述几个MVX相比,VIPER总结出了几个需要维护的东西:
在这其中,还可以进一步细化一些职责。VIPER实际上已经将Controller的概念淡化,这些拆分出来的部分都有很明确的单一职责,有些部分之间是完全隔离的,在开发时应该清晰地定义各自的职责,而不是将它们视为一个Controller。
VIPER的特色是职责明确,粒度细,隔离关系明确,这能带来很多优点:
VIPER架构,最初是在2013年由Jeff Gilbert 和 Conrad Stoll 在MutualMobile的技术博客上提出的。由于博客迁移,原文链接已经失效。以下是迁移后的博文链接:MEET VIPER: MUTUAL MOBILE’S APPLICATION OF CLEAN ARCHITECTURE FOR IOS APPS。
这是文章中提到的架构示意图:
Wireframe可以被视为Router的另一种表达方式。从图中可以看出,VIPER各组件之间的关系已经很明确了。作者随后于2014年在objc.io上发表了另一篇更详细的介绍文章:Architecting iOS Apps with VIPER。
作者的第一篇文章阐述了VIPER是在接触到Uncle Bob的Clean Architecture后,对其进行的一次实践。因此,VIPER的真正源头应该是Clean Architecture。
Uncle Bob于2011年提出的Clean Architecture
是一种平台无关的抽象架构。想要深入学习,可以阅读原作者的《Clean Architecture》:Clean Architecture,其翻译版本为《干净的架构The Clean Architecture》:干净的架构The Clean Architecture。
它通过梳理软件中不同层之间的依赖关系,提出了一种自外向内,单向依赖的架构,如下图所示:
越靠近内层,越变得抽象,越接近设计的核心。越靠近外层,越与具体的平台和实现技术相关。内层的部分完全不关心外层的存在和实现方式,代码只能从外层向内层引用,目的是为了实现层与层之间的隔离。将不同抽象程度的层进行隔离,实现了业务规则与具体实现的分离。可以认为外层是内层的delegate,外层只能通过内层提供的delegate接口来使用内层。
代表了这个软件项目的业务规则,通过数据实体体现,是一些可以在不同的程序应用之间共享的数据结构。
代表了本应用所使用的业务规则。封装并实现了用到的业务功能,会将各种实体的数据结构转换为在用例中传递的实体类,但与具体的数据库技术或UI无关。
接口适配层。将用例的规则与具体的实现技术进行抽象对接,将用例中用到的实体类转换为数据库存储格式或供View显示的格式。类似于MVVM中将Model转换为ViewModel以供View显示。
右下角表示了接口适配层中不同模块间的通信方式。不同的模块在业务用例中产生关联和数据传递。Input、Output就是Use Case提供给外层的数据流接口。
库和驱动器层,代表了选用的各种具体的实现技术,例如持久层使用SQLite还是Core Data,网络层使用NSURLSession、NSURLConnection还是AFNetworking等。
从Clean Architecture中可以看到Use Case、Interactor、Presenter等概念的出现,它们为VIPER的工程实现提供了设计思想。VIPER将这些设计转化为具体的实现。VIPER中的各部分正存在由外向内的依赖,表现为从外向内:View -> Presenter -> Interactor -> Entity
,Wireframe
严格来说也是一类特殊的Use Case,用于不同模块间的通信,连接了不同的Presenter
。
必须记住的是,VIPER架构是根据由外向内的依赖关系来设计的。这句话是指导我们进行进一步设计和优化的关键。
首先总结出一个绝对标准的VIPER,各部分遵循隔离关系,同时考虑到依赖注入、子模块通信、模块间解耦等问题,将VIPER各部分的职责变得更加明确,也新增了几个角色。示例图如下,各角色的颜色与Clean Architecture图中各层的颜色对应:
示例代码将以一个笔记应用作为演示。
View可以是一个UIView + UIViewController,也可以只是一个custom UIView,或者一个自定义的用于管理UIView的Manager,只要它实现了View的接口即可。
View层的职责:
View层会引入各种自定义控件,这些控件有许多代理,它们在View层实现,然后统一封装后交给Presenter层实现。因为Presenter层不知道View的实现细节,所以也就不知道这些控件的接口,Presenter层只知道View层统一暴露出来的接口。而且这些控件的接口在定义时可能会将数据获取、事件回调、控件渲染接口混淆在一起,最具代表性的就是UITableViewDataSource
中的-tableView:cellForRowAtIndexPath:
。这个接口同时涉及到了UITableViewCell
和渲染cell所需要的Model,非常容易产生耦合。因此需要对其进行分解。应该在View的数据源中定义一个从外部获取所需简单类型数据的方法,在-tableView:cellForRowAtIndexPath:
中使用获取到的数据渲染cell。示例代码:
@protocol ZIKNoteListViewEventHandler <NSObject>
- (void)handleDidSelectRowAtIndexPath:(NSIndexPath *)indexPath;
@end
@protocol ZIKNoteListViewDataSource <NSObject>
- (NSInteger)numberOfRowsInSection:(NSInteger)section;
- (NSString *)textOfCellForRowAtIndexPath:(NSIndexPath *)indexPath;
- (NSString *)detailTextOfCellForRowAtIndexPath:(NSIndexPath *)indexPath;
@end
@interface ZIKNoteListViewController () <UITableViewDelegate,UITableViewDataSource>
@property (nonatomic, strong) id<ZIKNoteListViewEventHandler> eventHandler;
@property (nonatomic, strong) id<ZIKNoteListViewDataSource> viewDataSource;
@property (weak, nonatomic) IBOutlet UITableView *noteListTableView;
@end
@implementation ZIKNoteListViewController
- (UITableViewCell *)cellForRowAtIndexPath:(NSIndexPath *)indexPath
text:(NSString *)text
detailText:(NSString *)detailText {
UITableViewCell *cell = [self.noteListTableView dequeueReusableCellWithIdentifier:@"noteListCell" forIndexPath:indexPath];
cell.textLabel.text = text;
cell.detailTextLabel.text = detailText;
return cell;
}
#pragma mark UITableViewDataSource
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
return [self.viewDataSource numberOfRowsInSection:section];
}
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
NSString *text = [self.viewDataSource textOfCellForRowAtIndexPath:indexPath];
NSString *detailText = [self.viewDataSource detailTextOfCellForRowAtIndexPath:indexPath];
UITableViewCell *cell = [self cellForRowAtIndexPath:indexPath
text:text
detailText:detailText];
return cell;
}
#pragma mark UITableViewDelegate
- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
[tableView deselectRowAtIndexPath:indexPath animated:YES];
[self.eventHandler handleDidSelectRowAtIndexPath:indexPath];
}
@end
通常,数据源和事件处理器都是由Presenter来扮演的。当Presenter接收到数据源请求时,从Interactor中获取并返回所需数据。你也可以选择在View和Presenter之间使用ViewModel进行交互。
Presenter由View持有,其职责包括:
Presenter是View和业务之间的中转站,它不包含业务实现代码,而是负责调用现成的各种Use Case,将具体事件转化为具体业务。Presenter中不应导入UIKit,否则就可能侵入View层的渲染工作。Presenter中也不应出现Model类。当数据从Interactor传递到Presenter时,应将其转换为简单的数据结构。
示例代码:
@interface ZIKNoteListViewPresenter ()
@property (nonatomic, strong) id<ZIKNoteListWireframeProtocol> wireframe;
@property (nonatomic, weak) id<ZIKViperView,ZIKNoteListViewProtocol> view;
@property (nonatomic, strong) id<ZIKNoteListInteractorProtocol> interactor;
@end
@implementation ZIKNoteListViewPresenter
#pragma mark ZIKNoteListViewDataSource
- (NSInteger)numberOfRowsInSection:(NSInteger)section {
return self.interactor.noteCount;
}
- (NSString *)textOfCellForRowAtIndexPath:(NSIndexPath *)indexPath {
NSString *title = [self.interactor titleForNoteAtIndex:indexPath.row];
return title;
}
- (NSString *)detailTextOfCellForRowAtIndexPath:(NSIndexPath *)indexPath {
NSString *content = [self.interactor contentForNoteAtIndex:indexPath.row];
return content;
}
#pragma mark ZIKNoteListViewEventHandler
- (void)handleDidSelectRowAtIndexPath:(NSIndexPath *)indexPath {
NSString *uuid = [self.interactor noteUUIDAtIndex:indexPath.row];
NSString *title = [self.interactor noteTitleAtIndex:indexPath.row];
NSString *content = [self.interactor noteContentAtIndex:indexPath.row];
[self.wireframe pushEditorViewForEditingNoteWithUUID:uuid title:title content:content delegate:self];
}
@end
Ineractor的职责:
Interactor是业务实现的践行者和维护者,它会调用各种Service来实现业务逻辑,并封装成明确的用例。这些Service在使用时也是基于接口的,因为Interactor的实现不与具体类绑定,而是由Application注入Interactor所需的Service。
示例代码:
@protocol ZIKNoteListInteractorProtocol <NSObject>
- (void)loadAllNotes;
- (NSInteger)noteCount;
- (NSString *)titleForNoteAtIndex:(NSUInteger)idx;
- (NSString *)contentForNoteAtIndex:(NSUInteger)idx;
- (NSString *)noteUUIDAtIndex:(NSUInteger)idx;
- (NSString *)noteTitleAtIndex:(NSUInteger)idx;
- (NSString *)noteContentAtIndex:(NSUInteger)idx;
@end
@interface ZIKNoteListInteractor : NSObject <ZIKNoteListInteractorProtocol>
@property (nonatomic, weak) id dataSource;
@property (nonatomic, weak) id eventHandler;
@end
@implementation ZIKNoteListInteractor
- (void)loadAllNotes {
[[ZIKNoteDataManager sharedInsatnce] fetchAllNotesWithCompletion:^(NSArray *notes) {
[self.eventHandler didFinishLoadAllNotes];
}];
}
- (NSArray<ZIKNoteModel *> *)noteList {
return [ZIKNoteDataManager sharedInsatnce].noteList;
}
- (NSInteger)noteCount {
return self.noteList.count;
}
- (NSString *)titleForNoteAtIndex:(NSUInteger)idx {
if (self.noteList.count - 1 < idx) {
return nil;
}
return [[self.noteList objectAtIndex:idx] title];
}
- (NSString *)contentForNoteAtIndex:(NSUInteger)idx {
if (self.noteList.count - 1 < idx) {
return nil;
}
return [[self.noteList objectAtIndex:idx] content];
}
- (NSString *)noteUUIDAtIndex:(NSUInteger)idx {
if (self.noteList.count - 1 < idx) {
return nil;
}
return [[self.noteList objectAtIndex:idx] uuid];
}
- (NSString *)noteTitleAtIndex:(NSUInteger)idx {
if (self.noteList.count - 1 < idx) {
return nil;
}
return [[self.noteList objectAtIndex:idx] title];
}
- (NSString *)noteContentAtIndex:(NSUInteger)idx {
if (self.noteList.count - 1 < idx) {
return nil;
}
return [[self.noteList objectAtIndex:idx] content];
}
@end
为Interactor提供各种封装好的服务,例如数据库访问、存储和定位功能等。Service在执行路由时由Application注入到Builder中,再由Builder注入到Interactor中。也可以只注入一个Service Router,这样在运行时通过这个Service Router来动态加载所需的Service,相当于注入了一个提供路由功能的Service。
Service可以被视为没有View的VIPER,它也有自己的路由和Builder。
翻译成中文为线框,用于表达从模块到另一个模块的过程。尽管它也担任路由角色的扮演者,但实际上它与Router有所不同。
Wireframe类似于Storyboard中已连接的一个个segue,负责提供一系列具体的路由用例。这些用例中已配置了源界面和目的界面的一些依赖,包括转场动画、模块间传参等。Wireframe的接口是供内部模块使用的,它通过调用Router来执行真正的路由操作。
示例代码:
@interface ZIKTNoteListWireframe : NSObject <ZIKTViperWireframe>
- (void)presentLoginViewWithMessage:(NSString *)message delegate:(id<ZIKTLoginViewDelegate>)delegate completion:(void (^ __nullable)(void))completion;
- (void)dismissLoginView:(UIViewController *)viewController animated:(BOOL)animated completion:(void (^ __nullable)(void))completion;
- (void)presentEditorForCreatingNewNoteWithDelegate:(id<ZIKTEditorDelegate>)delegate completion:(void (^ __nullable)(void))completion;
- (void)pushEditorViewForEditingNoteWithUUID:(NSString *)uuid title:(NSString *)title content:(NSString *)content delegate:(id<ZIKTEditorDelegate>)delegate;
- (UIViewController *)editorViewForEditingNoteWithUUID:(NSString *)uuid title:(NSString *)title content:(NSString *)content delegate:(id<ZIKTEditorDelegate>)delegate;
- (void)pushEditorViewController:(UIViewController *)destination fromViewController:(UIViewController *)source animated:(BOOL)animated;
- (void)quitEditorViewWithAnimated:(BOOL)animated;
@end
Router是Application提供的具体路由技术,它可以简单封装UIKit中的跳转方法,也可以使用URL Router来执行路由。但一个模块不需要知道app使用了什么样的具体技术。Router才是真正连接各个模块的地方。它还负责查找对应的目的模块,并通过Builder进行依赖注入。
示例代码:
@interface ZIKTRouter : NSObject <ZIKTViperRouter>
///封装UIKit的跳转方法
+ (void)pushViewController:(UIViewController *)destination fromViewController:(UIViewController *)source animated:(BOOL)animated;
+ (void)popViewController:(UIViewController *)viewController animated:(BOOL)animated;
+ (void)presentViewController:(UIViewController *)viewControllerToPresent fromViewController:(UIViewController *)source animated:(BOOL)animated completion:(void (^ __nullable)(void))completion;
+ (void)dismissViewController:(UIViewController *)viewController animated:(BOOL)animated completion:(void (^ __nullable)(void))completion;
@end
@implementation ZIKTRouter (ZIKTEditor)
+ (UIViewController *)viewForCreatingNoteWithDelegate:(id<ZIKTEditorDelegate>)delegate {
return [ZIKTEditorBuilder viewForCreatingNoteWithDelegate:delegate];
}
+ (UIViewController *)viewForEditingNoteWithUUID:(NSString *)uuid title:(NSString *)title content:(NSString *)content delegate:(id<ZIKTEditorDelegate>)delegate {
return [ZIKTEditorBuilder viewForEditingNoteWithUUID:uuid title:title content:content delegate:delegate];
}
@end
由Application实现,负责在模块通信时进行一些接口的转换,例如两个模块使用了相同业务功能的某个Service,使用的protocol实现一样,但protocol名称不同,就可以在路由时,在Adapter中进行一次转换。甚至只要定义的逻辑相同,依赖参数的名字和数据类型也可以允许不同。这样就能让模块不依赖于某个具体的protocol,而是依赖于protocol实际定义的依赖和接口。
注意这里的Adapter与Clean Architecture中的Interface Adapter
是不同的。这里的Adapter就是字面意义上的接口转换,而Clean Architecture中的Interface Adapter
则是更加抽象的一层,它是Use Case层与具体实现技术之间的转换,包含了更多的角色。
负责初始化整个模块,配置VIPER之间的关系,并声明模块所需的依赖,以便外部注入。
一个VIPER模块可以被看作是一个独立的组件,可以单独被封装成一个库,并由app引用。这时,app负责将各个模块连接起来,也就是图中的灰色部分Application Context
。一个模块肯定是存在于某个上下文环境中才能运行的。
Wireframe
-> Router
-> Adapter
-> Builder
实现了模块间完整的路由,并实现了模块间的解耦。
其中Wireframe和Builder分别由引用者模块和被引用模块提供,是两个模块的出口和入口,而Router和Adapter则是由模块的使用者——Application实现的。
当两个模块之间存在引用关系时,意味着存在业务逻辑上的耦合,这种耦合是业务的一部分,是不可能消除的。我们能做的就是把耦合尽量交给模块调用者,由Application提供具体的类并注入到各个模块中,而模块内部仅面向protocol即可。这样,被引用模块只需实现相同的接口,就可以随时替换,甚至当接口有细微差异时,只要被引用模块提供了相同功能的接口,也可以通过Adapter进行接口兼容转换,以便让引用者模块无需进行任何修改。
Wireframe相当于插头,Builder相当于插座,而Router和Adapter相当于电路和转接头,将不同规格的插座和插头连接起来。将这些连接和适配工作交给Application层,就可以让两个模块实现独立。
大部分方案都没有讨论子模块的情况。在VIPER中如何引入另一个VIPER模块?多个模块之间如何交互?子模块由谁初始化、由谁管理?
在其他几个实现中,只有Uber详细讨论了子模块的问题。在Uber的Riblets架构中,子模块的Router被添加到父模块的Router中,模块之间通过delegate和监听进行通信。这样做会导致模块间产生一定的耦合。如果子模块是由于父View使用了一个子View控件而被引入的,那么父Interactor的代码里会多出一个子Interactor,这样就会导致View的实现方式影响了Interactor的实现。
子模块的来源包括:
子View可能是一个UIView,也可能是一个Child UIViewController。因此子View可能需要向外部请求数据,也可能独立完成所有任务,不需要依赖父模块。
如果子View可以独立,那么在子模块中不会出现与父模块交互的逻辑,只有通过输出接口传递一些事件。这时只需要将子View的接口封装在父View的接口中即可,父Presenter和父Interactor并不知道父View提供的这些接口是通过子View实现的。这样父模块就能接收到子模块的事件,并且能保持Interactor和Presenter、View之间从低到高的依赖关系。
如果父模块需要调用子模块的某些功能或从子模块获取数据,可以选择将这些操作封装在父View的接口中,但如果涉及到数据模型,并且不想让数据模型出现在View的接口中,可以将子Interactor作为父Interactor的一个Service,在引入子模块时,通过父Builder注入到父Interactor中,或者根据依赖关系进行彻底解耦,将其注入到父Presenter中,让父Presenter再将接口转发给父Interactor。这样子模块和父模块就可以通过Service的形式进行通信了,而这时,父Interactor也不知道这个Service来自子模块。
在这种设计下,子模块和父模块都不知道彼此的存在,仅仅是通过接口进行交互。好处是,如果父View想要更换为另一个相同功能的子View控件,只需要在父View中进行修改,不会影响Presenter和Interactor。
这个VIPER的设计通过接口将各个部分组合在一起,一个类需要配置很多依赖,例如Interactor需要依赖多个Service。这就涉及到两个问题:
在这个方案中,由Builder声明整个模块的依赖,然后在Builder内部为不同的类设置依赖,外部在注入依赖时,就不必知道内部如何使用这些依赖参数。如果一个类有必需的依赖参数,可以在init方法中直接体现,而对于非必需的依赖,可以通过暴露接口来声明。
如果需要动态注入,而不是在模块初始化时就配置所有依赖,Builder也可以提供动态注入的接口。
如果你需要将一个模块从MVC重构到VIPER,可以按照以下步骤进行:
下面是第一步中可以在Controller中分隔的职责:
@implementation ViewController
//------View-------
//View的生命周期
#pragma mark View life
//View的配置,包括布局设置
#pragma mark View config
//更新View的接口
#pragma mark Update view
//View需要从model中获取的数据
#pragma mark Request view data source
//监控、接收View的事件
#pragma mark Send view event
//------Presenter-------
//处理View的事件
#pragma mark Handle view event
//界面跳转
#pragma mark Wireframe
//向View提供配置用的数据
#pragma mark Provide view data source
//提供生成model需要的数据
#pragma mark Provide model data source
//处理业务事件,调用业务用例
#pragma mark Handle business event
//------Interactor-------
//监控、接收业务事件
#pragma mark Send business event
//业务用例
#pragma mark Business use case
//获取生成model需要的数据
#pragma mark Request data for model
//维护model
#pragma mark Manage model
@end
这里缺少了View状态管理、业务状态管理等职责,因为这些状态通常都是@property,用pragma mark不能分隔它们,只能在@interface中声明时进行隔离。
上述方案旨在实现完全解耦,但在实际应用中,完全遵循此设计会导致代码量巨大。实际上,某些地方的耦合并不会引起太大问题,除非你的模块需要封装成通用组件以供多个应用使用。因此,我们将进一步简化方案,总结出哪些地方可以出现耦合,哪些耦合是不允许的。架构图如下:
UITableViewDataSource
),无需再次封装后交给Presenter。路由部分变化最大。View、Presenter和Interactor都可以通过路由获取某些模块。View可以通过路由获取子View,Presenter可以通过路由获取其他View模块,Interactor可以通过路由获取Service。
在实现时,可以将Wireframe、Router、Builder整合在一起,全部放到Router里,由模块实现并提供给外部使用。类似于Brigade团队和Rambler&Co团队的实现。但他们的实现是在Router中直接引入其他模块的Router,这样会导致依赖混乱,更好的方式是通过一个中间人提供其他模块的接口。
我在这里创建了一个轮子,通过protocol来寻找需要的模块并执行路由,无需直接导入目标模块中的类,并且提供了Adapter的支持,让多个protocol指向同一个模块。这样就能避免模块间的直接依赖。
示例代码:
///editor模块的依赖声明
@protocol NoteEditorProtocol <NSObject>
@property (nonatomic, weak) id<ZIKEditorDelegate> delegate;
- (void)constructForCreatingNewNote;
- (void)constructForEditingNote:(ZIKNoteModel *)note;
@end
@implementation ZIKNoteListViewPresenter
- (void)handleDidSelectRowAtIndexPath:(NSIndexPath *)indexPath {
NSAssert([[self.view routeSource] isKindOfClass:[UIViewController class]], nil);
//跳转到编辑器界面;通过protocol获取对应的router类,再通过protocol注入依赖
//App可以用Adapter把NoteEditorProtocol和真正的protocol进行匹配和转接
[ZIKViewRouterForConfig(@protocol(NoteEditorProtocol))
performWithConfigure:^(ZIKViewRouteConfiguration<NoteEditorProtocol> *config) {
//路由配置
//跳转的源界面
config.source = [self.view routeSource];
//设置跳转方式,支持所有界面跳转类型
config.routeType = ZIKViewRouteTypePush;
//Router内部负责用获取到的参数初始化editor模块
config.delegate = self;
[config constructForEditingNote:[self.interactor noteAtIndex:indexPath.row]];
config.prepareForRoute = ^(id destination) {
//跳转前配置目的界面
};
config.routeCompletion = ^(id destination) {
//跳转结束处理
};
config.performerErrorHandler = ^(SEL routeAction, NSError * error) {
//跳转失败处理
};
}];
}
@end
此方案依赖于一个统一的中间人,即路由工具。在我的实现中,就是ZIKRouter。View、Presenter、Interactor都可以使用对应功能的Router获取子模块。由于ZIKRouter仍然是通过protocol的方式与子模块交互,因此仍然维持模块间解耦。唯一的耦合就是各部分都引用了ZIKRouter这个工具。如果你想将模块与ZIKRouter的耦合也去除,可以让Router也变成面向接口,由外部注入。
针对两个方案,同时编写了两个具有相同功能的Demo,可以比较一下代码上的区别。项目中同样提供了Xcode File Template用于快速生成VIPER代码模板。将.xctemplate
后缀的文件夹复制到~/Library/Developer/Xcode/Templates/
目录下,在Xcode的New->File->Template
中即可选择代码模板快速生成代码。