【编者按】本篇文章由 Jeff Gilbert 和 Conrad Stoll 共同编写,通过构建一个基础示例应用,深入了解 VIPER,并从视图、交互器等多个部件理清 VIPER 的整体布局及思路。通过 VIPER 构建 iOS 应用架构,提升应用质量,迎接应用构建的新机遇!本文系 OneAPM 工程师编译整理,这是本系列的第 2 篇文章。

用 VIPER 构建 iOS 应用架构(1)

UIViewController 的确相当有用。

在 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 客户。交互器可以聚集来自多个源的数据,提供实现用例的所需信息。然后就看显示器采集交互器反馈的数据,并格式化用于显示。

数据存储器负责为交互器提供实体。当交互器应用其业务逻辑时,它将从数据存储器中检索实体、操纵实体,然后将更新的实体返回数据存储器。数据存储可以管理实体的持久性,但实体却不知道数据存储,因此更不知道如何坚持自身的持久性。

交互器也不知道如何将实体持久化。有时交互器可能使用名为数据管理器的对象类型,以促进与数据存储器的交互。数据管理器处理多个操作的特定存储类型,如创建提取请求、建立查询等。这使得交互器更专注于应用程序的逻辑,而无需知道实体如何聚集或持续。下面的例子就是说明数据管理器的意义。

这是示例应用的数据管理器接口:

@interface VTDListDataManager : NSObject

@property (nonatomic, strong) VTDCoreDataStore *dataStore;

- (void)todoItemsBetweenStartDate:(NSDate *)startDate endDate:(NSDate *)endDate completionBlock:(void (^)(NSArray *todoItems))completionBlock;

@end

当使用 TDD 开发交互器时,能切换出生产带测试双/模拟的数据存储器。避免远程服务器(Web服务)或触摸盘(数据库)可以使测试更快速,增强其复用性。

保持数据存储作为有明确界限的独立层的原因之一,在于它可以让你推迟选择一个特定的持久化技术。如果你的数据存储器是一个单独的类,你可以用基本的持久化策略来搭建应用,以后待需要时再升级到 SQLite 或核心数据,而不需要对应用代码库进行任何改变。

在 iOS 的项目中使用核心数据往往能激发比架构本身更大的争议。但是,在 VIPER 中使用核心数据可能是最好的核心数据体验。在持久化数据方面,核心数据是保持快速存取和低内存占用的绝佳工具。但它有个缺陷:itsNSManagedObjectContext 像触须似的贯穿所有应用的执行文件,特别是在一些它们不应该出现的地方。 VIPER 则可以保持核心数据出现在正确的地方——数据存储层。

在待办事项示例中,仅有应用程序的两个部件知道核心数据正在使用,其一是数据存储本身,其中建立核心数据堆栈;其二则是数据管理器。数据管理器执行读取请求,将数据存储所返回的 theNSManagedObjects,转换成标准 PONSO 模型对象,并返回至业务逻辑层。这样,应用程序的核心不再依赖核心数据,另一个好处是,你永远不用担心过去数据或组织很乱的 NSManagedObjects 来破坏你的成果。

当通过请求访问核心数据存储时,数据管理器执行如下代码:

@implementation 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 typeof(self) welf = self;
    [self.dataStore
     fetchEntriesWithPredicate:predicate
     sortDescriptors:sortDescriptors
     completionBlock:^(NSArray* entries) {
         if (completionBlock)
         {
             completionBlock([welf todoItemsFromDataStoreEntries:entries]);
         }
     }];
}

- (NSArray*)todoItemsFromDataStoreEntries:(NSArray *)entries
{
    return [entries arrayFromObjectsCollectedWithBlock:^id(VTDManagedTodoItem *todo) {
        return [VTDTodoItem todoItemWithDueDate:todo.date name:todo.name];
    }];
}

@end

像核心数据一样引起争议的是用户界面故事板。故事板有许多不容忽视的功能。然而,同时采用故事板的所有功能也难以实现 VIPER 的所有目标。

因此,我们往往退一步选择不使用 segues。在某些情况下,使用 segues 是很有意义的,但伴随着 segues 的风险,是难以原封不动地保持界面的独立,以及用户界面和应用程序逻辑之间的分离。一般来说,如果必须实施 prepareForSegue 方法,我们最好不采用 segues 。

但是,故事板却是实现布局的用户界面的有效办法,尤其在使用自动布局时。我们选择使用故事板来实现待办事项示例的两个界面,并用下面的代码来执行导航:

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)addModuleDidSaveAddAction;

@end

由于模块必须展现出来才有价值,所以模块的展示器通常实现了模块接口。当其他模块想展示当前模块时,它的展示器将实现模块代理协议,因此它知道模块之前显示时做了什么。

