MVC 在 iOS 中的最佳實踐

本文翻譯自《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/AppKitCore AnimationCore 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 里的所有代碼應該是跟 root UIView 的行為有關。
  • 它應該負責 root UIView 和 Controller 之間的溝通。比如通過 IBAction 調用 Controller 里的方法(FetchNumberOfTickets)。
  • UITableDataSourceUITableViewDelegate之類的代理方法也不應該放在這里。如果放在這里,就很難去測試。
  • 如果你認為 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 成為你的朋友而不是敵人。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 230,501評論 6 544
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,673評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,610評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,939評論 1 318
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,668評論 6 412
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 56,004評論 1 329
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 44,001評論 3 449
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 43,173評論 0 290
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,705評論 1 336
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,426評論 3 359
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,656評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,139評論 5 364
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,833評論 3 350
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,247評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,580評論 1 295
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,371評論 3 400
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,621評論 2 380

推薦閱讀更多精彩內容