在 iOS 中使用 MVC 架構感覺很奇怪? 遷移到MVVM架構又懷有疑慮?聽說過 VIPER 又不確定是否真的值得切換?
相信你會找到以上問題的答案,如果沒找到請在評論中指出。
你將要整理出你在 iOS 環境下所有關於架構模式的知識。我們將帶領大家簡要的回顧一些流行的架構,並且在理論和實踐上對它們進行比較,通過一些小的例子深化你的認知。如果對文中提到的一些關鍵詞有興趣,可以點選連線去檢視更詳細的內容。
掌控設計模式可能會使人上癮,所以要當心,你可能會對一些問題清晰明瞭,不再像閱讀之前那樣迷惑,比如下面這些問題:
誰應該來負責網路請求?Model 還是 Controller ?
應該怎樣向一個新的頁面的 ViewModel 傳入一個 Model ?
誰來建立一個 VIPER 模組,是 Router 還是 Presenter ?
為什麼要關注架構設計?
因為假如你不關心架構,那麼總有一天,需要在同一個龐大的類中除錯若干複雜的事情,你會發現在這樣的條件下,根本不可能在這個類中快速的找到以及有效的修改任何bug.當然,把這樣的一個類想象為一個整體是困難的,因此,有可能一些重要的細節總會在這個過程中會被忽略。如果現在的你正是處於這樣一個開發環境中,很有可能具體的情況就像下面這樣:
- 這個類是一個UIViewController的子類
- 資料直接在UIViewController中儲存
- UIView類幾乎不做任何事情
- Model 僅僅是一個資料結構
- 單元測試覆蓋不了任何用例
以上這些情況仍舊會出現,即使是你遵循了Apple的指導原則並且實現了其 MVC(模式,所以,大可不必驚慌。Apple所提出的 MVC 模式存在一些問題,我們之後會詳述。
在此,我們可以定義一個好的架構應該具備的特點:
- 任務均衡分攤給具有清晰角色的實體
- 可測試性通常都來自與上一條(對於一個合適的架構是非常容易)
- 易用性和低成本維護
為什麼採用分散式?
採用分散式可以在我們要弄清楚一些事情的原理時保持一個均衡的負載。如果你認為你的開發工作越多,你的大腦越能習慣複雜的思維,其實這是對的。但是,不能忽略的一個事實是,這種思維能力並不是線性增長的,而且也並不能很快的到達峰值。所以,能夠戰勝這種複雜性的最簡單的方法就是在遵循 單一功能原則 的前提下,將功能劃分給不同的實體。
為什麼需要易測性?
其實這條要求對於哪些習慣了單元測試的人並不是一個問題,因為在新增了新的特性或者要增加一些類的複雜性之後通常會失效。這就意味著,測試可以避免開發者在執行時才發現問題—-當應用到達使用者的裝置,每一次維護都需要浪費長達至少[一週](http://appreviewtimes.com)的時間才能再次分發給使用者。
為什麼需要易用性?
這個問題沒有固定的答案,但值得一提的是,最好的程式碼是那些從未寫過的程式碼。因此,程式碼寫的越少,Bug就越少。這意味著希望寫更少的程式碼不應該被單純的解釋為開發者的懶惰,而且也不應該因為偏愛更聰明的解決方案而忽視了它的維護開銷。
MV(X)系列概要
當今我們已經有很架構設計模式方面的選擇:
前三種設計模式都把一個應用中的實體分為以下三類:
- Models–負責主要的資料或者運算元據的資料訪問層,可以想象 Perspn 和 PersonDataProvider 類。
- Views–負責展示層(GUI),對於iOS環境可以聯想一下以 UI 開頭的所有類。
- Controller/Presenter/ViewModel–負責協調 Model 和 View,通常根據使用者在View上的動作在Model上作出對應的更改,同時將更改的資訊返回到View上。
將實體進行劃分給我們帶來了以下好處:
- 更好的理解它們之間的關係
- 複用(尤其是對於View和Model)
- 獨立的測試
讓我們開始瞭解MV(X)系列,之後再返回到VIPER模式。
MVC的過去
在我們探討Apple的MVC模式之前,我們來看下傳統的MVC模式。
傳統的MVC
在這裡,View並沒有任何界限,僅僅是簡單的在Controller中呈現出Model的變化。想象一下,就像網頁一樣,在點選了跳轉到某個其他頁面的連線之後就會完全的重新載入頁面。儘管在iOS平臺上實現這這種MVC模式是沒有任何難度的,但是它並不會為我們解決架構問題帶來任何裨益。因為它本身也是,三個實體間相互都有通訊,而且是緊密耦合的。這很顯然會大大降低了三者的複用性,而這正是我們不願意看到的。鑑於此我們不再給出例子。
“傳統的MVC架構不適用於當下的iOS開發”
蘋果推薦的MVC–願景
Cocoa MVC
由於Controller是一個介於View 和 Model之間的協調器,所以View和Model之間沒有任何直接的聯絡。Controller是一個最小可重用單元,這對我們來說是一個好訊息,因為我們總要找一個地方來寫邏輯複雜度較高的程式碼,而這些程式碼又不適合放在Model中。
理論上來講,這種模式看起來非常直觀,但你有沒有感到哪裡有一絲詭異?你甚至聽說過,有人將MVC的縮寫展開成(Massive View Controller),更有甚者,為View controller減負也成為iOS開發者面臨的一個重要話題。如果蘋果繼承並且對MVC模式有一些進展,所有這些為什麼還會發生?
蘋果推薦的MVC–事實
Realistic Cocoa MVC
Cocoa的MVC模式驅使人們寫出臃腫的檢視控制器,因為它們經常被混雜到View的生命週期中,因此很難說View和ViewController是分離的。儘管仍可以將業務邏輯和資料轉換到Model,但是大多數情況下當需要為View減負的時候我們卻無能為力了,View的最大的任務就是向Controller傳遞使用者動作事件。ViewController不再承擔一切代理和資料來源的職責,通常只負責一些分發和取消網路請求以及一些其他的任務,因此它的名字的由來…你懂的。
你可能會看見過很多次這樣的程式碼:
1 2 |
var userCell = tableView.dequeueReusableCellWithIdentifier("identifier") as UserCell userCell.configureWithUser(user) |
這個cell,正是由View直接來呼叫Model,所以事實上MVC的原則已經違背了,但是這種情況是一直髮生的甚至於人們不覺得這裡有哪些不對。如果嚴格遵守MVC的話,你會把對cell的設定放在 Controller 中,不向View傳遞一個Model物件,這樣就會大大減少Controller的體積。
“Cocoa 的MVC被寫成Massive View Controller 是不無道理的。”
直到進行單元測試的時候才會發現問題越來越明顯。因為你的ViewController和View是緊密耦合的,對它們進行測試就顯得很艱難–你得有足夠的創造性來模擬View和它們的生命週期,在以這樣的方式來寫View Controller的同時,業務邏輯的程式碼也逐漸被分散到View的佈局程式碼中去。
我們看下一些簡單的例子:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
import UIKit struct Person { // Model let firstName: String let lastName: String } class GreetingViewController : UIViewController { // View + Controller var person: Person! let showGreetingButton = UIButton() let greetingLabel = UILabel() override func viewDidLoad() { super.viewDidLoad() self.showGreetingButton.addTarget(self, action: "didTapButton:", forControlEvents: .TouchUpInside) } func didTapButton(button: UIButton) { let greeting = "Hello" + " " + self.person.firstName + " " + self.person.lastName self.greetingLabel.text = greeting } // layout code goes here } // Assembling of MVC let model = Person(firstName: "David", lastName: "Blaine") let view = GreetingViewController() view.person = model; |
“MVC可以在一個正在顯示的ViewController中實現”
這段程式碼看起來可測試性並不強,我們可以把和greeting相關的都放到GreetingModel中然後分開測試,但是這樣我們就無法通過直接呼叫在GreetingViewController中的UIView的方法(viewDidLoad和didTapButton方法)來測試頁面的展示邏輯了,因為一旦呼叫則會使整個頁面都變化,這對單元測試來講並不是什麼好訊息。
事實上,在單獨一個模擬器中(比如iPhone 4S)載入並測試UIView並不能保證在其他裝置中也能正常工作,因此我建議在單元測試的Target的設定下移除”Host Application”項,並且不要在模擬器中測試你的應用。
“View和Controller的介面並不適合單元測試。”
以上所述,似乎Cocoa MVC 看起來是一個相當差的架構方案。我們來重新評估一下文章開頭我們提出的MVC一系列的特徵:
- 任務均攤–View和Model確實是分開的,但是View和Controller卻是緊密耦合的
- 可測試性–由於糟糕的分散性,只能對Model進行測試
- 易用性–與其他幾種模式相比最小的程式碼量。熟悉的人很多,因而即使對於經驗不那麼豐富的開發者來講維護起來也較為容易。
如果你不想在架構選擇上投入更多精力,那麼Cocoa MVC無疑是最好的方案,而且你會發現一些其他維護成本較高的模式對於你所開發的小的應用是一個致命的打擊。
“就開發速度而言,Cocoa MVC是最好的架構選擇方案。”
MVP 實現了Cocoa的MVC的願景
Passive View variant of MVP
這看起來不正是蘋果所提出的MVC方案嗎?確實是的,這種模式的名字叫做MVC,但是,這就是說蘋果的MVC實際上就是MVP了?不,並不是這樣的。如果你仔細回憶一下,View是和Controller緊密耦合的,但是MVP的協調器Presenter並沒有對ViewController的生命週期做任何改變,因此View可以很容易的被模擬出來。在Presenter中根本沒有和佈局有關的程式碼,但是它卻負責更新View的資料和狀態。
“假如告訴你UIViewController就是View呢?”
就MVP而言,UIViewController的子類實際上就是Views並不是Presenters。這點區別使得這種模式的可測試性得到了極大的提高,付出的代價是開發速度的一些降低,因為必須要做一些手動的資料和事件繫結,從下例中可以看出:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 |
import UIKit struct Person { // Model let firstName: String let lastName: String } protocol GreetingView: class { func setGreeting(greeting: String) } protocol GreetingViewPresenter { init(view: GreetingView, person: Person) func showGreeting() } class GreetingPresenter : GreetingViewPresenter { unowned let view: GreetingView let person: Person required init(view: GreetingView, person: Person) { self.view = view self.person = person } func showGreeting() { let greeting = "Hello" + " " + self.person.firstName + " " + self.person.lastName self.view.setGreeting(greeting) } } class GreetingViewController : UIViewController, GreetingView { var presenter: GreetingViewPresenter! let showGreetingButton = UIButton() let greetingLabel = UILabel() override func viewDidLoad() { super.viewDidLoad() self.showGreetingButton.addTarget(self, action: "didTapButton:", forControlEvents: .TouchUpInside) } func didTapButton(button: UIButton) { self.presenter.showGreeting() } func setGreeting(greeting: String) { self.greetingLabel.text = greeting } // layout code goes here } // Assembling of MVP let model = Person(firstName: "David", lastName: "Blaine") let view = GreetingViewController() let presenter = GreetingPresenter(view: view, person: model) view.presenter = presenter |
關於整合問題的重要說明
MVP是第一個如何協調整合三個實際上分離的層次的架構模式,既然我們不希望View涉及到Model,那麼在顯示的View Controller(其實就是View)中處理這種協調的邏輯就是不正確的,因此我們需要在其他地方來做這些事情。例如,我們可以做基於整個App範圍內的路由服務,由它來負責執行協調任務,以及View到View的展示。這個出現並且必須處理的問題不僅僅是在MVP模式中,同時也存在於以下集中方案中。
我們來看下MVP模式下的三個特性的分析:
- 任務均攤–我們將最主要的任務劃分到Presenter和Model,而View的功能較少(雖然上述例子中Model的任務也並不多)。
- 可測試性–非常好,由於一個功能簡單的View層,所以測試大多數業務邏輯也變得簡單
- 易用性–在我們上邊不切實際的簡單的例子中,程式碼量是MVC模式的2倍,但同時MVP的概念卻非常清晰
“iOS 中的MVP意味著可測試性強、程式碼量大。”
MVP–繫結和訊號
還有一些其他形態的MVP–監控控制器的MVP。
這個變體包含了View和Model之間的直接繫結,但是Presenter仍然來管理來自View的動作事件,同時也能勝任對View的更新。
Supervising Presenter variant of the MVP
但是我們之前就瞭解到,模糊的職責劃分是非常糟糕的,更何況將View和Model緊密的聯絡起來。這和Cocoa的桌面開發的原理有些相似。
和傳統的MVC一樣,寫這樣的例子沒有什麼價值,故不再給出。
MVVM–最新且是最偉大的MV(X)系列的一員
MVVM架構是MV(X)系列最新的一員,因此讓我們希望它已經考慮到MV(X)系列中之前已經出現的問題。
從理論層面來講MVVM看起來不錯,我們已經非常熟悉View和Model,以及Meditor,在MVVM中它是View Model。
MVVM
它和MVP模式看起來非常像:
- MVVM將ViewController視作View
- 在View和Model之間沒有緊密的聯絡
此外,它還有像監管版本的MVP那樣的繫結功能,但這個繫結不是在View和Model之間而是在View和ViewModel之間。
那麼問題來了,在iOS中ViewModel實際上代表什麼?它基本上就是UIKit下的每個控制元件以及控制元件的狀態。ViewModel呼叫會改變Model同時會將Model的改變更新到自身並且因為我們繫結了View和ViewModel,第一步就是相應的更新狀態。
繫結
我在MVP部分已經提到這點了,但是該部分我們仍會繼續討論。
如果我們自己不想自己實現,那麼我們有兩種選擇:
- 基於KVO的繫結庫如 RZDataBinding 和 SwiftBond
- 完全的函式響應式程式設計,比如像ReactiveCocoa、RxSwift或者 PromiseKit
事實上,尤其是最近,你聽到MVVM就會想到ReactiveCoca,反之亦然。儘管通過簡單的繫結來使用MVVM是可實現的,但是ReactiveCocoa卻能更好的發揮MVVM的特點。
但是關於這個框架有一個不得不說的事實:強大的能力來自於巨大的責任。當你開始使用Reactive的時候有很大的可能就會把事情搞砸。換句話來說就是,如果發現了一些錯誤,除錯出這個bug可能會花費大量的時間,看下函式呼叫棧:
Reactive Debugging
在我們簡單的例子中,FRF框架和KVO被過渡禁用,取而代之地我們直接去呼叫showGreeting方法更新ViewModel,以及通過greetingDidChange 回撥函式使用屬性。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 |
import UIKit struct Person { // Model let firstName: String let lastName: String } protocol GreetingViewModelProtocol: class { var greeting: String? { get } var greetingDidChange: ((GreetingViewModelProtocol) -> ())? { get set } // function to call when greeting did change init(person: Person) func showGreeting() } class GreetingViewModel : GreetingViewModelProtocol { let person: Person var greeting: String? { didSet { self.greetingDidChange?(self) } } var greetingDidChange: ((GreetingViewModelProtocol) -> ())? required init(person: Person) { self.person = person } func showGreeting() { self.greeting = "Hello" + " " + self.person.firstName + " " + self.person.lastName } } class GreetingViewController : UIViewController { var viewModel: GreetingViewModelProtocol! { didSet { self.viewModel.greetingDidChange = { [unowned self] viewModel in self.greetingLabel.text = viewModel.greeting } } } let showGreetingButton = UIButton() let greetingLabel = UILabel() override func viewDidLoad() { super.viewDidLoad() self.showGreetingButton.addTarget(self.viewModel, action: "showGreeting", forControlEvents: .TouchUpInside) } // layout code goes here } // Assembling of MVVM let model = Person(firstName: "David", lastName: "Blaine") let viewModel = GreetingViewModel(person: model) let view = GreetingViewController() view.viewModel = viewModel |
讓我們再來看看關於三個特性的評估:
- 任務均攤 — 在例子中並不是很清晰,但是事實上,MVVM的View要比MVP中的View承擔的責任多。因為前者通過ViewModel的設定繫結來更新狀態,而後者只監聽Presenter的事件但並不會對自己有什麼更新。
- 可測試性 — ViewModel不知道關於View的任何事情,這允許我們可以輕易的測試ViewModel。同時View也可以被測試,但是由於屬於UIKit的範疇,對他們的測試通常會被忽略。
- 易用性 — 在我們例子中的程式碼量和MVP的差不多,但是在實際開發中,我們必須把View中的事件指向Presenter並且手動的來更新View,如果使用繫結的話,MVVM程式碼量將會小的多。
“MVVM很誘人,因為它集合了上述方法的優點,並且由於在View層的繫結,它並不需要其他附加的程式碼來更新View,儘管這樣,可測試性依然很強。”
VIPER–把LEGO建築經驗遷移到iOS app的設計
VIPER是我們最後要介紹的,由於不是來自於MV(X)系列,它具備一定的趣味性。
迄今為止,劃分責任的粒度是很好的選擇。VIPER在責任劃分層面進行了迭代,VIPER分為五個層次:
VIPER
- 互動器 — 包括關於資料和網路請求的業務邏輯,例如建立一個實體(資料),或者從伺服器中獲取一些資料。為了實現這些功能,需要使用服務、管理器,但是他們並不被認為是VIPER架構內的模組,而是外部依賴。
- 展示器 — 包含UI層面的業務邏輯以及在互動器層面的方法呼叫。
- 實體 — 普通的資料物件,不屬於資料訪問層次,因為資料訪問屬於互動器的職責。
- 路由器 — 用來連線VIPER的各個模組。
基本上,VIPER模組可以是一個螢幕或者使用者使用應用的整個過程–想想認證過程,可以由一屏完成或者需要幾步才能完成,你的模組期望是多大的,這取決於你。
當我們把VIPER和MV(X)系列作比較時,我們會在任務均攤性方面發現一些不同:
- Model 邏輯通過把實體作為最小的資料結構轉換到互動器中。
- Controller/Presenter/ViewModel的UI展示方面的職責移到了Presenter中,但是並沒有資料轉換相關的操作。
- VIPER是第一個通過路由器實現明確的地址導航模式。
“找到一個適合的方法來實現路由對於iOS應用是一個挑戰,MV(X)系列避開了這個問題。”
例子中並不包含路由和模組之間的互動,所以和MV(X)系列部分架構一樣不再給出例子。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 |
import UIKit struct Person { // Entity (usually more complex e.g. NSManagedObject) let firstName: String let lastName: String } struct GreetingData { // Transport data structure (not Entity) let greeting: String let subject: String } protocol GreetingProvider { func provideGreetingData() } protocol GreetingOutput: class { func receiveGreetingData(greetingData: GreetingData) } class GreetingInteractor : GreetingProvider { weak var output: GreetingOutput! func provideGreetingData() { let person = Person(firstName: "David", lastName: "Blaine") // usually comes from data access layer let subject = person.firstName + " " + person.lastName let greeting = GreetingData(greeting: "Hello", subject: subject) self.output.receiveGreetingData(greeting) } } protocol GreetingViewEventHandler { func didTapShowGreetingButton() } protocol GreetingView: class { func setGreeting(greeting: String) } class GreetingPresenter : GreetingOutput, GreetingViewEventHandler { weak var view: GreetingView! var greetingProvider: GreetingProvider! func didTapShowGreetingButton() { self.greetingProvider.provideGreetingData() } func receiveGreetingData(greetingData: GreetingData) { let greeting = greetingData.greeting + " " + greetingData.subject self.view.setGreeting(greeting) } } class GreetingViewController : UIViewController, GreetingView { var eventHandler: GreetingViewEventHandler! let showGreetingButton = UIButton() let greetingLabel = UILabel() override func viewDidLoad() { super.viewDidLoad() self.showGreetingButton.addTarget(self, action: "didTapButton:", forControlEvents: .TouchUpInside) } func didTapButton(button: UIButton) { self.eventHandler.didTapShowGreetingButton() } func setGreeting(greeting: String) { self.greetingLabel.text = greeting } // layout code goes here } // Assembling of VIPER module, without Router let view = GreetingViewController() let presenter = GreetingPresenter() let interactor = GreetingInteractor() view.eventHandler = presenter presenter.view = view presenter.greetingProvider = interactor interactor.output = presenter |
讓我們再來評估一下特性:
- 任務均攤 — 毫無疑問,VIPER是任務劃分中的佼佼者。
- 可測試性 — 不出意外地,更好的分佈性就有更好的可測試性。
- 易用性 — 最後你可能已經猜到了維護成本方面的問題。你必須為很小功能的類寫出大量的介面。
什麼是LEGO
當使用VIPER時,你的感覺就像是用樂高積木來搭建一個城堡,這也是一個表明當前存在一些問題的訊號。可能現在就應用VIPER架構還為時過早,考慮一些更為簡單的模式可能會更好。一些人會忽略這些問題,大材小用。假定他們篤信VIPER架構會在未來給他們的應用帶來一些好處,雖然現在維護起來確實是有些不合理。如果你也持這樣的觀點,我為你推薦 Generamba 這個用來搭建VIPER架構的工具。雖然我個人感覺,使用起來就像加農炮的自動瞄準系統,而不是簡單的像投石器那樣的簡單的拋擲。
總結
我們瞭解了集中架構模式,希望你已經找到了到底是什麼在困擾你。毫無疑問通過閱讀本篇文章,你已經瞭解到其實並沒有完全的銀彈。所以選擇架構是一個根據實際情況具體分析利弊的過程。
因此,在同一個應用中包含著多種架構。比如,你開始的時候使用MVC,然後突然意識到一個頁面在MVC模式下的變得越來越難以維護,然後就切換到MVVM架構,但是僅僅針對這一個頁面。並沒有必要對哪些MVC模式下運轉良好的頁面進行重構,因為二者是可以並存的。