本文翻譯自《Model-View-Controller (MVC) in iOS: A Modern Approach》
每一位剛入門的 iOS 開發者都會接觸到大量的知識信息,這些信息對我們學習一門新語言、新框架包括蘋果推薦的 MVC 設計模式來說非常重要。
要跟上 iOS 發展的腳步是一件不容易的事,很多時候開發者并沒有對 MVC 引起足夠的重視,然而很多問題確是由此導致的。
這篇文章會幫助你繞開 MVC 實踐中常見的陷阱。你可以學習到一種更現代的方法來正確地使用 MVC 開發你的 App。
而在文章的結尾,你將會了解如何防止架構上錯誤給你的開發工作埋下隱患。讓我們開始吧!
什么是 MVC
提示:如果你已經了解 MVC 的概念,你可以放心地跳過下面內容的開頭部分,直接從 MVC 的實踐開始。
MVC,顧名思義是由 Model 層、View 層和 Controller 層組成:
- Model:數據存放的地方。比如數據的持久化、數據模型對象、數據的解析以及網絡請求的代碼都會放在這里。
- View:用戶直接交互的地方。這里的類基本都是可以復用的,這些類里沒有特殊的邏輯。比如,
UILabel
就是把文本展示到屏幕上,并且它很容易被重用。 - Controller:Model 和 View 的中介,比較典型的是我們會在這里使用代理模式。在理想的情況下 View 對 Controller 來說是透明的。Controller 會通過一個抽象比如協議來和 View 進行交流,就像
UITableView
通過UITableViewDataSource
來和它的數據源進行交流一樣。
當你把這些放在一起時候,它應該是這樣的:
是不是很簡單呢?
但是常言道:細節決定成敗。只有當你真正實踐 MVC 的時候才會發現事情并不是想象中那么容易。
蘋果官方的 MVC 文檔 對 MVC 有詳細的闡述,這會讓你對 MVC 有一個系統的理論了解,幫助你避免潛在的問題。
但是,僅僅有理論是遠遠不夠,實踐才是檢驗真理的唯一標準。
MVC 的最佳實踐
雖然 MVC 的理論比較容易理解,但是在實踐的過程中我們還是會遇到很多棘手的問題。讓我們來著手解決這些問題吧。
View 層
當用戶使用你的 App 時,他們大部分時間就是在和 View 層打交道。View 層應該是 App 中最直白的部分,因為它不包含任何業務邏輯。在代碼層面,你通常可以在這一層看到:
-
UIView
的子類們。從最基本的UIView
到復雜的 UI 控件。 - 一個
UIViewController
(可以論證的)。我個人認為它應該屬于這一層,因為UIViewController
和其根UIView
以及它的生命周期(loadView
,viewDidLoad
) 是密不可分的。當然不是所有人都同意。 -
UIViewController
的 animations 和 transitions。 -
UIKit/AppKit
、Core Animation
和Core Graphics
中的部分類。
這一層的代碼異味(Code smell)可能有多種表現形式,但是總的來說就是在 View 層做了和 UI 不相關的事。一個典型的代碼異味就是在 UIViewController
中做網絡請求。
為了趕 deadline,往 UIViewController
中扔一堆代碼是一件很誘惑人的事。最好別這樣做,也許這在當下能給你節省幾分鐘時間,但是以后,你可能會為了找一個 bug 而花費幾個小時,或者當你想在另一個 view controller
中重用這段代碼的時候發現這很困難。
把你的 View 層和下面的清單進行核對:
- 它是否和 Model 層進行交互?
- 它是否包含任何業務邏輯?
- 它是否做了一些和 UI 不相關的事?
如果滿足上述任何一個條件,那么是時候對你的 View 層進行清理和重構了。
當然,這些規則不是鐵的定律,有時候由于各種原因你不等不違背。盡管如此,對它們抱以尊重還是很有必要的。
最后,如果你把這些類寫得很好,你總是可以重用它們。如果你不相信我,就看看 GitHub 上 UI 組件的數量吧。
Controller 層
Controller 層是你的 App 中最少重用的部分,因為這里面包含很多特定的邏輯。這并不奇怪,有些東西在你的 App 里有用,但是對其它 App 來說沒有任何用處。
通常,在這一層中你會思考這些問題:
- 先訪問持久化數據還是網絡數據?
- 多久刷新一次 App?
- 頁面的在不同的條件下應該如何跳轉?
- 當 App 進入后臺的時候,哪些需要被清理?
你應該把 Controller 層當作 App 的大腦:它決定了下一步會發生什么。你會經常測試這些類,以確保一切都是如期運行。
舉個例子
現在你應該對 Controller 層有了更好的認識,讓我們來看一個簡單例子。
提示:如果你想了解這在 App 環境下是如何工作的,可以下載我為你準備的簡單 App。
想象一下你有一個 UIViewController
的子類,它想知道參加今年 WWDC 的人員名單。為了達到這個目的,它會利用一個 controller
類。因為蘋果推薦我們應該重視從一個協議開始,所以我們會這么做:
enum UIState {
case Loading
case Success([Attendee])
case Failure(Error)
}
protocol WWDCAttendesDelegate: class {
var state: UIState { get set}
}
我們先將 state
初始化為 Loading
, 然后當參加 WWDC 的人員名單加載成功(或者失敗)的時候更新 state
值。
因為我們不希望在 UIViewController 中處理返回數據,所以用一個單獨的對象(WWDCAttendeesUIController
)來實現WWDCAttendesDelegate
。這樣分離的操作可以讓我們對 WWDCAttendeesUIController
進行獨立的測試。
下一步就是為 Controller 創建一個抽象,你可以把它注入到 UIViewController
中:
protocol WWDCAttendeesHandler: class {
var delegate: WWDCAttendesDelegate? { get set }
func fetchAttendees()
}
UIViewController
子類中的實現是像這樣的:
init(attendeesHandler: WWDCAttendeesHandler) {
self.attendeesHandler = attendeesHandler
super.init(nibName: nil, bundle: nil)
}
override func viewDidLoad() {
super.viewDidLoad()
atteendeesUIController = WWDCAttendeesUIController(view: view, tableView: tableView)
attendeesHandler.delegate = atteendeesUIController
attendeesHandler.fetchAttendees()
}
這種實現方式是把請求的操作放在UIViewController
中,把返回數據的處理操作放在WWDCAttendeesUIController
中:
extension WWDCAttendeesUIController: WWDCAttendesDelegate {
func update(newState: UIState) {
switch(state, newState) {
case (.Loading, .Loading): loadingToLoading()
case (.Loading, .Success(let attendees)): loadingToSuccess(attendees)
default: fatalError("Not yet implemented \(state) to \(newState)")
}
}
func loadingToLoading() {
view.addSubview(loadingView)
loadingView.frame = CGRect(origin: .zero, size: view.frame.size)
}
func loadingToSuccess(attendees: [Attendee]) {
loadingView.removeFromSuperview()
tableViewDataSource.dataSource = attendees.map(AttendeeCellController.init)
}
}
你可以看到 WWDCAttendeesUIController
是 UI 的大腦,而WWDCAttendeesController
是業務邏輯的大腦。
看吧,這并不難!但是這個例子引出了一個問題:誰來創建 Controller ?
我建議將 Controller 封裝成可注入的,所以 Controller 應該由你的 UIViewController
來提供。這有兩個主要的好處:
- 容易測試。你可以傳遞任何遵循
FetchNumberOfTickets
協議的對象。 - Controller 層可以干凈地被解耦。這有助于我們明晰層的責任,使代碼更加健壯。
Model 層
Model 層并不像它看起來那樣不需要解釋。
正如你期望的,這一層的主要組成部分是 model
對象。在票據的例子中,我們會有根據票的結構創建 model
。
除此之外,Model 層里還有以下組成:
- 網絡訪問代碼。它們是長這樣的。一般情況下,整個 app 中只有一個類負責網絡訪問活動。
- 數據持久化代碼。你會在這里使用 Core Data 或者簡單的把數據轉化為
NSData
存儲在磁盤上。 - 數據解析代碼。所有將網絡請求返回數據解析為
model
對象的工作都應該在 Model 層完成。
其中model
對象是領域特定(domain-specific)的,網絡訪問的代碼是高度可復用的。
Controller 會利用 Model 層里的所有元素來定義 App 中的信息流。
MVC: Massive View Controller?
一些不注意的開發者會把不屬于 UIViewController
職責的代碼放到 UIViewController
里,結果就變成了我們所說的 Massive View Controller。越來越多的不相關代碼比如網絡請求和數據解析等,最終讓 UIViewController
變得十分臃腫,導致你很難最終信息的流動。更糟糕的是你很難安全地重構,因為這部分代碼很難寫單元測試。
想要快速找到一個方法去處理 Massive ViewController 是很困難的,所以這往往會變成技術債。這個是 iOS 開發圈常見的問題,這也是為什么 MVC 模式有些“聲名狼藉”。
但是,活人總不能被尿憋死。
作為經驗法則,UIViewController
里的代碼不應該超過 130 行。這似乎很難做到,但是你嚴格地執行,還是很容易達到的。下面的幾條指導原則也許可以幫助你:
-
view controller
里的所有代碼應該是跟 rootUIView
的行為有關。 - 它應該負責 root
UIView
和 Controller 之間的溝通。比如通過IBAction
調用 Controller 里的方法(FetchNumberOfTickets
)。 -
UITableDataSource
、UITableViewDelegate
之類的代理方法也不應該放在這里。如果放在這里,就很難去測試。 - 如果你認為
view controller
有太多的屬性,可以把它拆分成多個view controller
或者創建一個自定義的UIView
。
這些僅僅是作為參考。有時候你的 UIViewController
就是很簡單,那么就沒必要把它拆分的那么細。需要記住的是,每當你讓 view controller
承擔新職責,那么就意味著你放棄了對這段代碼的測試和重用。
關于 MVVM
Model-View-ViewModel,所謂的 MVVM,是 MVC 的一個派生,概念上是相似的。它們之間最大的不同是層于層之間的交流方式,并且在 MVVM 中,Controller 被 ViewModel 所取代。
在實踐中,如果配合 FRP 框架進行使用,MVVM 可以大放異彩。因為 Model 被 ViewModel 監聽,ViewModel 被 View 所監聽,將 FRP 范式用作信息流的管理成為了一個自然而然選擇。 這可以讓層于層之間相互獨立,低耦合度的組件也更容易被測試。
必須要說的是:架構當然是重要的,但是正確的編程范式在提高整體的代碼質量中扮演著更加重要的角色。少數情況下我們會在一個 App 中引入不同的架構或者編程范式,你可能會覺得這樣做破壞了代碼的統一性,但是如果符合業務需求也未嘗不可。
更多
MVC 的出現已經有很多年歷史了,它也會一直發展下去。我們不應該讓 MVC 為開發者的使用不當而背鍋。
MVC 只是一個藍圖,還有很多東西需要開發者自己去填寫。你可以把 MVC 看作一個食譜,它只是指引你應該怎么做,但是還有仍然有很多東西需要你自己決定。這有利也有弊,弊的是,如果沒有足夠的經驗,你可能會繞遠路。利的是,它為你自己的設計預留了靈活的空間。
軟件架構沒有新舊之別,它是一顆銀彈。作為開發者首要關注的是好的工程原則。
假如我能給年輕時候的自己提供關于 MVC 的建議,我會告訴他:
- 首先,要明確每個對象的職責。然后才是思考代碼怎么寫。
- 不要低估依賴注入。它會給你代碼的重用性和可測試性帶來驚喜。
- 盡可能避免在
UIViewController
中寫邏輯。UIViewController
越干凈,你就越容易理解它的行為。
我提供了一個小工程來展示本文討論的 MVC 最佳實踐。
如果你遵循這些原則,就能讓 MVC 成為你的朋友而不是敵人。