MVVM VS MVC:透過 MVVM 設計模式重構 MVC 應用程式 減低應用程式的複雜性


在 iOS 開發人員維護軟體工程時,設計模式是一項非常重要的工具。我將在下文介紹一些設計模式、和最佳的實踐方式,希望可幫助開發人員創建可靠並可維護的應用程式,換句話說,設計模式可以幫助你管理軟體的複雜性

在本教程中,我將會介紹 “Model-View-ViewModel” (MVVM) 設計模式。為了從發展歷程和實用角度切入,我將以非常有名的 “Model-View-Controller” (MVC) 設計模式作比較,MVC 一直以來受到許多 iOS 開發人員的青睞,儘管如此,MVVM 卻同樣在開發人員中持續獲得關注。

為了幫助讀者理解這些設計模式,我將引導你完成下列顯示應用程式的設計和編碼:

App Demo

我是利用 MVVM 建構這個應用程式的,但本文會先使用 MVC 來建置應用程式的原型,然後將其重構為 MVVM 設計模式。

甚麼是設計模式?

Apple 官方將設計模式 (Design Pattern) 定義為:

⋯⋯「解決 context 內的問題。」讓我們逆向解題,先來解析這句話。Context 是指一個反復出現的情況;而問題就是你在 context 中嘗試實現的目標、以及 context 帶來的任何約束;解決方案就是你想達到的事:為實現目標和解決約束的一般設計。

一個設計模式提煉出具體設計結構的關鍵,而該設計已被證明是有效的。模式具有名稱,並可以識別適用的類別和對象、及其職責和協作,它還說明了後果(成本和收益)和可以應用模式的情況。一個設計模式是特定設計的一種模板或指南,從某種意義上說,具體設計是一個模式的「實例化」。設計模式不是絕對的,在應用層面存在一些靈活性,例如在編程語言和現有體系結構等將影響如何應用模式。 ⋯⋯

Apple、MVC 與 MVVM

讓我先定義一些初步的術語,然後再深入探討 MVC 和 MVVM 的機制。請注意,若對 Apple 的 “Developer” 入口網站搜索 “MVVM” 關鍵字,將得到 “No results were found. Please try a different keyword. (無符合資料,請使用其它關鍵字)” 的訊息,但你可以通過簡單的網頁搜索,找到 大量適用於 iOS 開發的 MVVM 資訊。我將在本教程中解釋 MVVM,但我想讓你知道,Apple 似乎仍然停留在 MVC 中 ── 至少到這個時候,Apple 還沒有談論任何其它的整體應用程式設計模式。

初步定義

你將會注意到文中出現 「視圖/控件」(views/controls) 和「視圖控制器」(view controllers),當我說「視圖/控件」時,指的是我們在 iOS 中用來構建用戶介面 (UI) 使用的所有物件。我們都認同,一個視圖表示可以呈現給用戶的東西,比如包含一段文本的 UITextView,而像是 UIButton 的控件是讓某人能夠與應用程式互動的物件。一個 UIView 可以作為我們組織所有其他控件的基本設計表面。所以「視圖/控件」是指 UIView 類別、和其它提供用戶交互的 UI 控件如 UISlider。請記住所有的控件,至少 UIKit 中的所有控件如 UIButtonUISlider,它們都繼承自 UIView

查看 UIKit 中的 UIButton 類別,在 Apple 的文檔頁面中找到 “Inherits From” 連結,查看繼承樹並從葉級層 UIButton 向上查找父類別 UIView。參考下圖,它看起來像這樣:

UIButton-Inheritance

在這個討論中,「視圖/控件」與 UIView 是同義的。

當我介紹「視圖控制器」時,我是指像 UIViewControllerUITableViewControllerUIPageViewController 等類別,這些類別使視圖/控件可用和實用。為了在 iOS 開發環境中討論 MVC 和 MVVM,「視圖/控件與視圖控制器」和 “MVC” 中的 “VC” 和 “MVVM” 中的第二個 “V” 同義。

請記住,每個 UIViewController 都有 view 的實例屬性 (instance property),它是「由 controller 管理的 view」

這個 view 的屬性被宣告如下:

var view: UIView! { get set }

你可以在 Storyboard 中看到 view 屬性與它的 Owner UIViewController 的關係:

View_Property_Of_Controller

這個 view 是個淡藍色的矩形。

Model-View-Controller (MVC)

