[譯]再也不對 MVVM 感到絕望

再也不對 MVVM 感到絕望

讓咱們想象一下,你有一個小項目,一般在短短兩天內你就能夠提供新的功能。而後你的項目變得愈來愈大。完成日期開始變得沒法控制,從2天到1周,而後是2周。它會把你逼瘋!你會不斷抱怨:一件好產品不該該那麼複雜!然而這正是我所面對過的,對我來講那確實是一段糟糕的經歷。如今,在這個領域工做了幾年,與許多優秀的工程師合做過,讓我真正意識到使代碼變得如此複雜的並非產品設計,而是我。前端

咱們都有過由於編寫麪條式代碼而損害咱們項目的經歷。問題是咱們該如何去修復它?一個好的架構模式可能會幫到你。在這篇文章中,咱們將要談論一個好的架構模式:Model-View-ViewModel (MVVM)。MVVM 是一種專一於將用戶界面開發與業務邏輯開發實現分離的 iOS 架構趨勢。android

「好架構」這個詞聽起來太抽象了。你會感到無從下手。這裏有一點建議:不要把重點放在體系結構的定義上,咱們能夠把重點放在如何提升代碼的可測試性上。現現在有不少軟件架構,好比 MVC、MVP、MVVM、VIPER。很明顯,咱們可能沒法掌握全部這些架構。可是,咱們要記住一個簡單的原則:無論咱們決定使用什麼樣的架構,最終的目標都是使測試變得更簡單。所以寫代碼以前咱們要根據這一原則進行思考。咱們強調如何直觀的進行責任分離。此外,保持這種思惟模式,架構的設計就會變得很清晰、合理,咱們就不會再陷入瑣碎的細節。ios

太長(若)不看(請看這裏)

在這篇文章中,你將學到:git

  • 咱們之因此選擇 MVVM 而不是 Apple MVC
  • 如何根據 MVVM 設計更清晰的架構
  • 如何基於 MVVM 編寫一個簡單的實際應用程序

你不會看到:github

  • MVVM、VIPER、Clean等架構之間的比較
  • 一個能解決全部問題的萬能方案

全部這些架構都有優勢和缺點,但都是爲了使代碼變得更簡單更清晰。因此咱們決定把重點放在爲何咱們選擇 MVVM 而不是 MVC,以及咱們如何從 MVC 轉到 MVVM。若是您對 MVVM 的缺點有什麼觀點,請參閱本文最後的討論。編程

讓咱們開始吧!swift

Apple MVC

MVC (Model-View-Controller) 是蘋果推薦的架構模式。定義以及 MVC 中對象之間的交互以下圖所示:後端

在 iOS/MacOS 的開發中,因爲引入了 ViewController,一般會變成:api

ViewController 包含 View 和 Model。問題是咱們一般都會在 ViewController 中編寫控制器代碼和視圖層代碼。它使 ViewController 變得太複雜。這就是爲何咱們把它稱爲 Massive View Controller(臃腫的視圖控制)。在爲 ViewController 編寫測試的同時,你須要模擬視圖及其生命週期。但視圖很難被模擬。若是咱們只想測試控制器邏輯,咱們實際上並不想模擬視圖。全部這些都使得編寫測試變得如此複雜。數組

因此 MVVM 來拯救你了。

MVVM — Model — View — ViewModel

MVVM 是由 John Gossman 在 2005 年提出的。MVVM 的主要目的是將數據狀態從 View 移動到 ViewModel。MVVM 中的數據傳遞以下圖所示:

根據定義,View 只包含視覺元素。在視圖中,咱們只作佈局、動畫、初始化 UI 組件等等。View 和 Model 之間有一個稱爲 ViewModel 的特殊層。ViewModel 是 View 的標準表示。也就是說,ViewModel 提供了一組接口,每一個接口表明 View 中的 UI 組件。咱們使用一種稱爲「綁定」的技術將 UI 組件鏈接到 ViewModel 接口。所以,在 MVVM 中,咱們不直接操做 View,而是經過處理 ViewModel 中的業務邏輯從而使視圖也相應地改變。咱們會在 ViewModel 而不是 View 中編寫一些顯示性的東西,例如將 Date 轉換爲 String。所以,沒必要知道 View 的實現就能夠爲顯示的邏輯編寫一個簡單的測試。

