鍍金池/ 教程/ iOS/ 使用 VIPER 構建 iOS 應用
與四軸無人機的通訊
在沙盒中編寫腳本
結構體和值類型
深入理解 CocoaPods
UICollectionView + UIKit 力學
NSString 與 Unicode
代碼簽名探析
測試
架構
第二期-并發(fā)編程
Metal
自定義控件
iOS 中的行為
行為驅動開發(fā)
Collection View 動畫
截圖測試
MVVM 介紹
使 Mac 應用數據腳本化
一個完整的 Core Data 應用
插件
字符串
為 iOS 建立 Travis CI
先進的自動布局工具箱
動畫
為 iOS 7 重新設計 App
XPC
從 NSURLConnection 到 NSURLSession
Core Data 網絡應用實例
GPU 加速下的圖像處理
自定義 Core Data 遷移
子類
與調試器共舞 - LLDB 的華爾茲
圖片格式
并發(fā)編程:API 及挑戰(zhàn)
IP,TCP 和 HTTP
動畫解釋
響應式 Android 應用
初識 TextKit
客戶端
View-Layer 協(xié)作
回到 Mac
Android
Core Image 介紹
自定義 Formatters
Scene Kit
調試
項目介紹
Swift 的強大之處
測試并發(fā)程序
Android 通知中心
調試:案例學習
從 UIKit 到 AppKit
iOS 7 : 隱藏技巧和變通之道
安全
底層并發(fā) API
消息傳遞機制
更輕量的 View Controllers
用 SQLite 和 FMDB 替代 Core Data
字符串解析
終身學習的一代人
視頻
Playground 快速原型制作
Omni 內部
同步數據
設計優(yōu)雅的移動游戲
繪制像素到屏幕上
相機與照片
音頻 API 一覽
交互式動畫
常見的后臺實踐
糟糕的測試
避免濫用單例
數據模型和模型對象
Core Data
字符串本地化
View Controller 轉場
照片框架
響應式視圖
Square Register 中的擴張
DTrace
基礎集合類
視頻工具箱和硬件加速
字符串渲染
讓東西變得不那么糟
游戲中的多點互聯(lián)
iCloud 和 Core Data
Views
虛擬音域 - 聲音設計的藝術
導航應用
線程安全類的設計
置換測試: Mock, Stub 和其他
Build 工具
KVC 和 KVO
Core Image 和視頻
Android Intents
在 iOS 上捕獲視頻
四軸無人機項目
Mach-O 可執(zhí)行文件
UI 測試
值對象
活動追蹤
依賴注入
Swift
項目管理
整潔的 Table View 代碼
Swift 方法的多面性
為什么今天安全仍然重要
Core Data 概述
Foundation
Swift 的函數式 API
iOS 7 的多任務
自定義 Collection View 布局
測試 View Controllers
訪談
收據驗證
數據同步
自定義 ViewController 容器轉場
游戲
調試核對清單
View Controller 容器
學無止境
XCTest 測試實戰(zhàn)
iOS 7
Layer 中自定義屬性的動畫
第一期-更輕量的 View Controllers
精通 iCloud 文檔存儲
代碼審查的藝術:Dropbox 的故事
GPU 加速下的圖像視覺
Artsy
照片擴展
理解 Scroll Views
使用 VIPER 構建 iOS 應用
Android 中的 SQLite 數據庫支持
Fetch 請求
導入大數據集
iOS 開發(fā)者的 Android 第一課
iOS 上的相機捕捉
語言標簽
同步案例學習
依賴注入和注解,為什么 Java 比你想象的要好
編譯器
基于 OpenCV 的人臉識別
玩轉字符串
相機工作原理
Build 過程

使用 VIPER 構建 iOS 應用

建筑領域流行這樣一句話,“我們雖然在營造建筑,但建筑也會重新塑造我們”。正如所有開發(fā)者最終領悟到的,這句話同樣適用于構建軟件。

編寫代碼中至關重要的是,需要使每一部分容易被識別,賦有一個特定而明顯的目的,并與其他部分在邏輯關系中完美契合。這就是我們所說的軟件架構。好的架構不僅讓一個產品成功投入使用,還可以讓產品具有可維護性,并讓人不斷頭腦清醒的對它進行維護!