Apple 仍在 developer library 中保留有關 MVC 的詳細介紹和大量文檔,儘管該公司將此開發指南標記為 “retired”,但當開發者使用 iOS SDK(如 FoundationUIKitCoreGraphics 時,還是可以明顯發現 Apple 仍是偏向 MVC 的。

想想你幾乎每天都會用到的類別,如 UIViewUIViewControllerUITableViewUITableViewController UIPageControlUIPageViewController。在 iOS 中,視圖/控件和視圖控制器之間關係非常緊密。你可以用程式碼刻出整個用戶介面,但這是很愚蠢的,尤其是在大型應用程式中,考慮螢幕佈局 (Adaptive Auto Layout) 時,根本無法管理和擴展編程建置的 UI。

是的,我們偶爾會需要幾行控制 UI 的程式碼,但會是整個 UI 嗎?我傾向將視圖/控件和視圖控制器當作不可分離的實體,封裝在 Storyboard 和相應的 Swift (.swift) 文件中。我的主要觀點是,對於大多數人來說,編程 iOS 時 “MVC” 中的 “VC” 意味著,在處理視圖/控件和視圖控制器時,兩者有巨大的交集。

在瀏覽或搜索 Apple 文檔時,在泛型資料結構方面,你不會看到 “Model” 這個詞來當 MVC 應用程式的模型,這有部分原因是由於對如 classstructenum 等擁有 Swift 和 Objective-C 語法支持,我希望開發人員將這些視為創建數據模型的首選平台。

當你在 Apple 文檔中遇到 “model” 這個詞時,它通常與非常特定的數據結構應用 Data Organization Tools 相關,例如 VNCoreMLModel。當然,Apple 提供了 Core Data「一個用來管理應用程式中 model layer 物件的框架」,但是它往往需要花費很多工作來啟動和運行,並且使用起來可能有些笨拙。

由於 Apple 一直以來都偏向 MVC,讓我們看看這個設計模式的定義

⋯⋯ MVC 設計模式將一個應用程式劃分成三組元件:模型 (Model)、視圖 (View) 和控制器 (Controller)。 MVC 模式定義了這三組元件在應用程式中扮演的角色和溝通方向。在設計一個應用程式時,最重要的一個步驟是選擇或創建屬於這三組元件之一的自定義類別。三組元件都會由抽象邊界來分隔開,並跨越邊界與其他元件溝通。

模型元件封裝數據和基本行為
模型元件代表特別和專業知識,它們保存應用程式的數據,並定義操作該數據的邏輯。一個設計得好的 MVC 應用程式會將所有重要數據封裝在模型元件中 ⋯⋯

視圖元件向用戶展示數據
視圖元件知道如何展示、甚至允許用戶去編輯應用程式模型的數據。視圖不應負責儲存正在展示的數據。

控制器元件把模型聯繫到視圖
控制器元件就是應用程式視圖和模型的中介。控制器通常負責確保視圖可以取得需要顯示的模型,並充當管道,讓視圖通過管道了解模型的變化。控制器元件還可以執行應用程式的設置和協調任務,並管理其他元件的生命週期。 …

Massive-View-Controller (MVC?)

MVC 雖然在理論上不錯,但它在 iOS 和 Xcode 的背景下通常無法實現,我一次又一次看到開發人員將大部分應用程式的模型(數據)和業務邏輯程式碼放入視圖控制器,所以才被人取笑是 “Massive-View-Controller” 設計模式。但這是一個自然發展的趨勢,因為視圖控制器在應用程式生命週期中扮演著一個最重要的角色:與用戶的互動。

用來對抗複雜性的最佳方法就是分而治之。透過區分權責思維模式,編寫小而邏輯集中組織的程式碼,就是控制複雜性的重要條件,正如下文將看到的 Swift extension 語法結構一樣。

請記住,還有其它非常強大的工具來控制複雜性,如 protocol-oriented programming (POP)(請參閱此處)、object-oriented programming (OOP)error checkingdelegation 以及 property observers 等。

過於肥大的視圖控制器將很難作測試,因為它們有很多屬性,這會帶來不可估量的狀態。如果它們包含許多將業務邏輯與 UI 組件混合的函式,那麼開發有效的測試協定 (testing protocols) 就會非常困難。

過去,Apple 的 Xcode 都鼓勵使用 MVC 模式(我喜歡稱之為「VC 減去 M」),以下是在創建新專案時普遍使用的 iOS Single View App 模板生成的默認文件和目錄結構:

Single_View_App_Template

請注意,圖片中 Xcode 模板文件沒有包含表示 data model 的 classstruct。MVC 的 Model 部分似乎被假定了,並且幾乎都會被有經驗的開發人員視為理所當然,但是新進開發者很可能就會遺忘。如果能由我作主,那麼當使用 Single View App 或其它專案模板創建新項目時,我會重寫 Xcode 以包含一個 “Model.swift” 文件,我的 Model.swift 文件看起來應像這樣:

//
//  Model.swift
//  MVC
//
//  Created by Andrew L. Jaffee on 5/17/18.
//  Copyright © 2018 Andrew L. Jaffee. All rights reserved.
//

import Foundation

class Model {
    
    // Define properties representing
    // your app's data here.
    
}

或是這樣:

//
//  Model.swift
//  MVC
//
//  Created by Andrew L. Jaffee on 5/17/18.
//  Copyright © 2018 Andrew L. Jaffee. All rights reserved.
//

import Foundation

struct Model {
    
    // Define properties representing
    // your app's data here.
    
}

兩個 Model.swift 文件版本都由新創建的專案進行編譯,所以 Xcode 建置的理想 iOS 應用模板看起來像這樣:

MVC_Single_View_App_Template

Model-View-ViewModel (MVVM)

我敢打賭,Model-View-ViewModel (MVVM) 設計模式與 MVC 的意圖差不多,MVVM 比 MVC 模型多添加了一個組件,稱為 ViewModel(視圖模型),它可以是 classstruct,但通常會是一個 class,因此可以在程式碼中傳遞同一個物件的 reference,而 ViewModel 就位於視圖控制器和模型之間。

記得我之前提到,為了在 iOS 開發環境中討論 MVC 和 MVVM,「視圖/控件和視圖控制器」與 “MVC” 中的 “VC”、和 “MVVM” 中的第二個子母 “V” 同義。我們將視圖/控件和視圖控制器當作不可分離的實體,封裝在 Storyboard 和相應的 Swift(.swift) 文件中。

讓我們透過定義每個組成字母 M、V、VM 來理解這種模式。

M 代表「模式」(Model) ── 這是一種以最簡單的格式儲存特定資訊(數據)的結構。藉由保持原始狀態,我們保有它的便攜性和可重用性。例如,我們可以將電話號碼存為 10 位數字或字符,將其保留為 ViewModel,把原始 “2125514701” 轉換為 “(212) 551-4701″、或 “212-551-4701″、或 “212.551.4701” 之類的東西,視圖/視圖控制器可以顯示它從 ViewModel 獲得的電話號碼資訊,如果收到撥打國際號碼要求,會將這些處理細節留給 ViewModel。

V 代表「視圖」(View) ── 我想無須再次討論「視圖/控件和視圖控制器」,請參考我在初步定義的討論。

VM 代表 ViewModel ── 如果由我作主,這是我將添加到 MVC 的新區塊,ViewModel 位於模型和視圖/視圖控制器之間,ViewModel 將模型中的數據轉換為可讀格式,可由視圖控制器在視圖中呈現。ViewModel 還可以利用 property observer 或 key-value observing (KVO) 處理用戶對視圖/視圖控制器呈現數據的更新,視圖數據的更新不會直接進入模型,而是視圖控制器先與 ViewModel 互動,然後與模型交戶。

具體來說,ViewModel 可以使用 DateFormatter 將模型中日期的原始表示,轉換為用戶在視圖上可讀的格式。

總括而言:

  • 視圖/視圖控制器將模型數據呈現給用戶,並且可以允許更新該模型數據。視圖/視圖控制器不會直接與模型溝通,視圖/視圖控制器只能通過 ViewModel 與模型間接溝通。
  • 視圖只能與視圖控制器交互。在 UI 中,應用程式數據完全由 ViewModel 提供給視圖控制器,並透過視圖顯示,而且對視圖中的數據所做的任何更改都必須先傳遞給視圖控制器,該控制器僅 與 ViewModel 溝通
  • ViewModel 是視圖/視圖控制器和模型之間的中介,它是單一的管道、一個守門者,數據通過它從模型流向視圖/視圖控制器,反之亦然。
  • 模型是一個僅用於儲存域名/應用程式特定資訊的數據結構,只有 ViewModel 可與模型對話,也只有 ViewModel 會與視圖/視圖控制器進行互動。

本文不會介紹到的東西

單純讓讀者理解 MVC 和 MVVM 的差異就已經很困難了,所以我不想在本教程中混進太多進階的主題。例如,我本可以展示用戶如何編輯一個被視圖控制器控制的 UITextField,而這個視圖控制器可以在 ViewModel 中設置一個屬性,並且透過 property observer 更新模型⋯⋯ 但我沒有有本文提及這些;另一方面,我也可以講述 ViewModel 如何監視 Model 的變化,然後更新視圖控制器來更新視圖⋯⋯ 但我也沒有提到;我更可以向你展示放在視圖控制器中的 KVO 程式碼⋯⋯ 但我都沒有提及。Swift 4 目前 KVO 實作的方法看起來很笨拙、與 Objective-C 密切相關,它未來可能會有變動,也可能漸漸不受重視。

關於 MVVM 的爭議

一如以往,大眾對 MVVM 設計模式亦有不同的聲音,而我是一個實用主義者,不是教條或專制主義,當我聽到別人說「永遠不會把 UIKit import 到 ViewModel Swift 檔案內,又或是「絕不會將 closure 從視圖控制器傳遞給 ViewModel」時,我都會說:「當我遇到這種情況時,我會進行評估並選擇最簡單和安全的方法」。你會看到以下兩個警告的例子。

我經常看到對於 MVVM 的一個爭論點,就是設計模式中用於下載檔案的 networking code 位置。對我來說答案很簡單:把用於下載檔案的 networking code 放在 ViewModel 內。模型儲存原始數據,但它不知道檔案需要透過何種傳輸方式(如 HTTPS)下載,也不知道顯示在哪些特定的控件(如 UIImageView);相反,ViewModel 層就更接近展示層 (presentation layer),負責處理數據,並且應該知道有關文件的儲存位置、以及如何獲取文件。

只是,到底哪裡是 MVVM 應用程式中下載文件的「最佳」位置?是否每次都會有一個「正確」答案?

範例程式碼

為了展示 MVC 和 MVVM,我創建一個範例應用程式,幫助對天文學感興趣的人觀看一類名為 “Messier object”(梅西爾天體)的恆星現象,如球狀星團和星雲。這應用程式可以作為在 “Hubble’s Messier Catalog” 內提供通往 NASA 收藏 Messier-type object 的 prototype:

「Charles Messier (1730–1817) 是一位法國天文學家,以 “Catalog of Nebulae and Star Clusters” 一作聞名。Messier 是一個狂熱的彗星獵人,他編制了一個深空天體目錄,以幫助其他彗星愛好者免於浪費時間研究非彗星的物體。」

MVC 應用程式

我構建了一個 prototype 應用程式,作為最終應用程式的概念驗證。我想知道 UI 呈現的樣貌、需要甚麼框架、需要寫多少程式碼、以及怎樣的程式碼。最重要的是,我使用了 MVC 設計模式,來看看大家有多容易陷入編寫 “Massive-View-Controller” 程式碼的陷阱,而不是真的在使用 MVC 設計模式。正如前文所指,這類 Massive-VC 應用程式仍然是我遇到新客戶時最常看到的情況。

以下是正在運行的 prototype 應用程式:

prototype_in_action_1

大部分人都應該明白我在做什麼。我已經連接了一個 UITableView 來顯示幾行數據,點擊每一行就會執行一個 segue,來顯示一個詳細頁面,它可以顯示多於一行的資訊。我將主要的 UIViewController 子類別嵌入到 UINavigationViewController 中,讓 segue 將詳細頁面視圖控制器推到螢幕上,用戶可方便地使用 “< Back” 按鈕返回到 UITableView

我們來看看這個 prototype 應用程式的專案結構,你應該可以立即看到一些明顯的缺陷:

Apple_MVC_Xcode_Project

記得我說我喜歡稱 Xcode MVC 模式為「VC 減去 M」嗎?我們看看主要的 UIViewController 程式碼再來討論。

我將一步一步講述程式碼,下文亦會逐步解釋。因此,當閱讀註釋中的步驟時,請參閱下面程式碼中的相同步驟。

MVC 實作程式碼

import UIKit

// #1 - WHY ARE ALL THESE PROTOCOLS ADOPTED HERE?
class ViewController: UIViewController, UITableViewDelegate, UITableViewDataSource {
    
    // #2 - WHY IS THE MODEL IN THE VIEW CONTROLLER?
    let dataSource = ["one", "two", "three"]
    
    // #3 - OK, ALL THIS SHOULD GO IN A
    // UIViewController SUBCLASS
    @IBOutlet weak var tableView: UITableView!
    
    // #3 - OK, ALL THIS SHOULD GO IN A
    // UIViewController SUBCLASS
    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view, typically from a nib.
        
        tableView.delegate = self
        tableView.dataSource = self
    }
    
    // #3 - OK, ALL THIS SHOULD GO IN A
    // UIViewController SUBCLASS
    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
    }
    
    // #3 - OK, ALL THIS SHOULD GO IN A
    // UIViewController SUBCLASS
    override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
        super.traitCollectionDidChange(previousTraitCollection)
    }
    
    // #3 - OK, ALL THIS SHOULD GO IN A
    // UIViewController SUBCLASS
    override func viewWillLayoutSubviews() {
        super.viewWillLayoutSubviews()
    }
    
    // #3 - OK, ALL THIS SHOULD GO IN A
    // UIViewController SUBCLASS
    override func updateViewConstraints() {
        super.updateViewConstraints()
    }
    
    // #3 - OK, ALL THIS SHOULD GO IN A
    // UIViewController SUBCLASS
    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        
        navigationItem.rightBarButtonItem = UIBarButtonItem(title: "Help", style: .plain, target: self, action: #selector(myRightSideBarButtonItemTapped(_:)))
    }
    
    // #3 - OK, ALL THIS SHOULD GO IN A
    // UIViewController SUBCLASS
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
    }
    
    // #3 - OK, ALL THIS SHOULD GO IN A
    // UIViewController SUBCLASS
    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
    }
    
    // #3 - OK, ALL THIS SHOULD GO IN A
    // UIViewController SUBCLASS
    override func viewDidDisappear(_ animated: Bool) {
        super.viewDidDisappear(animated)
    }
    
    // #3 - OK, ALL THIS SHOULD GO IN A
    // UIViewController SUBCLASS
    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
        // Dispose of any resources that can be recreated.
    }
    
    // #4 - IF YOU WANT MAINTAINABLE HELP, PUT
    // THIS IN A SEPARATE FILE
    @objc func myRightSideBarButtonItemTapped(_ sender:UIBarButtonItem!) {
        print("Right navbar button item tapped.")
    }
    
    // #5 - OK, THIS SHOULD GO IN A
    // UIViewController SUBCLASS
    override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
        
        if segue.identifier == "ShowDetail" {
            
            if let destinationViewController = segue.destination as? DetailViewController
            {
                let indexPath = self.tableView.indexPathForSelectedRow!
                let index = indexPath.row
                destinationViewController.data = dataSource[index]
            }
        }
        
    }

    // #6 - WHY IS THIS IN A UIViewController SUBCLASS?
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        tableView.deselectRow(at: indexPath, animated: true)
    }

    // #6 - WHY IS THIS IN A UIViewController SUBCLASS?
    func numberOfSections(in tableView: UITableView) -> Int {
        return 1
    }
    
    // #6 - WHY IS THIS IN A UIViewController SUBCLASS?
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return dataSource.count
    }
    
    // #6 - WHY IS THIS IN A UIViewController SUBCLASS?
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        
        let tableViewCell = tableView.dequeueReusableCell(withIdentifier: "Cell")
        
        tableViewCell?.textLabel?.text = dataSource[indexPath.row]
        tableViewCell?.detailTextLabel?.text = dataSource[indexPath.row] + " subtitle"
        
        return tableViewCell!
        
    }

} // end class ViewController

