關於 MVC 的一個常見的誤用

如何避免把 Model View Controller 寫成 Massive View Controller 已是老生常談的問題了。無論是拆分 View Controller 的功能 (使用多個 Child View Controller),仍是換用「廣義」的 MVC 框架 (好比 MVVM 或者 VIPER),又或者更激進一點,轉換思路使用 Reactive 模式或 Reducer 模式,其實所想要解決的問題本質在於,咱們要如何才能更清晰地管理「用戶操做,模型變動,UI 反饋」這一數據流動的方式。git

非傳統的 MVC 能夠幫助咱們遵循一些更不容易犯錯的編程範式 (這一點和 Java 很像,使用冗雜的 pattern 來規範開發,讓新人也能寫出「成熟」的代碼),可是若是不從根本上理解數據流動在 MVC 中的角色,那不過就是末學膚受,早晚會出現問題。github

例子

舉一個很是簡單的 View Controller 的例子。假設咱們有一個 Table View Controller 來記錄 To Do 列表,咱們能夠經過點擊導航欄的加號按鈕來追加一個條目,用 Swipe cell 的方式刪除條目。咱們但願最多同時只能存在 10 條待辦項目。這個 View Controller 的代碼很是簡單,可能也是不少開發者天天會寫的代碼。包括設置 Playground 和添加按鈕等等,一共也就 60 行。我將它放到了這個 gist 中,你能夠所有複製下來扔到 Playground 裏查看效果。面試

小編這裏推薦一個羣:691040931 裏面有大量的書籍和麪試資料,不少的iOS開發者都在裏面交流技術

面試資料截圖.jpg

這裏簡單對比較關鍵的代碼進行一些解釋。首先是模型定義:數據庫

// 定義簡單的 ToDo Model
struct ToDoItem {
    let id: UUID
    let title: String
    
    init(title: String) {
        self.id = UUID()
        self.title = title
    }
}

而後咱們使用 UITableViewController 的子類進行待辦事項的展現和添加:編程

class ToDoListViewController: UITableViewController {
    // 保存當前待辦事項
    var items: [ToDoItem] = []
    
    // 點擊添加按鈕
    @objc func addButtonPressed(_ sender: Any) {
        let newCount = items.count + 1
        let title = "ToDo Item \(newCount)"
        
        // 更新 `items`
        items.append(.init(title: title))
        
        // 爲 table view 添加新行
        let indexPath = IndexPath(row: newCount - 1, section: 0)
        tableView.insertRows(at: [indexPath], with: .automatic)
        
        // 肯定是否達到列表上限,若是達到,禁用 addButton
        if newCount >= 10 {
            addButton?.isEnabled = false
        }
    }
}

接下來,處理 table view 的展現,這部份內容乏善可陳:後端

extension ToDoListViewController {
    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return items.count
    }
    
    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: cellIdentifier, for: indexPath)
        cell.textLabel?.text = items[indexPath.row].title
        return cell
    }
}

最後,實現滑動 cell 刪除的功能:api

extension ToDoListViewController {
    override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
        let deleteAction = UIContextualAction(style: .destructive, title: "Delete") { _, _, done in
            // 用戶確認刪除,從 `items` 中移除該事項
            self.items.remove(at: indexPath.row)
            // 從 table view 中移除對應行
            self.tableView.deleteRows(at: [indexPath], with: .automatic)
            // 維護 addButton 狀態
            if self.items.count < 10 {
                self.addButton?.isEnabled = true
            }
            done(true)
        }
        return UISwipeActionsConfiguration(actions: [deleteAction])
    }
}

效果以下:
todo-demo.gif
看起來一切正常工做!不過,你能看出有什麼問題嗎?抑或說你以爲這段代碼已經完美無瑕了?數組

風險
簡單來講,這也已是對 MVC 的誤用了。上面的代碼存在着這些潛在問題:緩存

1.Model 層「寄生」在ViewController 中

在這段代碼中,View Controller 裏的 items 充當了 model。安全