在這篇文章中,我們介紹了一種稱之為 VIPER 的 iOS 應用架構的方式。VIPER 已經在很多大型的項目上成功實踐,但是出于本文的目的我們將通過一個待辦事項清單 (to-do app) 來介紹 VIPER 。你可以在 GitHub 上關注這個項目。

什么是 VIPER?

測試永遠不是構建 iOS 應用的主要部分。當我們 (Mutual Mobile) 著手改善我們的測試實踐時,我們發(fā)現(xiàn)給 iOS 應用寫測試代碼非常困難。因此如果想要設法改變測試的現(xiàn)狀,我們首先需要一個更好的方式來架構應用,我們稱之為 VIPER。

VIPER 是一個創(chuàng)建 iOS 應用簡明構架的程序。VIPER 可以是視圖 (View),交互器 (Interactor),展示器 (Presenter),實體 (Entity) 以及路由 (Routing) 的首字母縮寫。簡明架構將一個應用程序的邏輯結構劃分為不同的責任層。這使得它更容易隔離依賴項 (如數據庫),也更容易測試各層間的邊界處的交互:

http://wiki.jikexueyuan.com/project/objc/images/13-15.jpg" alt="" />

大部分 iOS 應用利用 MVC 構建,使用 MVC 應用程序架構可以引導你將每一個類看做模型,視圖或控制器中的一個。但由于大部分應用程序的邏輯不會存在于模型或視圖中,所以通常最終總是在控制器里實現(xiàn)。這就導致一個稱為重量級視圖控制器的問題,在這里,視圖控制器做了太多工作。為這些重量級視圖控制器瘦身并不是 iOS 開發(fā)者尋求提高代碼的質量所要面臨的唯一挑戰(zhàn),但至少這是一個很好的開端。

VIPER 的不同層提供了明確的程序邏輯以及導航控制代碼來應對這個挑戰(zhàn),利用 VIPER ,你會注意到在我們的待辦事項示例清單中的視圖控制器可以簡潔高效,意義明確地控制視圖。你也會發(fā)現(xiàn)視圖控制器中代碼和所有的其他類很容易理解,容易測試,理所當然也更易維護。

基于用例的應用設計

應用通常是一些用戶用例的集合。用例也被稱為驗收標準,或行為集,它們用來描述應用的用途。清單可以根據時間,類型以及名字排序,這就是一個用例。用例是應用程序中用來負責業(yè)務邏輯的一層,應獨立于用戶界面的實現(xiàn),同時要足夠小,并且有良好的定義。決定如何將一個復雜的應用分解成較小的用例非常具有挑戰(zhàn)性,并且需要長期實踐,但這對于縮小你解決的問題時所要面臨的范圍及完成的每個類的所要涉及的內容來說,是很有幫助的。

利用 VIPER 建立一個應用需要實施一組套件來滿足所有的用例,應用邏輯是實現(xiàn)用例的主要組成部分,但卻不是唯一。用例也會影響用戶界面。另一個重要的方面,是要考慮用例如何與其他應用程序的核心組件相互配合,例如網絡和數據持久化。組件就好比用例的插件,VIPER 則用來描述這些組件的作用是什么,如何進行交互。

我們其中一個用例,或者說待辦事項清單中其中的一個需求是可以基于用戶的選擇來將待辦事項分組。通過分離的邏輯將數據組織成一個用例,我們能夠在測試時使用戶界面代碼保持干凈,用例更易組裝,從而確保它如我們預期的方式工作。

VIPER 的主要部分

VIPER 的主要部分是:

  • 視圖:根據展示器的要求顯示界面,并將用戶輸入反饋給展示器。
  • 交互器:包含由用例指定的業(yè)務邏輯。
  • 展示器:包含為顯示(從交互器接受的內容)做的準備工作的相關視圖邏輯,并對用戶輸入進行反饋(從交互器獲取新數據)。
  • 實體:包含交互器要使用的基本模型對象。
  • 路由:包含用來描述屏幕顯示和顯示順序的導航邏輯。