談談 MVC 程式碼中的註釋

如下文所指,程式碼中大多數下了註釋的問題都與違反分治 (divide and conquer) 、關注點分離 (separation of concerns)、或單一責任原則 (single responsibility principle) 相關,你應該查找並研究這些術語。

#1 ── UIViewController 為什麼在這個 ViewController.swift 文件中指定 UITableViewDelegateUITableViewDataSource 協定?如此一來,這個 UIViewController 子類別不僅難以閱讀,而且這些協定的所有屬性和/或方法一致性要求必須在該文件中實現,因而導致程式碼膨脹。如果 UIViewController 類別需要更多的協定呢?我見過視圖控制器採用多達 6 到 8 種不同協定的例子。我們將在下面的 MVVM 範例中看到一個很好的解決方案。

#2 ── 儘管 MVC 模式指定 “M”(模型)是分開的,但許多開發者只是將整個數據模型推送到主視圖控制器子類別中,這違反了關注點分離並導致程式碼膨脹,特別是模型複雜的時候。

#3 ── 這些所有的 callbacks,包括 viewDidLoadviewDidLayoutSubviewsviewDidAppear 等,都應該放在視圖控制器子類別中。一個視圖控制器已經有很多職責,不應該再放置不屬於視圖控制器的程式碼,比如業務邏輯、補助函數 (Helper Functions)、數據結構、數據模型邏輯等。當一個視圖控制器多了這些額外的程式碼,就會變得難以讀取、維護、優化、支持,是時候面對專注點分離了。