讓咱們回過頭再看看上面的圖。一般狀況下,ViewModel 從 View 接收用戶交互,從 Model 中提取數據,而後將數據處理爲一組即將顯示的相關屬性。在 ViewModel 變化後,View 就會自動更新。這就是 MVVM 的所有內容。

具體來講,對於 iOS 開發中的 MVVM,UIView/UIViewController 表示 View。咱們只作:

  1. 初始化/佈局/呈現 UI 組件。
  2. 用 ViewModel 綁定 UI 組件。

另外一方面,在 ViewModel 中,咱們作:

  1. 編寫控制器邏輯,如分頁,錯誤處理等。
  2. 寫顯示邏輯,提供接口到視圖。

你可能會注意到這樣 ViewModel 會變得有點複雜。在本文的最後,咱們將討論 MVVM 的缺點。但不管如何,對於一箇中等規模的項目來講,想一點一點完成目標,MVVM 仍然是一個很棒的選擇。

在接下來的部分,咱們將使用 MVC 模式編寫一個簡單的應用程序,而後描述如何將應用程序重構爲 MVVM 模式。帶有單元測試的示例項目能夠在個人 GitHub 上找到:

讓咱們開始吧!

一個簡單的畫廊 app — MVC

咱們將編寫一個簡單的應用程序,其中:

  1. 該應用程序從 API 中獲取 500px 的照片,並在 UITableView 中列出照片。
  2. tableView 中的每一個 cell 顯示標題、說明和照片的建立日期。
  3. 用戶不能點擊未標記爲「for_sale」的照片。

在這個應用程序中,咱們有一個名爲 Photo 的結構,它表明一張照片。下面是咱們的 Photo 類:

struct Photo {
    let id: Int
    let name: String
    let description: String?
    let created_at: Date
    let image_url: String
    let for_sale: Bool
    let camera: String?
}
複製代碼

該應用程序的初始視圖控制器是一個包含名爲 PhotoListViewController 的 tableView 的 UIViewController。咱們經過 PhotoListViewController 中的 APIService獲取Photo 對象,並在獲取照片後從新載入 tableView:

self?.activityIndicator.startAnimating()
  self.tableView.alpha = 0.0
  apiService.fetchPopularPhoto { [weak self] (success, photos, error) in
      DispatchQueue.main.async {
        self?.photos = photos
        self?.activityIndicator.stopAnimating()
        self?.tableView.alpha = 1.0
        self?.tableView.reloadData()
      }
  }
複製代碼

PhotoListViewController 也是 tableView 的一個數據源:

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    // ....................
    let photo = self.photos[indexPath.row]
    //Wrap the date
    let dateFormateer = DateFormatter()
    dateFormateer.dateFormat = "yyyy-MM-dd"
    cell.dateLabel.text = dateFormateer.string(from: photo.created_at)
    //.....................
}
  
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    return self.photos.count
}
複製代碼

func tableView(_ tableView:UITableView,cellForRowAt indexPath:IndexPath) - > UITableViewCell 中,咱們選擇相應的 Photo 對象並將標題、描述和日期分配給一個 cell。因爲 Photo.date 是一個 Date 對象,咱們必須使用 DateFormatter 將其轉換爲一個 String。

如下代碼是 tableView 委託的實現:

func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? {
    let photo = self.photos[indexPath.row]
    if photo.for_sale { // If item is for sale 
        self.selectedIndexPath = indexPath
        return indexPath
    }else { // If item is not for sale 
        let alert = UIAlertController(title: "Not for sale", message: "This item is not for sale", preferredStyle: .alert)
        alert.addAction( UIAlertAction(title: "Ok", style: .cancel, handler: nil))
        self.present(alert, animated: true, completion: nil)
        return nil
    }
}
複製代碼

