iOS App 程式開發

Massive View Controller 重構:Coordinator 模式與 Flow Controller

Massive View Controller 重構:Coordinator 模式與 Flow Controller
Massive View Controller 重構:Coordinator 模式與 Flow Controller
In: iOS App 程式開發, Swift 程式語言

上一篇文章中,我們用了依賴注入的技巧,來將 View Controller 與 Model Manager 之間的耦合解開。然而,View Controller 的依賴並不只是這樣而已,View Controller 與 View Controller 之間的依賴更為常見。

比如說,當要在 View Controller 之間傳遞資料的時候,Apple 會告訴我們要這樣寫:

class MasterViewController: UITableViewController {

    var models: [Model] = []

    // 使用 Storyboard Segue 的話...
    override func prepare(for segue: UIStoryboardSegue, 
      sender: Any?) {

        if segue.identifier == "ShowDetail" {

            // 取得目的 VC。
            let detailVC = segue.destination as! DetailViewController

            // 取得要傳給目的 VC 的資料。
            let cell = sender as! UITableViewCell
            let indexPath = tableView!.indexPath(for: cell)!
            let model = self.models[indexPath.row]

            // 將資料傳給目的 VC。
            detailVC.model = model
        }
    }

    // 使用程式碼的話...
    func presentDetailViewController(model: Model) {

        // 創建 detailVC 並把 model 指派給它。
        let detailVC = DetailViewController()
        detailVC.model = model

        // 呈現 detailVC。
        present(detailVC, animated: true)
    }

} 

這樣的寫法再也普通不過。這段程式碼讓 MasterViewController 自己生成 DetailViewController,並向 DetailViewController 傳遞資料,最後再自己轉場到後者(或者交由 Segue 來轉場)。

coordinator-1

這看起來並沒有什麼問題,對吧?雖然使 MasterViewControllerDetailViewController  產生了依賴,但只用了一兩行,影響似乎不大。然而,當案子的架構越來越複雜的時候,這個依賴也會開始增長,少則數十行,多則數百行。舉例來說,如果我們要使這兩個 View Controller 同步更新的話,那可能需要寫一個DetailViewControllerDelegate 這樣的協定給 MasterViewController 去使用:

protocol DetailViewControllerDelegate: AnyObject {
    func detailViewControllerDidChange(_ detailVC: DetailViewController)
}

extension MasterViewController: DetailViewControllerDelegate {

    func detailViewControllerDidChange(_ detailVC: DetailViewController) {

        // 將 detailVC.model 透過 identifier 等方式寫回 models 裡面,並更新 tableView。
        // 實作...
    }

}

這樣下來,MasterViewController 的職責就一下子變多了,不只要把資料用一個 Table View 來顯示,還要管理一個 DetailViewController

但是,物件應該是單一職責的

在物件導向程式設計裡,最理想的物件應該只有一個職責。換句話說,「物件」的存在,本身就是為了包裝相關聯的一組狀態與行為。所以,如果一個物件負了兩個不怎麼相關的職責,那這個物件在設計上就可能有問題了。就 MasterViewController 而言,它的核心職責是「顯示/編輯一組 Model」,但我們現在又給它加上了「控制其它的 View Controller」 這樣的職責,這就會使它的內部狀態與邏輯更加複雜。

如果要使它回到只有一個職責的狀態,我們不只要剝除它對於其它 View Controller 的依賴,還要拿掉它自己轉場、自己傳遞資訊的能力。換句話說,它不會再複寫 prepare(for: sender:) 或持有 pesentDetailViewController(model:) 這類方法。同時,它不只不知道 DetailViewController 的存在,連任何類似 ModelEditor 這樣的抽象協定都不會知道。它所知道的,就真的只有對 models 的顯示與編輯而已。

但是這樣的話,我們要怎麼樣才能從 MasterViewController 轉場到 DetailViewController 呢?

所謂的 Coordinator 模式

當我們將一個物件的依賴去除或者抽象化之後,還是會需要在某處去告訴它實際上的依賴到底是什麼,因為依賴並不會憑空消失。即使依賴注入也並不是把依賴變不見,它只是把提供依賴的責任交給別的物件來承擔而已。

