E444d1b77102087b9b8c1891f7283ecf
iOS VIPER 架构实践(二):VIPER 详解与实现

第一篇文章对VIPER进行了简单的介绍,这篇文章将从VIPER的源头开始,比较现有的几种VIPER实现,对VIPER进行进一步的职责剖析,并对各种细节实现问题进行挖掘和探讨。最后给出两个完整的VIPER实现,并且提供快速生成VIPER代码的模板。

Demo和轮子的github地址是:ZIKViper,路由工具:ZIKRouter。有用请点个star~
注意,Demo需要先用pod install安装一下依赖库。

两个实现展示了以下问题的解决方案:

  • 如何彻底地解决不同模块之间的耦合
  • 如何在一个模块里引入子模块
  • 子模块和父模块之间如何通信
  • 如何对模块进行依赖注入
  • 面向接口的路由工具

目录

  • 起源
  • Clean Architecture
    • Enterprise Business Rules
    • Application Business Rules
    • Interface Adapters
    • Frameworks & Drivers
    • 总结
  • 现有的各种VIPER实现
    • Brigade团队的实现
      • 争议
    • Rambler&Co团队的实现
      • 争议
    • Uber团队的实现
      • 各部分职责
      • 数据驱动
      • 争议
      • 其他设计
  • 方案一:最完整的VIPER
    • View
    • Presenter
    • Interactor
    • Service
    • Wireframe
    • Router
    • Adapter
    • Builder
  • 模块间解耦
  • 子模块
    • 子模块的来源
    • 通信方式
  • 依赖注入
  • 映射到MVC
  • 方案二:允许适当耦合
    • View
    • Presenter
    • Interactor
    • 路由和依赖注入
    • 总结
  • Demo和代码模板
  • 参考

起源

VIPER架构,最初是2013年在MutualMobile的技术博客上,由Jeff Gilbert 和 Conrad Stoll 提出的。他们的博客网站有过一次迁移,原文地址已经失效,这是迁移后的博文:MEET VIPER: MUTUAL MOBILE’S APPLICATION OF CLEAN ARCHITECTURE FOR IOS APPS

这是文章中提出的架构示意图:

viper-mutualmobile

Wireframe可以看作是Router的另一种表达。可以看到,VIPER之间的关系已经很明确了。之后,作者在2014年在objc.io上发表了另一篇更详细的介绍文章:Architecting iOS Apps with VIPER

在作者的第一篇文章里,阐述了VIPER是在接触到了Uncle Bob的Clean Architecture后,对Clean Architecture的一次实践。因此,VIPER真正的源头应该是Clean Architecture。

Clean Architecture

由Uncle Bob在2011年提出的Clean Architecture,是一个平台无关的抽象架构。想要详细学习的,可以阅读作者的原文:Clean Architecture,翻译:干净的架构The Clean Architecture

它通过梳理软件中不同层之间的依赖关系,提出了一个自外向内,单向依赖的架构,如下图所示:

Clean Architecture

越靠近内层,越变得抽象,越接近设计的核心。越靠近外层,越和具体的平台和实现技术相关。内层的部分完全不知道外层的存在和实现方式,代码只能从外层向内层引用,目的是为了实现层与层之间的隔离。将不同抽象程度的层进行隔离,做到了把业务规则和具体实现分离开。你可以把外层看作是内层的delegate,外层只能通过内层提供的delegate接口来使用内层。

Enterprise Business Rules

代表了这个软件项目的业务规则。由数据实体体现,是一些可以在不同的程序应用之间共享的数据结构。

Application Business Rules

代表了本应用所使用的一些业务规则。封装和实现了用到的业务功能,会将各种实体的数据结构转为在用例中传递的实体类,但是和具体的数据库技术或者UI无关。

Interface Adapters

接口适配层。将用例的规则和具体的实现技术进行抽象地对接,将用例中用到的实体类转为供数据库存储的格式或者供View展示的格式。类似于MVVM中把Model的数据传递给ViewModel供View显示。

右下角表示了接口适配层中不同模块间的通信方式。不同的模块在业务用例中产生关联和数据传递。Input、Output就是Use Case提供给外层的数据流动接口。

