View Controller 與狀態機

本文亦發表於 AppCoda TW

我們常常會碰到一個 View Controller 要處理不同狀態的情況。比如說,它本身就提供了編輯與非編輯狀態。如果資料是從網路 API 抓回來的話,那可能要處理載入與錯誤狀態。如果資料缺乏內容的話,也許還要加上空白狀態。這些狀態每一個在處理的事情可能很簡單,但如果全部都丟給同一個 UIViewController 物件去處理的話,那它一定馬上就成為所謂的 Massive View Controller。除了充滿各式各樣的控制流語句之外,它還得一次控管各式各樣的資料與物件,使得出現 bug 的機率大增。

於是,人們為了解決過於肥大的 View Controller,發明出一個又一個的架構,試圖給各個控制器物件更清楚的職責。然而,常見的幾個架構都是依據職責的性質來分類的。比如說,某個物件負責網路呼叫,某個物件負責導航邏輯,某個物件負責資料與 UI 的繫結等等。雖然這樣子的分工是很有效,但通常還是無法解決當狀態複雜時邏輯糾纏在一起的情況。

被複雜的邏輯纏住而無法動彈的工程師腦。Entangled crab by NOAA Marine Debris Program / CC BY

一個「簡單」的網路串接範例

假設我們在做一個新聞 app,是把從 News API 抓回來的新聞條目顯示出來,並且讓使用者可以把條目刪除。

聽起來很簡單,但實際上光是在新聞條目列表的場景裡,就會出現這幾種狀態:

  1. 空白狀態:當條目歸零時,要顯示一個「無條目」的標籤,以及一個「載入條目列表」的按鈕。
  2. 載入中狀態:顯示一個活動指示器(UIActivityIndicatorView)。
  3. 錯誤狀態:彈出一個警告來顯示載入時的錯誤訊息,並讓使用者選擇要重試還是取消。
  4. 條目列表狀態:顯示一個列表並提供使用者刪除條目的能力。

如果把全部的狀態都放到 View Controller 裡,用方法來切換的話,那我們的 ViewController 大概就會長成這樣:

class ViewController : UIViewController {

    var articles: [Article]

    var emptyView: UIView

    var indicatorView: UIActivityIndicatorView

    var tableView: UITableView

    // view 載入完成後呼叫。
    // 呼叫 showEmptyState()。
    override func viewDidLoad()

    // 使用者按下空白狀態的載入按鈕時呼叫。
    // 移除 emptyView 並呼叫 loadArticles()。
    @objc func didPressButton(_ sender: UIButton)

    // 顯示 emptyView。
    func showEmptyState()

    // 顯示 indicatorView、進行網路呼叫並視結果成功與否來呼叫 showArticles(_:) 或 presentAlert(error:)。
    func loadArticles()

    // 將拿到的 articles 存到 self.articles 裡,並顯示 tableView。
    func showArticles(_ articles: [Article])

    // 呈現警告來顯示錯誤訊息,並讓使用者選擇要重試或取消。
    // 依結果來呼叫 showEmptyState() 或 loadArticles()。
    func presentAlert(error: Error)
}

extension ViewController : UITableViewDataSource {

    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell
}

extension ViewController : UITableViewDelegate {

    // 當刪除到 self.articles 變成空的時候,移除 tableView 並呼叫 showEmptyState()。
    func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration?
}

雖然算是淺顯易懂,但這裡有一個很大的問題是,不同成員之間的關係非常的隱晦。比如說,我們很難第一眼看出 didPressButton(_:) 被呼叫的前提是 showEmptyState()(要先顯示空白狀態,載入按鈕才可能被點擊),也很難知道 indicatorView 到底會在哪邊被顯示(是在 didPressButton(_:) 還是在 loadArticles()?)。更不用說當進入到新的狀態時,要怎麼清理舊狀態了(要在哪邊移除 indicatorView?)。

