objc:Issue13——Architecture【5】
用VIPER构建iOS应用——by Jeff Gilbert and Conrad Stoll
众所周知,在建筑领域,我们塑造我们的建筑,随后我们的建筑也塑造我们。正如程序员最终知道那样,这也适用于构建软件。
设计我们的代码很重要,这样每一个片段都很容易识别,有特定和明确的目的,以合理的方式同其他片段相配合。这就是我们所谓的软件架构。好的架构不是让产品成功,而是让产品可维护并且帮助维护人员保持一个清晰地思路。
在这篇文章中,我们将介绍一种称之为VIPER的iOS应用架构方案。VIPER
已被用来创建了很多大型的项目,但是为了这篇文章的我们通过创建的一个to-do
列表应用来向你展示VIPER
架构。你可以在GitHub上关注这个示例项目:
视频
VIPER为何物?
测试不总是构建iOS应用程序的主要部分。当我们开始寻求改善Mutual Mobile的测试实践时,我们发现为iOS应用写测试用例很困难。我们决定,如果我们打算改善测试软件的方式,我们首先要想出一个好的方式来构建应用程序。我们把这种方式称为VIPER
。
对iOS程序来说,VIPER
是应用整洁架构(Clean Architecture)的架构模式。单词VIPER
是由视图(View
)、交互器(Interactor
)、展示器(Presenter
)、实体(Entity
)和路由(Routing
)的首字母组合成的。整洁架构把应用逻辑结构划分为不同的职责层。这让依赖分离更加简单(如:你的数据库)并且层边界间的交互也很容易测试。
大多是iOS应用都是使用MVC
(model-view-controller)架构的。使用MVC
作为应用的架构让你认为每一个类既是模型(model
)也是视图(view
)和控制器(controller
)。由于很多应用逻辑都不属于模型(model
)和视图(view
),最后它们都被放在了控制器中。这就导致了一个被称之为大型视图控制器(Massive View Controller)的问题,在这里视图控制器做了太多的工作。为大型视图控制器瘦身不单单是寻求改善代码质量的iOS程序员所面临的挑战,它也是一个很好的开始(改善项目的架构的开始)。
VIPER
的不同层通过为应用逻辑和导航相关的代码提过清晰地位置来应对这一挑战。随着VIPER架构的应用,你会意识到在我们的to-do列表例子中的视图控制器很精简、很平衡,视图控制机(view controlling machines)。你也会发现在视图控制器和其他类中的代码很容易理解和测试,因此也更利于维护。
基于用例的应用设计
应用通常作为一组用例来实现。用例也成为验收标准或者行为,用来描述应用是用来干嘛的?也许列表需要按时间、类型或者名称进行排序。这就是个用例。用例是负责业务逻辑的应用层。用例应该独立于它们的用户界面实现。它们也应该小且易于定义。决定如何把复杂的应用分解成小巧的用例很有挑战性而且需要练习,但对于限制你解决的每一个问题和你写的每一个类的范围非常有用。
使用VIPER构建应用需要实现一系列组件来完成每一个用例。应用逻辑是每一个用例实现的主要部分,但不是唯一的部分。用例同样影响着用户界面。此外,考虑如何让用例与其他核心组件配合很重要,例如网络和数据展示。组件就像用例的插件一样,VIPER描述的是每一个组件等角色是什么和他们是如何同其他组件交互的。
对于我们的代办列表应用,其中一个用例或者需求是用用户选择的不同的方式组织这些代办事项。通过把组织数据的逻辑分离成用例,我们可以保持用户界面代码整洁且易于将用例包装在测试中,以保证它可以如预期的那样继续工作。
VIPER的主要部分
VIPER的主要部分是:
- 视图(
View
):显示展示器让它显示的东西并将用户的输入传回给展示器。 - 交互器(
Interactor
):包含用例指定的业务逻辑 - 展示器(
Presenter
):包含准备展示内容(当从交互器接收到)的逻辑,并对用户的输入进行反馈(通过从交互器请求新数据)。 - 实体(
Entity
):包含交互器使用的基本的模型对象。 - 路由(
Routing
):包含描述哪些界面按照什么样的顺序战士的导航逻辑。
这些拆分遵循单一责任原则。交互器(Interactor
)负责业务分析,展示器负责交互设计,视图负责视觉设计。
下面是不同组件的关系图以及它们是如何连接的:
viper-wireframe
VIPER的不同组件可以以任何顺序在应用中实现,我们选择按照推荐实现的顺序去介绍这些组件。你会发现这个顺序和构建整个应用的过程大概一致,首先是讨论产品需要做什么,然后用户如何与它交互。
交互器(Interactor
)
交互器表示单个应用用例。它包含操作模型对象(Entities
)的业务逻辑去执行特定的任务。交互器中所做的工作应该独立于UI。同样的交互器可以用在iOS应用中或者OSX应用中。
因为交互器是主要包含逻辑的简单对象(PONSO:Plain Old NSObject
),所以使用TDD很容易开发。
这个简单应用的主要用例是展示用户即将到来的代买事项(例如:下星期到期的任何东西)。这个用例的业务逻辑是查询出今天和下周末之间到期的任何待办事项,然后为其指定一个相关的到期时间:今天,明天,本周晚些时候,下周。
下面是来自VTDListInteractor
的相应方法:
- (vodd)findUpcomingItems {
__weak typeof(self) welf = self;
NSDate *today = [self.clock today];
NSDate *endOfNextWeek = [[NSCalendar currentCalendar] dateForEndOfFollowingWeekWithDate: today];
[self.dataManager todoItemsBetweenStartDate:today endDate:endOfNextWeek completionBlock:^(NSArray *todoItems) {
[welf.output foundUpcomingItems:[welf upcomingItemsFromToDoItems:todoItems]];
}];
}
实体(Entity
)
实体是由交互器(Interactor
)操作的模型对象。实体(Entity
)只能由交互器(Interactor
)来操作。交互器(Interactor
)绝不会把实体(Entity
)传递给展示层(如:展示器(Presenter
))。
实体(Entity
)往往也是普通对象。如果你是用Core Data
,你将会希望你的管理对象保持在数据层之后。交互器不应该同NSManagedObjects
一起使用。
下面是我们的待办项实体:
@interface VTDTodoItem: NSObject
@property (nonatomic, strong) NSDate *dueDate;
@property (nonatomic, copy) NSString *name;
+ (instancetype)todoItemWithDueDate:(NSDate *)dueDate name:(NSString *)name;
@end
如果你的实体仅仅只是数据结构请不要大惊小怪。任何应用相关的逻辑大多数都在交互器中。
展示器(Presenter
)
展示器是主要包含驱动UI逻辑的普通对象。它知道何时展示用户界面。它从用户交互中获取输入,所以它可以更新UI并向交互器发送请求。
当用户点击“+”按钮添加新代办事项时,addNewEntry
就被调用了。对于这个方法,展示器要求线框展示用于添加新项的UI:
- (void)addNewEntry {
[self.listWireframe presentAddInterface];
}
展示器也接收来自交互器的结果,并把结果转换为可以在视图中高校展示的表单。
下面是从交互器接收即将到来项目的方法。它会处理数据并决定向用户展示哪些东西:
- (void)foundUpcomingItems:(NSArray *)upcomingItems {
if([upcomingItems count] == 0) {
[self.userInterface showNoContentMessage];
} else {
[self updateUserInterfaceWithUpcomingItems:upcomingItems];
}
}
绝不会把实体从交互器传递到展示器。而是把简单没有行为的数据结构从交互器传到了展示器。这可以防止在展示器中完成任何“实际工作”。展示器只为视图准备展示的数据。
视图(View
)
视图是被动的。它等待展示器给它展示的内容;从不主动向展示器请求数据。为视图定义的方法(如:登陆界面的LoginView
)应该允许展示器在一个较高的抽象层次上与其通信,用其内容展示,而不是如何展示内容。展示器不知道UILabel
、UIButton
等的存在。只知道它持有的内容以及该何时展示。如何展示内容这取决于视图。
视图是一个定义为Objective—C协议的抽象接口。一个视图控制器(UIViewController
)或者其子类将会实现这个视图协议。例如,我们的示例中的添加界面有如下接口:
@protocol VTDAddViewInterface <NSObject>
- (void)setEntryName:(NSString *)name;
- (void)setEntryDueDate:(NSDate *)date;
视图和视图控制器都处理用户交互和输入。这也就不难理解为什么视图控制器总是会变得那么臃肿,因为这里是最容易处理该输入去执行一些动作的地方。为了让视图控制器保证精简,当用户执行确定的动作时我们需要提供一种方式去通知对其感兴趣的部分。视图控制器不能基于这些动作做出决定,但是可以把这些事件传递到可以做决定的地方。
在我们的例子中,“添加”视图控制器具有符合下面接口的事件处理器属性:
@protocol VTDAddModuleInterface <NSObject>
- (void)cancelAddAction;
- (void)saveAddActionWithName:(NSString *)name dueDate:(NSDate *)dueDate;
@end
当用户点击取消按钮,视图控制器告诉用户指定的事件处理器它去次奥了添加动作。那样,事件处理器可以做出如下处理:关闭“添加”视图控制器和通知列表视图更新。
视图和展示器之间的边界是使用ReactiveCocoa的绝佳地方。在这个例子中,视图控制器可以提供方法返回代表按钮动作的信号。这可以让展示器很容易的对这些信号进行响应,而不用破坏职责分离。
路由(Routing
)
由交互设计师设计的线框图定义了从一个界面到另一个界面的路由。在VIPER
中,路由职责由展示器和线框图这两个对象负责。线框图对象拥有UIWindow
、UINavigationController
、UIViewController
等。它负责穿件视图/视图控制器并把它加载到window上。
由于展示器包含响应用户输入的逻辑,所以展示器知道何时导航到其他的界面以及导航到哪个界面。当然,线框图也知道如何导航。因此,展示器将使用线框图执行导航。他们共同描述了一个从一个视图导航到下一个的路由。
线框图也是一个明显的处理导航转场动画的地方。看一下来自于"添加"线框图的例子:
@implementation VTDAddWireframe
- (void)presentAddInterfaceFromViewController:(UIViewController *)viewController {
VTDAddViewController *addViewController = [self addViewController];
addViewController.eventHandler = self.addPresenter;
addViewController.modalPresentationStyle = UIModalPresentationCustom;
addViewController.transitioningDelegate = self;
[viewController presentViewController:addViewController animated:YES completion:nil];
self.presentedViewController = viewController;
}
@end
#pragma mark - UIViewControllerTransitioningDelegate Methods
- (id<UIViewControllerAnimatedTransitioning>)animationControllerForDismissedController:(UIViewController *)dismissed {
return [[VTDAddDismissalTransition alloc] init];
}
- (id<UIViewControllerAnimatedTransitioning>)animationControllerForPresentedController:(UIViewController *)presented presentingController:(UIViewController *)presenting sourceController:(UIViewController *)source {
return [[VTDAddPresentationTransition alloc] init];
}
应用使用的是自定义视图控制器转场去展示“添加”视图控制器。因为线框图负责执行转场动作,所以它成了“添加”视图控制器的转场委托,并返回合适的转场动画。
适用于VIPER的应用组件
iOS应用架构需要考虑到一个事实,UIKit
和Cocoa Touch
是构建应用的主要工具。架构需要同应用中所有的组件和谐共处,但是,这也需要提供参考指南,用来说明框架中的一些模块如何使用以及用在何处。
iOS应用的主力是UIViewController
。我们很容易认为,取代MVC
的竞争者可以避免视图控制器的过度使用。但,视图控制器是平台的核心:它们处理屏幕翻转,响应用户输入,与像导航控制器这样的系统组件组合,现在在iOS7中,也许自定义界面转场动作。非常有用。
使用VIPER,视图控制器执行它应该做的事情:控制视图。我们的代办列表应用有两个视图控制器,一个是列表界面,另一个是“添加”界面。“添加”视图控制制器的实现很基础,因为它所要做的就是控制视图:
@implementation VTDAddViewController
- (void)viewDidAppear:(BOOL)animated {
[super viewDidAppear:animated];
UITapGestureRecognizer *gestureRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(dismiss)];
[self.transitioningBackgroundView addGestureRecognizer:gestureRecognizer];
self.transitioningBackgroundView.userInteractionEnabled = YES;
}
- (void)dismiss {
[self.eventHandler cancelAddAction];
}
- (void)setEntryName:(NSString *)name {
self.nameTextField.text = name;
}
- (void)setEntryDueDate:(NSDate *)date {
[self.datePicker setDate:date];
}
- (IBAction)save:(id)sender {
[self.eventHandler saveAddActionWithName:self.nameTextField.text dueDate:self.datePicker.date];
}
- (IBAction)cancel:(id)sender {
[self.eventHandler cancelAddAction];
}
#pragma mark - UITextFieldDelegate Methods
- (BOOL)textFieldShouldReturn:(UITextField *)textField {
[textField resignFirstResponder];
return YES;
}
@end
当应用连接网络后,通常会更具吸引力。但是联网应该发生在哪里?应该由谁启动它呢?通常的,由交互器决定去启动网络操作,但是它不会直接处理联网代码。它将会请求一个像网络管理器或者API
客户端的依赖。交互器可能需要从多个数据源汇总数据,以提供完成用例所需的信息。然后由展示器接收由交互器返回的数据,并为展示进行格式化。
数据存储负责向交互器提供实体。由于交互器应用其交互逻辑,它需要从数据存储取回实体,处理实体并把更新过的实体放回到数据存储中。数据存储管理持久化的实体。实体不知道数据存储,因此也就不知道如何对自己进行持久化。
交互器也不应该知道如何持久化实体。有时,交互器可能需要使用一个被称为数据管理器的对象去帮助自己同数据存储进行交互。数据管理器处理特定存储类型的操作,像创建获取数据请求,创建查询等。这让交互器更多的关注应用逻辑而不用知道实体是如何获取和持久化实体的。在你使用Core Data
的时候使用数据管理器才是有意义的,你可以在下面看到对他的描述。
这是示例应有的数据管理器接口:
@interface VTDListDataManager: NSObject
@property (nonatomic, strong) VTDCoreDataStore *dataStore;
- (void)todoItemsBetweenStartDate:(NSData *)startDate endDate:(NSDate *)endDate completionBlock:(void (^)(NSArray *todoItems))completionBlock;
@end
但是用TDD
开发交互器时,可以使用测试double/mock
来切换生产数据存储。不与远程服务器(用于web服务)和本地磁盘(用于数据库)进行通信可以让你的测试更快速且更可重复。
把数据存储放在边界明显的层的理由是,它允许你推迟选择特定的持久化技术。如果你的数据存储是单个类,你可以使用使用基本的持久策略启动你的应用,然后在适当的情况下升级到到SQLite
或者Core Date
,而无需更改应用代码库中的其他任何内容。
在iOS项目中使用Core Date
经常会引发比架构自己还要多的争议。然而,在VIPER
中使用Core Date
可以成为你曾经有过的最好的Core Date
使用体验。Core Date
是非常好的数据持久化工具,它有着极快的获取速度和极低的内存占用。但是有一个惯例,就是在应用程序的实现文件中,即使不应该出现,也需要设置繁琐的NSManagedObjectContext
。VIPER
把Core Data
放在了它应该在的地方:数据存储层。
在待办列表例子中,应用仅有的两个部分知道Core Data
正在被使用的是数据存储本身,在这里设置Core Data
堆栈和数据管理器。数据管理器执行获取请求,把数据存储层返回的NSManagedObjects对象转换成标准的简单对象模型,并把它返回给业务逻辑层。这样,应用程序的核心就不会依赖Core Data
,作为回报,你不用担心由于过时或线程有问题的NSManagedObjects
而导致应用无法工作。
在数据管理器中,当请求访问Core Data
存储时,看起来是下面这样:
@implementatin VTDListDataManager
- (void)todoItemsBetweenStartDate:(NSDate *)startDate endDate:(NSDate *)endDate completionBlock:(void (^)(NSArray *todoItems))completionBlock {
NSCalendar *calendar = [NSCalendar autoupdatingCurrentCalendar];
NSPredicate *predicate = [NSPredicate predicateWithFormat:@"(date >= %@) AND (date <= %@)", [calendar dateForBeginningOfDay:startDate], [calendar dateForEndOfDay:endDate]];
NSArray *sortDescriptors = @[];
__weak type(self) welf = self;
[self.dateStore fetchEntriesWithPredicate:predicate sortDescriptors:sortDescriptors completionBlock:^(NSArray *entries){
if(completionBlock) {
completionBlock([welf todoItemsFromDataStoreEntries:entries]);
}
}];
}
- (NSArray *)todoItemsFromDataStoreEntries:(NSArray *)entries {
return [entries arrayFromObjectsCollectedWithBlock:^id(VTDManagedTodoItems *todo) {
return [VTDTodoItem todoItemWithDueDate:todo.date name:todo.name];
}];
}
@end
几乎同Core Data
有同样争议的是UI Storyboards
。Storyboards
有很多使用的特性,完全的忽略它们是一个错误。然而,当使用storyboard
提供的所有特性时,很难实现VIPER的所有目标。
常常,我们做的妥协是选择不使用连线(segues
:storyboard
中controller
之间的连线)。可能存在一些使用连线是有意义的例子,使用连线(segues
)的危险在于,很难保持界面之间、UI和应用逻辑之间的完整分离。一般来说,当明显需要实现prepareForSegue
方法的时候,我们尽量不要使用连线(segues
)。
此外,storyboards
是一种很好的实现用户界面布局的方式,特别是在使用自动布局的时候(Auto Layout
)。待办列表例子中的两个界面我们都是用storyboard
来实现,然后用如下代码去执行我们自己的导航:
static NSString *ListViewControllerIdentifier = @"VTDListViewController";
@implementation VTDListWireframe
- (void)presentListInterfaceFromWindow:(UIWindow *)window {
VTDListViewController *listViewController = [self listViewControllerFromStoryboard];
listViewController.eventHandler = self.listPresenter;
self.listPresenter.userInterface = listViewController;
self.listViewController = listViewController;
[self.rootWireframe showRootViewController:listViewController inWindow:window];
}
- (VTDListViewController *)listViewControllerFromStoryboard {
UIStoryboard *storyboard = [self mainStoryboard];
VTDListViewController *viewController = [storyboard instantiateViewControllerWithIdentifier:ListViewControllerIdentifier];
return viewController;
}
- (UIStoryboard *)mainStoryboard {
UIStoryboard *storyboard = [UIStoryboard storyboardWithName:@"Main" bundle:[NSBundle mainBundle]];
return storyboard;
}
@end
使用VIPER构建模块
通常在使用VIPER的时候,你会发现一个界面或一组界面常常会作为一个模块组织在一起。一个模块可以有几种方式描述,通常的把它作为一个特性来描述是最好的选择。在一个播客应用中,模块可能是一个音频播放器或者订阅浏览器。在我们的待办列表应用中,列表和“添加”界面都构建成了独立的模块。
把你的应用设计成一系列模块有几个好处。其中一个是:模块有着清晰且定义良好的接口,同时独立于其他模块。这使得添加/移除特性或者改变你的接口向用户呈现各种模块的方式。
我们希望在待办列表例子中清晰的区分模块,所以我们为“添加”模块定义了两个协议。第一个是模块接口,这里定义了模块可以做什么。第二个是模块委托,这里描述模块做了什么。例如:
@protocol VTDAddModuleInterface <NSObject>
- (void)cancelAddAction;
- (void)saveAddActionWithName:(NSString *)name dueDate:(NSDate *)dueDate;
@end
@protocol VTDAddModuleDelegate <NSObject>
- (void)addModuleDidCancelAddAction;
- (void)addModelDidSaveAddAction;
@end
由于模块必须得呈现给用户,所以模块通常会实现模块接口。当另外一个接口想展示这个模块时,他的展示器需要实现模块接口协议,这样它就可以知道在展示它时模块做了什么。
模块可能包含用于多个界面的实体、交互器和管理器的通用应用逻辑层。当然,这依赖于这些界面之间的交互和他们之间的相似度。一个模块可以很容易的代表一个界面,正如在待办列表示例中多展示的那样。这种情况下,应用逻辑层可以对应于特定模块中非常具体的行为。
模块也是一种很好的组织代码方式。把一个模块的代码隐藏在自己的文件夹内并且Xcode
中组会让很容易的找到你需要修改的东西。当你在期望的地方找到一个类时,这是一种很棒的感觉。
使用VIPER构建模块的另一个好处是它们很容易扩展到多种形式。在交互层分离所有用例的应用逻辑让你在重用应用层的同时还专注于为平板电脑、手机、和mac电脑构建新的用户界面。
更进一步,iPad应用的用户界面可能会重用iPhone应用的一些视图、视图控制器和展示器。这种情况下,一个iPad界面可能会由父展示器和线框图所代表,它可能会使用已存在的iPhone展示器和线框图组成界面。构建和维护跨平台的应用会相当有挑战性,但是能在整个应用和应用层促进重用的良好架构可以让这变的更容易。
使用VIPER进行测试
VIPER鼓励分离关注点这使得它更容易适应TDD。交互器包含独立于UI的纯逻辑,这使得测试更容易驱动。展示器包含为展示准备数据的逻辑且它独立于任何UIKit
控件。开发这个逻辑也让测试更易驱动。
我们首选的方法从交互器开始。UI中的所有内容都可以满足用例的需要。通过使用TDD为交互器的API去测试驱动,你会更好的理解UI和用例之间的关系。
例如,我们将看到负责即将到来的待办事项列表的交互器。寻找即将到来项的规则是查询出截止到下周结束的所有待办事项并按照截止到今天、明天、本周晚些时候或者下周对每个待办项进行分类。
我们写的第一个例子是保证交互器找出截止到下周结束的所有待办事项:
- (void)testFindingUpcomingItemsRequestsAllToDoItemsFromTodayThroughEndOfNextWeek {
[[self.dataManager expect] todoItemsBetweenStartDate:self.today endDate:self.endOfNextWeek completionBlock:OCMOCK_ANY];
[self.interactor findUpcomingItems];
}
一旦我们知道交互器请求适当的待办事项,我们将会写几个测试方法去确定它把待办事项分配给正确的相关日期组(例如:今天、明天等)。
- (void)testFindingUpcomingItemsWithOneItemDueTodayReturnsOneUpcomingItemsForToday {
NSArray *todoItems = @[[VTDTodoItem todoItemWithDueDate:self.today name:@"Item 1"]];
[self dataStoreWillReturnToDoItems:todoItems];
NSArray *upcomingItems = @[[VTDUpcomingItem upcomingItemWithDateRelation:VTDNearTermDateRelationToday dueDate:self.today title:@"Item 1"]];
[self expectUpcomingItems:upcomingItems];
[self.interactor findUpcomingItems];
}
现在我们知道交互器的API长什么样了,我们可以开发展示器了。当展示器接收到来自交互器的即将到来的待办事项时,我们将要测试我们是否正确的格式化数据并把它显示在UI上:
- (void)testFoundZeroUpcomingItemsDisplaysNoContentMessage {
[[self.ui expect] showNoContentMessage];
[self.presenter foundUpcomingItems:@[]];
}
- (void)testFoundUpcomingItemForTodayDisplaysUpcomingDataWithNoDay {
VTDUpcomingDisplayData *displayData = [self displayDataWithSectionName:@"Today" sectionImageName:@"check" itemTitle:@"Get a haircut" itemDueDay:@""];
[[self.ui expect] showUpcomingDisplayData:displayData];
NSCalendar *calendar = [NSCalendar gregorianCalendar];
NSData *dueData = [calendar dateWithYear:2014 month:5 day:29];
VTDUpcomingItem *haircut = [VTDUpcomingItem upcomingItemWithDateRelation:VTDNearTermDateRelationToday dueDate:dueDate title:@"Get a haircut"];
[self.presenter foundUpcomingItems:@"haircut"];
}
- (void)testFoundUpcomingItemForTomorrowDisplaysUpcomingDataWithDay {
VTDUpcomingDisplayData *displayData = [self displayDataWithSectionName:@"Tomorrow" sectionImageName:@"alarm" itemTitle:@"Buy groceries" itemDueDay:@"Thursday"];
[[self.ui expect] showUpcomingDisplayData:displayData];
NSCalendar *calendar = [NSCalendar gregorianCalendar];
NSDate *dueDate = [calendar dateWithYear:2014 month:5 day:29];
VTDUpcomingItem *groceries = [VTDUpcomingItem upcomingItemWithDateRelation:VTDNearTermDateRelationTomorrow dueDate:dueDate title:@"Buy groceries"];
[self.presenter foundUpcomingItems:@[groceries]];
}
我们也想测试一下,当用户想添加新的待办事项时,应用将开始适当的操作:
- (void)testAddNewToDoItemActionPresentsAddToDoUI {
[[self.wireframe expect] presentAddInterface];
[self.presenter addNewEntry];
}
现在我们可以开发视图了。当没有即将到来的待办事项的时候,我们会展示一个特别的消息:
- (void)testShowingNoContentMessageShowsNoContentView {
[self.view showNoContentMessage];
XCTAssertEqualObjects(self.view.view, self.view.noContentView, @"the no content view should be the view");
}
当有即将到来的待办事项展示时,我想确定列表被展示了出来:
- (void)testShowingUpcomingItemsShowsTableView {
[self.view showUpcomingDisplayData:nil];
XCTAssertEqualObjects(self.view.view, self.view.tableView, @"the table view should be the view");
}
构建交互器首先是与TDD自然的契合。如果你首先开发交互器,然后是展示器,你会在这些层周围构建出一套测试方法,为实现这些用例打下基础。你可以快速的遍历这些类,因为你不需要为了测试他们而与UI进行交互。然后当你开始开发视图的时候,你会有一个可行且经过测试的逻辑还有一个与其连接的展示层。到那时,你完成视图开发,你可能会发现当你第一次运行应用的时候一切工作正常,因为所有你通过的测试都告诉你它会起作用。
结论
我希望你喜欢这篇对VIPER的介绍。现在,你们中的很多人可能想知道下一步怎么做。如果你想用VIPER构建你下一个应用,应该从哪里开始?
这篇文章以及使用VIPER实现的实例应用正和我们能够做到的那样具体且有着良好的定义。我们的待办事项列表应用相当简单,但也非常准确的阐述了怎样使用VIPER构建一个应用。在实际的项目中,你是否严格按照例子去实现依赖于你自己的一系列挑战和约束。根据我们的经验,我们的每一项目都略微的改变了VIPER的使用方式,但是他们都从指导他们的方法中受益匪浅。
出于各种原因,你可能会出现偏离VIPER制定的路线的情况。也许你会遇见一个“兔子”对象,或者你的应用会在Storyboard中使用连线(segues
)受益。没关系,在这些情况下,当你做决定的时候,想一下VIPER所代表的思想。VIPER的核心是一个基于单一责任原则的架构。当在决定如何继续下一步的时候,如果你有疑问可以想一下这个原则。
你可能想知道,如果在已存在的应用中使用VIPER是否可行。在这种情况下,可以考虑构建使用VIEPR构建一个新特性。很多我们已存在的项目都可以采取这种方式。这允许你使用VIPER构建一个模块,并且可以帮助你发现任何已存在的问题,这是这个问题让你很难适应基于单一责任原则的架构。
每一个应用都有所差异这是开发软件最重要的事情之一,并且构建app的方式也不尽相同。对于我们来说,这意味着每一个应用都是一个新的学习和尝试新鲜东西的机会。
Swift补遗
在上周的苹果开发者大会上,苹果介绍了作为未来开发Cocoa和Cocoa Touch的编程语言——Swift。对Swift语言进行深入的点评还为时过早,但是我们知道这个语言对如何设计和构建软件产生了重大的影响。我们决定使用Swift重写我们的VIPER待办示例应用去帮助我们认识这对VIPER意味着什么。目前为止,我们喜欢我们看到的东西。这里有几个我认为可以提高使用VIPER构建应用体验的Swift特性。
Structs
在VIPER中我们使用小且轻量级的模型类在层之间传递数据,如:从展示器到视图。这些普通对象通常只是想简单地携带少量的数据,并不想被子类化。Swift结构能够同这些情况非常完美的契合。下面是一个在VIPER Swift示例中使用结构的例子。注意这个结构需要相等操作,所以我们重载了“==”操作符去比较同类型的两个实例:
struct UpcomingDisplayItem: Equatable, Printable {
let title: String = ""
let dueDate: String = ""
var description: String {
get {
return "\(title) -- \(dueDate)"
}
}
init(title: String, dueDate: String) {
self.title = title
self.dueDate = dueDate
}
}
func ==(leftSide: UpcomingDisplayItem, rightSide: UpcomingDisplayItem) -> Bool {
var hasEqualSections = false
hasEqualSections = rightSide.title == leftSide.title
if hasEqualSections == false {
return false
}
hasEqualSections = rightSide.dueDate == rightSide.dueDate
return haseEqualSections
}
类型安全
也许Object-C和Swift两者最大的区别是对类型的处理。Object-C是动态类型而Swift对在编译时实现类型检查的方式非常严格。对于像VIPER这样的由多个不同层组成的架构来说,类型安全对程序员的效率和总体架构来说是一个巨大的胜利。编译器帮助你确保容器和对象在层边界间进行传递时类型的正确性。如上面所示,这是使用结构的好地方。如果结构想要在两层边界之间生存,多亏了类型安全,你可以保证它将永远不可能从这两层间逃离。