Frameworks & Drivers

库和驱动层,代表了选用的各种具体的实现技术,例如持久层使用SQLite还是Core Data,网络层使用NSURLSession、NSURLConnection还是AFNetworking等。

总结

可以看到,Clean Architecture里已经出现了Use Case、Interactor、Presenter等概念,它为VIPER的工程实现提供了设计思想,VIPER将它的设计转化成了具体的实现。VIPER里的各部分正是存在着由外向内的依赖,从外向内表现为:View -> Presenter -> Interactor -> EntityWireframe严格来说也是一类特殊的Use Case,用于不同模块之间通信,连接了不同的Presenter

必须要记住的是,VIPER架构是根据由外向内的依赖关系来设计的。这句话是指导我们进行进一步设计和优化的关键。

现有的各种VIPER实现

MutualMobile的那两篇文章虽然已经明确了VIPER各部分之间的职责,并且给出了简单的Demo,但是对Wireframe部分的实现有些争议,解耦做得不够彻底,并且对各层之间如何交互还处在最简单的实现上。之后出现了挺多文章来将VIPER进一步细化,不过某些细节的实现上有些差别,在给出我自己的VIPER之前,我将先对这些实现进行一次综合的比较分析,看看他们都使用了哪些技术,遇到了哪些争议点。不同实现之间已经公认的地方我就不再单独列出了。

Brigade团队的实现

原文地址:Brigade’s Experience Using an MVC Alternative: VIPER architecture for iOS applications

文章把VIPER的优点总结了一下,提出了这样的架构图:

Brigade’s VIPER

他们对VIPER的各部分都没有异议,只是对Interactor的实现进行了进一步细化。用一个Data Manager提供给各个Use Case管理Entity,比如获取、存储功能。在Service中调用网络层去获取服务端的数据。

文章中还认为应该由Wireframe负责初始化整个VIPER,生成各部分的类,并设置依赖关系,并且引用另一个模块的Wireframe,负责跳转到另一个界面。

和这个实现类似的还有:

针对VIPER需要编写太多初始化代码的麻烦,可以使用Xcode自带的Template解决。而很多作者都提到了一个代码生成工具:Generamba

争议

文章并没有对VIPER进行修改,只是进一步细化了。这应该是一个最简单的实现。如果你要实施VIPER,参照这篇文章来没有什么大问题。但是它没有探讨的问题是:

  • 如何解决不同Wrieframe之间的耦合?
  • Wrieframe如何知道其他模块需要的初始化参数?
  • 在模块间通信时,Interactor的数据如何传递给另一个模块?
  • 父模块和子模块之间是怎样的关系?

Rambler&Co团队的实现

一个对VIPER十分感兴趣的俄国团队,编写了一本关于VIPER的书:The-Book-of-VIPER。并且给出了一个目前网络上实现完成度最高的开源Demo:rambler-it-ios,以及他们用于实施VIPER的库:ViperMcFlurry

他们整理的VIPER架构图如下:

Rambler&Co's VIPER

和其他实现不同的是,他们把VIPER的初始化和装配工作单独放到了一个Assembly里,Router只做界面跳转的工作。并且把VIPER内不同部分之间的通信统一用Input和Output来表示。Input表示外部主动调用模块提供的接口,Output表示模块通过外部实现所要求的接口,将事件传递到外部。

之所以将模块初始化单独放到Assembly里,是因为Router如果负责初始化本模块,会违背单一职责原则。

争议

这个实现的愿景很好,只是在转变为具体实现的时候不够完美,有很多问题尚待解决。具体可以参见Demo。

  • Assembly使用了Typhoon这个依赖注入工具,通过Method Swizzling自动初始化VIPER的各个部分

我对Typhoon这个依赖注入工具不是特别感冒,它使用了十分复杂的run time技术,想要追踪一个对象的注入过程时,会看得晕头转向。而且它无法实现运行时由调用方动态注入,只能实现预定义好的静态注入。也就是不能动态传参。

  • 使用storyboard进行路由