這樣的隱性關聯,會帶給開發者莫大的負擔。不只在寫的時候需要絞盡腦汁去設計簡單明瞭的方法名稱,在除錯的時候也還是得先釐清整個物件的內部邏輯。另外,由於這個 View Controller 一次管理了三個 View,所以也要時時注意一次只讓一個 View 顯示出來。所有這些事情,都會佔據我們的思緒,讓我們無法專心的解決問題,也使錯誤更容易產生。

狀態那麼複雜,那就先來解決狀態問題吧

ViewController 裡,我們並不是針對狀態來建模,而是針對行為來建模——把狀態切換的過程用方法來再現。這樣的設計方式或許可以用「行為導向」來指稱,因為它只看要怎麼進入到下個狀態,而不是對每個狀態做完整的描述。所以在程式碼裡面,我們必須透過閱讀每個方法的實作,才能推斷出它大概有哪些狀態。而狀態一多,要理解也就更困難。

與之相對的,是「狀態導向」的設計方式。我們首先是將物件中不同的狀態建模出來,然後才去定義物件在這些狀態底下的行為。

怎麼把狀態建模呢?在用 Swift 寫程式的你,可能很快就會想到列舉(Enumeration)這個資料型態吧!比如說,我們可以把 ViewController 的不同狀態用 enum 來定義:

enum ViewControllerState {
    case empty, loading, error(Error), articles([Article])
}

然後設立一個 state 屬性,以用屬性觀察器來實作切換狀態時的行為:

class ViewController {

    var state: ViewControllerState = .empty {
        didSet {
            switch oldValue {
            // 清理舊狀態...
            }
            switch state {
            // 顯示新狀態...
            }
        }
    }

    // ...
}

如此一來,我們就可以清楚的知道 ViewController 現在是位於哪個狀態,而且行為與狀態之間的關聯也變得更清楚了。

不過老實說,把全部的切換狀態方法都寫到同一個 didSet 裡面,其實會讓它變得非常肥大。而且雖然相關的程式碼比較集中了,但狀態之間並不能有效地封裝。在這個例子裡,就是指 emptyViewindicatorViewtableView 可以在任何地方被修改。

還好,我們有狀態機可以用

狀態機呢,簡單來說,就是一個用來切換狀態的機器。

用來切換引擎行為的排檔桿。

聽起來跟可以切換 stateViewController 很像對不對?沒錯,它可以說是某種形式的狀態機,但通常在狀態機模式底下,與狀態相關的所有行為,都會寫進各個狀態的描述裡面。拿條目列表狀態來說,就是除了 articles 之外,還要連 tableView,或甚至 UITableViewDataSourceUITableViewDelegate 等協定,全部都建模到同一個地方。

換句話說,原本是 ViewController 在實作與狀態相關的行為,但用了狀態機的話,就變成是狀態本身在控制這些行為了。你也可以把 ViewController 想成是身體,不同狀態則是不同人格。是人格在控制身體的行為,而不是身體在控制人格。

只講概念可能還是有點飄渺,那不然我們就來看看實際上的架構長怎麼樣吧!

GKStateMachine,對,是 GameplayKit 裡的一個類型

如果你沒碰過 GameplayKit 這個框架的話,請先不要被它的名字給誤導了。它確實是因應遊戲開發的需求而設計的一套框架,但它裡面的許多類型都完全可以拿到別的地方用,沒問題。GKStateMachineGKState 就是最好的例子。如果點開文件來看的話,你會發現它們的設計真的是簡單到不行,都只有五個實體成員而已。而在這些成員當中,只有 update(deltaTime:) 這個方法是跟遊戲有直接關係的,其它的成員都是單純跟架構相關而已。

GKStateMachineGKState 都是 NSObject 的子類型,這代表了它們都有能力去套用 UITableViewDataSource 等協定、分擔 Controller 的工作。GKStateMachine 是一個具體類型,唯一的職責就是擔任狀態之間的切換者,通常不需要去建立子類型。GKState 則是一個抽象類型,所有的方法都是設計來被覆寫的,所以要寫子類型才有用。

