用 Swift 實作 Smalltalk MVC

在 Apple 開發圈,我們都很熟悉所謂的 MVC 設計模式——把整個程式裡的物件分成 Model、View 與 Controller 三種不同的角色,讓它們分別負責解決不同的問題。Model 物件專責資料的封裝與相關的基礎行為,View 物件代表使用者看得到的介面元件,而 Controller 物件負責把 Model 物件與 View 物件連結在一起。1它們之間的互動關係也被定義得很清楚:以 Controller 為核心去操控 View 與 Model 物件,並接收它們的通知。View 與 Model 物件之間不能直接溝通,要通過 Controller 才可以。

雖然畫成關係圖後看起來是個很簡潔的架構,但大家大都也知道 MVC 容易產生 Controller 肥大的問題。由於 View 與 Model 物件通常都有非常明確的職責,所以沒有明確職責的 Controller 就常常變成所有其它代碼的回收處,產生 Massive View Controller 之類的「神之物件」——什麼都做,什麼都不奇怪。

為了解決 MVC 中肥大的 Controller,Apple 開發圈引入了 MVVM 與 VIPER 等架構,意圖再把 Controller 拆分成職責更明確的角色。但是萬變不離其宗,它們其實都是 Mediator 模式的變種。他們都使不定數量的物件去做 View 與 Model 層之間的中介者,不管那是 View Model 還是 Presenter。

Mediator 模式的設計初衷是使 View 與 Model 的元件可以保持簡潔、容易測試與重用,但是它同時也帶來了兩個弊端:

  1. 間接費成本(overhead cost):View 與 Model 之間的溝通變得曲折,每一層中介都是多出來的維護成本。
  2. 資料流的複雜化:由於 Model 物件受 Mediator 物件所管理,所以需要特別花力氣去管理不同 Mediator 之間的資料同步與流動等等。

間接費是 Mediator 的原罪,但可以透過減少中間層來應對。資料流的問題牽涉更深,得要回答一個很根本的問題⋯⋯

為什麼 App 架構的最上層不能是 Model?

當我們在思考 App 架構時,通常會把它想成類似這樣的圖表吧:

也就是說,最上層的元件已經被我們預設為 Controller 了。Controller 好像就是責任最重大,也具有最終決定權的負責人一樣,其它角色的物件都要歸它管。用 OOP 的術語來說的話,Controller 就是最抽象的那一層,而整個 App 就是由不同的 Controller 物件與它們所控制的 View、Model 物件所組成。

但讓我們退後一步來思考看看。所謂的抽象,其實是將解決方案領域(Solution Domain)的概念轉譯、整理到問題領域(Problem Domain)的過程。比如說抽水馬桶的使用者並不需要知道它的運作原理(解決方案領域)是什麼,只需要知道按下沖水按鈕它就會幫你把排泄物沖走(問題領域)就好。

照這個定義來看的話,在 MVC 當中,最靠近問題領域的 Model,應該才是最抽象的那一層。比如說當使用者在一個文字處理器 app 裡編輯一份文件的時候,他其實不用知道這個 app 用了哪些 View 跟 Controller 元件(解決方案領域),只需要知道文件資料有照他的意思被編輯(問題領域)就好了。而代表文件資料的,正是 Model 層。

事實上,原始的 MVC —— Smalltalk MVC——的確就是這樣設計的。

Smalltalk MVC 的架構

一般說到 Smalltalk MVC 跟 Apple 版 MVC——Cocoa MVC——最顯著的差別,大概是在於它的 View 可以直接跟 Model 溝通而不用通過 Controller 吧。但是這只是非常表面的差異,它背後實際上是完全不同的兩個架構。不只是角色間的關係不同,連角色本身的意義都很不一樣。

第一:在 Smalltalk MVC 當中,Controller 並不是 Mediator。它跟職責模糊不清的 Cocoa MVC Controller 不一樣,是一個真正的 OOP 物件,負責對輸入裝置抽象。也就是說,它並不是「物件 Controller」,而是「遊戲主機 Controller(手把)」的那個 Controller。使用者透過 View 來觀看 Model、透過 Controller 去編輯 Model。

所以,在 Smalltalk MVC 裡,選單跟 UIControl 物件其實同時是 View 也是 Controller,因為除了顯示之外,使用者還可以透過這些元件去操控 app。這跟 Cocoa MVC 的 Controller 意義完全不一樣。

第二:Model 是最高的抽象層級,所以 View 與 Controller 都是隨附於 Model。如果有誰變更 Model 的話,是由 Model 自己去廣播變更,而不是由 Controller。

第三:View 並沒有處理使用者動作的責任,經常需要跟對應的 Controller 緊密配合,所以耦合會較強。同時,唯讀的 View 是完全不需要 Controller 的,因為它只需要顯示 Model 資料而已,不需配合 Controller 處理使用者輸入。