coordinator-2

所有的物件都可以用來提供依賴,但如果每個物件都在提供依賴給其他物件的話,那其實也跟沒有重構沒什麼兩樣了。

coordinator-3

更好的做法,是把所有相關的依賴集中到同一個物件身上,使這個物件專做管理依賴的工作。注入依賴給其他物件的責任,就是由所謂的 Coordinator(協調者)這個物件來負責。

coordinator-4

把依賴都集中管理,好處是其他物件可以保持簡單的依賴狀態,而且你也可以在同一個地方更動相關聯的依賴,而不用一次修改多個物件。

套用到前面 View Controller 的例子上來說,就是寫一個負責控制轉場與傳遞資訊的 Coordinator:

coordinator-5

這個 Coordinator 會在 MasterViewController 被選取了某個 Model 時,轉場到 DetailViewController。為此,我們可以寫一個 Delegate 來做選取 Model 時的通知:

protocol MasterViewControllerDelegate: AnyObject {

    // 抽象化之後,直接給出 Model 而不是 IndexPath。
    func masterViewController(_ masterVC: MasterViewController, didSelect model: Model)
} 

class MasterViewController: UITableViewController {

    var models: [Model] = []

    weak var delegate: MasterViewControllerDelegate?

    override func tableView(_ tableView: UITableView, 
         didSelectRowAt indexPath: IndexPath) {

        let model = self.models[indexPath.row]
        delegate?.masterViewController(self, didSelect: model)
    }

}

然後讓 Coordinator 去遵守它:

class Coordinator {

    let modelManager: ModelManager

    init(modelManager: ModelManager) {
        self.modelManager = modelManager
    }

    // 啟動 Coordinator 用的方法。
    func start(from vc: UIViewController) {

        let masterVC = MasterViewController()
        masterVC.delegate = self

        // 傳 models 給 masterVC。
        masterVC.models = modelManager.models

        // 從呼叫者所提供的 vc 去呈現 masterVC。
        vc.present(masterVC, animated: true)
    }

}

extension Coordinator: MasterViewControllerDelegate {

    // 當使用者選取了 masterVC 上的某個 model 時,生成一個 DetailViewController、把 model 傳給它,並用 masterVC 去呈現它。
    func masterViewController(_ masterVC: MasterViewController, didSelect model: Model) {
        let detailVC = DetailViewController()
        detailVC.model = model
        masterVC.present(detailVC, animated: true)

        // masterVC 不用再當 detailVC 的 delegate 了,都交給 Coordinator 即可。
        detailVC.delegate = self
    }

}

extension Coordinator: DetailViewControllerDelegate {

    func detailViewControllerDidChange(_ detailVC: DetailViewController) {

        // 將 detailVC.model 透過 identifier 等方式寫到 modelManage.models 與 masterVC.models 裡面。
        // 實作...
    }

}

由此可見,Coordinator 就像是多個 View Controller 的管理者,它自己完全沒有顯示任何內容,只負責協調管理的 View Controller 之間的資料傳遞與轉場邏輯。而這些 View Controller 也不用去當其他 View Controller 的 Delegate 或 Data Source 了,全都交給 Coordinator 去遵守即可。

Flow Controller:View Controller 版的 Coordinator

上面這個 Coordinator 也不是沒有缺點的。比如說,它還需要一個 start(from:) 來啟動,而且它不在響應鏈裡面。也就是說,它沒有辦法攔截子 VC 的響應事件,也無法提供 Undo Manager 給多個子 VC 用。還好,這些問題都可以用一個簡單的方法來解決:讓它去繼承 UIViewController。在繼承之後,start(from:) 可以改寫到 viewDidAppear(_:) 裡面,而它也會自然地被嵌進響應鏈裡面,使事件攔截與復原管理等變得更簡單。這樣的 Coordinator 有另一個名字,叫做 Flow Controller(流程控制器)

class FlowController: UIViewController {

    // 提供 UndoManager 給底下的 View Controller 用。
    private lazy var _undoManager = UndoManager()
    override var undoManager: UndoManager? {
        return _undoManager
    }

