-
-
Notifications
You must be signed in to change notification settings - Fork 24
Home
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 interface 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 -
TransitionParams
- information about current transition, passed to notification functions
Working with state machine consists of two major steps:
- Creation with initial setup and starting
- 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 and start, it is ready to process events
machine.processEvent(GreenEvent)
// ...
machine.processEvent(YellowEvent)
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.
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)
// ...
}
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())
// ...
}
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 { /*...*/ }
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 {
// ...
}
}
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>()
}
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 {
// Listen to all triggered transitions here
println(it.event)
}
}
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
// ...
}
}
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") }
}
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.
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) }
// ...
}
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 addFinalState()
function. In ChildMode.EXCLUSIVE
state machine finishes when enters
top-level FinalState
. In ChildMode.PARALLEL
state machine finishes when all its children has finished. On finish,
state machine notifies its listeners with onFinished()
callback.
createStateMachine {
val final = finalState("final")
setInitialState(final)
onFinished { println("State machine is finished") }
}
Note: FinalState
can not have its own transitions.
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()
}
}
}
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.
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>() }
}
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.
StateMachine
is a subclass of IState
, this allows to use it as a child of another state machine like a 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.
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:
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") { /* ... */ }
// ..
}
It is a common case when a state expects to receive some data from an 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.
Note: Type of arguments is Any?
, so it is not type safe ot use them.
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)
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.
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. You can post such events to some
queue to process them later or 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."
)
}
}
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.
Note: Currently transitions that use lambdas like transitionConditionally()
and transitionOn()
are not exported.
User defined lambdas that are passed to calculate next state could not be correctly called during export process as they
may touch application data that is not valid when export is running.
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
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)
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 or reordering of states in state machine setup
block to have a valid state references for transitions.
Keep in mind that states are mutated by machine instance, defining them with object
keyword (i.e. singleton) often
makes your states live longer than machine. It is common use case when you have multiple similar machines
that are using same singleton states sequentially. Library detects such cases automatically by default
(see autoDestroyOnStatesReuse
argument of createStateMachine
function) and cleans states allowing for future reuse.
You can disable automatic machine destruction on state reuse, and call StateMachine.destroy()
manually if required,
or just do not use object
keyword for defining states.
If you have your own DefaultState
subclasses that are singletons and has data fields, use
onCleanup()
callback to clean your data before state reuse.
- 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 itself.
Wrong - managing 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)
In certain scenarios (maybe like state pattern) it is fine to use events like some kind of setState() / goToState() function but in general it is wrong, as events are not commands.