在Demo中实现了在执行segue时用block来使用-prepareForSegue:sender:,实现向目的界面传参,实现了动态注入。但是这样就把路由限定在了storyboard的segue技术上,那么对于那些没有使用storyboard的项目应该怎么办呢?Demo并没有给出答案。而且-prepareForSegue:sender:只能向View传参,但是有一些参数是View不应该接触到的,而是应该直接传给Presenter或者Interactor的。

  • 有时候模块需要从Output中获取数据,例如Presenter主动获取View中的文字,传递给Interactor,此时Output并不能完整描述它的职责,还可以再进一步划分

也就是说,他们的方案在设计上是不错的,但在技术上还有很多改进空间。

Uber团队的实现

Uber由于业务越来越复杂,旧项目的架构已经无法满足当前的需求,因此在2016年完全重构了他们的 rider app。他们借鉴VIPER,并且设计出了一个VIPER的变种架构:Riblets。文章地址:ENGINEERING THE ARCHITECTURE BEHIND UBER’S NEW RIDER APP

架构图如下:

riblets

数据流向图:

数据流向

父模块和子模块之间通信:

父子模块间通信

各部分职责

这里只列出一些和VIPER有差异的地方:

  • Builder负责初始化Riblets模块内的各个部分,定义了模块的依赖参数
  • Component负责获取和初始化那些不是Riblets模块内的部分,例如services,并注入到Interactor中
  • Router负责管理子模块,持有子模块的Router,并把子模块的View添加到视图树上
  • Interactor通过调用Service管理Model,而不是在Interactor中直接管理
  • Interactor和子模块的Interactor通过监听者模式和delegate互相通信

数据驱动

最大的改变是将Router从Presenter移到了Interactor,改变了模块的主从关系,整个模块的生命周期现在由Interactor来管理。而之前的VIPER模块是依赖于View的生命周期的。这样一来,整个架构就从View驱动变成了业务驱动,或者数据驱动。

关于这个改变,Uber给出了两个原因:

  • 想要统一iOS和Andorid的软件架构,以及更好地互相借鉴开发经验和教训,因而需要改变iOS中视图驱动的设计
  • 想要创建一个没有View,只有业务逻辑的模块,因此生命周期需要由Interactor管理

争议

Uber团队的确很有想法。在对他们的这个方案进行深入实践之前,我无法评论这个方案是好是坏,我只在这里提出一些实践中可能会遇到的问题。

关于Uber给出的第一个原因,这是Uber团队基于协调两个开发团队的情况而做出的选择,如果我们没有他们这样统一开发的需求,并没有必要借鉴。iOS的UIKit是一个视图驱动的框架,很难做到100%数据驱动,在实践中将会遇到许多需要解决的问题,除非有足够的开发时间,否则不要草率地投入其中。是否要使用数据驱动的设计,还是应该由项目的业务设计来决定。当数据变化大部分是由后端的Service和网络数据引起时,再去考虑数据驱动吧。例如Uber的地图路线由定位模块不断计算,自动更新,就比较适合使用数据驱动。

关于第二个原因,一个没有View和Presenter的VIPER,就只剩下Router、Interactor、Model,这时这个模块可以看做是一个可以通过Router调用的Service或者Manager,这个Service有自己的状态和生命周期,Service也可以在View销毁后继续完成剩余的业务工作,只要业务需要,可以进行自持有,自释放。而且这个Service最终还是会表现在某个View上。这么看来,Router的层级已经升高了,成为了整个app内的模块间通信工具,可以连接任意模块,不仅仅是VIPER,因此Router由谁持有,就完全由模块内部自由管理了。

只是,在iOS中的VIPER里,实际的路由API都是存在于UIViewController上的,Router会直接和View产生引用,把Router放到和View隔离的Interactor里会破坏隔离。而且从Clean架构的分层来看,层级升高后的Router应该是处在Interface Adapter层和Framework & Driver层之间,而Interactor则是在Application Business Rules层,由Interactor来管理其他角色,会破坏了Clean Architecture里的依赖关系。