#4 ── 由於我已經構建了許多提供 Interactive Help 的應用程式,因此我使用 OOP 創建了自己的 help class 層次結構。這樣它就可重複使用並易於維護,並提供索引、搜索功能和目錄。只要把應用程式/特定領域的資料填充到模型裡面,就可以在不同的應用程式中使用它。至少你可以將你的 help-handling 入口方法添加到不同的 Swift 文件中,比如 help.swift

#5 ── prepare(for:sender:) 的實例在這裡完全適用。移除所有違反關注點分離原則的程式碼,就會有足夠的空間放下 prepare(for:sender:) 的實例。

#6 ── 為什麼我們需要這些方法來遵循 UITableViewDelegateUITableViewDataSource 協定?這違反了關注點分離,我們將在下面的 MVVM 範例中看到一個很好的解決方案。

MVVM 應用程式

MVVM

我用 MVVM 模式構建了一個天文學 Messier object 的探索應用程式,我真的遵循了 MVVM 的關注點分離原則,有一個模型、視圖/視圖控制器、一個 ViewModel,我們閱讀並討論下列程式碼,以逐個認識 MVVM 的元件。

以下是正在運行的 prototype 應用程式:

App Demo

大部分讀者應該明白我在做什麼,就如同我在前文 「MVC 應用程式」 中第三段給的解釋一樣,只是這些程式碼是使用 MVVM 建構的。

