iOS VIPER架構實踐(二):VIPER詳解與實現

黑超熊貓zuik發表於2017-08-21

第一篇文章對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

Ineractor的職責:

  • 實現和封裝各種業務的Use Case,供外部呼叫
  • 維護和業務相關的各種狀態,比如是否正在編輯筆記
  • Interactor可以獲取各種Manager和Service,用於組合實現業務邏輯,這些Manager和Service應該是由外部注入的依賴,而不是直接引用具體的類
  • 通過DataManager維護Model
  • 監聽各種外部的業務事件並處理,必要時將事件傳送給eventHandler
  • Interactor持有一個由外部注入的eventHandler物件,將需要外部處理的業務事件傳送給eventHandler,或者通過eventHandler介面對某些資料操作的過程進行回撥
  • Interactor持有一個由外部注入的dataSource物件,用於獲取View上的資料,以更新Model

Interactor是業務的實現者和維護者,它會呼叫各種Service來實現業務邏輯,封裝成明確的用例。而這些Service在使用時,也都是基於介面的,因為Interactor的實現不和具體的類繫結,而是由Application注入Interactor需要的Service。

示例程式碼:

@protocol ZIKNoteListInteractorInput <NSObject>
- (void)loadAllNotes;
- (NSInteger)noteCount;
- (NSString *)titleForNoteAtIndex:(NSUInteger)idx;
- (NSString *)contentForNoteAtIndex:(NSUInteger)idx;
- (NSString *)noteUUIDAtIndex:(NSUInteger)idx;
- (NSString *)noteTitleAtIndex:(NSUInteger)idx;
- (NSString *)noteContentAtIndex:(NSUInteger)idx;
@end
複製程式碼
@interface ZIKNoteListInteractor : NSObject <ZIKNoteListInteractorInput>
@property (nonatomic, weak) id dataSource;
@property (nonatomic, weak) id eventHandler;
@end

@implementation ZIKNoteListInteractor

- (void)loadAllNotes {
    [[ZIKNoteDataManager sharedInsatnce] fetchAllNotesWithCompletion:^(NSArray *notes) {
        [self.eventHandler didFinishLoadAllNotes];
    }];
}

- (NSArray<ZIKNoteModel *> *)noteList {
    return [ZIKNoteDataManager sharedInsatnce].noteList;
}

- (NSInteger)noteCount {
    return self.noteList.count;
}

- (NSString *)titleForNoteAtIndex:(NSUInteger)idx {
    if (self.noteList.count - 1 < idx) {
        return nil;
    }
    return [[self.noteList objectAtIndex:idx] title];
}

- (NSString *)contentForNoteAtIndex:(NSUInteger)idx {
    if (self.noteList.count - 1 < idx) {
        return nil;
    }
    return [[self.noteList objectAtIndex:idx] content];
}

- (NSString *)noteUUIDAtIndex:(NSUInteger)idx {
    if (self.noteList.count - 1 < idx) {
        return nil;
    }
    return [[self.noteList objectAtIndex:idx] uuid];
}

- (NSString *)noteTitleAtIndex:(NSUInteger)idx {
    if (self.noteList.count - 1 < idx) {
        return nil;
    }
    return [[self.noteList objectAtIndex:idx] title];
}

- (NSString *)noteContentAtIndex:(NSUInteger)idx {
    if (self.noteList.count - 1 < idx) {
        return nil;
    }
    return [[self.noteList objectAtIndex:idx] content];
}

@end
複製程式碼

Service

向Interactor提供各種封裝好的服務,例如資料庫的訪問、儲存,呼叫定位功能等。Service由Application在執行路由時注入到Builder裡,再由Buidler注入到Interactor裡。也可以只注入一個Service Router,在執行時再通過這個Service Router懶載入需要的Service,相當於注入了一個提供Router功能的Service。

Service可以看作是沒有View的VIPER,也有自己的路由和Builder。

Wireframe

翻譯成中文叫線框,用於表達從一個Module到另一個Module的過程。雖然也是扮演者執行路由的角色,但是其實它和Router是有區別的。

Wireframe和storyboard中連線好的一個個segue類似,負責提供一系列具體的路由用例,這個用例裡已經配置好了源介面和目的介面的一些依賴,包括轉場動畫、模組間傳參等。Wireframe的介面是提供給模組內部使用的,它通過呼叫Router來執行真正的路由操作。

示例程式碼:

@interface ZIKTNoteListWireframe : NSObject <ZIKTViperWireframe>
- (void)presentLoginViewWithMessage:(NSString *)message delegate:(id<ZIKTLoginViewDelegate>)delegate completion:(void (^ __nullable)(void))completion;
- (void)dismissLoginView:(UIViewController *)viewController animated:(BOOL)animated completion:(void (^ __nullable)(void))completion;
- (void)presentEditorForCreatingNewNoteWithDelegate:(id<ZIKTEditorDelegate>)delegate completion:(void (^ __nullable)(void))completion;
- (void)pushEditorViewForEditingNoteWithUUID:(NSString *)uuid title:(NSString *)title content:(NSString *)content delegate:(id<ZIKTEditorDelegate>)delegate;
- (UIViewController *)editorViewForEditingNoteWithUUID:(NSString *)uuid title:(NSString *)title content:(NSString *)content delegate:(id<ZIKTEditorDelegate>)delegate;
- (void)pushEditorViewController:(UIViewController *)destination fromViewController:(UIViewController *)source animated:(BOOL)animated;
- (void)quitEditorViewWithAnimated:(BOOL)animated;
@end
複製程式碼

Router

Router則是由Application提供的具體路由技術,可以簡單封裝UIKit裡的那些跳轉方法,也可以用URL Router來執行路由。但是一個模組是不需要知道app使用的是什麼具體技術的。Router才是真正連線各個模組的地方。它也負責尋找對應的目的模組,並且通過Buidler進行依賴注入。

示例程式碼:

@interface ZIKTRouter : NSObject <ZIKTViperRouter>
///封裝UIKit的跳轉方法
+ (void)pushViewController:(UIViewController *)destination fromViewController:(UIViewController *)source animated:(BOOL)animated;
+ (void)popViewController:(UIViewController *)viewController animated:(BOOL)animated;
+ (void)presentViewController:(UIViewController *)viewControllerToPresent fromViewController:(UIViewController *)source animated:(BOOL)animated completion:(void (^ __nullable)(void))completion;
+ (void)dismissViewController:(UIViewController *)viewController animated:(BOOL)animated completion:(void (^ __nullable)(void))completion;
@end

@implementation ZIKTRouter (ZIKTEditor)

+ (UIViewController *)viewForCreatingNoteWithDelegate:(id<ZIKTEditorDelegate>)delegate {
    return [ZIKTEditorBuilder viewForCreatingNoteWithDelegate:delegate];
}

+ (UIViewController *)viewForEditingNoteWithUUID:(NSString *)uuid title:(NSString *)title content:(NSString *)content delegate:(id<ZIKTEditorDelegate>)delegate {
    return [ZIKTEditorBuilder viewForEditingNoteWithUUID:uuid title:title content:content delegate:delegate];
}

@end
複製程式碼

Adapter

由Application實現,負責在模組通訊時進行一些介面的轉換,例如兩個模組使用了相同業務功能的某個Service,使用的protocol實現一樣,但是protocol名字不一樣,就可以在路由時,在Adapter裡進行一次轉換。甚至只要定義的邏輯一樣,依賴引數的名字和資料型別也可以允許不同。這樣就能讓模組不依賴於某個具體的protocol,而是依賴於protocol實際定義的依賴和介面。

注意這裡的Adapter和Clean Architecture裡的Interface Adapter是不一樣的。這裡的Adapter就是字面意義上的介面轉換,而Clean Architecture裡的Interface Adapter層更加抽象,是Use Case層與具體實現技術之間的轉換,囊括了更多的角色。

Builder

負責初始化整個模組,配置VIPER之間的關係,並對外宣告模組需要的依賴,讓外部執行注入。

模組間解耦

一個VIPER模組可以看做是一個獨立的元件,可以被單獨封裝成一個庫,被app引用。這時候,app就負責將各個模組連線起來,也就是圖中灰色的Application Context部分。一個模組,肯定是存在於一個上下文環境中才能執行起來的。

Wireframe -> Router -> Adapter -> Builder 實現了一個完整的模組間路由,並且實現了模組間的解耦。

其中Wireframe和Builder是分別由引用者模組和被引用模組提供的,是兩個模組的出口和入口,而Router和Adapter則是由模組的使用者——Application實現的。

當兩個模組之間存在引用關係時,說明存在業務邏輯上的耦合,這種耦合是業務的一部分,是不可能消除的。我們能做的就是把耦合儘量交給模組呼叫者,由Application來提供具體的類,注入到各個模組之中,而模組內部只面向protocol即可。這樣的話,被引用模組只要實現了相同的介面,就可以隨時替換,甚至介面有一些差異時,只要被引用模組提供了相同功能的介面,也可以通過Adapter來做介面相容轉換,讓引用者模組無需做任何修改。

Wireframe相當於插頭,Builder相當於插座,而Router和Adapter相當於電路和轉接頭,將不同規格的插座和插頭連線起來。把這些連線和適配的工作交給Application層,就能讓兩個模組實現各自獨立。