這種分隔形式同樣遵循單一責任原則。交互器負責業(yè)務分析的部分,展示器代表交互設計師,而視圖相當于視覺設計師。

以下則是不同組件的相關圖解,并展示了他們之間是如何關聯(lián)的:

http://wiki.jikexueyuan.com/project/objc/images/13-16.png" alt="" />

雖然在應用中 VIPER 的組件可以以任意順序實現(xiàn),我們在這里選擇按照我們推薦的順序來進行介紹。你會注意到這個順序與構建整個應用的進程大致符合 -- 首先要討論的是產品需要做什么,以及用戶會如何與之交互。

交互器

交互器在應用中代表著一個獨立的用例。它具有業(yè)務邏輯以操縱模型對象(實體)執(zhí)行特定的任務。交互器中的工作應當獨立與任何用戶界面,同樣的交互器可以同時運用于 iOS 應用或者 OS X 應用中。

由于交互器是一個 PONSO (Plain Old NSObject,普通的 NSObject),它主要包含了邏輯,因此很容易使用 TDD 進行開發(fā)。

示例應用的主要用例是向用戶展示所有的待辦事項(比如任何截止于下周末的任務)。此類用例的業(yè)務邏輯主要是找出今天至下周末之間將要到期的待辦事項,然后為它們分配一個相對的截止日期,比如今天,明天,本周以內,或者下周。

以下是來自 VTDListInteractor 的對應方法:

- (void)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]];
    }];
}

實體

實體是被交互器操作的模型對象,并且它們只被交互器所操作。交互器永遠不會傳輸實體至表現(xiàn)層 (比如說展示器)。

實體也應該是 PONSOs。如果你使用 Core Data,最好是將托管對象保持在你的數據層之后,交互器不應與 NSManageObjects 協(xié)同工作。

這里是我們的待辦事項服務的實體:

@interface VTDTodoItem : NSObject

@property (nonatomic, strong)   NSDate*     dueDate;
@property (nonatomic, copy)     NSString*   name;

+ (instancetype)todoItemWithDueDate:(NSDate*)dueDate name:(NSString*)name;

@end

不要詫異于你的實體僅僅是數據結構,任何依賴于應用的邏輯都應該放到交互器中。

展示器

展示器是一個主要包含了驅動用戶界面的邏輯的 PONSO,它總是知道何時呈現(xiàn)用戶界面。基于其收集來自用戶交互的輸入功能,它可以在合適的時候更新用戶界面并向交互器發(fā)送請求。

當用戶點擊 “+” 鍵新建待辦事項時,addNewEntry 被調用。對于此項操作,展示器會要求 wireframe 顯示用戶界面以增加新項目:

- (void)addNewEntry
{
    [self.listWireframe presentAddInterface];
}

展示器還會從交互器接收結果并將結果轉換成能夠在視圖中有效顯示的形式。

下面是如何從交互器接受待辦事項的過程,其中包含了處理數據的過程并決定展現(xiàn)給用戶哪些內容:

- (void)foundUpcomingItems:(NSArray*)upcomingItems
{
    if ([upcomingItems count] == 0)
    {
        [self.userInterface showNoContentMessage];
    }
    else
    {
        [self updateUserInterfaceWithUpcomingItems:upcomingItems];
    }
}

實體永遠不會由交互器傳輸給展示器,取而代之,那些無行為的簡單數據結構會從交互器傳輸到展示器那里。這就防止了那些“真正的工作”在展示器那里進行,展示器只能負責準備那些在視圖里顯示的數據。

視圖

視圖一般是被動的,它通常等待展示器下發(fā)需要顯示的內容,而不會向其索取數據。視圖(例如登錄界面的登錄視圖控件)所定義的方法應該允許展示器在高度抽象的層次與之交流。展示器通過內容進行表達,而不關心那些內容所顯示的樣子。展示器不知道 UILabelUIButton 等的存在,它只知道其中包含的內容以及何時需要顯示。內容如何被顯示是由視圖來進行控制的。

