[譯]不再對 MVVM 感到絕望

JaY_zHao發表於2018-02-05

不再對 MVVM 感到絕望

[譯]不再對 MVVM 感到絕望

讓我們想象一下,你有一個小專案,通常在短短兩天內你就可以提供新的功能。然後你的專案變得越來越大。完成日期開始變得無法控制,從2天到1周,然後是2周。它會把你逼瘋!你會不斷抱怨:一件好產品不應該那麼複雜!然而這正是我所面對過的,對我來說那確實是一段糟糕的經歷。現在,在這個領域工作了幾年,與許多優秀的工程師合作過,讓我真正意識到使程式碼變得如此複雜的並不是產品設計,而是我。

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

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

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

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

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

你不會看到:

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

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

讓我們開始吧!

Apple MVC

MVC (Model-View-Controller) 是蘋果推薦的架構模式。定義以及 MVC 中物件之間的互動如下圖所示:

[譯]不再對 MVVM 感到絕望

在 iOS/MacOS 的開發中,由於引入了 ViewController,通常會變成:

[譯]不再對 MVVM 感到絕望

ViewController 包含 View 和 Model。問題是我們通常都會在 ViewController 中編寫控制器程式碼和檢視層程式碼。它使 ViewController 變得太複雜。這就是為什麼我們把它稱為 Massive View Controller(臃腫的檢視控制)。在為 ViewController 編寫測試的同時,你需要模擬檢視及其生命週期。但檢視很難被模擬。如果我們只想測試控制器邏輯,我們實際上並不想模擬檢視。所有這些都使得編寫測試變得如此複雜。

所以 MVVM 來拯救你了。

MVVM — Model — View — ViewModel

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

[譯]不再對 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 元件抽象為一組規範化的表示:

[譯]不再對 MVVM 感到絕望

每個 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。

如下圖所示:

[譯]不再對 MVVM 感到絕望

處理使用者互動

我們來看看使用者互動。在 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前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章