子模組

大部分方案都沒有討論子模組存在的情況。在VIPER裡如何引入另一個VIPER模組?多個模組之間如何互動?子模組由誰初始化、由誰管理?

其他幾個實現中,只有Uber較為詳細地討論了子模組的問題。在Uber的Riblets架構裡,子模組的Router被新增到父模組的Router,模組之間通過delegate和監聽的方式進行通訊。這樣做會讓模組間產生一定的耦合。如果子模組是由於父View使用了一個子View控制元件而被引入的,那麼父Interactor就會在程式碼裡多出一個子Interactor,這樣就導致了View的實現方式影響了Interactor的實現。

子模組的來源

子模組的來源有:

  • View引用了一個封裝好的子View控制元件,連帶著引入了子View的整個VIPER
  • Interactor使用了一個Service

通訊方式

子View可能是一個UIView,也可能是一個Child UIViewController。因此子View有可能需要向外部請求資料,也可能獨立完成所有任務,不需要依賴父模組。

如果子View可以獨立,那在子模組裡不會出現和父模組互動的邏輯,只有把一些事件通過Output傳遞出去的介面。這時只需要把子View的介面封裝在父View的介面裡即可,父Presenter和父Interactor是不知道父View提供的這幾個介面是通過子View實現的。這樣父模組就能接收到子模組的事件了,而且能夠保持Interactor和Presenter、View之間從低到高的依賴關係。

如果父模組需要呼叫子模組的某些功能,或者從子模組獲取資料,可以選擇封裝到父View的介面裡,不過如果涉及到資料模型,並且不想讓資料模型出現在View的介面中,可以把子Interactor作為父Interactor的一個Service,在引入子模組時,通過父Builder注入到父Interactor裡,或者根據依賴關係解耦地再徹底一點,注入到父Presenter裡,讓父Presenter再把介面轉發給父Interactor。這樣子模組和父模組就能通過Service的形式進行通訊了,而這時,父Interactor也不知道這個Service是來自子模組裡的。

在這樣的設計下,子模組和父模組是不知道彼此的存在的,只是通過介面進行互動。好處是父View如果想要更換為另一個相同功能的子View控制元件,就只需要在父View裡修改,不會影響Presenter和Interactor。

依賴注入

這個VIPER的設計是通過介面將各個部分組合在一起的,一個類需要設定很多依賴,例如Interactor需要依賴許多Service。這就涉及到了兩個問題:

  • 在哪裡配置依賴
  • 一個類怎麼宣告自己的依賴

在這個方案中,由Builder宣告整個模組的依賴,然後在Builder內部為不同的類設定依賴,外部在注入依賴時,就不必知道內部是怎麼使用這些依賴引數的。一個類如果有必需的依賴引數,可以直接在init方法裡體現,對於那些非必需的依賴,可以通過暴露介面來宣告。

如果需要動態注入,而不是在模組初始化時就配置所有的依賴,Builder也可以提供動態注入的介面。

對映到MVC

如果你需要把一個模組從MVC重構到VIPER,可以先按照這個步驟:

  • 整理Controller中的程式碼,把不同職責的程式碼用pragma mark分隔好
  • 整理好後,按照各部分的職責,將程式碼分散到VIPER的各個角色中,此時View、Presenter、Interactor之間可以直接互相引用
  • 把View、Presenter、Interactor進行解耦,抽出介面,互相之間依賴介面進行互動

下面就是第一步裡在Controller中可以分隔出的職責:

@implementation ViewController
//------View-------

//View的生命週期
#pragma mark View life

//View的配置,包括佈局設定
#pragma mark View config

//更新View的介面
#pragma mark Update view

//View需要從model中獲取的資料
#pragma mark Request view data source

//監控、接收View的事件
#pragma mark Send view event

//------Presenter-------

//處理View的事件
#pragma mark Handle view event

//介面跳轉
#pragma mark Wireframe

//向View提供配置用的資料
#pragma mark Provide view data source

//提供生成model需要的資料
#pragma mark Provide model data source

//處理業務事件,呼叫業務用例
#pragma mark Handle business event

//------Interactor-------

//監控、接收業務事件
#pragma mark Send business event

//業務用例
#pragma mark Business use case

//獲取生成model需要的資料
#pragma mark Request data for model

//維護model
#pragma mark Manage model

@end
複製程式碼

這裡缺少了View狀態管理、業務狀態管理等職責,因為這些狀態一般都是@property,用pragma mark不能分隔它們,只能在@interface裡宣告的時候進行隔離。

方案二:允許適當耦合