咱們在 func tableView(_ tableView:UITableView,willSelectRowAt indexPath:IndexPath) - > IndexPath 中選擇相應的 Photo 對象,檢查 for_sale 屬性。若是是 ture,就保存到 selectedIndexPath。若是是 false,則顯示錯誤消息並返回 nil。

PhotoListViewController 的源碼在這裏,請參考標籤「MVC」。

那麼上面的代碼有什麼問題呢?在 PhotoListViewController 中,咱們能夠找到顯示的邏輯,如將 Date 轉換爲 String 以及什麼時候啓動/中止活動指示符。咱們也有 Veiw 層代碼,如顯示/隱藏 tableView。另外,在視圖控制器中還有另外一個依賴項 ,API 服務。若是你打算爲PhotoListViewController編寫測試,你會發現你被卡住了,由於它太複雜了。咱們必須模擬 APIService,模擬 tableView 以及 cell 來測試整個 PhotoListViewController。唷!

記住,咱們想讓測試變得更容易?讓咱們試試 MVVM 的方法!

嘗試 MVVM

爲了解決這個問題,咱們的首要任務是整理視圖控制器,將視圖控制器分紅兩部分:View 和 ViewModel。具體來講,咱們要:

  1. 設計一組綁定的接口。
  2. 將顯示邏輯和控制器邏輯移到 ViewModel。

首先,咱們來看看 View 中的 UI 組件:

  1. activity Indicator (加載/結束)
  2. tableView (顯示/隱藏)
  3. cells (標題,描述,建立日期)

因此咱們能夠將 UI 組件抽象爲一組規範化的表示:

每一個 UI 組件在 ViewModel 中都有相應的屬性。能夠說咱們在 View 中看到的應該和咱們在 ViewModel 中看到的同樣。

可是咱們該如何綁定呢?

Implement the Binding with Closure

在 Swift 中,有不少方式來實現「綁定」:

  1. 使用 KVO (Key-Value Observing) (鍵值觀察)模式。
  2. 使用第三方庫 FRP (函數式響應編程) 例如 RxSwift 和 ReactiveCocoa。
  3. 本身定製。

使用 KVO 模式是個不錯的注意, 但它可能會建立大量的委託方法,咱們必須當心 addObserver/removeObserver,這可能會成爲 View 的一個負擔。理想的方法是使用 FRP 中的綁定方案。若是你熟悉函數式響應編程,那就放手去作吧!若是不熟悉的話,那麼我不建議使用 FRP 來實現綁定,這樣子就太大材小用了。Here 是一個很好的文章,談論使用裝飾模式來本身實現綁定。在這篇文章中,咱們將把事情簡單化。咱們使用閉包來實現綁定。實際上,在 ViewModel 中,綁定接口/屬性以下所示:

var prop: T {
    didSet {
        self.propChanged?()
    }
}
複製代碼

另外一方面,在 View 中,咱們爲 propChanged 指定一個做爲值更新回調的閉包。

// When Prop changed, do something in the closure 
viewModel.propChanged = { in
    DispatchQueue.main.async {
        // Do something to update view 
    }
}
複製代碼

每次屬性 prop 更新時,都會調用 propChanged。因此咱們就能夠根據 ViewModel 的變化來更新 View。很簡單,對嗎?

在 ViewModel 中進行綁定的接口

如今,讓咱們開始設計咱們的 ViewModel,PhotoListViewModel。給定如下三個UI組件:

  1. tableView
  2. cells
  3. activity indicator

咱們在 PhotoListViewModel 中建立綁定的接口/屬性:

private var cellViewModels: [PhotoListCellViewModel] = [PhotoListCellViewModel]() {
    didSet {
        self.reloadTableViewClosure?()
    }
}
var numberOfCells: Int {
    return cellViewModels.count
}
func getCellViewModel( at indexPath: IndexPath ) -> PhotoListCellViewModel

var isLoading: Bool = false {
    didSet {
        self.updateLoadingStatus?()
    }
}
複製代碼

