文/某鳥
前言
不知不覺,筆者也撸碼也已經一年多了。隨著撸碼的數量疾速上漲,如何高效,簡單的組織代碼,經常引起筆者的思考。作為一個方法論及其實踐者(這個定義是筆者自己胡謅的),始終希望能夠找到一些簡單、有效的方法來解決問題,由此,也開始了一段構建代碼的實踐體驗。
這次要分享的,是自己在長期實踐 MVVM 結構後,對 MVVM 框架的一些理解與自己的工作流程。其中或許還有一些地方拿捏欠妥,希望大家能一起相互交流。
前戲
ViewModel 這個概念是基於 MVVM 結構提出的,全稱應該叫做 Model-View-ViewModel,從結構上來說,應該是 Model-ViewModel-ViewController-View。簡單來說,就是在 MVC 結構的基礎上,將 ViewController 中數據相關的職能剝離出來,單獨形成一個結構層級。
關於 ViewModel 的詳細定義,可以參考這篇 MVVM介紹。
此外,在工作流中,筆者在一定程度上參考了 BDD 的代碼構建思路,雖然沒有真正意義上的按照行為構建測試代碼,但是其書寫過程與 BDD 確實有相似之處。關於 BDD,可以參考這篇 行為驅動測試。
為本篇文章所編寫的 Demo 已經傳至 Github:傳送門~
好吧,我們開始。
ViewModel 與 ViewController
基類
嗯,在這裡,需要用到 OOP 的經典模式 —— 繼承。
我們不打算把 ViewModel 的功能構建的太重,所以,它只需要一個指向擁有自己的 ViewController 指針,與一個賦值 ViewController 的工廠方法。 就像下面這段代碼:
//BCBaseViewModel.h @interface BCBaseViewModel : NSObject @property (nonatomic,weak,readonly) UIViewController *viewController; +(BCBaseViewModel *)modelWithViewController:(UIViewController *)viewController; @end
ViewModel 只需要一個 weak 類型的 viewController 指針指向自己的 viewController,而 viewModel 則由 viewController 使用 strong 指針持有,用於規避循環引用。
這樣,就足夠了。
委托者與代理者
為了讓 ViewModel 與 ViewController 的關系更加清晰,也為了能夠批量化的生產 ViewModel,接下來要定義的,就是 ViewModel 與 ViewController 的結構特征了。
在分析了 ViewModel 劃分層次的原因與主要承擔的功能之後,我們大致可以總結出這麼幾個特征:
ViewModel 與 ViewController 是一一對應的
ViewModel 實現的功能是從 ViewController 中剝離出來的
ViewModel 是 ViewController 的附屬對象
根據上面幾點特征,最容易想到的類間關系應該就是代理/委托關系了,把一眼就看到的關系說的復雜可能會招罵,但是對接下來的論述,上面多多少少會起到點決定性的作用。
比如,雖然確定了代理與委托,但究竟誰是代理者,誰是委托者呢?換句話說,誰是協議的制定方,而誰又是實現方呢?
筆者這裡給出兩個依據來確認。
協議方法是被動調用方法,也就是反向調用。基於此,協議的實現方,應該同時是事件的響應方,以事件驅動正向調用,再由此觸發反向調用。
協議的實現方實現的方法是通行,且抽象的。反推之,協議的制定方需要實現更難抽象或是更為具體的方法。 這個依據也可以從另外一個層面來理解,即協議的實現方的可替換性應該更強。
第一條依據相對毋庸置疑,畢竟 ViewController 是 View 的持有者與管理者,更是 View 與 ViewModel 相互影響的唯一渠道。讓 ViewModel 作為 View 事件的響應方來驅動 UIViewController,從結構上有些說不通。
第二條則是實踐得來的結論,在實際開發時,由外而內,視圖的修改頻度往往是大於數據的。因此,重構 ViewController 的概率也要大於重構 ViewModel 的概率。不過這種歸納性的結論無法一言蔽之,反而會建議諸位在實際的開發過程當中,應當針對這些開發訴求對結構做更靈活的調整和優化。
這次實踐,則會以 ViewModel 作為協議的制定方,來構建代碼。
讓協議輕一點
在 OC 中,有 @protocol 相關的一系列語法專門用於聲明與實現協議相關的所有功能。但是考慮到具體的 ViewModel 與 ViewController 之間的相互調用都各不相同,如果我們為每一組 ViewModel 與 ViewController 都聲明一份協議,並且交由彼此實現和調用,代碼量激增基本上是一種必然了。
為了讓整個協議結構輕一點,這裡並沒有采用 @protocol 相關語法來實現,而是利用如下代碼:
typedef NSUInteger BCViewControllerCallBackAction; @interface UIViewController(ViewModel) -(void)callBackAction:(BCViewControllerCallBackAction)action info:(id)info; @end
這段代碼做了這麼幾件事:
利用分類,為 UIViewController 拓展了 ViewModel 相關的回調方法聲明。功能類似於父類聲明抽象接口,而交由子類去實現。
接口支持傳參,具體的類不再制定協議方法,而只需要協議參數。
將該分類聲明在 ViewModel 的基類中,即可保證對 ViewModel 可見的 UIViewController 都實現了協議方法,從而不需要再編寫 @protocol 段落。
在具體的 ViewModel 與 ViewController 子類中,只需要根據具體的需求設計回調參數,構建一個 對應的枚舉即可。
將整個協議結構輕質化,主要的原因是因為協議內容變動頻繁。使用枚舉而非 protocol,可以減小改動范圍,且代碼量較少,定制方便。
筆者曾經也嘗試過雙向抽象方法定義,即對 ViewModel 也做一些抽象方法,使雙方僅根據基類約定的協議工作。但實踐下來,ViewModel 的方法並不易於抽象,因為其公共方法往往直接體現了 ViewController 的數據需求。如果強行擬訂抽象方法,反而會在構建具體類時產生歸納困惑,由此產生的最壞結果就是放棄遵守協議,整個代碼反而會變的難以維護。
化需求為行為
在開發過程當中,最常見的開發流還是需求驅動型開發流。說白了,就是扔給你一張示意圖,有時運氣好點還有交互原型神馬的(運氣不好就是別人家的 App = =),然後就交由你任性的東一榔頭西一棒槌的寫寫畫畫。
這個時候,還是建議適當的規劃一下開發流程。主要是考慮這麼幾點:
開發層級與順序;
單位時間內只關心盡可能少的東西;
易於構建和調試;
合理簡省重復性工作。
其實說簡單點,就是讓整個工作流變的有規則和秩序,以確保開發有理有據且可控。另外,也能有效避免反錯的頻率和嚴重程度。
這裡,筆者不要臉的分享自己的簡易工作流。
整個過程並不復雜,其實就是先撸 ViewController 界面,遇到需要數據的地方,就在 ViewModel 中聲明一個方法,然後佯裝調用。撸的代碼大概是這個樣子的:
typedef NS_ENUM(BCViewControllerAction,BCTopViewCallBackAction){ BCTopViewCallBackActionReloadTable = 1 < < 0, BCTopViewCallBackActionReloadResult = 1 << 1 }; @interface BCTopViewModel : BCBaseViewModel - (NSString *)LEDString; - (NSUInteger)operationCount; - (NSString *)operationTextAtIndex:(NSUInteger)index; - (void)undo; - (void)clear; @end @interface BCTopViewController ()@property (nonatomic,strong) BCTopViewModel *model; @property (nonatomic,weak) IBOutlet UITableView *operationTable; @property (nonatomic,weak) IBOutlet UILabel *result; @end @implementation BCTopViewController - (void)viewDidLoad{ [super viewDidLoad]; self.operationTable.tableFooterView = UIView.new; } #pragma mark - action - (IBAction)undo:(UIButton *)sender{ [self.model undo]; } - (IBAction)clear:(UIButton *)sender{ [self.model clear]; } #pragma mark - call back - (void)callBackAction:(BCViewControllerAction)action{ if (action & BCTopViewCallBackActionReloadTable) { [self.operationTable reloadData]; } if (action & BCTopViewCallBackActionReloadResult) { self.result.text = self.model.LEDString; } } #pragma mark - tableView datasource & delegate - (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section{ return self.model.operationCount; } - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath{ UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"cell" forIndexPath:indexPath]; cell.textLabel.text = [self.model operationTextAtIndex:indexPath.row]; return cell; } @end
這樣開發利用了一個 Runtime trick,那就是 Nil 可以響應任何消息。
所以,雖然我們只聲明了方法,並沒有實現,上面的代碼也是隨時可以運行的。換言之,你可以隨時運行來調試界面,而不用擔心 ViewModel 的實現。
相對麻煩的是測試回調方法,筆者自己的建議是在編寫好回調方法之後,在 ViewController 中對應的 ViewModel正向調用之後直接調用自己的回調,如果遇到可能的網絡請求或者需要延時處理的回調,也可以考慮編寫一個基於 dispatch_after 的測試宏來測試回調。
一般來說,視圖界面層的開發總是所見即所得的,所以測試標准就是頁面需求本身。當肉眼可見的所有需求實現,我們的界面編寫也就告一段落了。當然了,此時的代碼依舊是脆弱的,因為我們只做了正向實現,還沒有做邊界用例測試,所以並不知道在非正常情況下,是否會出現什麼詭異的事情。
不過值得慶幸的是,我們已經成功的把 ViewController 中數據相關的部分成功的隔離了出去。在未來的測試中,發現的任何與數據相關的 BUG,我們都可以拍著胸脯說,它肯定和 ViewController 無關。
另外,一如我所說,需求本身就是頁面的測試標准。也就是說,當你實現了需求,你的視圖層就已經通過了測試。是的,我要開始套用 TDD 的思考方式了。我們已經拿著需求當了測試用例,並且一一 Pass。
而當我們開發完 ViewController 的同時,我們也已經為 ViewModel 聲明好了所有公共方法,並且在對應的位置做了調用。BDD 的要點在於 It...when...should 的行為斷言,在此時的環境下,It 就是 ViewModel,when 就是 ViewController 中的每次調用,而 should ,則對應著 ViewModel 所有數據接口所衍生出的變化。
換句話說,我們可能沒辦法從界面上看到所有的行為引發的變化,但是我們已經在 ViewModel 實現之前構建了一個可測試環境。如果時間充足的話,此時的第一件事應當是根據具體的調用環境,為每個公共方法編寫足夠強壯的測試代碼,來避免數據錯誤。
順便說幾句風月場上的虛話。在構建程序的時候,面向接口是優於面向實現的,因為在任何一個系統中,比起信息的產生,信息的傳遞更決定著系統本身是否強大。而編寫代碼的時候,先將抽象功能方法具體化,再將數據逐步抽象化,經歷一個類似梭型的過程,可以更完美的貼合“高內聚、低耦合”的目標。
Fat Model
如果單從 ViewModel 實踐上來說,以上的內容已然解釋的差不多了。不過鑒於筆者手賤撸了一整個 Demo,就額外解釋下其它幾個地方的設計了。
首先是關於胖 Model 的設計。關於胖瘦 Model 的概念筆者也是最近才從這篇 iOS 應用架構談 view層的組織和調用方案 上看到。在此之前,只是憑直覺和朋友討論過 Model 與 Model 之間也應該有所區分。
Model 的胖瘦是根據業務相關性來劃分的。所以,筆者有時會直接將胖 Model 稱之為業務層 Model 以區分瘦 Model。在示例代碼中,CalculatorBrain 應該算是一個相對標准的業務層 Model 了。
如果遇到單個 ViewModel(或者 MVC 中的 Controller)無法解決的需求時,就需要整體業務下沉,交給一個相對獨立的 Model 來解決問題。上層只持有該 Model 開放出來的接口,以此促成的業務層 Model,帶有明顯的業務痕跡,說白了,就是不容易復用。
不過,筆者自己的開發觀點是,弱業務相關的模塊復用性應該強,即功能應該盡量單元化。而強業務相關的模塊則應該有更好的重構性和替換性能,即盡可能的功能內聚。說簡單點,比如這個 Demo 不再是一個計算器,而需要變成一個計數器或者別的什麼,需要重構的就只有 CalculatorBrain 這個類。(當然,這只是基於假設,界面不變底層數據狂變的需求不敢想象…)
從另外一方面來看,在整個 MVVM 框架中,也可以將每個單獨的 ViewModel 視作一個管道。在整個業務鏈中做了雙向的抽象,使整個業務鏈各個部分的替換性都有所提升,筆者個人傾向於將其解釋為,通過設計中間層,均衡了上下層級的復雜度。
更輕量級的 ViewController
objccn.io 第一期的第一篇文章就是更輕量的 View Controllers。文章內曾提到,通過將各個 protocol 的實現挪到 ViewController 之外,來為 ViewController 瘦身。
筆者也曾是這個建議的實踐者之一,甚至一度認為這也是 ViewModel 的主要功能。不過隨著開發時間拉長,筆者不得不重新開始審視這個問題。
首先,這種方法會產生很多額外的接口。我們依舊用 UITableView 來舉例。 假設我們讓 ViewModel 實現了 UITableViewDelegate 與 UITableViewDataSource 協議。這個時候,如果 ViewController 的另一個控件想要根據 tableView 的滾動位置做出響應該怎麼辦呢?由於 ViewModel 才是 tableView 的 delegate,所以我們就需要為 ViewController 聲明額外的公共方法,供 ViewModel 在回調方法中調用。
而我們不難發現,基本所有視圖控件的 Delegate 協議都涉及到視圖本身的響應,只要涉及到同界面下不同控件元素的互動,就不可避免的需要 ViewController 的參與。
筆者也嘗試過將 UITableViewDelegate 實現在 ViewController 中,而把 UITableViewDataSource 托付給 ViewModel 的方式。蛋疼的事情發生在動態高度 Cell 的實現上,我們一方面在 ViewModel 內部給 tableView:cellForRowAtIndexPath 輸入數據,一方面卻又要為 tableView:heightForRowAtIndexPath: 開設接口提供相同的數據以供計算高度。
筆者最後總結了原因,是因為 View 層 與 ViewController 層本身是持有與被持有的依賴關系,所以任何類作為 ViewController 的類內實例來實現協議回調,實際上都是在跨層調用,所以,就注定要以額外的接口為代價,換言之,ViewController 的內聚性變差了。
而另外一方面的原因,則是關於測試。我們說 ViewController 難以測試的原因是因為在大部分情況下,它並沒有幾個像樣的公共方法,且私有方法中還有一大部分方法是傳參回調。如果我們將這些 protocol 實現在另一個類中,其實並不會提升它們的可測試性。更為行之有效的方式,應該是將 protocol 的實現與數據接口隔離開來,讓實現方通過接口來填充數據,而非自身。
在 Demo 中,TopViewModel 便為 cell 的內容填充開設了 operationCount 與 operationTextAtIndex: 這樣的數據接口。相信,為這樣的數據接口構造測試環境,要比為 tableView:cellForRowAtIndexPath 這種方法構造測試環境要簡單的多。從側面來看,這樣的接口反而更合適於測試覆蓋。
基於以上這兩點原因,在之後的開發中,筆者開始將越來越多的 protocol 又請回了 ViewController 中。並且,由於 ViewModel 的存在,筆者更傾向與將 ViewController 構建成為一個獨立實現且只負責實現界面布局、邏輯的類,讓一個類做更少的事,但做的更好。
後記
本文的相關 Demo,實現的功能並不復雜,甚至有些簡陋的不好見人。見責於筆者想象力不周,本著以實踐演示為主的心態,做個參考就好吧。
筆者自诩為方法論及其實踐者,比較認同“構建代碼的方法比代碼更有價值”這個觀點。寫出一兩句驚艷的代碼或許是運氣,掌握方法去構建代碼本身才是戰斗力吧。盡可能讓自己每一句代碼都有理有據,而不是隨心所欲,也覺得會比較負責,起碼寫起來有個交待。
以上的總結見識有限,很多地方或許會有疏漏之處,希望能與諸位看官一起交流,如果能指出其中疏漏甚至錯誤的觀點,那就不甚感激了。
另外,說點說出來就不嫌丟人的話。截至筆者寫完這篇博文,雖然對“設計模式”的相關概念有各種旁敲側擊的求證與查詢,但仍未系統學習過相關概念。說來慚愧,有時候自己花好大功夫才弄明白、想清楚的答案,突然發現某本書、某篇文章上早已幾句話講的明明白白,其實還挺挫敗的。次數多了,甚至會對未知的知識產生抗拒,用來安慰自己很牛逼,這也是特地聲明沒有系統學習的原因吧。
不過開發路漫漫,其實大家都知道,我們只不過是爬到巨人肩上的搬磚工人而已。回頭看看自己腳下的路,每一塊磚都足以讓自己自慚形穢,自欺欺人什麼的,也只不過是浮躁上頭,丟人現眼罷了。
所以筆者在寫本文的中途,已經購買了《設計模式:可復用面向對象軟件的基礎》一書,希望能系統的學習一些代碼的構建技巧吧(以後也好接著吹牛= =)。
後記的後記
筆者最近正在謀求一份新的工作,意向依舊是 iOS 開發,坐標仍舊是深圳。如果您有緣看到這篇文章,且希望我成為您並肩作戰的戰友,或是有個不錯的去處可以推薦的話,還希望您能與我聯系。在此先行謝過了~