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