讓我們來看看這個 prototype 應用程式的專案架構,應該可以明顯看到許多比 Massive-VC app 程式碼進步的地方:

MVVM Project Structure

我將逐步解釋程式碼,而這些步驟也可對應下方程式碼中的註釋。因此,當閱讀註釋中的步驟時,請參閱下面程式碼中的相同步驟。

MVVM 程式碼範例:模型

模型是僅用於存放特定應用程式/領域資訊的數據結構,只有 ViewModel 能與視圖/視圖控制器對話,同樣地,也只有 ViewModel 會與模型進行交談。

這個模型嚴格按照原始數據類型進行構建,以實現可移植性。應用程式介面可能會改變,模型可能用於不同的應用程式,我希望這個模型可以長久使用。我對於程式碼唯一的評論是,我不是從 NASA JSONRSS feed 中讀取數據,而是寫死這個模型數據。MVVM 已經夠難理解了,無需在解析某些結構化文件格式時仔細查看一堆輔助程式碼。想了解更多關於使用 NASA 數據的資料,請參閱此連結

import Foundation

// MARK: - Model Support

struct updatedDate {
    
    let month:  Int
    let day:    Int
    let year:   Int
    
}

// MARK: - Model

struct MessierDataModel {
    
    let formalName: String  // "Messier" #
    let commonName: String  // common name
    let pageLink:   String  // NASA overview page
    let imageLink:  String  // NASA detail image link
    let updateDate: updatedDate // NASA page updated date
    let description: String // Messier object description
    let thumbnail:  String  // placeholder for detail image
    
}

// MARK: - Model Data

// For info on use of NASA data, see https://www.nasa.gov/multimedia/guidelines/index.html

let updateDateMessier1 = updatedDate(month: 10, day: 19, year: 2017)

let Messier1 = MessierDataModel(formalName: "Messier 1", commonName: "The Crab Nebula", pageLink: "https://www.nasa.gov/feature/goddard/2017/messier-1-the-crab-nebula", imageLink: "https://www.nasa.gov/sites/default/files/thumbnails/image/crab-nebula-mosaic.jpg", updateDate: updateDateMessier1, description: "In 1054, Chinese astronomers took notice of a \'guest star\' that was, for nearly a month, visible in the daytime sky. The \'guest star\' they observed was actually a supernova explosion, which gave rise to the Crab Nebula, a six-light-year-wide remnant of the violent event. ...", thumbnail: "telescope")

let updateDateMessier8 = updatedDate(month: 10, day: 19, year: 2017)

let Messier8 = MessierDataModel(formalName: "Messier 8", commonName: "The Lagoon Nebula", pageLink: "https://www.nasa.gov/feature/goddard/2017/messier-8-the-lagoon-nebula", imageLink: "https://www.nasa.gov/sites/default/files/thumbnails/image/heic1015a.jpg", updateDate: updateDateMessier8, description: "Commonly known as the Lagoon Nebula, M8 was discovered in 1654 by the Italian astronomer Giovanni Battista Hodierna, who, like Charles Messier, sought to catalog nebulous objects in the night sky so they would not be mistaken for comets. This star-forming cloud of interstellar gas is located in the constellation Sagittarius and its apparent magnitude of 6 makes it faintly visible to the naked eye in dark skies. The best time to observe M8 is during August. ...", thumbnail: "telescope")