    var modelManager: ModelManager!

    convenience init(modelManager: ModelManager) {
        self.init()
        self.modelManager = modelManager
    }

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)

        let masterVC = MasterViewController()
        masterVC.delegate = self

        // 傳 models 給 masterVC。
        masterVC.models = modelManager.models

        // 自己呈現 masterVC。
        present(masterVC, animated: false)
    }

}

extension FlowController: MasterViewControllerDelegate {

    // 處理 MasterViewController 事件...

}

extension FlowController: DetailViewControllerDelegate {

    // 處理 DetailViewController 事件...

}

由此可見,Flow Controller 就是除了顯示與排版的外甚麼都管的 View Controller,可以說是最接近純 Controller 的 View Controller。

Bar Items 管理

仔細想的話,你會發現 View Controller 的 navigationItemtoolbarItems 是很有趣的屬性。它們有點像 View 但又不是 View,而且它們只有在該 VC 被嵌入到 Navigation Controller 裡的時候才有用。最重要的是,它們的內容物其實不需要跟所屬的 VC 有直接關係。比如說,我可以把某個 VC 的 editButtonItem 丟到另一個 VC 的 toolbarItems 裡面,完全沒有問題。這在使用 Container View Controller 時特別有用,因為這樣顯示出來的 Bar Items 通常是屬於母 VC 的,但這些 Bar Items 所控制的卻是子 VC。

除了處理轉場與資料傳遞之外,Coordinator 或 Flow Controller 也可以去處理 Bar Items。首先,我們得先避免除了 Flow Controller 以外的 View Controller 管理自己的 navigationItemtoolbarItems。再來,我們讓子 VC 去直接管理跟自己有關的 Bar Items,像是 editButtonItem 或者 UISplitViewControllerdisplayModeButtonItem 等寫法。最後,就是在 Flow Controller 把這些 Bar Items 自由指派到想放的地方了。

結論

Coordinator 模式可說是讓依賴注入更完整,因為我們其實是把不相關的依賴(包括 Model Manager 與其它 View Controller),從各個負責顯示內容的 View Controller 抽走,再將這些依賴集中到同一個物件裡面去管理。而 Flow Controller 其實就是 View Controller 版的 Coordinator,可以更方便地管理子 VC。

其實,Coordinator 模式也是 “Composition over Inheritance” 物件導向程式設計原則的體現。我們不是透過繼承的方式去實作轉場,而是透過將不同的 View Controller 組裝到一個 Coordinator 裡面來實作,這樣一來,轉場的程式碼就更為集中和有彈性。

Coordinator 也不一定只有一個。有時候,一個 app 裡面有很多不同的轉場脈絡,那我們就可以針對每個脈絡寫一個 Coordinator。比如說,設定頁面就可以有一個 SettingsFlowController 這樣。

要注意的是,Coordinator 模式先天跟 Storyboard Segue 轉場是不合的,所以這裡所有轉場都是以純程式碼來執行的。

在有許多 Container View Controller 的時候,Coordinator 模式就尤其方便,像 UINavigationControllerDelegateUIPageViewControllerDataSourceUIPageViewControllerDelegateUISplitViewControllerDelegate 等很多 Delegate 都可以由 Coordinator 來擔任。所以,下次碰到 Page View Controller 的時候,先別急著去寫它的子類、或是把母 VC 當成它的 Data Source,考慮寫一個 Flow Controller 去管理母 VC、Page VC 與子 VC 吧!

參考資料

作者
Hsu Li-Heng
iOS 開發者、寫作者、filmmaker。現正負責開發 Storyboards by narrativesaw 此一故事板文件 app 中。深深認同 Swift 對於程式碼易讀性的重視。個人網站:lihenghsu.com。電郵:[email protected]
評論
很好! 你已成功註冊。
歡迎回來! 你已成功登入。
你已成功訂閱 AppCoda 中文版 電子報。
你的連結已失效。
成功! 請檢查你的電子郵件以獲取用於登入的連結。
好! 你的付費資料已更新。
你的付費方式並未更新。