善用狀態機架構 大幅簡化 View Controller !
我們常常會碰到一個 View Controller 要處理不同狀態的情況,比如說,它本身就提供了編輯與非編輯狀態。如果資料是從網路 API 抓回來的話,那可能要處理載入與錯誤狀態;如果資料缺乏內容的話,也許還要加上空白狀態。這些狀態每一個在處理的事情可能很簡單,但如果全部都丟給同一個 UIViewController
物件去處理的話,那它一定馬上就成為所謂的 Massive View Controller。除了充滿各式各樣的控制流語句之外,它還得一次控管各式各樣的資料與物件,使得出現 bug 的機率大增。
於是,人們為了解決過於肥大的 View Controller,發明出一個又一個的架構,試圖給各個控制器物件更清楚的職責。然而,常見的幾個架構都是依據職責的性質來分類的。比如說,某個物件負責網路呼叫,某個物件負責導航邏輯,某個物件負責資料與 UI 的繫結等等。雖然這樣子的分工是很有效,但通常還是無法解決當狀態複雜時邏輯糾纏在一起的情況。
一個「簡單」的網路串接範例
假設我們在做一個新聞 app,是把從 News API 抓回來的新聞條目顯示出來,並且讓使用者可以把條目刪除。
聽起來很簡單,但實際上光是在新聞條目列表的場景裡,就會出現這幾種狀態:
- 空白狀態:當條目歸零時,要顯示一個「無條目」的標籤,以及一個「載入條目列表」的按鈕。
- 載入中狀態:顯示一個活動指示器 (
UIActivityIndicatorView
)。 - 錯誤狀態:彈出一個警告來顯示載入時的錯誤訊息,並讓使用者選擇要重試還是取消。
- 條目列表狀態:顯示一個列表,並提供使用者刪除條目的能力。
如果把全部的狀態都放到 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
裡面,其實會讓它變得非常肥大。而且,雖然相關的程式碼比較集中了,但狀態之間並不能有效地封裝。在這個例子裡,就是指 emptyView
、indicatorView
與 tableView
可以在任何地方被修改。
還好,我們有狀態機可以用
狀態機呢,簡單來說,就是一個用來切換狀態的機器。
聽起來跟可以切換 state
的 ViewController
很像對不對?沒錯,它可以說是某種形式的狀態機,但通常在狀態機模式底下,與狀態相關的所有行為,都會寫進各個狀態的描述裡面。拿條目列表狀態來說,就是除了 articles
之外,還要連 tableView
,或甚至 UITableViewDataSource
、UITableViewDelegate
等協定,全部都建模到同一個地方。
換句話說,原本是 ViewController
在實作與狀態相關的行為,但用了狀態機的話,就變成是狀態本身在控制這些行為了。你也可以把 ViewController
想成是身體,不同狀態則是不同人格。是人格在控制身體的行為,而不是身體在控制人格。
只講概念可能還是有點飄渺,那不然我們就來看看實際上的架構長怎麼樣吧!
GameplayKit 裡的 GKStateMachine 類型
如果你沒碰過 GameplayKit 這個框架的話,請先不要被它的名字給誤導了。它確實是因應遊戲開發的需求而設計的一套框架,但它裡面的許多類型都完全可以拿到別的地方用。GKStateMachine
與 GKState
就是最好的例子。如果點開文件來看的話,你會發現它們的設計真的是簡單到不行,都只有五個實體成員而已。而在這些成員當中,只有 update(deltaTime:)
這個方法是跟遊戲有直接關係的,其它的成員都是單純跟架構相關而已。
GKStateMachine
與 GKState
都是 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 { }
在 ViewController
的 viewDidLoad()
內,加上 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
加到 viewController
的 view
裡面:
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
套用 UITableViewDataSource
與 UITableViewDelegate
這兩個 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(_:)
的時候,呼叫它 currentState
的 isValidNextState(_:)
,來檢查這個新狀態是不是被允許切過去的。
要注意的是,在這個方法裡,我們最好只去檢查它的 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)