視圖是一個抽象的接口 (Interface),在 Objective-C 中使用協(xié)議被定義。一個 UIViewController 或者它的一個子類會實現(xiàn)視圖協(xié)議。比如我們的示例中 “添加” 界面會有以下接口:

@protocol VTDAddViewInterface <NSObject>

- (void)setEntryName:(NSString *)name;
- (void)setEntryDueDate:(NSDate *)date;

@end

視圖和視圖控制器同樣會操縱用戶界面和相關輸入。因為通常來說視圖控制器是最容易處理這些輸入和執(zhí)行某些操作的地方,所以也就不難理解為什么視圖控制器總是這么大了。為了使視圖控制器保持苗條,我們需要使它們在用戶進行相關操作的時候可以有途徑來通知相關部分。視圖控制器不應當根據這些行為進行相關決定,但是它應當將發(fā)生的事件傳遞到能夠做決定的部分。

在我們的例子中,Add View Controller 有一個事件處理的屬性,它實現(xiàn)了如下接口:

@protocol VTDAddModuleInterface <NSObject>

- (void)cancelAddAction;
- (void)saveAddActionWithName:(NSString *)name dueDate:(NSDate *)dueDate

@end

當用戶點擊取消鍵的時候,視圖控制器告知這個事件處理程序用戶需要其取消這次添加的動作。這樣一來,事件處理程序便可以處理關閉 add view controller 并告知列表視圖進行更新。

視圖和展示器之間邊界處是一個使用 ReactiveCocoa 的好地方。在這個示例中,視圖控制器可以返回一個代表按鈕操作的信號。這將允許展示器在不打破職責分離的前提下輕松地對那些信號進行響應。

路由

屏幕間的路徑會在交互設計師創(chuàng)建的線框 (wireframes) 里進行定義。在 VIPER 中,路由是由兩個部分來負責的:展示器和線框。一個線框對象包括 UIWindow,UINavigationController,UIViewController 等部分,它負責創(chuàng)建視圖/視圖控制器并將其裝配到窗口中。

由于展示器包含了響應用戶輸入的邏輯,因此它就擁有知曉何時導航至另一個屏幕以及具體是哪一個屏幕的能力。而同時,線框知道如何進行導航。在兩者結合起來的情況下,展示器可以使用線框來進行實現(xiàn)導航功能,它們兩者一起描述了從一個屏幕至另一個屏幕的路由過程。

線框同時也明顯是一個處理導航轉場動畫的地方。來看看這個 add wireframe 中的例子吧:

@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;
}

#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];
}

@end

應用使用了自定義的視圖控制器轉場來呈現(xiàn) add view controller。因為線框部件負責實施這個轉場,所以它成為了 add view controller 轉場的委托,并且返回適當的轉場動畫。

利用 VIPER 組織應用組件

iOS 應用的構架需要考慮到 UIKit 和 Cocoa Touch 是建立應用的主要工具。架構需要和應用的所有組件都能夠和平相處,但又需要為如何使用框架的某些部分以及它們應該在什么位置提供一些指導和建議。

iOS 應用程序的主力是 UIViewController,我們不難想象找一個競爭者來取代 MVC 就可以避免大量使用視圖控制器。但是視圖控制器現(xiàn)在是這個平臺的核心:它們處理設備方向的變化,回應用戶的輸入,和類似導航控制器之類的系統(tǒng)系統(tǒng)組件集成得很好,而現(xiàn)在在 iOS 7 中又能實現(xiàn)自定義屏幕之間的轉換,功能實在是太強大了。

有了 VIPER,視圖控制器便就能真正的做它本來應該做的事情了,那就是控制視圖。 我們的待辦事項應擁有兩個視圖控制器,一個是列表視圖,另一個是新建待辦。因為 add view controller 要做的所有事情就是控制視圖,所以實現(xiàn)起來非常的簡單基礎:

@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

應用在接入網絡以后會變得更有用處,但是究竟該在什么時候聯(lián)網呢?又由誰來負責啟動網絡連接呢?典型的情況下,由交互器來啟動網絡連接操作的項目,但是它不會直接處理網絡代碼。它會尋找一個像是 network manager 或者 API client 這樣的依賴項。交互器可能聚合來自多個源的數據來提供所需的信息,從而完成一個用例。最終,就由展示器來采集交互器反饋的數據,然后組織并進行展示。

