Skip to content
Mikhail Fedotov edited this page Nov 4, 2021 · 14 revisions

KStateMachine documentation

Workflow

Building blocks (main interfaces) of the library:

  • StateMachine - is a collection of states and transitions between them, processes events when started
  • IState - states where state machine can go to
  • Event - is a base class for events or other words actions which are processed by state machine and may trigger transitions
  • Transition - is an operation of moving from one state to another

Working with state machine consists of two major steps:

  • Creation and initial setup
  • Processing events, on which state machine can switch its states and notify about changes
val machine = createStateMachine {
    // Setup is made in this block ...
}
// After setup it is ready to process events
machine.processEvent(GreenEvent)
// ...
machine.processEvent(YellowEvent)

Create state machine

First we create a state machine with createStateMachine() function:

val machine = createStateMachine(
    "Traffic lights" // Optional name is convenient for logging debugging and export
) {
    // Set up state machine ...
}

By default, createStateMachine() starts state machine. You can control it using start argument.

Setup states

Default states

IState is just an interface, DefaultState & co. are implementations.

Use default states if you do not need to distinguish states (by type) outside from state machine. Otherwise, consider using state subclasses.

In state machine setup block define states with initialState(), state() and finalState() functions:

createStateMachine {
    // Use initialState() function to create initial State and add it to StateMachine
    // State machine enters this state after setup is complete
    val greenState = initialState()
    // State name is optional and is useful to getting state instance
    // after state machine setup and for debugging
    val yellowState = state("Yellow")
    val redState = finalState()
    // ...
}

You can use setInitialState() function to set initial state separately:

createStateMachine {
    val greenState = state()
    setInitialState(greenState)
    // ...
}

State subclasses

You can use your own IState subclasses with addInitialState(), addState() and addFinalState() functions. Subclass DefaultState, DefaultFinalState or their data analogs DefaultDataState , DefaultFinalDataState, then you can easily distinguish your states by type when observing state changes:

class SomeState : DefaultState()

createStateMachine {
    val someState = addState(SomeState())
    // ...
}

Listen states

In state setup blocks we can add listeners for states:

state {
    onEntry { println("Enter $name state") }
    onExit { println("Exit $name state") }
}

Or even shorter:

state().onEntry { /*...*/ }

Setup transitions

In a state setup block we define which events will trigger transitions to another states. The simplest transition is created with transition() function:

greenState {
    // Setup transition which is triggered on YellowEvent
    transition<YellowEvent> {
        // Set target state where state machine go when this transition is triggered
        targetState = yellowState
    }
    // The same with shortcut version
    transition<RedEvent>("My transition", redState)
}

Same as for states we can listen to transition triggering:

transition<YellowEvent> {
    targetState = yellowState
    onTriggered { println("Transition to $targetState is triggered by ${it.event}") }
}

There is an extended version of transition() function, it is called transitionOn(). It works the same way but takes a lambda to calculate target state. This allows to use lateinit state variables and to choose target state depending on an application business logic like with conditional transitions but with shorter syntax and less flexibility:

createStateMachine {
    lateinit var yellowState: State

    greenState {
        transitionOn<YellowEvent> {
            targetState = { yellowState }
        }
    }

    yellowState = state {
        // ...
    }
}

Targetless transitions

Transition may have no target state (targetState is null) which means that state machine stays in current state when such transition triggers:

greenState {
    transition<YellowEvent>()
}

Listen to all transitions in one place

There might be many transitions from one state to another. It is possible to listen to all of them in state machine setup block:

createStateMachine {
    // ...
    onTransition { sourceState, targetState, event, argument ->
        // Listen to all triggered transitions here
    }
}

Guarded transitions

Guarded transition is triggered only if specified guard function returns true. Guarded transition is a special kind of conditional transition with shorter syntax. Use transition() or transitionOn() functions to create guarded transition:

state1 {
    transition<SwitchEvent> {
        guard = { value > 10 }
        targetState = state2
        // ...
    }
}

See guarded transition sample

Guarded transition diagram

Conditional transitions

State machine becomes more powerful tool when you can choose target state depending on your business logic (some external data). Conditional transitions give you maximum flexibility on choosing target state and conditions when transition is triggered.

There are three options to choose transition direction:

  • stay() - transition is triggered but state is not changed;
  • targetState(nextState) - transition is triggered and state machine goes to the specified state;
  • noTransition() - transition is not triggered.