let updateDateMessier57 = updatedDate(month: 10, day: 19, year: 2017)

let Messier57 = MessierDataModel(formalName: "Messier 57", commonName: "The Ring Nebula", pageLink: "https://www.nasa.gov/feature/goddard/2017/messier-57-the-ring-nebula", imageLink: "https://www.nasa.gov/sites/default/files/thumbnails/image/ring-nebula-full_jpg.jpg", updateDate: updateDateMessier57, description: "M57, or the Ring Nebula, is a planetary nebula, the glowing remains of a sun-like star. The tiny white dot in the center of the nebula is the star\'s hot core, called a white dwarf. M57 is about 2,000 light-years away in the constellation Lyra, and is best observed during August. Discovered by the French astronomer Antoine Darquier de Pellepoix in 1779, the Ring Nebula has an apparent magnitude of 8.8 and can be spotted with moderately sized telescopes. ...", thumbnail: "telescope")

MVVM 程式碼範例:The ViewModel

ViewModel 是視圖/視圖控制器和模型之間的中介,它是單一的管道、一個守門者,數據通過它從模型流向視圖/視圖控制器,反之亦然。

import Foundation

// #1 - "Should I Stay or Should I Go"
// - The Clash
import UIKit

/**
 #2 - Define a closure TYPE for updating a UIImageView once an image downloads.
 
 - parameter imageData: raw NSData making up the image
 */
public typealias ImageDownloadCompletionClosure = (_ imageData: NSData ) -> Void

// MARK: - #3 - App data through ViewModel

var messierViewModel: [MessierViewModel] =
    [MessierViewModel(messierDataModel: Messier1),
     MessierViewModel(messierDataModel: Messier8),
     MessierViewModel(messierDataModel: Messier57)]

// MARK: - #4 - View Model

class MessierViewModel
{
    
    // #5 - I use some private properties solely for
    // preparing data for presentation in the UI.
    
    private let messierDataModel: MessierDataModel
    
    private var imageURL: URL
    
    private var updatedDate: Date?
    
    init(messierDataModel: MessierDataModel)
    {
        self.messierDataModel = messierDataModel
        self.imageURL = URL(string: messierDataModel.imageLink)!
    }
    
    public var formalName: String {
        return "Formal name: " + messierDataModel.formalName
    }
    
    public var commonName: String {
        return "Common name: " + messierDataModel.commonName
    }
    
    // #6 - Data is made available for presentation only
    // through public getters. No direct access to Model.
    // Some getters prepare data for presentation.
    
    public var dateUpdated: String {
        
        let dateString = String(messierDataModel.updateDate.year) + "-" +
                         String(messierDataModel.updateDate.month) + "-" +
                         String(messierDataModel.updateDate.day)
        
        let dateFormatterGet = DateFormatter()
        dateFormatterGet.dateFormat = "yyyy-MM-dd"
        
        let dateFormatterPrint = DateFormatter()
        dateFormatterPrint.dateFormat = "MMMM dd, yyyy"
        
        if let date = dateFormatterGet.date(from: dateString) {
            updatedDate = date
            return "Updated: " + dateFormatterPrint.string(from: date)
        }
        else {
            return "There was an error decoding the string"
        }
    }
    
    // #7 - Controversial? Should this SOLELY live in the UI?
    public var textDescription: NSAttributedString {
        
        let fontAttributes = [NSAttributedStringKey.font:  UIFont(name: "Georgia", size: 14.0)!, NSAttributedStringKey.foregroundColor: UIColor.blue]
        let markedUpDescription = NSAttributedString(string: messierDataModel.description, attributes:fontAttributes)
        return markedUpDescription
        
    }
    
    public var thumbnail: String {
        return messierDataModel.thumbnail
    }
    
    // #8 - Controversial? Is passing a completion handler into the view
    // model problematic? Should I use KVO or delegation? All's I'm
    // doing is getting some NSData/Data.
    func download(completionHanlder: @escaping ImageDownloadCompletionClosure)
    {
        
        let sessionConfig = URLSessionConfiguration.default
        let session = URLSession(configuration: sessionConfig)
        let request = URLRequest(url:imageURL)
        
        let task = session.downloadTask(with: request) { (tempLocalUrl, response, error) in
            
            if let tempLocalUrl = tempLocalUrl, error == nil {
                if let statusCode = (response as? HTTPURLResponse)?.statusCode {
                    let rawImageData = NSData(contentsOf: tempLocalUrl)
                    completionHanlder(rawImageData!)
                    print("Successfully downloaded. Status code: \(statusCode)")
                }
            } else {
                print("Error took place while downloading a file. Error description: \(String(describing: error?.localizedDescription))")
            }
        } // end let task
        
        task.resume()
        
    } // end func download

} // end class MessierViewModel

#1 ── 在 ViewModel 中導入 UIKit 會是個問題嗎?我扮演魔鬼的擁護者,我很好奇讀者如何看待我在 ViewModel 中使用 UIFont 的情況,我會不會太接近 UI?許多人說,ViewModel 的主要功能就是提供呈現邏輯,所以「我應該留下還是拿掉它?」

#2 ── 請注意,我定義了一個閉包型別,所以我可以在 MVVM 各層之間傳遞程式碼,這樣我是否違反了關注點分離原則?我對此表示懷疑。閉包非常方便且易於控制。

#3 ── 實際上,應用程式的數據來源是 ViewModel。