一个模块可能包括实体、交互器、管理器,可以被用于多个界面的共同应用逻辑层。当然,这取决于界面之间的交互,以及它们是否类似。模块可以很容易地在待办事项示例中展示单个界面。这样说来,应用逻辑层可以针对特定模块的行为而定制。

模块也是组织代码的简易途径。将模块的所有代码都放在自己的文件夹中,并用 Xcode 分组,便于你在需要时寻找和改动。当你想找的一个类刚好就在你所期望的地方出现时,这种 Feel 倍儿爽!

用 VIPER 构建模块的另一个好处是,更容易将其扩展到多个平台。具有独立于交互器层的所有用例的应用程序逻辑,通过复用应用程序层,可以让你专注于在平板电脑端、手机端或 Mac 端构建新的用户界面。

进一步说,适用于 iPad 应用的用户界面能够重用一些 iPhone 应用的视图、视图控制器和控制器。这样的话,iPad 界面将由「超级」展示器和线框图来展现,也就是改写现成的 iPhone 端的展示器和线框构成。构建并维护跨平台的应用程序相当具有挑战性,但良好的架构可以促进模型和应用层的重用,从而让跨平台实现容易得多。

用VIPER测试

VIPER 的出现激发了关注点的分离,这使得采用 TDD 变得更加简便。交互器含有独立于任何用户界面的纯逻辑,测试起来更加容易。展示器包含用于显示准备数据的逻辑,并且独立于任何 UIKit 部件。开发这种逻辑也便于测试。

我们的首选方法是从交互器开始。UI 中的一切是服务于用例的需求。通过使用 TDD 来测试交互器的 API,你可以更好地了解用户界面和用例之间的关系。

例如,我们着眼于交互器负责的待办事项列表。寻找新的列表的策略是,要找到所有截止于下周末的待办事项,并将每个待办事项归类为到期日是今天、明天、本周晚些时候或下周。

为确保交互器找到截止于下周末的所有待办事项,我们编写第一个测试:

- (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 的样子,就可以开发展示器。当展示器收到来自交互器的待办事项,我们将测试是否恰当地格式化数据,并在用户界面中显示:

- (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];
    NSDate *dueDate = [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 所指定的道路。也许你遇到了很多「bunny」对象,或者你的应用程序将受益于在故事板中使用 segues。没关系,在这种情况下,在做出决定时想一想 VIPER 所代表的精神。它的核心始终是:基于单一责任原则的架构。如果遇到问题,在决定如何向前推进时想想这个原则。

你可能想知道在现有应用中是否能使用 VIPER。遇到这种情况,你可以考虑用 VIPER 建一个新功能,许多项目都采取了这种方法。这能让你用 VIPER 构建模块,帮助你发现许多建立在单一责任原则基础上造成难以运用架构的问题。

开发软件的最大挑战在于,每个应用都迥然不同,应用程序的架构方式也不一样。对我们来说,这意味着每个应用程序都是学习和尝试新事物的机遇。如果你决定尝试 VIPER,你也会受益匪浅。

Swift 补充

不久前,在 WWDC 上苹果推出了 Swift 编程语言,这将成为 Cocoa 和 Cocoa Touch 开发的未来。现在评判 Swift 语言还太早,但我们知道,语言与我们如何设计、构建软件息息相关。我们决定用 Swift 改写 VIPER TODO 示例应用,帮助我们了解 Swift 对 VIPER 的意义。迄今为止,我们确实有所收获。以下是 Swift 的几个特点,可能会改善用 VIPER 开发应用程序的体验。

结构体

在 VIPER 中,我们采用小型的、轻量化、模型类来传递层之间的数据,比如展示器到视图。这些 PONSOs 通常只是简单地采取少量数据,并且这些类通常不会被继承。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 hasEqualSections
}

类型安全

或许,Objective-C 和 Swift 之间最大的区别在于类型处理上的不同。 Objective-C 是动态类型,而 Swift 在编译中对实现类型检查时非常严格。对于像 VIPER 的架构,当一个应用程序由多个不同层构成,类型安全对开发者效率和构架结构来说都是巨大的优势。编译器帮助你确保在层边界传递时,容器和对象始终是正确的类型。由上文可知,这便是使用结构体的最佳位置。如果一个结构体能在两层之间的边界保驾护航,由于类型安全的限制,你就能保证它永远无法逃离边界。

(完结)

用 VIPER 构建 iOS 应用架构(1)

原文地址:Architecting iOS Apps with VIPER

本文系 OneAPM 工程师编译整理。OneAPM应用性能管理领域的新兴领军企业,能帮助企业用户和开发者轻松实现:缓慢的程序代码和 SQL 语句的实时抓取。想阅读更多技术文章,请访问 OneAPM 官方博客