Stop Rolling Your Own State Machine Code
FIGURE 1. The Flight Plan iOS App
This sub heading is aimed at me to serve as a reminder to stop reinventing the wheel! A lot of the apps that I write benefit from having a Finite State Machine computational model. In an earlier article I wrote about controlling the Tello drone remotely using Swift. If you have a look at this code you will see that I have implemented a simple state machine to track the state of the drone. You need this because you can't send the drone a command unless WiFi is connected and the drone is in command mode (activated by sending it the "command" string via UDP). Thus our app responds differently depending on what state the drone is in.
I felt justified in writing my own state machine code because the initial application was relatively simple. If I was writing a game then I would always use GKStateMachine, the state machine class provided by Apple as part of game kit, but because this is a utility and I was in the UIKit headspace as opposed to the SceneKit/GameplayKit space I didn't think about it. But there is no reason you can't use GameplayKit classes in your UIKit app and in retrospect that is what I should have done (and just spent a day refactoring my code to do). Insert face palm emoji here!
I have continued to add functionality to my drone control app and as the complexity increased my home grown state machine started to become part of the problem and not the solution. Due to the organic development process (i.e. unstructured), I ended up with two state machines which were not scaling well. More importantly the app was acting weird and ending up in undefined states. Of course I could have fixed this in time, but I realised that Apple have already spent a lot of time putting together a robust state machine class and I should be using that!
FIGURE 2. Drone State Machine Diagram
The collateral benefit of having to refactor my code using GKStateMachine was that it made me sit down and plan out what states I needed and what would cause a transition. In other words I needed to develop a state transition table or diagram (Figure 2). After doing this exercise it became apparent that I didn't need two state machines, I just needed to add two states to the original machine. In addition, being forced to come up with the table made me think about some states and/or transitions that I wasn't handling.
TL;DR - Use GKStateMachine even for simple applications!
To demonstrate how easy it is, I will include the boiler plate code for my drone app.
STEP 1 - Create the state classes
For every state in your FSM you need a class to handle transitions, etc. Typically you will need to override the functions shown. I have included the outline for the disconnected state class below. The other state classes have exactly the same format but with different names.
//
// DisconnectedState.swift
// FlightPlan
//
// Created by David Such on 18/8/19.
// Copyright © 2019 Kintarla Pty Ltd. All rights reserved.
//
import Foundation
import GameplayKit
class DisconnectedState: GKState {
unowned let viewController: ViewController
init(viewController: ViewController) {
self.viewController = viewController
super.init()
}
override func didEnter(from previousState: GKState?) {
viewController.statusLabel.text = "DISC"
viewController.WiFiImageView.image = UIImage(named: "WiFiDisconnected")
if !UserDefaults.standard.warningShown {
viewController.showAlert(title: "Not Connected to Tello WiFi", msg: "In order to control the Tello you must be connected to its WiFi network. Turn on the Tello and then go to Settings -> WiFi to connect.")
UserDefaults.standard.warningShown = true
}
}
override func willExit(to nextState: GKState) {
}
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
return (stateClass == WiFiUpState.self) || (stateClass == PlanningState.self)
}
override func update(deltaTime seconds: TimeInterval) {
}
}
A couple of points. Firstly, make sure that you import GameplayKit. Second, note the constant definition:
unowned let viewController: ViewController
In my app this is the main view controller which contains the UI and will never be NIL. To prevent a retain cycle we use unowned (and not weak since that view controller can never be NIL).
This constant is used to update the UI based on state changes (alternatively you could use a delegate).
STEP 2 - Define the State Machine
Next, within the view controller referred to in step 1, you need to define your state machine.
//
// ViewController.swift
// FlightPlan
//
// Created by David Such on 3/6/19.
// Copyright © 2019 Kintarla Pty Ltd. All rights reserved.
//
import UIKit
import GameplayKit
class ViewController: UIViewController {
lazy var stateMachine: GKStateMachine = GKStateMachine(states: [
DisconnectedState(viewController: self),
WiFiUpState(viewController: self),
CommandState(viewController: self),
PlanningState(viewController: self),
ManualState(viewController: self),
AutoPilotState(viewController: self)
])
As shown above, this is very straight forward. A lazy stored property is a property whose initial value is not calculated until the first time it is used. You indicate a lazy stored property by writing the lazy modifier before its declaration. We need this so that we can assign a pointer to the class containing our state machine (i.e. viewController which is an instance of ViewController) after it has been initialised.
STEP 3 - Use the State Machine
Now we can use our new state machine to keep track of the drone state and handle transitions between states. The first thing you will want to do is to set the initial state. For our drone this is the disconnected state.
stateMachine.enter(DisconnectedState.self)
You will probably do this in the viewDidLoad method of viewController. Then you can change states when the appropriate event is triggered. For example, the following method is called when the take off button is tapped.
@IBAction func takeOffTapped(_ sender: UIButton) {
switch stateMachine.currentState {
case is DisconnectedState:
showAlert(title: "Not Connected to Tello WiFi", msg: "In order to control the Tello you must be connected to its WiFi network. Turn on the Tello and then go to Settings -> WiFi to connect.")
case is WiFiUpState:
showAlert(title: "Awaiting CMD Response", msg: "We haven't received a valid response to our initialisation command. Try sending again from Setup.")
case is CommandState:
tello.takeOff()
stateMachine.enter(ManualState.self)
case is PlanningState:
if tello.flightPlan.count == 0 {
let zoom = scrollView.zoomScale - 0.25
let pitch = dronePointer.frame.size.height
tello.flightPlan.append(CMD.takeOff)
dronePointerCenter.y -= pitch
UIView.animate(withDuration: 0.5, delay: 0, options: .curveEaseInOut, animations: {self.dronePointer.center = self.dronePointerCenter}, completion: nil)
scrollView.setZoomScale(zoom, animated: true)
}
default:
break
}
}
Depending on the current drone state (stateMachine.currentState) we want to perform different actions. To take off manually, we need to be in the command state. In the planning state, we add the take off command to our flight plan, and animate the action on our viewController.
One last tip. In the example above we are using switch for program control to handle the various states. If you want to check the current state against only one state don't use "==". It wont compile. You need to use "is" instead. For example, to check if the current state is Auto Pilot, you would use:
if stateMachine.currentState is AutoPilotState {
tello.stopAutoPilot()
}
}
That's it. Next time you need a state machine, don't write your own! Hop on over to GameplayKit and grab GKStateMachine.
No comments:
Post a Comment