我們常常會碰到一個 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)