Use transitionConditionally() function to create conditional transition and specify a function which makes desired decision:

redState {
    // A conditional transition helps to control when it 
    // should be triggered and determine its target state
    transitionConditionally<GreenEvent> {
        direction = {
            // Suppose you have a function returning some 
            // business logic value which may differ
            fun getCondition() = 0

            when (getCondition()) {
                0 -> targetState(greenState)
                1 -> targetState(yellowState)
                2 -> stay()
                else -> noTransition()
            }
        }
    }
    // Same as before you can listen when conditional transition is triggered
    onTriggered { println("Conditional transition is triggered") }
}

Transition event type matching

By default, event type that triggers transition is matched as instance of specified event class. For example transition<SwitchEvent>() matches SwitchEvent class and its subclasses. If you have event hierarchy it might be necessary to control matching mechanism, it might be done with eventMatcher argument of transition builder functions:

transition<SwitchEvent> {
    eventMatcher = isEqual()
}

There are two predefined event matchers:

  • isInstanceOf() matches specified class and its subclasses (default)
  • isEqual() matches only specified class

You can define your own matchers by subclassing EventMatcher class.

Logging

You can enable internal state machine logging on your platform.

On JVM:

createStateMachine {
    logger = StateMachine.Logger { println(it) }
    // ...
}

On Android:

createStateMachine {
    logger = StateMachine.Logger { Log.d(this::class.qualifiedName, it) }
    // ...
}

Finishing state machine

Some of state machines are infinite, but other ones may finish. State machine that was finished stops processing incoming events. To make state machine finishing, add FinalState to it with finalState() function or add any subclass of FinalState with addState() function. State machine finishes when enters top-level FinalState and notifies its listeners with onFinished() callback.

createStateMachine {
    final = finalState("final")
    setInitialState(final)

    onFinished { println("State machine is finished") }
}

Note: FinalState can not have its own transitions.

Nested states

With nested states you can build hierarchical state machines and inherit transitions by grouping states.

To create nested states simply use same functions (state(), initialState() etc.) as for state machine but in state setup block:

val machine = createStateMachine {
    val topLevelState = initialState {
        // ...
        val nestedState = initialState {
            // ...
            initialState()
            state()
            finalState()
        }
    }
}

Inherit transitions by grouping states

Suppose you have three states that all should have a transitions to another state. You can explicitly set this transition for each state but with this approach complexity grows and when you add fourth state you have to remember to add this specific transition. This problem can be solved with adding parent state which defines such transition and groups its child states. Child states inherit there parent transitions.

Inherit transitions diagram

A child state can override an inherited transition. To override parent transition child state should define any transition that matches the event.

createStateMachine {
    val state2 = state("state2")
    // all nested states inherit this parent transition
    transition<SwitchEvent> { targetState = state2 }

    // child state overrides transitions for all events
    initialState("state1") { transition<Event>() }
}

Cross level transitions

A transition can have any state as its target. This means that the target state does not have to be on the same level in the state hierarchy as the source state.

Cross level transition diagram

Composed (nested) state machines

StateMachine is a subclass of State this allows to use it as a child of another state machine like simple state. The parent state machine treats the child machine as an atomic state. It is not possible to reference states of a child machine from parent transitions and vise versa. Child machine is automatically started when parent enters it. Events from parent machine are not passed to it child machines. Child machine receives events only from it own processEvent() calls.

Parallel states

Sometimes it might be useful to have a state machine containing mutually exclusive properties. Assume your laptop, it might be charging, sleeping, its lid may be open at the same time. If you try to create a state machine for those properties you will have 3 * 3 = 9 amount of states ("Charging, Sleeping, LidOpen", "OnBattery, Sleeping, LidOpen", etc...). This is where parallel states come into play. This feature helps to avoid combinatorial explosion of states. Using parallel states this machine will look like this:

Parallel states diagram

Set childMode argument of a state machine, or a state creation functions to ChildMode.PARALLEL. When a state with parallel child mode is entered, all its child states will be simultaneously entered:

createStateMachine(childMode = ChildMode.PARALLEL) {
    state("Charger") {
        initialState("Charging") { /* ... */ }
        state("OnBattery") { /* ... */ }
    }
    state("Lid") { /* ... */ }
    // ..
}

Typesafe transitions