Smalltalk MVC 的互動關係雖然是三向進行的,但其實概念非常的簡單,就是以 Model 為核心延伸出不定數量的 View 來顯示資料、Controller 來接受編輯更動,並將變更通知廣播給所有這些 Dependent(隨附物件)。Dependent 之間——尤其是成對的 View 與 Controller 物件之間——可以互相溝通,不會像 Mediator 模式一樣一定要透過一個中間人。

而由於 Model 對它的所有 Dependent 廣播是用 Observer 模式來達成,所以它不需要知道它們是什麼、有幾個,耦合度不高。Model 所要做的僅僅是發出通知說什麼資料更新了,然後 Dependent 自己要去實作應對資料更新的行為。

看到這裡,你可能會擔心 Smalltalk MVC 不適合用在 Apple 開發圈,但實際上無論是 UIKit 還是 AppKit 都多少具備了 Smalltalk MVC 的特性在裡面。舉例來說,NSObject 本身就已經透過 KVO 來實作了 Observer 模式,很適合用在 Model 的資料變更廣播上面。又比如說 UIViewController 比起強調解耦的 Cocoa MVC 來說,反而更接近強調 View 與 Controller 搭配合作的 Smalltalk MVC。

實作範例:一個筆記 app

讓我們用 Smalltalk MVC 來實作一個簡單的筆記 app 看看。它大概會長這個樣子:

這個 app 裡有三則筆記,使用者點選 Table View 當中的筆記 Cell 後,會彈出一個筆記 View 來給使用者編輯。筆記 View 中的所有文字變更都會在結束編輯後同步到 Table View 中相對應的筆記 Cell 上。

由於一則一則的筆記就是使用者概念上互動的東西,我們就從筆記 Model 來下手吧。

//  NoteModel.swift

final class NoteModel {

    init(text: String) {
        self.text = text
    }
    
    var text = "" 
}

這個是 NoteModel 本身的資料結構。如果它只有這樣的話,那它就只是一個 Dumb Model 而已。但在 Smalltalk MVC 裡面,它還需要能通知它的 Dependent 資料有更動。我們之前有談到,NSObject 的 KVO 機制很適合做這個廣播功能,但問題是 KVO 並不那麼適合 Swift 的 Value Type,所以得想其它方法來實作。NotificationCenter 與 Combine 都是不錯的選擇,但今天先讓我們 DIY 一個簡單的 Observer 機制吧!

//  NoteModel.swift

protocol NoteDependent: AnyObject {
    func didAdd(to model: NoteModel)
    func updateText(_ text: String)
    func willRemoveFromModel()
}

final class NoteModel {
    
    init(text: String) {
        self.text = text
    }
    
    var text = "" {
        didSet {
            for dependent in dependents.values {
                dependent.updateText(text)
            }
        }
    }
    
    private var dependents = [ObjectIdentifier: NoteDependent]()
    
    func addDependent(_ dependent: NoteDependent) {
        
        dependents[ObjectIdentifier(dependent)] = dependent
        dependent.didAdd(to: self)
    }
    
    func removeDependent(_ dependent: NoteDependent) {
        
        dependent.willRemoveFromModel()
        dependents[ObjectIdentifier(dependent)] = nil
    }
}

在這個實作裡,我們利用 Swift Standard Library 的 ObjectIdentifier 來做字典的 key,以方便在 NoteModel 裡儲存要通知的 NoteDependent 對象,而不用再另外給 Dependent 定義 identifier。

Model 會在三個不同的時機通知它的 Dependent:

  1. didAdd(to:):將 Model 的 reference 傳給 Dependent,讓 Dependent 可以把它存下來備用。同時,這也是 Dependent 抓取初始資料狀態的好時機。
  2. updateText(_:):Model 的資料有更新的時候。
  3. willRemoveFromModel():Model 移除 Dependent 之前。適合把狀態清理乾淨。

這樣已經是一個可以運作的 Observer 機制了,但因為 Dictionary 的 key 跟 value 都是 strong reference,很容易造成裡面的物件無法被釋放,所以最好再透過一個 Value Type 去把它轉成 weak reference:

//  NoteModel.swift

protocol NoteDependent: AnyObject {
    func didAdd(to model: NoteModel)
    func updateText(_ text: String)
    func willRemoveFromModel()
}

final class NoteModel {

    struct Dependency {
        weak var dependent: NoteDependent?
    }
    
    init(text: String) {
        self.text = text
    }
    
    var text = "" {
        didSet {
            for dependency in dependencies.values {
                dependency.dependent?.updateText(text)
            }
        }
    }
    
    private var dependencies = [ObjectIdentifier: Dependency]()
    
    func addDependent(_ dependent: NoteDependent) {
        
        dependencies[ObjectIdentifier(dependent)] = Dependency(dependent: dependent)
        dependent.didAdd(to: self)
    }
    
    func removeDependent(_ dependent: NoteDependent) {
        
        dependent.willRemoveFromModel()
        dependencies[ObjectIdentifier(dependent)] = nil
    }
}