這致使了幾個問題:咱們難以從外界維護或者同步 items的狀態,添加和刪除操做被「綁定」在了這個 View Controller 裏,若是你還想經過其餘 View Controller 維護待辦列表的話,就不得不考慮數據同步的問題 (咱們會在稍後看到幾個具體的這方面的例子);另外,這樣的設置致使 items 難以測試。你幾乎沒法爲添加/刪除/修改待辦列表進行 Model 層的測試。

2.違反數據流動規則和單一職責規則

若是咱們仔細思考,會發現,用戶點擊添加按鈕,或者側滑刪除 cell 時,在 View Controller 中其實發生了這些事情:

1.維護 Model (也就是 items)
2.增刪 table view 的 cell
3.維護 addButton 的可用狀態
也就是說,UI 操做不只致使了 Model 的變動,還同時致使了 UI 的變化。理想化的數據流動應該是單向的:UI 操做 -> 經由 View Controller 進行模型更新 -> 新的模型經由 View Controller 更新 UI -> 等待新的 UI 操做,而在例子中,咱們變成了「經由 View Controller 進行模型更新以及 UI 操做」。雖然看起來這是很不起眼的變動,可是會在項目複雜後帶來麻煩。

也許你如今並不以爲有什麼問題,讓咱們來假設一些情景,你能夠思考一下如何實現吧。

場景一
首先來看看待辦條目的編輯,咱們可能須要一個詳情頁面,用來編輯某個待辦的細節,好比爲 ToDoItem 添加上 datelocationdetail 這類的屬性。另外,PM 和用戶也許但願在詳情頁面中也能直接刪除這個正在編輯的待辦。

以如今的實現來看,一個很樸素的想法是新建 ToDoEditViewController,而後設置 delegate 來告訴 ToDoListViewController 某個 ToDoItem 發生了變化,而後在 ToDoListViewController 進行對 items 進行操做:

protocol ToDoEditViewControllerDelegate: class {
    func editViewController(_ viewController: ToDoEditViewController, editToDoItem original: ToDoItem, to new: ToDoItem)
    func editViewController(_ viewController: ToDoEditViewController, remove item: ToDoItem)
}

// 在 ToDoListViewController 中
extension ToDoListViewController: ToDoEditViewControllerDelegate {
    func editViewController(_ viewController: ToDoEditViewController, remove item: ToDoItem) {
        guard let index = (items.index { $0.id == item.id }) else { return }
        items.remove(at: index)
        let indexPath = IndexPath(row: index, section: 0)
        tableView.deleteRows(at: [indexPath], with: .automatic)
        if self.items.count < 10 {
            self.addButton?.isEnabled = true
        }
    }
    
