A finite state machine (FSM) is a mathematical model of computation. An FSM can be described as a set of relations of the form:
State(S) x Event(E) -> Actions (A), State(S')
If we are in state
S
and the eventE
occurs, we should perform the actionsA
and make a transition to the stateS'
.
Using a finite state machine helps us to reason about the code and the application state at all times with absolute certainty. Apple's GameplayKit library has built-in FSM classes GKStateMachine
and GKState
using which we can model an FSM.
Here I will describe how to use an FSM for UI state transitions for an iOS app. I will use an example of how I have used it in my own app API Tester Pro to model the request-response screens. This is the screen which is a bit complex with a few states it can be in than other screens which are direct transition from one screen to another. I had used FSM only in this particular section of the UI as setting up these classes involves a certain degree of complexity.
// RequestStateMachine.swift
import Foundation
import GameplayKit
class RequestStateMachine: GKStateMachine {
unowned var request: ERequest
weak var manager: RequestManager?
init(states: [GKState], request: ERequest, manager: RequestManager? = nil) {
self.request = request
self.manager = manager
super.init(states: states)
}
}
This is the main state machine class. The state machine is associated with a request. So it takes an ERequest
object and RequestManager
is the class which handles the events and actions.
// RequestStates.swift
import Foundation
import GameplayKit
extension Notification.Name {
static let extrapolateDidFail = Notification.Name("extrapolate-did-fail")
static let invalidURL = Notification.Name("invalid-url")
}
/// User has tapped the Go button and the request is being processed for sending.
class RequestPrepareState: GKState {
unowned var request: ERequest
let nc = NotificationCenter.default
init(_ request: ERequest) {
self.request = request
super.init()
}
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
return stateClass == RequestSendState.self || stateClass == RequestCancelState.self
}
override func didEnter(from previousState: GKState?) {
guard let fsm = self.stateMachine as? RequestStateMachine, let man = fsm.manager else { return }
do {
try man.prepareRequest()
} catch let error {
if error is AppError {
if error.code == AppError.extrapolate.code {
self.nc.post(name: .extrapolateDidFail, object: self, userInfo: ["msg": "Extrapolating env variables failed. Please check the variables and env."])
} else if error.code == AppError.invalidURL.code {
self.nc.post(name: .invalidURL, object: self, userInfo: ["msg": "Constructing the request failed. Please check the URL format."])
}
}
fsm.enter(RequestCancelState.self)
}
}
}
/// The request has be constructed and has been send to the server, and waiting for response.
class RequestSendState: GKState {
unowned var request: ERequest
var urlReq: URLRequest?
init(_ request: ERequest) {
self.request = request
super.init()
}
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
return stateClass == RequestResponseState.self || stateClass == RequestCancelState.self
}
override func didEnter(from previousState: GKState?) {
guard let fsm = self.stateMachine as? RequestStateMachine, let man = fsm.manager, let urlReq = self.urlReq else { return }
man.sendRequest(urlReq)
}
}
/// The response has been obtained.
class RequestResponseState: GKState {
unowned var request: ERequest
var urlRequest: URLRequest?
var response: HTTPURLResponse?
var metrics: URLSessionTaskMetrics?
var error: Error?
let nc = NotificationCenter.default
var result: Result<(Data, HTTPURLResponse, URLSessionTaskMetrics?), Error>?
var elapsed: Int = 0 // ms
var responseBodyData: Data? // response body data
var data: ResponseData?
init(_ request: ERequest) {
self.request = request
super.init()
}
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
return stateClass == RequestPrepareState.self
}
override func didEnter(from previousState: GKState?) {
guard let fsm = self.stateMachine as? RequestStateMachine, let man = fsm.manager, let data = self.data else { return }
man.viewResponseScreen(data: data)
}
}
/// User cancels the request
class RequestCancelState: GKState {
unowned var request: ERequest
init(_ request: ERequest) {
self.request = request
super.init()
}
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
return stateClass == RequestPrepareState.self
}
override func didEnter(from previousState: GKState?) {
guard let fsm = self.stateMachine as? RequestStateMachine, let man = fsm.manager else { return }
man.requestDidCancel()
}
}
This is the full code for various states the app can be in for a request. There are only four states which are RequestPrepareState
, RequestSendState
, RequestResponseState
and RequestCancelState
. For each GKState
we can see the possible next states it can be in. And the state machine will transition according to these rules only. RequestResponseState
is the final state and it doesn't require RequestCancelState
. Other states takes RequestCancelState
because at any time user can cancel the ongoing request.
// RequestTableViewController.swift
import Foundation
import UIKit
class RequestTableViewController: APITesterProTableViewController {
private unowned var reqMan: RequestManager?
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
self.initManager()
}
func initManager() {
if let req = self.request {
self.reqMan = AppState.getFromRequestState(req.getId())
if self.reqMan == nil {
let man = RequestManager(request: req, env: self.env)
AppState.addToRequestState(man)
self.reqMan = man
}
}
if let man = self.reqMan {
guard let state = man.fsm.currentState else {
self.displayRequestDidCompleteUIChanges()
return
}
if state.classForCoder != RequestCancelState.self {
self.displayRequestInProgressUIChanges()
}
}
}
@IBAction func goButtonDidTap(_ sender: Any) {
if self.reqMan == nil { self.initManager() }
guard let man = self.reqMan else { return }
if self.isRequestInProgress {
man.cancelRequest()
return
}
man.env = self.env
man.start()
self.isRequestInProgress = true
UIView.animate(withDuration: 0.3) {
self.displayRequestInProgressUIChanges()
}
}
}
RequestTableViewController
is the class which instantiates the RequestManager
which has the logic to manage the state and actions associated with each state. When user taps the go
button the FSM enters its first state which is RequestPrepareState
which is done using the man.start()
call.
// RequestManager.swift
import Foundation
import GameplayKit
extension Notification.Name {
static let requestDidCancel = Notification.Name("request-did-cancel")
}
final class RequestManager {
var request: ERequest
var fsm: RequestStateMachine
private let http: EAHTTPClient
init(request: ERequest, env: EEnv? = nil) {
self.request = request
self.http = EAHTTPClient()
self.fsm = RequestStateMachine(states: RequestManager.getAllRequestStates(request), request: request)
self.fsm.manager = self
}
private static func getAllRequestStates(_ req: ERequest) -> [GKState] {
return [RequestPrepareState(req), RequestSendState(req), RequestResponseState(req), RequestCancelState(req)]
}
func start() {
self.fsm.enter(RequestPrepareState.self)
}
func prepareRequest() throws {
let urlReq = try self.requestToURLRequest(self.request)
let state = self.fsm.state(forClass: RequestSendState.self)
state?.urlReq = urlReq
self.fsm.enter(RequestSendState.self)
}
func sendRequest(_ urlReq: URLRequest) {
self.http.process(request: urlReq, completion: { result in
self.responseDidObtain(result, request: urlReq, elapsed: elapsed)
})
}
func responseDidObtain(_ result: Result<(Data, HTTPURLResponse, URLSessionTaskMetrics?), Error>, request: URLRequest, elapsed: Int64) {
if let _ = self.fsm.currentState as? RequestCancelState { return }
guard let state = self.fsm.state(forClass: RequestResponseState.self) else { return }
switch result {
case .success(let (data, resp, metrics)):
state.response = resp
state.responseBodyData = data
state.metrics = metrics
case .failure(let err):
state.error = err
}
DispatchQueue.main.async {
var info: ResponseData!
if let err = state.error {
info = ResponseData(error: err, elapsed: elapsed, request: state.request, metrics: state.metrics)
}
if let resp = state.response {
info = ResponseData(response: resp, request: state.request, urlRequest: request, responseData: state.responseBodyData,
elapsed: elapsed, metrics: state.metrics)
}
self.saveResponse(&info)
}
func saveResponse(_ info: inout ResponseData) {
// .. save response
let state = self.fsm.state(forClass: RequestResponseState.self)
state?.data = info
self.fsm.enter(RequestResponseState.self)
}
func cancelRequest() {
self.fsm.enter(RequestCancelState.self)
}
}
}
In this RequestManager
class we can see the state progression. I am showing only the relevant code for brevity.
-
First it enters
RequestPrepareState
. On entering this state thedidEnter()
method will be invoked by GameplayKit. Here we call theman.prepareRequest()
where the request is processed, extrapolated and a finalURLRequest
object is returned by the methodfunc requestToURLRequest(_ req: ERequest) throws -> URLRequest?
. Then it moves toRequestSendState
. -
In the
RequestSendState
, it callsman.sendRequest(urlReq)
which sends theURLRequest
object using theEAHTTPClient
class. It is an async function and when the response is obtained, it will invoke theresponseDidObtain()
function where the response is processed and saved. It then moves toRequestResponseState
. -
In the
RequestResponseState
which is entered after a response to the request is obtained, we display the result in the UI usingman.viewResponseScreen(data: data)
, be it a success or an error. -
Then there is
RequestCancelState
which is entered when user cancels an ongoing request which sends a notification to cancel the existing request.
Using finite state machines will help us build robust applications. I must say, GameplayKit is just splendid.