如此一來,NoteModel 的主要功能——持有資料與發出通知——就完成了。接下來,就要創造它的 Dependent——NoteTableViewCellNoteViewController 了:

//  NoteTableViewCell.swift

import UIKit

class NoteTableViewCell: UITableViewCell {

}

extension NoteTableViewCell: NoteDependent {
    
    func didAdd(to model: NoteModel) {
        textLabel?.text = model.text
    }
    
    func updateText(_ text: String) {
        textLabel?.text = text
    }
    
    func willRemoveFromModel() {
        textLabel?.text = nil
    }
}

在 Cocoa MVC 中,Cell 因為是屬於 View,所以最好要透過 Controller 去跟 Model 溝通。然而 Smalltalk MVC 並沒有這樣的限制,View 與 Controller 相對於 Model 的關係是平起平坐的,所以這邊的 NoteTableViewCell 可以遵守 NoteDependent,並直接抓取 NoteModel 的資料來顯示。

//  NoteViewController.swift

import UIKit

class NoteViewController: UIViewController {
    
    weak var model: NoteModel?

    @IBOutlet weak var textView: UITextView! {
        didSet {
            textView.delegate = self
        }
    }

    deinit {
        model?.removeDependent(self)
    }
}

extension NoteViewController: UITextViewDelegate {
    
    func textViewDidEndEditing(_ textView: UITextView) {
        model?.text = textView.text
    }
}

extension NoteViewController: NoteDependent {
    
    func didAdd(to model: NoteModel) {
        self.model = model
        updateText(model.text)
    }
    
    func updateText(_ text: String) {
        loadViewIfNeeded()
        guard textView.text != text else { return }
        textView.text = text
    }
    
    func willRemoveFromModel() {
        updateText("")
        self.model = nil
    }
}

NoteViewContoller 裡,由於需要將輸入的文字回傳給 NoteModel,所以在 didAdd(to:) 裡我們就把 Model 的 reference 存起來,在文字被編輯的時候就可以直接更動 Model 的值。

最後,是筆記列表 View:

//  NoteListViewController.swift

import UIKit

class NoteListViewController: UITableViewController {
    
    var noteModels: [NoteModel] = [
        NoteModel(text: "🐶"),
        NoteModel(text: "🐱"),
        NoteModel(text: "🐷")
    ]

    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        noteModels.count
    }
    
    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) as! NoteTableViewCell

        noteModels[indexPath.row].addDependent(cell)

        return cell
    }
    
    override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
        switch segue.identifier {
        case "PresentNote":
            guard let noteVC = segue.destination as? NoteViewController,
                let cell = sender as? UITableViewCell,
                let indexPath = tableView.indexPath(for: cell) else { break }

            noteModels[indexPath.row].addDependent(noteVC)

        default:
            break
        }
    }
}

NoteListViewController 裡,我們不直接去抓取 NoteModel 資料並把它在 NoteTableViewCell 上顯示出來,而是把 Cell 加入到 Model 成為它的 Dependent。其它的事情,就留給它們自己去解決了。對 NoteViewController 也是一樣的處理方法。所以在這裡,你只會看到類似 model.addDependent(cell) 的語句而已。

到這裡,整個實作就大致完成了。

結論

其實在實作面來看,除去我們 DIY 出來的 Observer 機制,其它代碼可能跟一般的 Cocoa MVC 實作相差不多,這是因為它們兩者之間的差別主要是概念上的。Smalltalk MVC 的 Model、View 與 Controller 有更清晰的職責分配,分別是負責資料顯示輸入。雖然會造成一些 View 對 Model 的依賴與耦合,但像 NoteTableViewCell 這樣的 View 類型,或許原本概念上就跟 NoteModel 難分難捨吧?

不過即使 Smalltalk MVC 有諸多好處,也沒有必要把所有的專案都朝這個方向來重構,畢竟很多時候既有的 Cocoa MVC 或其它類似的模式可能更好用。Smalltalk MVC 最大的優勢還是在當同一個 Model 同時有多個 View 與 Controller 的時候,因為它能夠大幅減少資料流的複雜度。如果你的 side project 正好符合這點,不妨給它一個機會試試看。

參考資料

閒談軟體架構:MVC – 閒談軟體架構 – Medium

WWDC 2014 Session 229 Advanced iOS Architecture and Patterns

Glenn E. Krasner and Stephen T. Pope – 1988 – A Description of the Model-View-Controller User Interface Paradigm in the Smalltalk-80 System

Cocoa Design Patterns

Using Key-Value Observing in Swift | Apple Developer Documentation

How did MVC get so F’ed up?

Trygve/MVC

Observers in Swift – Part 1 | Swift by Sundell

  1. https://developer.apple.com/library/archive/documentation/General/Conceptual/CocoaEncyclopedia/Model-View-Controller/Model-View-Controller.html#//apple_ref/doc/uid/TP40010810-CH14