用 VIPER 構建 iOS 應用架構(2)

OneAPM官方技術部落格發表於2015-08-07

【編者按】本篇文章由 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 官方部落格

相關文章