比如一个没有View的、用于管理语音通话数据的Interactor,收到了通话异常中断的事件,在处理事件时,它不应该通过Router将自己移除,或者结束整个语音通话业务,或者自动调用重新拨号的业务,这样很容易会让不同的Use Case之间产生耦合,这些都应该由更上层的Service去选择执行,如果有页面跳转的设计,则应该把事件转发给一个存在Presenter层的Parent VIPER模块,由parent来决定是退出通话界面还是弹窗提示。当一个Interactor没有Presenter和View时,它一定是另一个VIPER的子模块。这么看来,在没有View时,或许让Service来持有Router才是正确的。

因此,如果真的有把VIPER变成数据驱动的需求,主要还是源于Uber给出的第一个基于团队统一的理由。

其他设计

文章里还给出了一些很有参考价值的内容,比如:

  • 对Interactor进行注入的Component
  • 视图树变成了Router树
  • Interactor不直接维护Model,而是通过对应的Service来维护Model
  • 父模块和子模块之间通过Interactor来通信

Uber的这个方案讲了很多其他方案没有提到的方面,比如依赖注入、如何引入子模块等问题。不过这个方案并没有开源。

方案一:最完整的VIPER

首先总结出一个绝对标准的VIPER,各部分遵循隔离关系,同时考虑到依赖注入、子模块通信、模块间解耦等问题,将VIPER的各部分的职责变得更加明确,也新增了几个角色。示例图如下,各角色的颜色和Clean Architecture图中各层的颜色对应:

thorough viper

示例代码将用一个笔记应用作为演示。

View

View可以是一个UIView + UIViewController,也可以只是一个custom UIView,也可以是一个自定义的用于管理UIView的Manager,只要它实现了View的接口就可以。

View层的职责:

  • 展示界面,组合各种UIView,并在UIViewController内管理各种控件的布局、更新
  • View对外暴露各种用于更新UI的接口,而自己不主动更新UI
  • View持有一个由外部注入的eventHandler对象,将View层的事件发送给eventHandler
  • View持有一个由外部注入的viewDataSource对象,在View的渲染过程中,会从viewDataSource获取一些用于展示的数据,viewDataSource的接口命名应该尽量和具体业务无关
  • View向Presenter提供routeSource,也就是用于界面跳转的源界面

View层会引入各种自定义控件,这些控件有许多delegate,都在View层实现,统一包装后,再交给Presenter层实现。因为Presenter层并不知道View的实现细节,因此也就不知道这些控件的接口,Presenter层只知道View层统一暴露出来的接口。而且这些控件的接口在定义时可能会将数据获取、事件回调、控件渲染接口混杂起来,最具代表性的就是UITableViewDataSource里的-tableView:cellForRowAtIndexPath:。这个接口同时涉及到了UITableViewCell和渲染cell所需要的Model,是非常容易产生耦合的地方,因此需要做一次分解。应该在View的dataSource里定义一个从外部获取所需要的简单类型数据的方法,在-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

一般来说,viewDataSource和eventHandler都是由Presenter来担任的,Presenter接收到dataSource请求时,从Interactor里获取并返回对应的数据。你也可以选择在View和Presenter之间用ViewModel来进行交互。

Presenter

Presenter由View持有,它的职责有:

  • 接收并处理来自View的事件
  • 维护和View相关的各种状态和配置,比如界面是否使用夜间模式等
  • 调用Interactor提供的Use Case执行业务逻辑
  • 向Interactor提供View中的数据,让Interactor生成需要的Model
  • 接收并处理来自Interactor的业务事件回调事件
  • 通知View进行更新操作
  • 通过Wireframe跳转到其他View

Presenter是View和业务之间的中转站,它不包含业务实现代码,而是负责调用现成的各种Use Case,将具体事件转化为具体业务。Presenter里不应该导入UIKit,否则就有可能入侵View层的渲染工作。Presenter里也不应该出现Model类,当数据从Interactor传递到Presenter里时,应该转变为简单的数据结构。

示例代码:

  • @interface ZIKNoteListViewPresenter () <ZIKNoteListViewDataSource, ZIKNoteListViewEventHandler>
  • @property (nonatomic, strong) id<ZIKNoteListWireframeProtocol> wireframe;
  • @property (nonatomic, weak) id<ZIKViperView,ZIKNoteListViewProtocol> view;
  • @property (nonatomic, strong) id<ZIKNoteListInteractorInput> 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

Interactor

top Created with Sketch.