iOS架構設計:揭祕MVC, MVP, MVVM以及VIPER

gzhongcheng發表於2018-10-22

iOS架構設計:揭祕MVC, MVP, MVVM以及VIPER

不要錯過最新的iOS開發技能樹 —— github地址

更新:在這裡可以看到幻燈片 在iOS中使用MVC時感覺怪怪的?對切換到MVVM有疑慮?聽說過VIPER,但不知道是否值得?

往下看,你將會找到這些問題的答案,如果還有疑問,請在評論區留言。

你將瞭解到在iOS環境下如何進行系統架構設計。我們將簡單回顧一些流行的框架,並通過實踐一些小例子來比較它們的理論。如果需要更多詳細資訊,請參考文章中出現的連結。

掌握設計模式可能會讓人上癮,所以要小心:你可能在閱讀這篇文章之前已經問過自己一些問題,比如說: 誰應該擁有聯網請求:Model還是Controller? 如何將Model傳遞到新View的View Model中? 誰建立了一個新的VIPER模組:Router還是Presenter?

iOS架構設計:揭祕MVC, MVP, MVVM以及VIPER

為什麼要糾結選擇什麼架構呢?

假如有一天,你在除錯一個實現了幾十種功能的龐大的類時,你會發現自己很難找到並修復你的類中的任何錯誤。並且,很難把這個類作為一個整體來考慮,因此,你總會忽略一些重要的細節。如果你的應用程式中已經出現了這種情況,那麼很有可能:

  • 這類是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。

傳統的MVC

在上圖的情況下,View是無狀態的。一旦Model被改變,Controller就會簡單地渲染它。例如:網頁完全載入後,一旦你按下連結,就導航到其他地方。
雖然在iOS應用用傳統的MVC架構也可以實現,但這並沒有多大意義,由於架構問題 ——三個實體是緊耦合的,每個實體和其他兩個通訊。這大大降低了可重用性——這可不是你希望在你的應用程式看到的。出於這個原因,我們甚至不想編寫規範的MVC示例。

傳統的MVC似乎不適用於現代IOS開發。

蘋果的MVC

願景:

Cocoa MVC

Controller是View和Model之間的中介,這樣他們就解耦了。最小的可重用單元是Controller,這對我們來說是個好訊息,因為我們必須有一個來放那些不適合放入Model的複雜業務邏輯的地方。
從理論上講,它看起來很簡單,但你覺得有些地方不對,對吧?你甚至聽到有人說MVC全稱應該改為Massive View Controller(大量的檢視控制器)。此外,為View controller減負也成為iOS開發者面臨的一個重要話題。
如果蘋果只接受傳統的MVC並改進了它,為什麼會出現這種情況呢?

實際情況:

事實上的Cocoa 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的願景

Passive View 變體 — MVP

這看起來不正是蘋果的MVC嗎?是的,它的名字是MVP(Passive View variant,被動檢視變體)。等等...這是不是意味著蘋果的MVC實際上是MVP?不,不是這樣。如果你仔細回憶一下,View是和Controller緊密耦合的,但是MVP的中介Presenter並沒有對ViewController的生命週期做任何改變,因此View可以很容易的被模擬出來。在Presenter中根本沒有和佈局有關的程式碼,但是它卻負責更新View的資料和狀態。

iOS架構設計:揭祕MVC, MVP, MVVM以及VIPER

假如告訴你,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的更新。

監聽Controller的MVP

但是我們之前就瞭解到,模糊的職責劃分是非常糟糕的,更何況將View和Model緊密的聯絡起來。這和Cocoa的桌面開發的原理有些相似。

和傳統的MVC一樣,寫這樣的例子沒有什麼價值,故不再給出。

MVVM

最新且是最偉大的MV(X)系列的一員

MVVM架構是MV(X)系列最新的成員,我們希望它已經考慮到MV(X)系列中之前已經出現的問題。
從理論層面來講Model-View-ViewModel看起來不錯,我們已經非常熟悉View和Model,以及Meditor(中介),在這裡它叫做View Model。

MVVM

它和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通知,但它們沒有繫結方便。
如果我們自己不想自己實現,那麼我們有兩種選擇:

事實上,尤其是最近,你聽到MVVM就會想到ReactiveCoca,反之亦然。儘管通過簡單的繫結來使用MVVM是可實現的,但是ReactiveCocoa(或其變體)卻能更好的發揮MVVM的特點。

函式響應式框架有一個殘酷的事實:強大的能力來自於巨大的責任。當你開始使用Reactive的時候有很大的可能就會把事情搞砸。換句話來說就是,如果發現了一些錯誤,除錯出這個bug可能會花費大量的時間,看下函式呼叫棧:

Reactive Debugging
在我們簡單的例子中,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分為五個層次:

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模式下運轉良好的頁面進行重構,因為二者是可以並存的。

讓一切儘可能簡單,但不是愚蠢。  ——  阿爾伯特·愛因斯坦

譯自:iOS Architecture Patterns

相關文章