前面說到,狀態機的狀態就像是 ViewController 的人格一樣,在控制著它。而這些狀態是被狀態機所持有的,狀態機又是被 ViewController 持有的,所以整個關係大概可以畫成這樣:

實作這個關係圖之前,首先不要忘記引進 GameplayKit:

import GameplayKit

接著在 ViewController 裡,建立一個 stateMachine 的強參照屬性:

class ViewController: UIViewController {
    var stateMachine: GKStateMachine?
}

然而,GKState 本身並沒有提供存取狀態機所屬物件的方式,所以我們必須要自己建立連結,像是這篇範例程式碼一樣:

class ViewControllerState: GKState {

    // 用 unowned let 來防止循環持有。
    unowned let viewController: ViewController

    // 用來存取 viewController.view 的捷徑。
    var view: UIView {
        viewController.view
    }

    init(viewController: ViewController) {
        self.viewController = viewController
    }
}

而這個 ViewControllerState 則將作為接下來所有狀態的父類型,免除要在每個類型都寫這段程式碼的麻煩。

到這邊為止,我們已經準備好整個狀態機模式的骨架了!

那就開始把狀態用 GKState 建模吧

GameplayKit 有一個特點,是它很依賴型別判斷。比如說在 GKStateMachine 裡的每一個 GKState 都必須是不同的類型,而且切換狀態時不是用實體來切換,而是用型別來切換。讀取狀態的時候也是一樣。所以,我們並沒有其它選擇,就乖乖把不同狀態直接宣告成不同的 ViewControllerState 子類型吧:

class EmptyState: ViewControllerState { }

class LoadingState: ViewControllerState { }

class ArticlesState: ViewControllerState { }

class ErrorState: ViewControllerState { }

ViewControllerviewDidLoad() 內,加上 stateMachine 的建構程式碼:

    override func viewDidLoad() {
        super.viewDidLoad()

        // 狀態機初始化之後就不能增減狀態了,所以要在這邊把全部的狀態建構好再拿來建構狀態機。
        stateMachine = GKStateMachine(states: [
            ErrorState(viewController: self),
            EmptyState(viewController: self),
            LoadingState(viewController: self),
            ArticlesState(viewController: self)
        ])

        // 要狀態機先進入空白狀態。
        stateMachine?.enter(EmptyState.self)
    }

信不信由你,接下來我們就都不用再碰 ViewController 本身的程式碼了。也就是說,整個 ViewController 就長這個樣子,20行不到:

class ViewController: UIViewController {

    var stateMachine: GKStateMachine?

    override func viewDidLoad() {
        super.viewDidLoad()

        stateMachine = GKStateMachine(states: [
            EmptyState(viewController: self),
            LoadingState(viewController: self),
            ArticlesState(viewController: self),
            ErrorState(viewController: self)
        ])

        stateMachine?.enter(EmptyState.self)
    }

}

接下來,我們就一個一個狀態來實作吧!

EmptyState

GKState 裡,最重要的方法就是 didEnter(from:) 了,因為我們需要在這個方法裡實作進入狀態的行為。在 EmptyState 裡,這代表我們要把 emptyView 加到 viewControllerview 裡面:

class EmptyState: ViewControllerState {