每一個 PhotoListCellViewModel 對象在 tableView 中造成一個規範顯示的 cell。它提供了用於渲染 UITableView cell 的數據接口。咱們把全部的 PhotoListCellViewModel 對象放入一個數組 cellViewModels 中,cell 的數量剛好是該數組中的項目數。咱們能夠說數組 cellViewModels 表示 tableView。一旦咱們更新 ViewModel 中的 cellViewModels,閉包 reloadTableViewClosure 將被調用而且 View 將進行相應地更新。

一個簡單的 PhotoListCellViewModel 以下所示:

struct PhotoListCellViewModel {
    let titleText: String
    let descText: String
    let imageUrl: String
    let dateText: String
}
複製代碼

正如你所看到的,PhotoListCellViewModel 提供了綁定到 View 中的 UI 組件接口的屬性。

將 View 與 ViewModel 綁定

有了綁定的接口,如今咱們將重點放在 View 部分。首先,在 PhotoListViewController 中,咱們初始化 viewDidLoad 中的回調閉包:

viewModel.updateLoadingStatus = { [weak self] () in
    DispatchQueue.main.async {
        let isLoading = self?.viewModel.isLoading ?? false
        if isLoading {
            self?.activityIndicator.startAnimating()
            self?.tableView.alpha = 0.0
        }else {
            self?.activityIndicator.stopAnimating()
            self?.tableView.alpha = 1.0
        }
    }
}
    
viewModel.reloadTableViewClosure = { [weak self] () in
    DispatchQueue.main.async {
        self?.tableView.reloadData()
    }
}
複製代碼

而後咱們要重構數據源。在 MVC 模式中,咱們在 func tableView(_ tableView:UITableView,cellForRowAt indexPath:IndexPath) - > UITableViewCell 中設置了顯示邏輯,如今咱們必須將顯示邏輯移動到 ViewModel。重構的數據源以下所示:

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    guard let cell = tableView.dequeueReusableCell(withIdentifier: "photoCellIdentifier", for: indexPath) as? PhotoListTableViewCell else { fatalError("Cell not exists in storyboard")}
		
    let cellVM = viewModel.getCellViewModel( at: indexPath )
		
    cell.nameLabel.text = cellVM.titleText
    cell.descriptionLabel.text = cellVM.descText
    cell.mainImageView?.sd_setImage(with: URL( string: cellVM.imageUrl ), completed: nil)
    cell.dateLabel.text = cellVM.dateText
		
    return cell
}
複製代碼

數據流如今變成:

  1. PhotoListViewModel 開始獲取數據。
  2. 獲取數據後,咱們建立 PhotoListCellViewModel 對象並更新 cellViewModels
  3. PhotoListViewController 被通知更新,而後使用更新後的 cellViewModels 佈局 cells。

以下圖所示:

處理用戶交互

咱們來看看用戶交互。在 PhotoListViewModel 中,咱們建立一個函數:

func userPressed( at indexPath: IndexPath )
複製代碼

當用戶點擊單個 cell 時,PhotoListViewController 使用此函數通知 PhotoListViewModel。因此咱們能夠在 PhotoListViewController 中重構委託方法:

func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? {	
    self.viewModel.userPressed(at: indexPath)
    if viewModel.isAllowSegue {
        return indexPath
    }else {
        return nil
    }
}
複製代碼

這意味着一旦 func tableView(_ tableView:UITableView,willSelectRowAt indexPath:IndexPath) - > IndexPath 被調用,則該操做將被傳遞給 PhotoListViewModel。委託函數根據由 PhotoListViewModel 提供的 isAllowSegue 屬性決定是否繼續。咱們就成功地從視圖中刪除了狀態。🍻

PhotoListViewModel 的實現

這是一個漫長的過程,對吧?耐心點,咱們已經觸及到了 MVVM 的核心! 在 PhotoListViewModel 中,咱們有一個名爲 cellViewModels 的數組,它表示 View 中的 tableView。

private var cellViewModels: [PhotoListCellViewModel] = [PhotoListCellViewModel]()
複製代碼

咱們如何獲取並排列數據呢?實際上咱們在 ViewModel 的初始化中作了兩件事:

  1. 注入依賴項目:APIService
  2. 使用 APIService 獲取數據
