更新:在這里可以看到幻燈片
在iOS中使用MVC時感覺怪怪的?對切換到MVVM有疑慮?聽說過VIPER,但不知道是否值得?
往下看,你將會找到這些問題的答案,如果還有疑問,請在評論區留言。
你將了解到在iOS環境下如何進行系統架構設計。我們將簡單回顧一些流行的框架,并通過實踐一些小例子來比較它們的理論。如果需要更多詳細信息,請參考文章中出現的鏈接。
掌握設計模式可能會讓人上癮,所以要小心:你可能在閱讀這篇文章之前已經問過自己一些問題,比如說:
誰應該擁有聯網請求:Model還是Controller?
如何將Model傳遞到新View的View Model中?
誰創建了一個新的VIPER模塊:Router還是Presenter?
為什么要糾結選擇什么架構呢?
假如有一天,你在調試一個實現了幾十種功能的龐大的類時,你會發現自己很難找到并修復你的類中的任何錯誤。并且,很難把這個類作為一個整體來考慮,因此,你總會忽略一些重要的細節。如果你的應用程序中已經出現了這種情況,那么很有可能:
- 這類是UIViewController類。
- UIViewController直接存儲和處理你的數據
- 你的UIView中幾乎沒有做任何事情
- Model僅僅是一個數據結構
- 單元測試覆蓋不了任何內容
即使你遵循了蘋果的指導方針并實現了蘋果的MVC模式,這種情況還是會發生的,所以不要難過。蘋果的MVC有點問題,這個我們稍后再談。
讓我們定義一個優秀系統結構的特征:
1.角色間職責的清晰分配(分布式)。
2.可測試性通常來自第一個特性(不必擔心:使用適當的系統結構是很容易的)。
3.使用方便,維護成本低。
為什么要采用分布式
當我們想弄清楚某些事情是如何運作時,采用分布式能讓我們的大腦思路清晰。如果你認為你開發越多,你的大腦就越能理解復雜性,那么你是對的。但這種能力不是線性的,很快就會達到上限。因此,克服復雜性的最簡單方法是按照單一職責原則在多個實體之間劃分職責。
為什么要可測試
對于那些已經習慣了單元測試的人來說,這通常不是問題,因為在添加了新的特性或者要增加一些類的復雜性之后通常會失敗。這意味著測試能夠降低應用程序在用戶的設備上發生問題的概率,那時修復也許需要一個星期(審核)才能到達用戶。
為什么要易用性
這并不需要回答,但值得一提的是,最好的代碼是從未編寫過的代碼。因此,你擁有的代碼越少,你擁有的bug就越少。這意味著編寫更少代碼的愿望決不能僅僅由開發人員的懶惰來解釋,你不應該偏愛看起來更聰明的解決方案而忽視它的維護成本。
MV(X) 簡介
現在我們在架構設計模式上有很多選擇:
他們中的三個假設將應用程序的實體分成3類:
- Models — 負責保存數據或數據訪問層,操縱數據,例如“人”或“提供數據的人”。
- Views? — ?負責表示層(GUI),iOS環境下通常以“UI”前綴。
- Controller/Presenter/ViewModel? — ?Model和View之間的中介,一般負責在用戶操作View時更新Model,以及當Model變化時更新View。
這種劃分能讓我們:
- 更好地理解它們(如我們所知)
- 重用它們(尤其是View和Model)
- 獨立地進行測試(單元測試)
讓我們從MV(X)開始,稍后在回到VIPER:
MVC
曾經
在討論蘋果對MVC的看法之前,讓我們先看看傳統的MVC。
在上圖的情況下,View是無狀態的。一旦Model被改變,Controller就會簡單地渲染它。例如:網頁完全加載后,一旦你按下鏈接,就導航到其他地方。
雖然在iOS應用用傳統的MVC架構也可以實現,但這并沒有多大意義,由于架構問題?——三個實體是緊耦合的,每個實體和其他兩個通信。這大大降低了可重用性——這可不是你希望在你的應用程序看到的。出于這個原因,我們甚至不想編寫規范的MVC示例。
傳統的MVC似乎不適用于現代IOS開發。
蘋果的MVC
愿景:
Controller是View和Model之間的中介,這樣他們就解耦了。最小的可重用單元是Controller,這對我們來說是個好消息,因為我們必須有一個來放那些不適合放入Model的復雜業務邏輯的地方。
從理論上講,它看起來很簡單,但你覺得有些地方不對,對吧?你甚至聽到有人說MVC全稱應該改為Massive View Controller(大量的視圖控制器)。此外,為View controller減負也成為iOS開發者面臨的一個重要話題。
如果蘋果只接受傳統的MVC并改進了它,為什么會出現這種情況呢?
實際情況:
Cocoa MVC鼓勵人們編寫大規模的視圖控制器,而且由于它們涉及View的生命周期,所以很難說它們(View和Controller)是分離的。
雖然你仍有能力將一些業務邏輯和數據轉換成Model,但你沒辦法將View從Controller中分離。在大多數時候所有View的責任是把事件傳遞給Controller。
ViewController最終演變成一個其他人的delegate和data source,通常負責分派和取消網絡請求…你明白的。
你見過多少這樣的代碼?:
var userCell = tableView.dequeueReusableCellWithIdentifier("identifier") as UserCell
userCell.configureWithUser(user)
Cell(一個View)跟一個Model直接綁定了!所以MVC準則被違反了,但是這種情況總是發生,通常人們不會覺得它是錯誤的。如果你嚴格遵循MVC,那么你應該從Controller配置cell,而不是將Model傳遞到cell中,這將增大Controller。
Cocoa MVC 的全稱應該是 Massive View Controller.
在單元測試之前,這個問題可能并不明顯(希望在你的項目中是這樣)。
由于視圖控制器與視圖緊密耦合,因此很難測試——因為在編寫視圖控制器的代碼時,你必須模擬View的生命周期,從而使你的業務邏輯盡可能地與View層的代碼分隔開來。
讓我們看一看簡單的操場例子:
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
}
// 這里寫布局代碼
}
// 組裝MVC
let model = Person(firstName: "David", lastName: "Blaine")
let view = GreetingViewController()
view.person = model;
MVC在可見的ViewController中進行組裝
這似乎不太容易測試,對嗎?
我們可以將greeting移動到新的GreetingModel類中并分別進行測試,但我們不能在不調用GreetingViewController的有關方法(viewDidLoad, didTapButton,這將會加載所有的View) 的情況下測試UIView中的顯示邏輯(雖然在上面的例子中沒有太多這樣的邏輯)。這不利于單元測試。
事實上,在一個模擬器(如iPhone 4S)中測試UIViews并不能保證它會在其他設備良好的工作(例如iPad),所以我建議從你的單元測試Target中刪除“Host Application”選項,然后脫離應用程序運行你的測試。
View和Controller之間的交互在單元測試中是不可測試的。
如此看來,Cocoa MVC 模式 似乎是一個很糟糕的選擇。但是讓我們根據文章開頭定義的特性來評估它:
- 職責拆分 — View和Model實現了分離,但是View與Controller仍是緊耦合。
- 可測性 — 由于模式的原因,你只能測試你的Model。
- 易用性 — 相比于其他模式代碼量最少。此外,每個人都熟悉它,即使經驗不太豐富的開發人員也能夠維護它。
如果你不愿意在項目的架構上投入太多的時間,那么Cocoa MVC 就是你應該選擇的模式。而且你會發現用其他維護成本較高的模式開發小的應用是一個致命的錯誤。
Cocoa MVC是開發速度最快的架構模式。
MVP
MVP 實現了Cocoa的MVC的愿景
這看起來不正是蘋果的MVC嗎?是的,它的名字是MVP(Passive View variant,被動視圖變體)。等等...這是不是意味著蘋果的MVC實際上是MVP?不,不是這樣。如果你仔細回憶一下,View是和Controller緊密耦合的,但是MVP的中介Presenter并沒有對ViewController的生命周期做任何改變,因此View可以很容易的被模擬出來。在Presenter中根本沒有和布局有關的代碼,但是它卻負責更新View的數據和狀態。
假如告訴你,UIViewController就是View呢?
在MVP中,UIViewController的子類實際上是Views而不是Presenters。這種模式的可測試性得到了極大的提高,付出的代價是開發速度的一些降低,因為必須要做一些手動的數據和事件綁定,從下例中可以看出:
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
}
// 布局代碼
}
// 裝配 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
關于Bindings和Hooters
還有一些其他形態的MVP —— Supervising Controller MVP(監聽Controller的MVP)。這個變體的變化包括View和Model之間的直接綁定,但是Presenter(Supervising Controller)仍然來管理來自View的動作事件,同時也能勝任對View的更新。
但是我們之前就了解到,模糊的職責劃分是非常糟糕的,更何況將View和Model緊密的聯系起來。這和Cocoa的桌面開發的原理有些相似。
和傳統的MVC一樣,寫這樣的例子沒有什么價值,故不再給出。
MVVM
最新且是最偉大的MV(X)系列的一員
MVVM架構是MV(X)系列最新的成員,我們希望它已經考慮到MV(X)系列中之前已經出現的問題。
從理論層面來講Model-View-ViewModel看起來不錯,我們已經非常熟悉View和Model,以及Meditor(中介),在這里它叫做View Model。
它和MVP模式看起來很像:
- MVVM也將ViewController視作View
- 在View和Model之間沒有耦合
此外,它還有像Supervising版本的MVP那樣的綁定功能,但這個綁定不是在View和Model之間而是在View和ViewModel之間。
那么在iOS中ViewModel到底代表了什么?它基本上就是UIKit下的獨立控件以及控件的狀態。ViewModel調用會改變Model同時會將Model的改變更新到自身并且因為我們綁定了View和ViewModel,第一步就是相應的更新狀態。
綁定
我在MVP部分已經提到這點了,但是在這里我們來繼續討論。
綁定是從OS X開發中衍生出來的,但是我們沒有在iOS開發中使用它們。當然我們有KVO通知,但它們沒有綁定方便。
如果我們自己不想自己實現,那么我們有兩種選擇:
- 基于KVO進行綁定,如 RZDataBinding 和 SwiftBond
- 完全的函數響應式編程,例如ReactiveCocoa、RxSwift或者 PromiseKit
事實上,尤其是最近,你聽到MVVM就會想到ReactiveCoca,反之亦然。盡管通過簡單的綁定來使用MVVM是可實現的,但是ReactiveCocoa(或其變體)卻能更好的發揮MVVM的特點。
函數響應式框架有一個殘酷的事實:強大的能力來自于巨大的責任。當你開始使用Reactive的時候有很大的可能就會把事情搞砸。換句話來說就是,如果發現了一些錯誤,調試出這個bug可能會花費大量的時間,看下函數調用棧:
在我們簡單的例子中,FRF框架和KVO被禁用,取而代之地我們直接去調用showGreeting方法更新ViewModel,以及通過greetingDidChange 回調函數使用屬性。
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
}
// 裝配 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分為五個層次:
- 交互器(Interactor) — 包括關于數據和網絡請求的業務邏輯,例如創建一個實體(Entities),或者從服務器中獲取一些數據。為了實現這些功能,需要使用服務、管理器,但是他們并不被認為是VIPER架構內的模塊,而是外部依賴。
- 展示器(Presenter) — 包含UI層面(但UIKit獨立)的業務邏輯以及在交互器(Interactor)層面的方法調用。
- 實體(Entities) — 普通的數據對象,不屬于數據訪問層,因為數據訪問屬于交互器(Interactor)的職責。
- 路由器(Router) — 用來連接VIPER的各個模塊。
基本上,VIPER的模塊可以是一個屏幕或者用戶使用應用的整個過程 —— 例如認證過程,可以由一屏完成或者需要幾步才能完成。你想讓模塊多大,這取決于你。
當我們把VIPER和MV(X)系列作比較時,我們會在職責劃分方面發現一些不同:
- Model(數據交互)邏輯以實體(Entities)為單位拆分到交互器(Interactor)中。
- Controller/Presenter/ViewModel 的UI展示方面的職責移到了Presenter中,但是并沒有數據轉換相關的操作。
- VIPER 是第一個通過路由器(Router)實現明確的地址導航的模式。
找到一個適合的方法來實現路由對于iOS應用是一個挑戰,MV(X)系列并未涉及這一問題。
例子中并不包含路由和模塊之間的交互,所以和MV(X)系列部分架構一樣不再給出例子。
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
}
// 布局代碼
}
// 裝配 VIPER 模塊(不包含路由)
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模式下運轉良好的頁面進行重構,因為二者是可以并存的。
讓一切盡可能簡單,但不是愚蠢。 ?—— ?阿爾伯特·愛因斯坦