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 event E occurs, we should perform the actions A and make a transition to the state S'.

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.

  1. First it enters RequestPrepareState. On entering this state the didEnter() method will be invoked by GameplayKit. Here we call the man.prepareRequest() where the request is processed, extrapolated and a final URLRequest object is returned by the method func requestToURLRequest(_ req: ERequest) throws -> URLRequest?. Then it moves to RequestSendState.

  2. In the RequestSendState, it calls man.sendRequest(urlReq) which sends the URLRequest object using the EAHTTPClient class. It is an async function and when the response is obtained, it will invoke the responseDidObtain() function where the response is processed and saved. It then moves to RequestResponseState.

  3. In the RequestResponseState which is entered after a response to the request is obtained, we display the result in the UI using man.viewResponseScreen(data: data), be it a success or an error.

  4. 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.


PS: I am too lazy to draw a state diagram. Will leave it as an exercise to the reader.