上面的方案是以最徹底的解耦為目標設計的,在實踐中,如果真的完全按照這個設計,程式碼量的確不小。其實一些地方的耦合並不會引起多大問題,除非你的模組需要封裝成通用元件供多個app使用,否則並不需要按照100%的解耦要求來編寫。因此接下來我再總結一個稍微簡化的方案,總結一下各部分可以在哪些地方出現耦合,哪些耦合不能出現。

在這個方案裡,我使用了一箇中介者來減少一部分程式碼,Router就是一個很適合成為中介者的角色。

架構圖如下:

final viper

View

  • View可以直接通過Router引入另一個子View,不需要通過Presenter的路由來引入
  • View中的一些delegate如果變化的可能性不大,可以直接讓Presenter實現(例如UITableViewDataSource),不用再封裝一遍後交給Presenter
  • View不能出現Model類

Presenter

  • Presenter可以直接呼叫Router執行路由,不用再通過Wireframe封裝一遍
  • Presenter的介面引數中可以出現Model類,但是不能匯入Model類的標頭檔案並且使用Model類,只能用於引數傳遞
  • Presenter中不建議匯入UIKit,除非能保證不會使用那些會影響控制元件渲染的方法

Interactor

  • 一些app中常用的Service可以直接引入,不需要通過外部注入的方式來使用
  • Interactor可以用一個Service Router來動態獲取Service

路由和依賴注入

改變得最多的就是路由部分。View、Presenter和Interactor都可以使用路由來獲取一些模組。View可以通過路由獲取子View,Presenter可以通過路由獲取其他View模組,Interactor可以通過路由獲取Service。

在實現時,可以把Wireframe、Router、Builder整合到一起,全都放到Router裡,Router由模組實現並提供給外部使用。類似於Brigade團隊和Rambler&Co團隊的實現。但是他們的實現都是直接在Router裡引入其他模組的Router,這樣會導致依賴混亂,更好的方式是通過一箇中間人統一提供其他模組的介面。

我在這裡造了個輪子,通過protocol來尋找需要的模組並執行路由,不用直接匯入目的模組中的類,並且提供了Adapter的支援,可以讓多個protocol指向同一個模組。這樣就能避免模組間的直接依賴。

示例程式碼:

///editor模組的依賴宣告
@protocol NoteEditorProtocol <NSObject>
@property (nonatomic, weak) id<ZIKEditorDelegate> delegate;
- (void)constructForCreatingNewNote;
- (void)constructForEditingNote:(ZIKNoteModel *)note;
@end

@implementation ZIKNoteListViewPresenter

- (void)handleDidSelectRowAtIndexPath:(NSIndexPath *)indexPath {
    NSAssert([[self.view routeSource] isKindOfClass:[UIViewController class]], nil);
    
    //跳轉到編輯器介面;通過protocol獲取對應的router類,再通過protocol注入依賴
    //App可以用Adapter把NoteEditorProtocol和真正的protocol進行匹配和轉接
    [ZIKViewRouterToModule(NoteEditorProtocol)
	     performFromSource:[self.view routeSource] //跳轉的源介面
	     configuring:^(ZIKViewRouteConfiguration<NoteEditorProtocol> *config) {
	         //路由配置
	         //設定跳轉方式,支援所有介面跳轉型別
	         config.routeType = ZIKViewRouteTypePush;
	         //Router內部負責用獲取到的引數初始化editor模組
	         config.delegate = self;
	         [config constructForEditingNote:[self.interactor noteAtIndex:indexPath.row]];
	         config.prepareForRoute = ^(id destination) {
	             //跳轉前配置目的介面
	         };
	         config.routeCompletion = ^(id destination) {
	             //跳轉結束處理
	         };
	         config.performerErrorHandler = ^(SEL routeAction, NSError * error) {
	             //跳轉失敗處理
	         };
	     }];
}

@end
複製程式碼

總結

這個方案依賴於一個統一的中間人,也就是路由工具,在我的實現裡就是ZIKRouter。View、Presenter、Interactor都可以使用對應功能的Router獲取子模組。而由於ZIKRouter仍然是通過protocol的方式來和子模組進行互動,因此仍然可保持模組間解耦。唯一的耦合就是各部分都引用了ZIKRouter這個工具。如果你想把模組和ZIKRouter的耦合也去除,可以讓Router也變成面向介面,由外部注入。

Demo和程式碼模板

針對兩個方案,同時寫了兩個相同功能的Demo,可以比較一下程式碼上的區別。地址在:ZIKViper。注意,Demo需要先用pod install安裝一下依賴庫。

專案裡也提供了Xcode File Template用於快速生成VIPER程式碼模板。把.xctemplate字尾的資料夾拷貝到~/Library/Developer/Xcode/Templates/目錄下,就可以在Xcode的New->File->Template裡選擇程式碼模板快速生成程式碼。

參考

相關文章