    // 創造包含標籤與按鈕的 emptyView。
    // 設定按鈕的 target 為 self。
    // 用 private 把 emptyView 封裝起來。
    private lazy var emptyView: UIView = {
        let label = UILabel()
        label.text = "No Article"
        let button = UIButton(type: .system)
        button.setTitle("Load Articles", for: .normal)
        button.addTarget(self, action: #selector(didPressButton(_:)), for: .touchUpInside)
        let stackView = UIStackView(arrangedSubviews: [label, button])
        stackView.axis = .vertical
        stackView.translatesAutoresizingMaskIntoConstraints = false
        return stackView
    }()

    // 將 emptyView 加到 view 裡面。
    override func didEnter(from previousState: GKState?) {
        view.addSubview(emptyView)
        NSLayoutConstraint.activate([
            emptyView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            emptyView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
        ])
    }

由於 GKState 本身是一個 NSObject,所以我們可以把處理載入按鈕事件的 didPressButton(_:) 放在這裡:

    // 按下載入按鈕時,呼叫 stateMachine 進入載入狀態。
    @objc func didPressButton(_ sender: UIButton) {
        stateMachine?.enter(LoadingState.self)
    }

willExit(to:) 是在離開此狀態前會被狀態機呼叫的方法,我們在這裡實作離開狀態前的清理,也就是把 emptyView 從 View 階層裡移除:

    // 在離開狀態之前,把 emptyView 從 View 階層移除。
    override func willExit(to nextState: GKState) {
        emptyView.removeFromSuperview()
    }
}

LoadingState

LoadingState 要處理的事有兩個。一個是進行網路呼叫,一個是顯示 indicatorView

class LoadingState: ViewControllerState {

    // 網路呼叫的錯誤。
    enum Error: Swift.Error {
        case noData
    }

    // 創造一個 UIActivityIndicatorView。
    private var indicatorView: UIActivityIndicatorView = {
        let indicatorView = UIActivityIndicatorView(style: .gray)
        indicatorView.translatesAutoresizingMaskIntoConstraints = false
        return indicatorView
    }()


    override func didEnter(from previousState: GKState?) {

        // 把 indicatorView 顯示出來。
        view.addSubview(indicatorView)
        NSLayoutConstraint.activate([
            indicatorView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            indicatorView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
        ])
        indicatorView.startAnimating()

        // 進行網路呼叫。
        // 請自行加上 API key。
        let url = URL(string: "https://newsapi.org/v2/top-headlines?country=tw")!
        let task = URLSession.shared.dataTask(with: url) { [weak self] data, response, error in
            DispatchQueue.main.sync {
                do {
                    if let error = error { throw error }
                    guard let data = data else { throw Error.noData }
                    let response = try JSONDecoder().decode(ArticlesResponse.self, from: data)

                    // 成功拿到 articles 的話就交給 ArticlesState 並切換過去。
                    self?.stateMachine?.state(forClass: ArticlesState.self)?.articles = response.articles
                    self?.stateMachine?.enter(ArticlesState.self)
                } catch {

                    // 出錯的話就把 error 交給 ErrorState 去顯示。
                    self?.stateMachine?.state(forClass: ErrorState.self)?.error = error
                    self?.stateMachine?.enter(ErrorState.self)
                }
            }
        }
        task.resume()
    }

    // 清理狀態。
    override func willExit(to nextState: GKState) {
        indicatorView.stopAnimating()
        indicatorView.removeFromSuperview()
    }
}

其實就是把原本的 loadArticles() 方法搬過來而已。

ArticlesState

這個狀態負責把 LoadingState 拿到的 articles 顯示出來,所以它實際上是類似於一個 UITableViewController

// UITableViewDataSource 所用。
private let cellReuseIdentifier = "Cell"

class ArticlesState: ViewControllerState {
    
    // 持有 articles。
    var articles = [Article]()
    
    // 創造一個 UITableView。
    private lazy var tableView: UITableView = {
        let tableView = UITableView(frame: view.bounds, style: .plain)
        tableView.dataSource = self
        tableView.delegate = self
        tableView.register(UITableViewCell.self, forCellReuseIdentifier: cellReuseIdentifier)
        tableView.translatesAutoresizingMaskIntoConstraints = false
        return tableView
    }()
    
    // 將 tableView 顯示出來。
    override func didEnter(from previousState: GKState?) {
        view.addSubview(tableView)
        NSLayoutConstraint.activate([
            view.leadingAnchor.constraint(equalTo: tableView.leadingAnchor),
            view.topAnchor.constraint(equalTo: tableView.topAnchor),
            tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
        ])
        tableView.reloadData()
    }
    