#4 ── 我選擇了一個類別 (class) 為 ViewModel 型別,所以我可以使用 reference 特性,並在應用程式內傳遞模型。

#5 ── 我使用一些私人屬性來準備在 UI 中展示的數據。例如,我將模型中的 hyperlink 存為 String,但將其轉換為 URL 以下載文件。

#6 ── 數據只能通過 public getters 進行呈現,直接訪問模型是不允許的。有些 getters 會對數據進行預先處理以供展示。

#7 ── 在 ViewModel 內使用 UIKitUIFont,這個「有爭議」的處理方式請參閱#1

#8 ── 將@escapingcompletion handler 傳送到 ViewModel 會有問題嗎?我應該使用 KVO 或是委任 (delegation)?這裡我做的就是獲取一些 NSData/Data。如果對應用程式進行了改動,那麼我可能不得不更改委任程式碼,因為閉包允許創建幾乎任何類型的程式碼塊,更可隨時隨地調用。它們是獨立的,但同時亦可以從定義的環境中取得變數和常數。它們亦可以被分配給屬性、變數和/或常數,並作為參數傳遞給函數/方法。

MVVM 程式碼範例:視圖控制器程式碼

應用程式的主視圖控制器變得非常簡單,程式碼只有一頁,我是如何做到的?下面就是 ViewController.swift 的程式碼:

import UIKit

// MARK: - View Controller

class ViewController: UIViewController {
            
    @IBOutlet weak var tableView: UITableView!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        // #1 - The UITableViewDataSource and
        // UITableViewDelegate protocols are
        // adopted in extensions.
        tableView.delegate = self
        tableView.dataSource = self
    }

    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
    }
    
    override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
        
        if segue.identifier == "DetailSegue" {
            
            if let destinationViewController = segue.destination as? DetailViewController
            {
                let indexPath = self.tableView.indexPathForSelectedRow!
                let index = indexPath.row
                // #2 - The ViewModel is the app's de facto data source.
                // The ViewModel data for the currently-selected table
                // view cell representing a Messier object is passed to
                // a detail view controller via a segue.
                destinationViewController.messierViewModel = messierViewModel[index]
            }
        }
        
    } // end func prepare

} // end class ViewController

#1 ── 透過使用 Swift extension 語法,我能夠移動主視圖控制器的程式碼,以符合 UITableViewDelegateUITableViewDataSource 協定。這個技巧非常簡單。你可能會說使用Swift extension 與 MVVM 無關,但我不認同,因為設計模式的目的就是幫助開發者達到專注點分離,將程式碼分解為更小、更易於管理、更符合邏輯的組件,請參閱下面應用程式專案中的下兩個文件。

#2 ── 應用程式的數據源不是模型,而是 ViewModel。當前選定代表 Messier object 的 TableViewCell,其 ViewModel 數據通過 segue 傳遞給 Detail View Controller,然後用戶就可以在大螢幕上看到 Messier object。

這是主視圖控制器的程式碼,在 VC-Extension + TableViewDelegate.swift 中遵循 UITableViewDelegate 協定:

import Foundation

import UIKit

// MARK: - UITableView Delegate

extension ViewController : UITableViewDelegate {
    
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        tableView.deselectRow(at: indexPath, animated: true)
    }
    
}

我在 didSelectRowAt 中執行的操作是不選擇一個指定的 Table View Row,這樣一來,它被選中時將移除 highlight 效果,讓用戶可以知道他們選擇了哪些 Row;否則,當 Row 一旦被點擊過,它將保持 highlight 顯示效果。

這是主視圖控制器的程式碼,在 VC-Extension + TableViewDelegate.swift 中遵循 UITableViewDataSource 協定:

import Foundation

import UIKit

// MARK: - UITableView Data Source

extension ViewController : UITableViewDataSource {
    
    func numberOfSections(in tableView: UITableView) -> Int {
        return 1
    }
    
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return messierViewModel.count
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        
        let tableViewCell = tableView.dequeueReusableCell(withIdentifier: "Cell")
        
        // #1 - The ViewModel is the app's de facto data source.
        tableViewCell?.imageView?.image = UIImage(named: messierViewModel[indexPath.row].thumbnail)
        tableViewCell?.textLabel?.text = messierViewModel[indexPath.row].formalName
        tableViewCell?.detailTextLabel?.text = messierViewModel[indexPath.row].commonName
        
        return tableViewCell!
    }
    
} // end extension ViewController : UITableViewDataSource

#1 – 應用程式的數據源不是模型,而是 ViewModel。

大多數讀者應該對這些程式碼很熟悉,我將存在 ViewModel 中的每個 Messier object 與 UITableViewCell 中的每個默認 prototype cell UI 元素連接起來。

來看看最後一個視圖控制器的程式碼,我們將在 DetailViewController.swift 中查看 DetailViewController class:

import UIKit

// MARK: - Detail View Controller

class DetailViewController: UIViewController {
    
    var messierViewModel: MessierViewModel?
    
    @IBOutlet weak var titleLabel: UILabel!
    @IBOutlet weak var subtitleLabel: UILabel!
    @IBOutlet weak var updatedLabel: UILabel!
    @IBOutlet weak var descriptionTextView: UITextView!
    @IBOutlet weak var imageView: UIImageView!
    @IBOutlet weak var activitySpinner: UIActivityIndicatorView!
    
