I was recently listening to a fantastic episode of the Fragmented pocast where the guest, Ray Ryan, mentioned that they make heavy use of finite state machines at Square. Inspired by that podcast and a Github gist detailing the use of composable finite state machines in Swift, I decided to try and utilize a similar concept to build an Android application, with the goal being to come up with an architecture that both explicitly manages UI state and allows screen complexity to grow in a maintanable manner.
What is a finite state machine?
If you’re not familiar with the concept of a
finite state machine,
you can think of them as a set of States
with pre-defined edges between them.
An Action
is fed into the finite state machine, at which point the finite
state machine produces a new State
. They’re typically drawn as circles
representing states with lines connecting them representing transitions:
So in the above example of a turnstile finite state machine we have two
States
- Locked
and Un-locked
. We also have two possible Actions
-Push
and Coin
, which represents depositing a coin into the turnstile.
All finite state machines follow the following simple formula we alluded to
above:
State + Action = new State.
So in the turnstile example above, we can model our finite state machine with the following formulas:
Locked
+ Push
= Locked
- A locked turnstile remains locked when pushed
Locked
+ Coin
= Unlocked
- A locked turnstile unlocks when money is
inserted
Unlocked
+ Push
= Locked
- An unlocked turnstile locks after its used
Unlocked
+ Coin
= Unlocked
- An unlocked turnstile remains unlocked if you
insert more money
How does this fit into Android development?
When building up a screen in an Android app, we often find ourselves wrestling with growing UI state. A view can be loading, populated, scrolling, animating, pending and so on. As our views grow, the complexity grows exponentially and we often find ourselves struggling to find a way to properly organize and navigate the increasingly convoluted landscape. Finite state machines offer a unique answer to this problem - they convert your previously unmanaged implicit state into managed explicit state.
Designing our application
For this demo we’ll build a minimal single Activity
app focused on managing a
users love of sandwiches (side note - it turns out I had no idea how to spell
sandwich before I wrote this blog post). Our app will have 3 screens:
A listing screen, where a user can see their favorite sandwiches and initiate a flow to add a new sandwich:
A sandwich selection screen, where the user can either select a predefined sandwich or select a category for their own, new sandwich: (Note that I only have three images of sandwiches, so please excuse the duplicate deliciousness!)
And finally, a sandwich naming screen, where the user can name the new sandwich they started to build on screen #2
You’re probably assuming that I’ve won multiple design awards for the layout of this app. Shockingly I have not.
Defining our finite state machine
It’s worth taking a moment to think through what problem we’re hoping our finite state machine will solve. Currently, our activity has three distinct states that need some degree of coordination - we need to figure out when to hide the sandwich list screen and show the add sandwich screen and so on. Our finite state machine should be the engine that makes these decisions.
Now that we know what problem we’re trying to solve, we can move on to building our finite state machine.
Defining our States
First, we need to decide on a set of States
for the app.
Since we want our finite state machine to drive the UI, it makes sense
to create a State
in our state machine for each of the screens listed above.
Our states will thus be SandwichList
for the listing screen, AddSandwich
for the sandwich selection screen, and NameSandwich
for the sandwich naming
screen.
So our set of states is: SandwichList
, AddSandwich
, and NameSandwich
.
Defining our Actions
Next we need to figure out what our Actions
are.
Since our finite state machine is the engine driving our view, it makes sense
that interactions with that view would affect the finite state machine. So we
can model user interactions as Actions
being fed into our finite state
machine!
On the first screen, the only action the user can take is clicking the “Add
Sandwich” button - so we’ll want an AddSandwichClicked
Action
. On the
sandwich selection screen, the user can either select a predefined sandwich or
select a sandwich category. So we’ll want two more actions -
SandwichTypeSelected
and PredefinedSandwichSelected
. Finally, on the
sandwich naming screen, the only action the user can take is clicking the
“Submit” button, so we’ll need a SubmitSandwichClicked
action.
Our set of Actions
doesn’t necessarily need to be limited to user interaction;
if we received a notification or something along those lines that could be an
Action
for the finite state machine as well.
So our set of actions is: AddSandwichClicked
, SandwichTypeSelected
,
PredefinedSandwichSelected
, and SubmitSandwichClicked
.
Putting everything together
The final step in constructing our finite state machine is figuring out the set of transitions that we have - we want to setup a list of equations for our sandwich app in the same way we set them up for the turnstile above.
SandwichList
+ AddSandwichClicked
= AddSandwich
- After the user clicks
“add sandwich” we want to transition to the AddSandwich
state.
AddSandwich
+ PredefinedSandwichSelected
= SandwichList
- If the user
selects a predefined sandwich, we want to take them right back to the
SandwichList
state with their newly selected sandwich added to the list.
AddSandwich
+ SandwichTypeSelected
= NameSandwich
- After the user selects
a sandwich type, we want to take them to the NameSandwich
state to choose a
name for their new sandwich.
NameSandwich
+ SubmitSandwichClicked
= SandwichList
- After the user
submits their sandwich name, we’ll combine that data with the sandwich type they
selected before to create their new sandwich and add it to the list.
The following equations lead us to this finite state machine diagram:
Time to write some code
Now that we’ve constructed the theoretical underpinnings of our app, let’s actually write some code.
The first thing we’ll create is an interface
for a State
in our state
machine:
interface SandwichState {
fun consumeAction(action: Action): SandwichState
}
This interface models our equation above - State
+ Action
= new State
. Any
State
that implements this interface will have to supply a new SandwichState
when provided with an Action
.
Next up is to define our Action
. We’ll model our Actions
as a sealed class
to utilize Kotlins exhaustive when
statements later on:
sealed class Action {
class AddSandwichClicked : Action()
class SandwichTypeSelected(val type: SandwichType): Action()
class PredefinedSandwichSelected(val sandwich: Sandwich): Action()
class SubmitSandwichClicked(val sandwichName: String): Action()
}
Since we’re using a sealed class
instead of an enum
we can pass data through
our Actions
.
Next up we’ll define a few simple model objects:
data class Sandwich(val name: String, val type: SandwichType)
enum class SandwichType {
GRINDER,
WRAP
}
A sandwich object consists of a name and a SandwichType
, which is either
GRINDER
or WRAP
.
Implementing our States
Now we can work on actually defining our SandwichState
implementations.
First up we’ll create a new SandwichList
class that implements
SandwichState
. This class will represent the SandwichList
State
we reasoned about earlier.
class SandwichList(private val sandwiches: List<Sandwich>): SandwichState {
override fun consumeAction(action: Action): SandwichState {
TODO("Fill in the details")
}
}
The List<Sandwich>
that we pass in represents the sandwiches the user has
already added to their list of favorite sandwiches. We’re going to be building
this list up as we flow through our finite state machine.
We can refer to the equations we wrote above to figure out how to handle our
Actions
. The only formula relevant to the SandwichList
state is the
following:
SandwichList
+ AddSandwichClicked
= AddSandwich
The only Action
we’re going to handle in the SandwichList
state is the
AddSandwichClicked
Action
. When we encounter it we want to proceed to
the AddSandwich
state, which we’ll define shortly:
class SandwichList(private val sandwiches: List<Sandwich>): SandwichState {
override fun consumeAction(action: Action): SandwichState {
return when(action) {
is Action.AddSandwichClicked -> AddSandwich(sandwiches)
else -> throw IllegalStateException("Invalid action $action passed to state $this")
}
}
}
If we receive a different Action
than the one we’re expecting we know
something has gone wrong and we’ll throw our hands up. An alternative approach
could be to just return this
current state.
Next up we’ll implement the AddSandwich
State
:
class AddSandwich(private val sandwiches: List<Sandwich>): SandwichState {
override fun consumeAction(action: Action): SandwichState {
TODO("Fill in the details")
}
}
Again, we can utilize the relevant formulas we wrote to fill in the
consumeAction
method:
AddSandwich
+ PredefinedSandwichSelected
= SandwichList
AddSandwich
+ SandwichTypeSelected
= NameSandwich
class AddSandwich(private val sandwiches: List<Sandwich>): SandwichState {
override fun consumeAction(action: Action): SandwichState {
return when (action) {
is Action.SandwichTypeSelected -> NameSandwich(sandwiches, action.type)
is Action.PredefinedSandwichSelected -> {
SandwichList(sandwiches + action.sandwich)
}
else -> throw IllegalStateException("Invalid action $action passed to state $this")
}
}
}
This State
is slightly more complicated than SandwichList
was. The logic
breaks down as follows:
When the
SandwichTypeSelected
Action
is provided, we’re going to transition to theNameSandwich
State
, passing in theSandwichType
that the user selected so theNameSandwich
class can actually construct a newSandwich
When the
PredefinedSandwichSelected
Action
is provided, we want to add theSandwich
the user selected to our existing list ofSandwiches
. Since the user selected a fully formed sandwich rather than opting for creating their own, we don’t need to transition to theNameSandwich
State
. Instead, we just loop back to theSandwichList
State
.
Last but not least we’ll define the NameSandwich
State
. Again, we’ll use the
formulas defined above to inform our implementation:
class NameSandwich(private val sandwiches: List<Sandwich>,
private val newSandwichType: SandwichType): SandwichState {
override fun consumeAction(action: Action): SandwichState {
return when (action) {
is Action.SubmitSandwichClicked -> {
val newSandwich = Sandwich(action.sandwichName, newSandwichType)
SandwichList(sandwiches + newSandwich)
}
}
}
}
This state is fairly straightforward - once the user clicks submit, we use the
SandwichType
they selected in the previous screen and the sandwich name they
entered to create a new Sandwich
and add it to our list of existing
sandwiches. We’ll then transition to our new state, SandwichList
, with the
updated list of sandwiches.
Building our Activity
We now have a fully functional finite state machine that builds up lists of
sandwiches. The last thing we need to do is connect our finite state machine to
an Activity
to actually drive our UI.
class SandwichActivity : AppCompatActivity() {
var currentState by Delegates.observable<SandwichState>(SandwichList(emptyList()), { _, old, new ->
renderViewState(new, old)
})
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_sandwhich)
}
private fun renderViewState(newState: SandwichState, oldState: SandwichState) {
when (newState) {
is SandwichList -> showSandwichList(newState.sandwiches)
is AddSandwich -> showAddSandwichView(predefinedSandwiches)
is NameSandwich -> showSandwichInputFields()
}
when (oldState) {
is SandwichList -> hideAddSandwichView()
is AddSandwich -> hideSandwichInputFields()
is NameSandwich -> hideSandwichList()
}
}
}
We’ve defined a new SandwichActivity
that holds onto the current state of our
state machine via an observable
delegate.
That means that whenever we update the value of currentState
, we’ll call into
renderViewState
with both the old and new values of our state machine. The
renderViewState
method will update our view depending on what states we
transitioned to and from. Let’s take a look at the showSandwichList
method:
private fun showSandwichList(sandwiches: List<Sandwich>) {
sandwich_list_container.visibility = View.VISIBLE
added_sandwich_list.adapter = SandwichAdapter(sandwiches)
add_sandwich_button.setOnClickListener {
currentState = currentState.consumeAction(AddSandwichClicked())
}
}
Most of this method is normal Android code. The most interesting piece is the
click listener added to the add_sandwich_button
:
currentState = currentState.consumeAction(AddSandwichClicked())
This is where we’re passing an Action
to our finite state machine.
Specifically, when the user clicks the add_sandwich_button
, we pass the
AddSandwichClicked
Action
into our state machine and update our
currentState
to be whatever the new state is. Since we’re using an
observable
delegate, we’ll be notified in our renderViewState
method about
both the old value of the state machine and the new one!
The rest of the methods in our Activity
look very similar:
private fun hideSandwichList() {
sandwich_list_container.visibility = View.GONE
}
private fun showAddSandwichView(predefinedSandwiches: List<Sandwich>) {
add_sandwich.visibility = View.VISIBLE
wrap_button.setOnClickListener {
currentState = currentState.consumeAction(SandwichTypeSelected(WRAP))
}
grinder_button.setOnClickListener {
currentState = currentState.consumeAction(SandwichTypeSelected(GRINDER))
}
predefined_sandwich_list.adapter = SandwichAdapter(predefinedSandwiches, {
currentState = currentState.consumeAction(PredefinedSandwichSelected(it))
})
}
private fun hideAddSandwichView() {
add_sandwich.visibility = View.GONE
}
private fun showSandwichInputFields() {
sandwich_inputs.visibility = View.VISIBLE
submit_button.setOnClickListener {
val sandwichName = sandwich_name.text.toString()
currentState = currentState.consumeAction(SubmitSandwichClicked(sandwichName))
}
}
private fun hideSandwichInputFields() {
sandwich_inputs.visibility = View.GONE
}
Now our view is properly forwarding Actions
to our finite state machine, and
the shifting of our States
in the state machine are being reflected in the
view!
For those that are using the MVP architecture
pattern,
instead of passing the Actions
in through the Activity
you could delegate
that responsibility to the Presenter
, preserving testability.
We’ve now built a functioning application whose logic is primarily driven by a finite state machine! But there’s still improvements/concerns to be addressed. How do we handle mutations/logic that needs to be addressed outside of the finite state machine? Is there a cleaner, more type safe way to translate our states into views? How well does this approach scale?
This blog post has already gotten quite long, so we’ll address those questions and more in a future post expanding on using finite state machines in Android development!