In this story, I’ll talk about why I believe we can strongly improve the UI State management between the View and ViewModel by using a Model-View-Intent architecture with the help of a Finite State Machine. It provides more structure to the communication, plus, it improves code legibility, maintainability, and testability.
Before we start, I’ll assume the reader has basic knowledge about Model-View-Intent and also about Model-View-ViewModel.
Who’s Next?!
To explain this idea, I’ll use a personal project called Who’s Next?!, which consists of a simple timer application that lets you know when it’s time to change the goalkeeper in a football game with friends.
Who’s Next!?
Finite State Machine (FSM)
A state machine is a behaviour model. It’s called a finite state machine because it consists of a finite number of states. Based on the current state the machine relies on user input or in-state calculation to determine the next state. I prefer to use deterministic FSMs because they’re easier to reason about, the same input will always result in the same output. When implementing a FSM the syntax to keep in mind is: From a current State
on a specific Event
we transit to another State
and that may produce a SideEffect
.
In Who’s Next?!, we can receive one of the following State
s:
sealed class State { | |
object Idle : State() | |
object SettingTimer : State() | |
object CountingDown : State() | |
object Paused : State() | |
object Restarting : State() | |
} |
FSM states
To change between them we have the following Event
s which represent user actions performed when using the application:
sealed class Event { | |
object OnSetTimer : Event() | |
class OnTimerSet(val value: Int) : Event() | |
object OnStart : Event() | |
object OnFinish : Event() | |
object OnPause : Event() | |
object OnStop : Event() | |
object OnReset : Event() | |
} |
FSM events
We can also visually describe the relationship between State
s and Event
s by a diagram:
Who’s Next FSM
Finally, our state machine can also produce SideEffect
s when transitioning between states:
sealed class SideEffect { | |
object SetTimer : SideEffect() | |
class TimerReady(val value: Int) : SideEffect() | |
object StartCountDown : SideEffect() | |
object Restarting : SideEffect() | |
object Pause : SideEffect() | |
object Stop : SideEffect() | |
object Reset : SideEffect() | |
} |
FSM side effects
Later, I’ll use these side effects as “state transition callbacks” to trigger UI State changes but I’ll cover that in the next section.
Implementation
I’ve used Tinder’s State Machine, a Kotlin and Swift Domain Specific Language (DSL) for FSM, and thanks to it the implementation is pretty straightforward:
val stateMachine = StateMachine.create<State, Event, SideEffect> { | |
initialState(State) | |
state<State> { | |
on<Event> { | |
transitionTo(State, SideEffect) | |
} | |
//more events | |
} | |
//more states | |
onTransition { | |
//process side effects | |
} | |
} |
DSL
Let’s recall our diagram and write the FSM code to alternate between both Idle
and SettingTimer
states:
FROM -> TO by [EVENT] Idle -> SettingTimer by [OnSetTimer] SettingTimer -> Idle by [OnTimerSet]
StateMachine.create<TimerState.State, TimerState.Event, TimerState.SideEffect> { | |
initialState(TimerState.State.Idle) | |
state<TimerState.State.Idle> { | |
on<TimerState.Event.OnSetTimer> { | |
transitionTo(TimerState.State.SettingTimer, TimerState.SideEffect.SetTimer) | |
} | |
} | |
state<TimerState.State.SettingTimer> { | |
on<TimerState.Event.OnTimerSet> { | |
transitionTo(TimerState.State.Idle, TimerState.SideEffect.TimerReady(it.value)) | |
} | |
} | |
onTransition { | |
val validTransition = it as? StateMachine.Transition.Valid ?: return@onTransition | |
//emit ui state changes | |
} | |
} |
Idle and SettingTimer configuration
And the result is:
FSM operations output
Pretty simple. Now that we know how to setup a state machine and how it behaves, let’s see how can we use its side effects to emit UI State updates.
Job Offers
Model-View-Intent (MVI)
It’s out of scope a deep dive explanation about this architecture – there are plenty out there and very good ones -, but briefly, it provides the following:
- Immutable model (state) to achieve the single source of truth principle
- Unidirectional and cyclical data flow
- Thread safe when implemented with functional reactive programming
To take advantage of this architecture I’ve used Orbit Multiplatform library because I believe it offers three no-brainers:
- A very lightweight learning curve
- Composition over inheritance approach (incremental adoption 🎉)
- MVVM architecture update (with KMM support)
And thus, like the authors, I consider it to be a MVVM+.
While this library has the same notion of State
and Side Effects
we use them in different ways. The first represents the UI State at a given point in time, the second is used to perform one-off events like Toasts, Navigation, etc. We won’t be using the latter in this example.
In the heart of the Orbit Multiplatform system we have a Container
which is responsible to retain the State
and expose two channels to listen for data updates (I’ll describe them shortly). There’s also a ContainerHost
interface that allow us to use MVI verbs, we call them operators.
To handle user interaction we have at our disposal three operators: intent
, reduce
and postSideEffects
. Within the first – intent
– we can invoke the other two to run business operations to transform data. With – reduce
– we atomically create the new UI States by applying transformations to the current one. These changes will be sent automatically to the stateFlow
channel observed by the View. Finally, we have the postSideEffects
that sends one-off events to the sideEffectFlow
channel (also observed by the View).
Implementation
Let’s go back to Who’s Next?! application – where we’ve implemented a FSM to alternate between Idle
and SettingTimer
-, to add UI State update logic.
It’s represented by:
data class TimerUiState( | |
val value: Int = 0, //in seconds | |
val progress: Float = 0f, //0-100 | |
val isSettingTimer: Boolean = false, | |
val isCountingDown: Boolean = false, | |
val isRestarting: Boolean = false | |
) |
MVI State
Before we adopt the MVI architecture our ViewModel and View looked like:
class TimerViewModel : ViewModel() { | |
private val _state = MutableLiveData<TimerUiState>(TimerUiState()) | |
val state: LiveData<TimerUiState> = _state | |
fun settingTime() { | |
_state.value = state.value.copy(isSettingTimer = true, isCountingDown = false, isRestarting = false) | |
} | |
fun setTime(seconds: Int) { | |
_state.value = TimerUiState(seconds) | |
} | |
} | |
//Observing state changes in the View | |
@Composable | |
fun TimerScreen(viewModel: TimerViewModel) { | |
with(viewModel.state.observeAsState().value) { /*...*/ } | |
} |
MVVM implementation
With Orbit Multiplatform we change it to:
class TimerViewModel : ViewModel(), ContainerHost<TimerUiState, Nothing> { | |
override val container = viewModelScope.container<TimerUiState, Nothing>(TimerUiState()) | |
fun settingTime() { | |
intent { reduce { state.copy(isSettingTimer = true, isCountingDown = false, isRestarting = false) } } | |
} | |
fun setTime(seconds: Int) { | |
intent { reduce { TimerUiState(seconds) } } | |
} | |
} | |
//Collecting state changes in the View | |
@Composable | |
fun TimerScreen(viewModel: TimerViewModel) { | |
with(viewModel.collectAsState().value) { /*...*/ } | |
} |
MVI implementation
Almost done, the only piece missing is connecting with the FSM. Let’s first remember its syntax: from a current State
on a specific Event
we transit to another State
and that may produce a SideEffect
. I’ve also said that I would use these SideEffect
s to trigger TimerUiState
changes.
Thus, our FSM+MVI becomes:
class TimerViewModel : ViewModel(), ContainerHost<TimerUiState, Nothing> { | |
override val container = viewModelScope.container<TimerUiState, Nothing>(TimerUiState()) | |
private val stateMachine = StateMachine.create<TimerState.State, TimerState.Event, TimerState.SideEffect> { | |
initialState(TimerState.State.Idle) | |
state<TimerState.State.Idle> { | |
on<TimerState.Event.OnSetTimer> { | |
transitionTo(TimerState.State.SettingTimer, TimerState.SideEffect.SetTimer) | |
} | |
} | |
state<TimerState.State.SettingTimer> { | |
on<TimerState.Event.OnTimerSet> { | |
transitionTo(TimerState.State.Idle, TimerState.SideEffect.TimerReady(it.value)) | |
} | |
} | |
onTransition { | |
val validTransition = it as? StateMachine.Transition.Valid ?: return@onTransition | |
when (validTransition.sideEffect as TimerState.SideEffect) { | |
is TimerState.SideEffect.SetTimer -> intent { reduce { state.copy(isSettingTimer = true, isCountingDown = false, isRestarting = false) } } | |
is TimerState.SideEffect.TimerReady -> intent { reduce { TimerUiState(effect.value) } } | |
} | |
} | |
} | |
fun settingTime() { | |
stateMachine.transition(TimerState.Event.OnSetTimer) | |
} | |
fun setTime(seconds: Int) { | |
stateMachine.transition(TimerState.Event.OnTimerSet(seconds)) | |
} | |
} | |
//Collecting state changes in the View | |
@Composable | |
fun TimerScreen(viewModel: TimerViewModel) { | |
with(viewModel.collectAsState().value) { /*...*/ } | |
} |
FSM (state: Idle and SettingTimer) + MVI implementation
Have you noticed the only public methods represent an intent to change the state, but they all get double checked by the FSM before using the MVI operations to do so? Pretty cool! 🍒
FSM+MVI
Conclusions
The following animation show us all Who’s Next!? State
s available as well as the FSM console output:
Who’s Next!?
This project is not public yet, but it will, stay tuned for the next article 😉
Final thoughts
I found it very efficient the use of both this two architectures, even if by that it means we need to add some extra lines of code for the State Machine. It will pay off in terms of legibility, testability and robustness.
The FSM helps us fully specify and validate all the states and transitions available. We can create it even before having UI or business logic.
Regarding Orbit Multiplatform system, we have a very clean and simple MVI(MVVM+) architecture out of the box. It abstract us from the complexity that others libraries don’t, and the fact that it’s built upon a composition methodology it makes it possible to adopt/migrate at our own pace. Fun fact: you don’t even have to use it with ViewModels if you want to.
Also, I believe the single source of truth principle is doubly assured, because with MVI we get that by default, but with FSM we make sure that the only public methods from the ViewModel are the ones that will trigger knownState
transitions which will produce SideEffects
to change atomically the UI State. The FSM acts as an extra layer of security.
As always, I hope you find this article useful, thanks for reading and Matthew Dolan for his review.
Thanks to Matthew Dolan.