數據存儲模塊負責提供實體給交互器。因為交互器要完成業(yè)務邏輯,因此它需要從數據存儲中獲取實體并操縱它們,然后將更新后的實體再放回數據存儲中。數據存儲管理實體的持久化,而實體應該對數據庫全然不知,正因如此,實體并不知道如何對自己進行持久化。

交互器同樣不需要知道如何將實體持久化,有時交互器更希望使用一個 data manager 來使其與數據存儲的交互變得容易。Data manager 可以處理更多的針對存儲的操作,比如創(chuàng)建獲取請求,構建查詢等等。這就使交互器能夠將更多的注意力放在應用邏輯上,而不必再了解實體是如何被聚集或持久化的。下面我們舉一個例子來說明使用 data manager 有意義的,這個例子假設你在使用 Core Data。這是示例應用程序的 data manager 的接口:

@interface VTDListDataManager : NSObject

@property (nonatomic, strong) VTDCoreDataStore *dataStore;

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

@end

當使用 TDD 來開發(fā)一個交互器時,是可以用一個測試用的模擬存儲來代替生產環(huán)境的數據存儲的。避免與遠程服務器通訊(網絡服務)以及避免讀取磁盤(數據庫)可以加快你測試的速度并加強其可重復性。

將數據存儲保持為一個界限清晰的特定層的原因之一是,這可以讓你延遲選擇一個特定的持久化技術。如果你的數據存儲是一個獨立的類,那你就可以使用一個基礎的持久化策略來開始你的應用,然后等到有意義的時候升級至 SQLite 或者 Core Data。而因為數據存儲層的存在,你的應用代碼庫中就不需要改變任何東西。

在 iOS 的項目中使用 Core Data 經常比構架本身還容易引起更多爭議。然而,利用 VIPER 來使用 Core Data 將給你帶來使用 Core Data 的前所未有的良好體驗。在持久化數據的工具層面上,Core Data 可以保持快速存取和低內存占用方面,簡直是個神器。但是有個很惱人的地方,它會像觸須一樣把 NSManagedObjectContext 延伸至你所有的應用實現(xiàn)文件中,特別是那些它們不該待的地方。VIPER 可以使 Core Data 待在正確的地方:數據存儲層。

在待辦事項示例中,應用僅有的兩部分知道使用了 Core Data,其一是數據存儲本身,它負責建立 Core Data 堆棧;另一個是 data manager。Data manager 執(zhí)行了獲取請求,將數據存儲返回的 NSManagedObject 對象轉換為標準的 PONSO 模型對象,并傳輸回業(yè)務邏輯層。這樣一來,應用程序核心將不再依賴于 Core Data,附加得到的好處是,你也再也不用擔心過期數據 (stale) 和沒有良好組織的多線程 NSManagedObjects 來糟蹋你的工作成果了。

在通過請求訪問 Core Data 存儲時,data manager 中看起來是這樣的:

@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

與 Core Data 一樣極富爭議的恐怕就是 UI 故事板了。故事板具有很多有用的功能,如果完全忽視它將會是一個錯誤。然而,調用故事版所能提供的所有功能來完成 VIPER 的所有目標仍然是很困難的。

我們所能做出的妥協(xié)就是選擇不使用 segues 。有時候使用 segues 是有效的,但是使用 segues 的危險性在于它們很難原封不動地保持屏幕之間的分離,以及 UI 和應用邏輯之間的分離。一般來說,如果實現(xiàn) prepareForSegue 方法是必須的話,我們就盡量不去使用 segues。