    // 移除 tableView。
    override func willExit(to nextState: GKState) {
        tableView.removeFromSuperview()
    }
}

// 實作 UITableViewDataSource。
extension ArticlesState: UITableViewDataSource {
    
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        articles.count
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: cellReuseIdentifier, for: indexPath)
        let article = articles[indexPath.row]
        cell.textLabel?.text = article.title
        return cell
    }
}

// 實作 UITableViewDelegate。
extension ArticlesState: UITableViewDelegate {
    
    func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
        return UISwipeActionsConfiguration(actions: [
            .init(style: .destructive, title: "Delete") { [weak self] action, sourceView, completion in
                guard let self = self else { completion(false); return }
                
                // 刪除條目。
                self.articles.remove(at: indexPath.row)
                tableView.deleteRows(at: [indexPath], with: .left)
                
                // 如果沒有剩下條目的話,就進入到空白狀態。
                if self.articles.isEmpty {
                    self.stateMachine?.enter(EmptyState.self)
                }
                
                completion(true)
            }
        ])
    }
}

我們再次得益於 GKState 相容於 Objective-C 的特點,讓 ArticlesState 套用 UITableViewDataSourceUITableViewDelegate 這兩個 Objective-C 協定,直接去管理 tableView

ErrorState

錯誤狀態比較特別一點。我們在這裡不使用加入 View 的方式顯示錯誤資訊,而是去呈現一個 UIAlertController

class ErrorState: ViewControllerState {
    
    // 將要顯示的 Error。
    var error: Error?

    // 顯示中的 alertController。
    var alertController: UIAlertController?
    
    // 呈現一個 UIAlertController。
    override func didEnter(from previousState: GKState?) {
        
        guard let error = error else { return }
        let alertController = UIAlertController(title: "Error", message: error.localizedDescription, preferredStyle: .alert)
        self.alertController = alertController
        
        let dismissAction = UIAlertAction(title: "Dismiss", style: .cancel) { [weak self] action in
            
            // 如果選擇取消的話,進到空白狀態。
            self?.stateMachine?.enter(EmptyState.self)
        }
        alertController.addAction(dismissAction)
        
        let retryAction = UIAlertAction(title: "Retry", style: .default) { [weak self] action in
            
            // 如果選擇重試的話,進到載入中狀態。
            self?.stateMachine?.enter(LoadingState.self)
        }
        alertController.addAction(retryAction)
        
        viewController.present(alertController, animated: true)
    }
    
    // 以防 alertController 還沒被去除掉。
    override func willExit(to nextState: GKState) {
        if alertController != nil, alertController === viewController.presentedViewController {
            viewController.dismiss(animated: true)
        }
        alertController = nil
    }
}

這裡可以看到狀態機的靈活度非常高,可以用任何方式來實作 View 的部份。因為它是一種比 View Controller 更高層級的抽象,所以不被 View Controller 所限制。

到這邊為止,我們已經寫出一個可以正常在四種不同狀態間切換,卻還保持高度可讀性的 View Controller 了!不只維護簡單,寫的時候更不用去思考要怎麼為方法命名、設計流程。只要先把各個狀態定義出來並寫成 GKState 的子類型,接下來的實作就會像水到渠成一般地自然。然而,GKStateMachine 還有一個重量級的殺手級特色,那就是⋯⋯

它可以把狀態流程圖畫給你看

GKState 裡面,除了前面提到的 update(deltaTime:)didEnter(from:)willExit(to:) 這三個方法之外,還有一個方法叫做 isValidNextState(_:)。這個方法可以限制狀態之間切換的可能性,讓不該發生的狀態切換不會發生。GKStateMachine 會在被呼叫 canEnterState(_:)enter(_:) 的時候呼叫它 currentStateisValidNextState(_:),來檢查這個新狀態是不是被允許切過去的。

