- 原文地址:How not to get desperate with MVVM implementation
- 原文作者:S.T.Huang
- 譯文出自:掘金翻譯計劃
- 本文永久連結:github.com/xitu/gold-m…
- 譯者:JayZhaoBoy
- 校對者:swants,ryouaki
不再對 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 中物件之間的互動如下圖所示:
在 iOS/MacOS 的開發中,由於引入了 ViewController,通常會變成:
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。我們只做:
- 初始化/佈局/呈現 UI 元件。
- 用 ViewModel 繫結 UI 元件。
另一方面,在 ViewModel 中,我們做:
- 編寫控制器邏輯,如分頁,錯誤處理等。
- 寫顯示邏輯,提供介面到檢視。
你可能會注意到這樣 ViewModel 會變得有點複雜。在本文的最後,我們將討論 MVVM 的缺點。但無論如何,對於一箇中等規模的專案來說,想一點一點完成目標,MVVM 仍然是一個很棒的選擇。
在接下來的部分,我們將使用 MVC 模式編寫一個簡單的應用程式,然後描述如何將應用程式重構為 MVVM 模式。帶有單元測試的示例專案可以在我的 GitHub 上找到:
讓我們開始吧!
一個簡單的畫廊 app — MVC
我們將編寫一個簡單的應用程式,其中:
- 該應用程式從 API 中獲取 500px 的照片,並在 UITableView 中列出照片。
- tableView 中的每個 cell 顯示標題、說明和照片的建立日期。
- 使用者不能點選未標記為「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。具體來說,我們要:
- 設計一組繫結的介面。
- 將顯示邏輯和控制器邏輯移到 ViewModel。
首先,我們來看看 View 中的 UI 元件:
- activity Indicator (載入/結束)
- tableView (顯示/隱藏)
- cells (標題,描述,建立日期)
所以我們可以將 UI 元件抽象為一組規範化的表示:
每個 UI 元件在 ViewModel 中都有相應的屬性。可以說我們在 View 中看到的應該和我們在 ViewModel 中看到的一樣。
但是我們該如何繫結呢?
Implement the Binding with Closure
在 Swift 中,有很多方式來實現「繫結」:
- 使用 KVO (Key-Value Observing) (鍵值觀察)模式。
- 使用第三方庫 FRP (函式式響應程式設計) 例如 RxSwift 和 ReactiveCocoa。
- 自己定製。
使用 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元件:
- tableView
- cells
- 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
}
複製程式碼
資料流現在變成:
- PhotoListViewModel 開始獲取資料。
- 獲取資料後,我們建立 PhotoListCellViewModel 物件並更新 cellViewModels。
- 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 的初始化中做了兩件事:
- 注入依賴專案:APIService
- 使用 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) 和兩個著名的架構,VIPER 和 Clean 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
掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 Android、iOS、前端、後端、區塊鏈、產品、設計、人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃、官方微博、知乎專欄。