除此之外,故事板是一個實現(xiàn)用戶界面布局有效方法,特別是在使用自動布局的時候。我們選擇在實現(xiàn)待辦事項兩個界面的實例中使用故事板,并且使用這樣的代碼來執(zhí)行自己的導航操作。

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 的時候,你會發(fā)現(xiàn)一個屏幕或一組屏幕傾向于聚在一起作為一個模塊。模塊可以以多種形式體現(xiàn),但一般最好把它想成是一種特性。在播客應用中,一個模塊可能是音頻播放器或訂閱瀏覽器。然而在我們的待辦事項應用中,列表和添加事項的屏幕都將作為單獨的模塊被建立。

將你的應用作為一組模塊來設計有很多好處,其中之一就是模塊可以有非常明確和定義良好的接口,并且獨立于其他的模塊。這就使增加或者移除特性變得更加簡單,也使在界面中向用戶展示各種可變模塊變得更加簡單。

我們希望能將待辦事項中各模塊之間分隔更加明確,我們?yōu)樘砑幽K定義了兩個協(xié)議。一個是模塊接口,它定義了模塊可以做什么;另一個則是模塊的代理,用來描述該模塊做了什么。例如:

@protocol VTDAddModuleInterface <NSObject>

- (void)cancelAddAction;
- (void)saveAddActionWithName:(NSString *)name dueDate:(NSDate *)dueDate;

@end

@protocol VTDAddModuleDelegate <NSObject>

- (void)addModuleDidCancelAddAction;
- (void)addModuleDidSaveAddAction;

@end

因為模塊必須要被展示,才能對用戶產生價值,所以模塊的展示器通常需要實現(xiàn)模型的接口。當另一個模型想要展現(xiàn)當前模塊時,它的展示器就需要實現(xiàn)模型的委托協(xié)議,這樣它就能在展示時知道當前模塊做了些什么。

一個模塊可能包括實體,交互器和管理器的通用應用邏輯層,這些通??捎糜诙鄠€屏幕。當然,這取決于這些屏幕之間的交互及它們的相似度。一個模塊可以像在待辦事項列表里面一樣,簡單的只代表一個屏幕。這樣一來,應用邏輯層對于它的特定模塊的行為來說就非常特有了。

模塊同樣是組織代碼的簡便途徑。將模塊所有的編碼都放在它自己的文件夾中并在 Xcode 中建一個 group,這會在你需要尋找和改變更加容易。當你在要尋找一個類時,它恰到好處地就在你所期待的地方,這種感覺真是無法形容的棒。

利用 VIPER 建立模塊的另一個好處是它使得擴展到多平臺時變得更加簡單。獨立在交互器層中的所有用例的應用邏輯允許你可以專注于為平板,電話或者 Mac 構建新的用戶界面,同時可以重用你的應用層。

進一步來說,iPad 應用的用戶界面能夠將部分 iPhone 應用的視圖,視圖控制器及展示器進行再利用。在這種情況下,iPad 屏幕將由 ‘super’ 展示器和線框來代表,這樣可以利用 iPhone 使用過的展示器和線框來組成屏幕。建立進而維護一個跨多平臺的應用是一個巨大的挑戰(zhàn),但是好的構架可以對整個模型和應用層的再利用有大幅度的提升,并使其實現(xiàn)起來更加容易。

利用 VIPER 進行測試

VIPER 的出現(xiàn)激發(fā)了一個關注點的分離,這使得采用 TDD 變得更加簡便。交互器包含獨立與任何 UI 的純粹邏輯,這使測試驅動開發(fā)更加簡單。同時展示器包含用來為顯示準備數據的邏輯,并且它也獨立于任何一個 UIKit 部件。對于這個邏輯的開發(fā)也很容易用測試來驅動。

我們更傾向于先從交互器下手。用戶界面里所有部分都服務于用例,而通過采用 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 長什么樣,接下來就是開發(fā)展示器。一旦展示器接收到了交互器傳來的待辦事項,我們就需要測試看看我們是否適當的將數據進行格式化并且在用戶界面中正確的顯示它。

- (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];
}

這時我們可以開發(fā)視圖功能了,并且在沒有待辦事項的時候我們想要展示一個特殊的信息。

- (void)testShowingNoContentMessageShowsNoContentView
{
    [self.view showNoContentMessage];

    XCTAssertEqualObjects(self.view.view, self.view.noContentView, @"the no content view should be the view");
}