It is a common case when a state expects to receive some data from event. Library provides typesafe API for such case. It is implemented with DataEvent and DataState. Both interfaces are parameterized with data type. To create typesafe transition use dataTransition() and dataTransitionOn() functions. This API helps to ensure that event data parameter type matches data parameter type that is expected by a target state of a transition. Compiler will protect you from defining a transition with incompatible data type parameters of event and target state.

class StringEvent(override val data: String) : DataEvent<String>

createStateMachine {
    val state2 = dataState<String> {
        onEntry { println("State data: $data") }
    }

    initialState {
        dataTransition<StringEvent, String> { targetState = state2 }
    }
}

State data field value is set and might be accessed only while the state is active. When DataState is activated it requires data value from a DataEvent. This should be taken into account when mixing typesafe transitions with cross-level transitions. Cross-level transition may trigger DataState activation implicitly, and exception will be thrown in such case.

Arguments

Note: Type of arguments is Any?, so it is not type safe ot use them.

Event argument

Usually if event may hold some data we define Event subclass, it is type safe. Sometimes if data is optional it may be simpler to use event argument. You can specify arbitrary argument with an event in processEvent() function. Then you can get this argument in a state and transition listeners.

val machine = createStateMachine {
    state("offState").onEntry {
        println("Event ${it.event} argument: ${it.argument}")
    }
    // ...
}
// Pass argument with event
machine.processEvent(TurnOn, 42)

Transition argument

If transition listener produce some data, you can pass it to target state as a transition argument:

val second = state("second").onEntry {
    println("Transition argument: ${it.transition.argument}")
}
state("first") {
    transition<SwitchEvent> {
        targetState = second
        onTriggered { it.transition.argument = 42 }
    }
}

Note: it is up to user to control that argument field is set from one listener. You can use some mutable data structure and fill it from multiple listeners.

Error handling

By default, state machine simply ignores events that does not match any defined transition. You can see those events if logging is enabled or use custom IgnoredEventHandler:

createStateMachine {
    // ...
    ignoredEventHandler = StateMachine.IgnoredEventHandler { event, _ ->
        error("unexpected $event")
    }
}

It is not allowed to call processEvent() while state machine is already processing event. For example from notification listener. By default, state machine will throw exception in this case, but you can set custom PendingEventHandler:

createStateMachine {
    // ...
    pendingEventHandler = StateMachine.PendingEventHandler { pendingEvent, _ ->
        error(
            "$this can not process pending $pendingEvent " +
                    "as event processing is already running. " +
                    "Do not call processEvent() from notification listeners."
        )
    }
}

Multithreading

State machine is designed to work in single thread. So if you need to process events from different threads you can post them to some thread safe queue and start single thread which will pull events from that queue in a loop and call processEvent() function.

Export

Note: Currently transitions defined by transitionConditionally() and transitionOn() functions are not exported. User defined lambdas that are passed to calculate next state could not be correctly called during export process.

PlantUML

Use exportToPlantUml() extension function to export state machine to PlantUML state diagram.

val machine = createStateMachine { /*...*/ }
println(machine.exportToPlantUml())

Copy/paste resulting output to Plant UML online editor

Testing

For testing, it might be useful to check how state machine reacts on events from particular state. There is Testing.startFrom() function which allows starting the machine from a specified state:

lateinit var state2: State

val machine = createStateMachine(start = false) {
    initialState("state1")
    state2 = state("state2")
    // ...
}

machine.startFrom(state2)

Consider using Kotlin sealed classes

With sealed classes for states and events your state machine structure may look simpler. Try to compare this two samples they both are doing the same thing but using of sealed classes makes code self explaining:

Minimal sealed classes sample vs Minimal syntax sample

Also sealed classes eliminate need of using lateinit states variables and reordering of states in state machine setup block to have a valid state references for transitions.

Do not

  1. Keep in mind that currently states are mutable and defining them as object requires usage in a single instance of state machine. If you are using multiple instances of machines then separate state instances must be created for each machine.

  2. State machine is a powerful tool to control states, so let it do its job. Do not try to rule it from outside ( selecting a target state) by sending different event types depending on business logic state. Let the state machine to make decisions for you.

Wrong - managing state machine target state from outside:

if (somethingHappend)
    machine.processEvent(GoToState1Event)
else 
    machine.processEvent(GoToState2Event)

Correct - let the state machine to make decisions on an event:

machine.processEvent(SomethingHappenedEvent)
Clone this wiki locally