要注意的是,在這個方法裡,我們最好只去檢查它的 stateClass 是不是某個類型,不要去用其它的方式來檢查:

class EmptyState: ViewControllerState {

    // ...

    override func isValidNextState(_ stateClass: AnyClass) -> Bool {
        return stateClass == LoadingState.self
    }
}

class LoadingState: ViewControllerState {

    // ...
    
    override func isValidNextState(_ stateClass: AnyClass) -> Bool {
        return stateClass == ArticlesState.self || stateClass == ErrorState.self
    }
}

class ArticlesState: ViewControllerState {

    // ...
    
    override func isValidNextState(_ stateClass: AnyClass) -> Bool {
        return stateClass == EmptyState.self
    }
}

class ErrorState: ViewControllerState {

    // ...
    
    override func isValidNextState(_ stateClass: AnyClass) -> Bool {
        return stateClass == EmptyState.self || stateClass == LoadingState.self
    }
}

也就是說,不管何時呼叫這個方法,只要 stateClass 沒有變,它的回傳值都應該要是一樣的。

為什麼呢?因為 Xcode 會用這個方法來把狀態流程圖畫給你看。

對,就是這麼神奇。

只要找個可以存取到 stateMachine 的地方下斷點,在執行到這個斷點而暫停的時候,打開 stateMachine 的快速預覽,你就會看到 Xcode 畫好的狀態流程圖。

透過 isValidNextState(_:) 方法,我們可以大幅縮減狀態之間轉換排列組合的數量。如此一來,錯誤產生的機率也會降低很多,而這是單純的列舉做不到的。而附帶產生的流程圖更是絕佳的邏輯除錯工具,比起一開始我們還要從 ViewController 的各個方法來想像整個流程,真的是更直覺到不知道幾層天。

結論

狀態機在遊戲開發界是一個非常基本的程式架構,因為遊戲中的狀態複雜度是一般的 app 所無法比擬的。不過,隨著一般 app 的狀態越來越精緻,狀態機也就派得上用場了。

只是,習慣了 Cocoa 式物件導向程式設計的開發者,可能會對狀態機架構有點陌生。Cocoa 與 Cocoa Touch 等框架都是以所謂的 Controller 為最高控制者去持有、管理其它的物件,所以開發者很容易把狀態機當成跟一般的 Manager 或甚至 Helper 一樣層級的物件,最終還是把所有的職責丟給 Controller 來擔。

狀態機不是這樣的。狀態機是比它的所屬物件更高層次的一種抽象,是狀態在控制物件,不是物件在控制狀態。所以如果要在一個 Controller 裡使用狀態機的話,那狀態物件的控制層級應該是比 Controller 還要高的。而且由於 GKState 屬於 NSObject,所以完全有能力承擔 Cocoa 裡 Controller 的責任。 raywenderlich.com 的這篇文章就是用狀態機對 Coordinator 進行抽象,從狀態物件去控制 Coordinator 進行畫面間的導航。當然,你也可以把它套用在其它的特殊領域上面,比如說網路呼叫等等。

當然,最後還是要不免俗的呼籲一下,狀態機並不是萬靈丹。它雖然強大,但也是讓你的 app 架構又多了一個層架(overhead)。如果你的物件本身狀態已經很簡單的話,那用狀態機的意義也就不大。熟悉並活用抽象、封裝、組合優於繼承(composition over inheritance)、單一真值來源(single source of truth)等概念才是優良程式架構的不二法門,而狀態機只是其中的一種工具罷了。

參考資料

An iOS architecture approach for UIViewController states & error management in Swift

GameplayKit State Machine for non-game Apps

Practical State Machines with GameplayKit | raywenderlich.com

實作 UIStack — 讓 UI 不再出錯 – Liyao Chen – Medium

How to fix a bad user interface[翻譯] 如何修正壞 UI – zonble – Medium