tree_state_machine 3.0.0-dev.6
tree_state_machine: ^3.0.0-dev.6 copied to clipboard
Define and run hierarchical state machines in Dart and Flutter.
tree_state_machine #
tree_state_machine
is a Dart package for defining and executing hierarchical state machines.
Features #
- Hierarchical state trees
- Asynchronous message processing
- Stream based event notifications
- Declarative state definitions with automated generation of state diagrams in DOT format
- Nested state machines
Getting Started #
The primary API for the working with a tree state machine is provided by the tree_state_machine
library. The API for
defining state trees is provided by tree_builders
library. Depending on how your application is structured, you will
need to import one or both of these libraries.
import 'package:tree_state_machine/declarative_builders.dart';
import 'package:tree_state_machine/tree_state_machine.dart';
Overview #
The tree_state_machine
library provides APIs for both defining a hierarchcal tree of states, and creating state
machines that can instantiate one of those state trees and managing the current state. The state machine can be used to
dispatch messages to the current state for processing, and receiving notifications as state transitions occur.
The typical usage pattern is simular to the following:
// Define state keys that identify the states in the state tree
sealed class States {
static const state1 = StateKey('state1');
static const state1 = StateKey('state2');
}
// Define the state tree
var treeBuilder = StateTreeBuilder(initialChild: States.state1);
treeBuilder.state(States.state1, (state) {
state.onMessage<AMessage>((handler) => handler.goTo(States.state2));
});
treeBuilder.state(States.state2, emptyState);
// Create and start a state machine for the state tree
var machine = TreeStateMachine(treeBuilder);
var currentState = await machine.start();
// Send a message to be processed by the current state, which may potentially
// cause a transition to a different state
await currentState.post(AMessage());
The following sections describe these steps in more detail.
State Trees #
Naming States #
Each state in a state tree is uniquely identified by a StateKey
. These keys are used when defining states, and naming
the destination state when a state transition occurs. For convenience, they are often grouped together into a containing
class. For example:
sealed class States {
static const unauthenticated = StateKey('unauthenticated');
static const authenticated = StateKey('authenticated');
// Use DataStateKey to identify a data state with an associated state data type
static const login = DataStateKey<LoginData>('login');
// ...
}
StateTreeBuilder
is used to to declare the states in a state tree. There are several factories available to create a
a StateTreeBuilder
, but the simplest names the state that will initially be active when the state machine starts.
var treeBuilder = StateTreeBuilder(initialChild: States.unauthenticated);
Defning States #
States are declared using the StateTreeBuilder.state
method, passing a StateKey
to identify the state, and a
callback that will be used to define how the state behaves.
treeBuilder.state(States.unauthenticated, (stateBuilder) {
// Use state builder to define how the unauthenticated state behaves
});
See Message Handlers to see how to use the state builder.
Child States
A state can be defined as a child of another state by specifying parent
when the state is declared. If a state does
not handle a message, the parent state will have an opportunity to handle it.
Once a state has been assigned a parent, that parent state must define which of its child states must be entered when
the parent is entered using initialChild
.
treeBuilder.state(States.splash, (stateBuilder) {
// Use state builder to define how the splash state behaves
}, parent: States.unauthenticated);
treeBuilder.state(States.unauthenticated, (stateBuilder) {
// Use state builder to define how the unauthenticated state behaves
}, initialChild: InitialChild(States.splash));
If a state declaration does not specify initialChild
, that state is considered a leaf state in the state tree.
Data States
States often require additional data to model their behavior. For example, a 'counting' state might need an integer value to store the number of times an event occured. The data value(s) needed by a state are collectively referred to as state data (or more formally, extended state variables).
A data state can be defined using the dataState
method, providing the type of state data as a type parameter. An
InitialData
must be provided, indicating how to create the initial value of the state data when the state is entered.
Also note that a DataStateKey
, not a StateKey
, must be used to identify a data state.
class LoginData {
String username = '';
String password = '';
String errorMessage = '';
}
treeBuilder.dataState<LoginData>(
States.login,
// The initial value for the state data when the state is entered.
InitialData(() => LoginData()),
(stateBuilder) {
// Use state builder to define how the login state behaves. The state has access
// to a LoginData value while the state is active.
},
parent: States.unauthenticated,
);
See Reading and writing state data to learn how to read and write state data when handling a message with a data state.
Final States
States may be delared as final states. Once a final state has been entered, no further message processing or state transitions will occur, and the state tree is considered ended, or complete. Note that a final state is always considered a child of the root state, and may not have any child states.
treeBuilder.finalState(States.lockedOut, (stateBuilder) {
// Use state builder to define behavior for the lockedOut state when
// it is entered.
});
Machine States
Existing state trees or state machines can be composed with a state tree builder as a machine state. A machine state is a leaf state, and when it is entered a nested state machine will be started. The machine state will forward any messages to the nested state machine, and will remain the current state until the nested state machine reaches a final state. When it does so, the machine state will invoke a callback to determine the next state to transition to.
StateTreeBuilder nestedTreeBuilder() {
var treeBuilder = new StateTreeBuilder();
// ...define a state tree
return treeBuilder;
}
final nestedMachineState = StateKey('nestedMachine');
final otherState = StateKey('otherState');
treeBuilder.machineState(
nestedMachineState,
// A nested state machine will be created from this state tree
InitialMachine.fromTree((transCtx) => nestedTreeBuilder()),
(b) {
// When the nested machine completes, go to otherState
b.onMachineDone((b) => b.goTo(otherState));
}
);
Defining Message Handlers #
The way a state responds to a message is defined by the MessageHandler
function for the state. A message handler is
provided a MessageContext
describing the message, and must return a MessageResult
describing how the state responds
to the message.
typedef MessageHandler = FutureOr<MessageResult> Function(MessageContext ctx);
Message handlers are typically not defined directly. Instead StateBuilder
, MessageHandlerBuilder
and associated
types can be used to specify the handler in a declarative fashion. MessageHandlerBuilder
has several methods, such as
goTo
, that correspond to different types of MessageResult
.
Note that any object can be a message, and message handlers can be declared by message type or message value.
class GoToLogin { }
enum Messages { goToRegister }
treeBuilder.state(States.unauthenticated, (sb) {
// When this state receives a message of type GoToLogin, go to the login
// state
sb.onMessage<GoToLogin>((mhb) => mhb.goTo(States.login));
// When this state receives a goToRegister message value, go to the
// registration state
sb.onMessageValue(Messages.goToRegister, (mhb) => mhb.goTo(States.registration));
}, initialChild: InitialChild(States.splash));
Message actions
Often addition actions need to be taken when a state handles a message, in addition to transitioning to a different
state. These actions can be defined with MessageActionBuilder
and associated types, and passed to the action
parameter of goTo
and similar methods. These actions are run before MessageResult
returned by the message handler
is processed by the state machine.
Reading and writing state data
A data state can access its associated state data. Additionally, any state can access the state data of an ancestor
data state. This data can be requested in several ways, but often updateData
is used to read and update state data
when handling a message.
treeBuilder.state(States.credentialsRegistration, (b) {
b.onMessage<SubmitCredentials>((b) {
b.goTo(States.demographicsRegistration,
// Update the RegisterData state data owned by the parent
// Registration state. The callback is provided a
// MessageHandlerContext, which gives access to the
// SubmitCredentials message being handled, and the current
// state data value. The callback returns the new state data value.
action: b.act.updateData<RegisterData>((ctx) => ctx.data
..email = ctx.message.email
..password = ctx.message.password));
});
}, parent: States.registration);
Defining Transition Handlers #
A state can receive notifications when it is entered or exited. These notifications are calls to TransitionHandler
functions, and the handlers are passed a TransitionContext
describing the transition that is occurring.
typedef TransitionHandler = FutureOr<void> Function(TransitionContext ctx);
Similar to message handlers, transition handlers are typically not defined directly. StateBuilder
has methods such as
onEnter
and onExit
for declaring how these handlers should behave.
treeBuilder.state(States.authenticating, (b) {
b.onEnter((b) {
// When this state is entered, perform a login operation, and post
// the result of the login as a message for future processing.
b.post<AuthFuture>(
getMessage: (transCtx) => _doLogin(transCtx.payload as SubmitCredentials));
});
}
Entry channels #
Sometimes when a data state is entered, it requires some contexual information in order to initialize its state date.
This information typically needs to be provided by the state that initiates the state transition, as the payload
argument of goTo
. The Channel<P>
type provides a contract indicating that in order to enter a state, a value of
type P
must be provided.
// In order to enter the authenticating state, a SubmitCredentials value is
// required.
final authenticatingChannel = Channel<SubmitCredentials>(States.authenticating);
treeBuilder.state(States.loginEntry, (b) {
b.onMessage<SubmitCredentials>((b) {
// enterChannel is similar to goTo, but enforces that a SubmitCredentials
// value is provided.
b.enterChannel(authenticatingChannel, (ctx) => ctx.message);
});
}, parent: States.login);
treeBuilder.state(States.authenticating, (b) {
// onEnterFromChannel is similar to onEnter, but but enforces that a
// SubmitCredentials was provided.
b.onEnterFromChannel<SubmitCredentials>(authenticatingChannel, (b) {
// The builder argument provides access to the SubmitCredentials, in this
// case as as argument to the getMessage function
b.post<AuthFuture>(getMessage: (ctx) => _login(ctx.context, authService));
});
}, parent: States.login)
State Machines #
Creating a state machine #
Once a state tree has been defined, a state machine can be created.
var treeBuilder = defineStateTree();
var stateMachine = TreeStateMachine(treeBuilder);
Starting a state machine #
Before messages can be sent to the state machine for processing, it has be started. The start
method returns a future
that yields a CurrentState
that serves as a proxy to the current state after the machine has fully started.
var currentState = await stateMachine.start();
Sending messages #
Once a state machine has started, a message may be dispatched to the current leaf state using the post
method of
CurrentState
. This returns a future that completes when the message has been processed and any resulting transition
has completed.
var message = SubmitCredentials(username: 'foo', password: 'bar');
var messageResult = await currentState.post(message);
State machine streams #
In addition to inspecting the results of post
to learn about how a message was processed, it is also possible to
subscribe to several stream properties of TreeStateMachine:
processedMessages
yields an event for every message that is processed by the tree, whether or not the message was handled successfully, and whether or not a transition occurred as a result of the message.handledMessages
is a convenience stream that yield an event when only when a message is successfully handled. Note that the HandledMessage event is also emitted on the processedMessages stream.transitions
yields an event each time a transition between states occurs.
Ending a state machine #
A state machine can end in two ways.
- When processing a message, if the state machine transitions to a final state, then no other message processing or state transitions will occur, and the state machine has ended. This is called an 'internal stop'.
- Calling
stop
on the state machine will transition the machine to an implicit final state, identified bystoppedStateKey
. This state will always be available, and does not need to be added when defining a state tree. This is called an 'external stop'.await stateMachine.stop(); // done will be true after stopping. var done = stateMachine.isDone;
State machine logging #
The tree_state_machine
packages logs diagnostic messages using the Dart SDK logging
package. An application can
enable logging
output to view the messages. If hierarchical logging is enabled, all logging is peformed under a parent
logger named tree_state_machine
.
hierarchicalLoggingEnabled = true;
Logger('tree_state_machine').level = Level.ALL;
Logger.root.onRecord.listen((record) {
print('${record.level.name}: ${record.time}: ${record.message}');
});