    override func viewDidLoad() {
        
        super.viewDidLoad()
        
        imageView.alpha = 0.0
        
        // #1 - Define a closure (completion block) INSTANCE for updating a UIImageView
        // once an image downloads.
        let imageCompletionClosure = { ( imageData: NSData ) -> Void in
            
            // #2 - Download occurs on background thread, but UI update
            // MUST occur on the main thread.
            DispatchQueue.main.async {
                
                // #3 - Animate the appearance of the Messier image.
                UIView.animate(withDuration: 1.0, animations: {
                    self.imageView.alpha = 1.0
                    self.imageView?.image = UIImage(data: imageData as Data)
                    self.view.setNeedsDisplay()
                })
                
                // #4 - Stop and hide the activity spinner as the
                // image has finished downloading
                self.activitySpinner.stopAnimating()
                
            } // end DispatchQueue.main.async
            
        } // end let imageCompletionClosure...
        
        // #5 - Start and show the activity spinner as the
        // image is about to start downloading in background.
        activitySpinner.startAnimating()
        
        // #6 - Update the UI with info from the Messier object
        // the user chose to inspect.
        titleLabel.text = messierViewModel?.formalName
        subtitleLabel.text = messierViewModel?.commonName
        updatedLabel.text = messierViewModel?.dateUpdated
        descriptionTextView.attributedText = messierViewModel?.textDescription
        
        // #7 - Start image downloading in background.
        messierViewModel?.download(completionHanlder: imageCompletionClosure)
        
    } // end func viewDidLoad
    
    override func viewDidLayoutSubviews() {
        
        super.viewDidLayoutSubviews()
        // #8 - make sure UITextView shows beginning
        // of Messier object description
        self.descriptionTextView.setContentOffset(CGPoint.zero, animated: false)
        
    }

    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
    }

} // end class DetailViewController

#1 ── 定義一個用於在圖像下載後更新 UIImageView 的閉包(completion block)實例。

#2 ── 在背景線程上執行下載動作,但 UI 更新必須在主線程上執行(在 completion block 中)。

#3 ── 為 Messier 圖片設置動畫(在 completion block 中)。

#4 ── 完成圖片下載後,停止並隱藏 activity spinner(在 completion block 中)。

#5 ── 當圖片將開始在背景進行下載時,啟動並顯示 activity spinner。

#6 ── 使用 user 選擇查看的 Messier object 資訊更新 UI。

#7 ── 在背景啟動圖片下載作業。

#8 ── 確保 UITextView 顯示 Messier object 敘述的起始文本。

MVVM 範例程式碼:視圖/視圖控制器

快速瀏覽一下我的 Storyboard。我已經連接了一個 UITableView 來顯示幾行 Messier object 數據,點擊每一個 row 將執行一個 segue 來顯示一個詳細資訊頁面,頁面將顯示多於一行的資訊,還包括 Messier object 的圖片。圖片將在背景下載,因為它們是非常大的 multi-megabyte 檔案。我將主要的 UIViewController 子類別嵌入到 UINavigationViewController 中,以便 segueUIViewController 子類別的詳細頁面顯示到螢幕上,然後用戶可以使用內置的 “< Back” 按鈕返回到 UITableView

這是 Main.storyboard 檔案的螢幕截圖:

Main_storyboard

結論

在開發軟件時,我們真的是在控制混亂 ── 至少是在嘗試控制混亂。隨著開發人員向專案添加更多程式碼(variables、constants、structures、enumerations、classes、protocols、conditionals、repetitive constructs 等),軟體複雜性指數式增長。看看這裡的圖表,你就會發現當一個軟體應用程式碼接近六千行,複雜性會逼近無限 ── 不可知、完全不可預測、不可控制。

據估計,Windows 7 包含約四千萬行程式碼,而 macOS 10.4(Tiger)包含約八千五百萬行程式碼,預計這些系統可能表現的行為總量在計算上是不可能的。記得我提到過「指數式」,即復雜性接近無限時,任何具有四千或八千五百萬行程式碼的應用程式都是無限複雜,沒有人能夠知道這些應用程式可以展示的每種可能狀態或行為,我們只能盡力控制混亂。

請記住,即使你和團隊處理的那些「較小」應用程式,也可以輕易達到無限複雜。

那麼工程師可以怎樣做?繼續學習與設計模式有關的文章、看看幫助控制軟體複雜性的其他工具、查詢術語「軟體複雜性 (software complexity)」並加以研究。

請記住,世上沒有完美解決方案。不要與提倡烏托邦或主張 all-or-nothing 的人爭論。我們只是人類,都會隨著時間一直在成長,盡力而為吧。

譯者簡介:陳奕先-過去為平面財經記者,專跑產業新聞,2015 年起跨進軟體開發世界,希望在不同領域中培養新的視野,於新創學校 ALPHA Camp 畢業後,積極投入 iOS 程式開發,目前任職於國內電商公司。聯絡方式:電郵 [email protected]

FB : https://www.facebook.com/yishen.chen.54
Twitter : https://twitter.com/YeEeEsS

原文Introduction to MVVM: Refactoring a MVC App Using the MVVM Design Pattern


熱愛寫作的多產作家,亦是軟體工程師、設計師、和開發員。最近專注於 Objective-C 和 Swift 的 iOS 手機 App 開發。但對於 C#、C++、.NET、JavaScript、HTML、CSS、jQuery、SQL Server、MySQL、Oracle、Agile、Test Driven Development、Git、Continuous Integration、Responsive Web Design 等。

blog comments powered by Disqus
Shares
Share This