有待辦事項出現(xiàn)時,我們要確保列表是顯示出來的:

- (void)testShowingUpcomingItemsShowsTableView
{
    [self.view showUpcomingDisplayData:nil];

    XCTAssertEqualObjects(self.view.view, self.view.tableView, @"the table view should be the view");
}

首先建立交互器是一種符合 TDD 的自然規(guī)律。如果你首先開發(fā)交互器,緊接著是展示器,你就可以首先建立一個位于這些層的套件測試,并且為實現(xiàn)這是實例奠定基礎。由于你不需要為了測試它們而去與用戶界面進行交互,所以這些類可以進行快速迭代。在你需要開發(fā)視圖的時候,你會有一個可以工作并測試過的邏輯和表現(xiàn)層來與其進行連接。在快要完成對視圖的開發(fā)時,你會發(fā)現(xiàn)第一次運行程序時所有部件都運行良好,因為你所有已通過的測試已經告訴你它可以工作。

結論

我們希望你喜歡這篇對 VIPER 的介紹?;蛟S你們都很好奇接下來應該做什么,如果你希望通過 VIPER 來對你下一個應用進行設計,該從哪里開始呢?

我們竭盡全力使這篇文章和我們利用 VIPER 實現(xiàn)的應用實例足夠明確并且進行了很好的定義。我們的待辦事項里列表程序相當直接簡單,但是它準確地解釋了如何利用 VIPER 來建立一個應用。在實際的項目中,你可以根據你自己的挑戰(zhàn)和約束條件來決定要如何實踐這個例子。根據以往的經驗,我們的每個項目在使用 VIPER 時都或多或少地改變了一些策略,但它們無一例外的都從中得益,找到了正確的方向。

很多情況下由于某些原因,你可能會想要偏離 VIPER 所指引的道路。可能你遇到了很多 對象,或者你的應用使用了故事板的 segues。沒關系的,在這些情況下,你只需要在做決定時稍微考慮下 VIPER 所代表的精神就好。VIPER 的核心在于它是建立在單一責任原則上的架構。如果你碰到了些許麻煩,想想這些原則再考慮如何前進。

你一定想知道在現(xiàn)有的應用中能否只用 VIPER 。在這種情況下,你可以考慮使用 VIPER 構建新的特性。我們許多現(xiàn)有項目都使用了這個方法。你可以利用 VIPER 建立一個模塊,這能幫助你發(fā)現(xiàn)許多建立在單一責任原則基礎上造成難以運用架構的現(xiàn)有問題。

軟件開發(fā)最偉大的事情之一就是每個應用程序都是不同的,而設計每個應用的架構的方式也是不同的。這就意味著每個應用對于我們來說都是一個學習和嘗試的機遇,如果你決定開始使用 VIPER,你會受益匪淺。感謝你的閱讀。

Swift 補充

蘋果上周在 WWDC 介紹了一門稱之為 Swift 的編程語言來作為 Cocoa 和 Cocoa Touch 開發(fā)的未來。現(xiàn)在發(fā)表關于 Swift 的完整意見還為時尚早,但眾所周知編程語言對我們如何設計和構建應用有著重大影響。我們決定使用 Swift 重寫我們的待辦事項清單,幫助我們學習它對 VIPER 意味著什么。至今為止,收獲頗豐。Swift 中的一些特性對于構建應用的體驗有著顯著的提升。

結構體

在 VIPER 中我們使用小型,輕量級的 model 類來在比如從展示器到視圖這樣不同的層間傳遞數據。這些 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 的最大區(qū)別是它們在對于類型處理上的不同。 Objective-C 是動態(tài)類型,而 Swift 故意在編譯時做了嚴格的類型檢查。對于一個類似 VIPER 的架構, 應用由不同層構成,類型安全是提升程序員效率和設計架構有非常大的好處。編譯器幫助你確保正確類型的容器和對象在層的邊界傳遞。如上所示,這是一個使用結構體的好地方。如果一個結構體的被設計為存在于兩層之間,那么由于類型安全,你可以保證它將永遠無法脫離這些層之間。

擴展閱讀