init( apiService: APIServiceProtocol ) {
    self.apiService = apiService
    initFetch()
}
func initFetch() {	
    self.isLoading = true
    apiService.fetchPopularPhoto { [weak self] (success, photos, error) in
        self?.processFetchedPhoto(photos: photos)
        self?.isLoading = false
    }
}
複製代碼

在上面的代碼片斷中,咱們將屬性 isLoading 設置爲 true,而後開始從 APIService 中獲取數據。因爲咱們以前所作的綁定,將 isLoading 設置爲 true 意味着視圖將切換活動指示器。在 APIService 的回調閉包中,咱們處理提取的照片 models 並將 isLoading 設置爲 false。咱們不須要直接操做 UI 組件,但很顯然,當咱們改變 ViewModel 的這些屬性時,UI 組件就會像咱們所指望的那樣工做。

這裏是 processFetchedPhoto( photos: [Photo] ) 的實現:

private func processFetchedPhoto( photos: [Photo] ) {
    self.photos = photos // Cache
    var vms = [PhotoListCellViewModel]()
    for photo in photos {
        vms.append( createCellViewModel(photo: photo) )
    }
    self.cellViewModels = vms
}
複製代碼

它作了一個簡單的工做,將照片 models 裝成一個 PhotoListCellViewModel 數組。當更新 cellViewModels 屬性時,View 中的 tableView 會相應的更新。

耶,咱們完成了 MVVM 🎉

示例應用程序能夠在個人 GitHub 上找到:

若是你想查看 MVC 版本(標籤:MVC),而後 MVVM(最新的提交)

Recap

在本文中,咱們成功地將一個簡單的應用程序從 MVC 模式轉換爲 MVVM 模式。咱們:

  • 使用閉包建立綁定主題。
  • 從 View 中刪除了全部的控制器邏輯。
  • 建立了一個可測試的 ViewModel。

探討

正如我上面提到的,架構都有優勢和缺點。在閱讀個人文章以後,若是你對 MVVM 的缺點有一些見解。這裏有不少關於 MVVM 缺點的文章,好比:

MVVM is Not Very Good — Soroush Khanlou The Problems with MVVM on iOS — Daniel Hall

我最關心的是 MVVM 中 ViewModel 作了太多的事情。正如我在本文中提到的,咱們在 ViewModel 中有控制器和演示器。此外,MVVM 模式中不包括構建器和路由器。咱們一般把構建器和路由器放在 ViewController 中。若是你對更清晰的解決方案感興趣,能夠了解 MVVM + FlowController (Improve your iOS Architecture with FlowControllers) 和兩個着名的架構,VIPERClean by Uncle Bob.

從小處着手

總會存在更好的解決方案。做爲專業的工程師,咱們一直在學習如何提升代碼質量。許多像我同樣的開發者曾經被這麼多架構所淹沒,不知道如何開始編寫單元測試。因此 MVVM 是一個很好的開始。很簡單,可測試性仍是很不錯的。在另外一篇 Soroush Khanlou 的文章中,8 Patterns to Help You Destroy Massive View Controller,這裏有有不少好的模式,其中一些也被MVVM所採用。與其受一個巨大的架構所阻礙,咱們何不開始用小而強大的 MVVM 模式開始編寫測試呢?

「The secret to getting ahead is getting started.」 — Mark Twain

在下一篇文章中,我將繼續談談如何爲咱們簡單的畫廊應用程序編寫單元測試。敬請關注!

若是你有任何問題,留下評論。歡迎任何形式的討論!感謝您的關注。

參考

Introduction to Model/View/ViewModel pattern for building WPF apps — John Gossman Introduction to MVVM — objc iOS Architecture Patterns — Bohdan Orlov Model-View-ViewModel with swift — SwiftyJimmy Swift Tutorial: An Introduction to the MVVM Design Pattern — DINO BARTOŠAK MVVM — Writing a Testable Presentation Layer with MVVM — Brent Edwards Bindings, Generics, Swift and MVVM — Srdan Rasic


掘金翻譯計劃 是一個翻譯優質互聯網技術文章的社區,文章來源爲 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智能等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章
相關標籤/搜索