    func editViewController(_ viewController: ToDoEditViewController, editToDoItem original: ToDoItem, to new: ToDoItem) {
        //...
    }
}`

有一部分和以前重複的代碼,雖然能夠經過將它們提取成像是 removeItem(at index: Int) 這樣的方法,可是並不能改變非單一功能的問題。ToDoEditViewController 自己也沒法和 items 通信,所以它扮演的角色幾乎就是一個「專用」的 View,一旦脫離了 ToDoListViewController,則「難堪重任」。

場景二
另外,純單機的 app 已經跟不上時代了,無論是 iCloud 同步仍是自建服務器,咱們老是想要一個後端來爲用戶跨設備保存列表,這是一個很是可能的加強。在現有架構下,把從服務器獲取已有條目的邏輯放到 ToDoListViewController 也是很天然的想法:

override func viewDidLoad() {
    super.viewDidLoad()
    //..
    NetworkService.getExistingToDoItems().then { items in 
        self.items = items
        self.tableView.reloadData()
        if self.items.count >= 10 {
            self.addButton?.isEnabled = false
        }
    }
}

這種簡單的實現面臨不少挑戰,是咱們在實際 app 中不得不考慮的:

1.是否是應該須要在 getExistingToDoItems 過程當中 block 掉 UI,不然用戶在請求完成前所添加的條目將被覆蓋。
2.在添加和刪除條目的時候,咱們都須要進行網絡請求,另外咱們也須要根據請求返回的狀態更新添加按鈕的狀態。
3.Block 用戶輸入將讓 app 變爲沒網沒法使用,不進行 block 的話則須要考慮數據同步的問題。
4.另外,咱們需不須要在沒網時依然讓用戶能夠進行增長或刪除,並緩存操做,等到有網時再將這些緩存反映給服務器。
5.若是須要實現 4,那麼還要考慮操做結果致使超出條目最大數量限制的錯誤處理,以及多設備間數據衝突處理的問題。
是否是忽然感受有些頭大?

改善
這些問題的來源其實都是咱們爲了「省事」,選擇了一個不那麼有效的 Model,以及存在風險的數據流動方式。或者說,咱們沒有正確和嚴格地使用 MVC 架構。

關於 MVC,斯坦福的 CS193p Paul 老師有一張很是經典的圖,相信不少 iOS 的開發者也都看過:
image.png
咱們的例子中,咱們等於把 Model 放到了 Controller 裏,並且 Model 也沒法與 Controller 進行有效的通信 (圖中的 Notification & KVO 部分)。這致使 Controller 承載了太多的功能,這每每是光榮地邁向 Massive View Controller 的第一步。

單獨的 Model
當務之急是將 Model 層提取出來,爲了說明簡單,暫時先只考慮純本地的狀況:

extension ToDoItem: Equatable {
    public static func == (lhs: ToDoItem, rhs: ToDoItem) -> Bool {
        return lhs.id == rhs.id
    }
}

class ToDoStore {
    static let shared = ToDoStore()
    
    private(set) var items: [ToDoItem] = []
    private init() {}
    
    func append(item: ToDoItem) {
        items.append(item)
    }
    
    func append(newItems: [ToDoItem]) {
        items.append(contentsOf: newItems)
    }
    
    func remove(item: ToDoItem) {
        guard let index = items.index(of: item) else { return }
        remove(at: index)
    }
    
    func remove(at index: Int) {
        items.remove(at: index)
    }
    
    func edit(original: ToDoItem, new: ToDoItem) {
        guard let index = items.index(of: original) else { return }
        items[index] = new
    }
    
    var count: Int {
        return items.count
    }
    
    func item(at index: Int) -> ToDoItem {
        return items[index]
    }
}

固然,爲了一步到位,也能夠直接把上面的 NetworkService 加上,寫成異步 API,例如:

func getAll() -> Promise<[ToDoItem]> {
    return NetworkService.getExistingToDoItems()
      .then { items in
          self.items = items
          return Promise.value(items)
      }
}

func append(item: ToDoItem) -> Promise<Void> {
    return NetworkService.appendToDoItem(item: item)
      .then {
          self.items.append(item)
          return Promise.value(())
      }
}
爲了好看,這裏用了一些  PromiseKit 的東西,若是你不熟悉 Promise,也不用擔憂,能夠將它們簡單地看做 closure 的形式就好,這並不會影響繼續閱讀本文:
func getAll(completion: @escaping (([ToDoItem]?, Error?) -> Void)?) {
    NetworkService.getExistingToDoItems { response, error in
        if let error = error {
            completion?(nil, error)
        } else {
            self.items = response.items 
            completion?(response.items, nil)
        }
    }
}

這樣,咱們就能夠將 itemsToDoListViewController 拿出來。對單獨提取的 Model 進行測試變得很是容易,純 Model 的操做與 Controller 無關,ToDoEditViewController 也再也不須要將行爲 delegate 回 ToDoListViewController,編輯條目的 View Controller 能夠經過成爲了真正意義上的 View Controller,而不止是 ToDoListViewController 的「隸屬 View」。

單獨的 ToDoStore 做爲模型帶來的另外一個好處是,由於它與具體的 View Controller 分離了,在進行持久化時,咱們能夠有更多的選擇。不管是從網絡獲取,仍是保存在本地的數據庫,這些操做都沒必要 (也不該寫在 View Controller 中)。若是有多種數據來源,咱們能夠輕鬆地建立相似 ToDoStoreCoordinator 或者 ToDoStoreDataProvider 這樣的類型。既能夠知足單一職責,也易於覆蓋完整的測試。

單向數據流動
接下來,將數據流動按照 MVC 的標準進行梳理就是天然而然的事情了。咱們的目標是避免 UI 行爲直接影響 UI,而是由 Model 的狀態經過 Controller 來肯定 UI 狀態。這須要咱們的 Model 可以以某種「非直接」的方式向 Controller 進行彙報。按照上面的 MVC 圖,咱們使用 Notification 來搞定。

ToDoStore 進行一些改造:

class ToDoStore {
    enum ChangeBehavior {
        case add([Int])
        case remove([Int])
        case reload
    }
    
    static func diff(original: [ToDoItem], now: [ToDoItem]) -> ChangeBehavior {
        let originalSet = Set(original)
        let nowSet = Set(now)
        
        if originalSet.isSubset(of: nowSet) { // Appended
            let added = nowSet.subtracting(originalSet)
            let indexes = added.compactMap { now.index(of: $0) }
            return .add(indexes)
        } else if (nowSet.isSubset(of: originalSet)) { // Removed
            let removed = originalSet.subtracting(nowSet)
            let indexes = removed.compactMap { original.index(of: $0) }
            return .remove(indexes)
        } else { // Both appended and removed
            return .reload
        }
    }
    
    private var items: [ToDoItem] = [] {
        didSet {
            let behavior = ToDoStore.diff(original: oldValue, now: items)
            NotificationCenter.default.post(
                name: .toDoStoreDidChangedNotification,
                object: self,
                typedUserInfo: [.toDoStoreDidChangedChangeBehaviorKey: behavior]
            )
        }
    }
    
    // ...
}

這裏添加了 ChangeBehavior 做爲「提示」,來具體告訴外界 Model 中發生了什麼。diff 方法經過比較原始 items 和當前 items 來肯定發生了哪一種 ChangeBehavior。最後,使用 itemsdidSet 來發送 Notification。

因爲 Swift 的數組是值類型,對於 items 的元素增長,刪除,修改或者總體變量替換,都會觸發 didSet 的調用。Swift 的值語義編程帶來了很大的便利。

ToDoListViewController,如今只須要訂閱這個通知,而後根據消息內容進行 UI 反饋便可:

class ToDoListViewController: UITableViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        //...
        NotificationCenter.default.addObserver(
            self, 
            selector: #selector(todoItemsDidChange),
            name: .toDoStoreDidChangedNotification, 
            object: nil)
    }
    
    private func syncTableView(for behavior: ToDoStore.ChangeBehavior) {
        switch behavior {
        case .add(let indexes):
            let indexPathes = indexes.map { IndexPath(row: $0, section: 0) }
            tableView.insertRows(at: indexPathes, with: .automatic)
        case .remove(let indexes):
            let indexPathes = indexes.map { IndexPath(row: $0, section: 0) }
            tableView.deleteRows(at: indexPathes, with: .automatic)
        case .reload:
            tableView.reloadData()
        }
    }
    
    private func updateAddButtonState() {
        addButton?.isEnabled = ToDoStore.shared.count < 10
    }

    @objc func todoItemsDidChange(_ notification: Notification) {
        let behavior = notification.getUserInfo(for: .toDoStoreDidChangedChangeBehaviorKey)
        syncTableView(for: behavior)
        updateAddButtonState()
    }
}
Notification 自己有很長的歷史,是一套基於字符串的鬆散 API。這裏經過擴展和泛型的方式,由  .toDoStoreDidChangedNotification.toDoStoreDidChangedChangeBehaviorKey 和  post(name:object:typedUserInfo) 以及  getUserInfo(for:) 構成了一套更 Swifty 的類型安全的  NotificationCenter 和  userInfo 的使用方式。若是你感興趣的話,能夠參看 最後的代碼

最後,咱們能夠把以前用來維護 table view cell 和 addButton 狀態的代碼都刪除了。用戶操做 UI 惟一的做用就是觸發模型的更新,而後模型更新經過通知來刷新 UI:

class ToDoListViewController: UITableViewController {
    // 保存當前待辦事項
    // var items: [ToDoItem] = []
    
    // 點擊添加按鈕
    @objc func addButtonPressed(_ sender: Any) {
        // let newCount = items.count + 1
        // let title = "ToDo Item \(newCount)"
        
        // 更新 `items`
        // items.append(.init(title: title))
        
        // 爲 table view 添加新行
        // let indexPath = IndexPath(row: newCount - 1, section: 0)
        // tableView.insertRows(at: [indexPath], with: .automatic)
        
        // 肯定是否達到列表上限,若是達到,禁用 addButton
        // if newCount >= 10 {
        //    addButton?.isEnabled = false
        // }
        let store = ToDoStore.shared
        let newCount = store.count + 1
        let title = "ToDo Item \(newCount)"
        
        store.append(item: .init(title: title))
    }
}

extension ToDoListViewController {
    override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
        let deleteAction = UIContextualAction(style: .destructive, title: "Delete") { _, _, done in
            // 用戶確認刪除,從 `items` 中移除該事項
            // self.items.remove(at: indexPath.row)
            // 從 table view 中移除對應行
            // self.tableView.deleteRows(at: [indexPath], with: .automatic)
            // 維護 addButton 狀態
            // if self.items.count < 10 {
            //     self.addButton?.isEnabled = true
            // }
            ToDoStore.shared.remove(at: indexPath.row)
            done(true)
        }
        return UISwipeActionsConfiguration(actions: [deleteAction])
    }
}

如今,不妨再考慮一下上一節中場景一 (編輯條目) 和場景二 (網絡同步) 的需求,是否是以爲結構會清晰不少呢?

  1. 咱們如今有了一個單獨的能夠測試的 Model 層,經過簡單的 Mock,ToDoListViewController 也能夠被方便地測試。
  2. UI 操做 -> 經由 Controller 進行模型變動 -> 經由 Controller 將當前模型「映射」爲 UI 狀態,這個數據流動方向是嚴格可預測的 (而且應當時刻牢記須要保持這個循環)。這大大減小了 Controller 層的負擔。
  3. 因爲模型層再也不被單一 View Controller 持有,其餘的 Controller (不單指像是編輯用的 Edit View Controller 這樣的視圖控制器,也包括好比負責下載的 Controller 等等這類數據控制器) 也能夠操做模型層。在此同時,全部的模型結果會被自動且正確地反應到 View 上,這爲多 Controller 協同工做和更復雜的場景提供了堅實的基礎。

這個例子的修改後的最終版本能夠在這裏找到

其餘選項

MVC 自己的概念至關簡單,同時它也給了開發者很大的自由度。Massive View Controller 每每就是利用了這個自由度,「隨意」地將邏輯放在 controller 層所形成的後果。

有一些其餘架構選擇,最經常使用的好比 MVVM 和響應式編程 (好比 RxSwift)。MVVM 能夠說幾乎就是一個 MVC,不過經過 View Model 層來將數據和視圖進行綁定。若是你寫過 Reactive 架構的話,可能會發現咱們在本文中 MVC 的 Controller 層的通知接收和 Rx 的事件流很是類似。不一樣之處在於,響應式編程「借用」了 MVVM 的思路,提供了一套 API 將事件流與 UI 狀態進行綁定 (RxCocoa)。

這些「超越」 MVC 的架構方式無一例外地加入了額外的規則和限制,提供了相對 MVC 來講更小的自由度。這能夠在必定程度上規範開發者的行爲,提供更加統一的代碼 (固然代價是額外的學習成本)。徹底理解和嚴格遵照 MVC 的思想,咱們其實也能夠將 MVC 用得「小而美」。第一步,就從避免文中這類常見「錯誤」開始吧~

可以使用簡單的架構來搭建複雜的工程,製做出讓其餘開發者能夠輕鬆理解的軟件,避免高額的後續維護成本,讓軟件可持續發展並長期活躍,應該是每一個開發者在構建軟件是必須考慮的事情。

小編這裏推薦一個羣:691040931 裏面有大量的書籍和麪試資料,不少的iOS開發者都在裏面交流技術

面試資料截圖.jpg

本文轉載於 https://onevcat.com/2018/05/m...

相關文章
相關標籤/搜索