MVI - Desired State Pattern

Car Image

In this blog, I want to briefly look at a pattern for dealing with progressive asynchronous state changes. For the example, we will have an engine and software to mange the startup and shutdown.

We'll be using this with MVI Android Compose.

The handling of the engine itself is very basic. We need to know if it is stopped or running. But, we also need to know if it between those states. We need starting and stopping states as well.

This is mainly aimed at intermediate to advanced programmers, but the MVI covered and thinking behind everything should also be of interest to anyone.

Why?

I had no intention of writing this blog post originally, but I came across this while working on another post. I was interested in the Reducer pattern; a way of handling all UI state updates in one function.

For that post, I originally created a UI example for a car's audio / electronic / engine; however, the engine part of that didn't really work very well and I came across the Desired State pattern. This is more common for software interfacing with real-world hardware so it may not be so well known to Android programmers.

My interest in this was primarily for games programming, but I think it also has other useful applications.

Situation + States and Actions

For this article, we'll focus on a UI to start and monitor an engine. We will keep it basic and we will not handle errors. No consideration has been given to failure to transition state.

Following the MVI pattern, we need clear states and defined actions. These are typically done with sealed interfaces or classes. You could also use Enums for this if you wanted, but that is less common.

The main advantage of this is it makes everything easier to work with: all possible states can be easily seen and passing actions around is very simple.

Let's define our four states as explained above:

sealed class EngineState(val description: String) {
object Starting: EngineState("starting")
object Running: EngineState("running")
object Stopping: EngineState("stopping")
object Stopped: EngineState("stopped")
}
Of course, we could also add errors and other issues with the Engine but we want to keep this example simple. So our Engine can report any one of these four states.

Note: we've added text descriptions to make everything a little easier to work with. If we didn't have this, we could used a sealed interface instead.

Our actions are even easier:
sealed class EngineAction(val description: String) {
object StartEngine: EngineAction("Start")
object StopEngine: EngineAction("Stop")
}
We simply have a Start and Stop button.
Start Button Image
Again, we have the description to simplify things so both classes contain a toString() override. 
override fun toString(): String = description

Our Engine

I'll start with the final Engine code then explain how I got there.
private class Engine(
private val desiredState: StateFlow<EngineState>,
scope: CoroutineScope
) {
private val _engineState =
MutableStateFlow(
desiredState.value
)
val engineState = _engineState.asStateFlow()

init {
scope.launch {
desiredState
.drop(1) // ignore initial emission
.collectLatest { desired ->
when (desired) {
EngineState.Running -> startEngine()
EngineState.Stopped -> stopEngine()
else -> {}
}
}
}
}

private suspend fun startEngine() {
_engineState.update { EngineState.Starting }
delay(Random.nextLong(750, 1250))
_engineState.update { EngineState.Running }
}

private suspend fun stopEngine() {
_engineState.update { EngineState.Stopping }
delay(Random.nextLong(550, 1000))
_engineState.update { EngineState.Stopped } // bug fix
}
}

Reactive (No Actions)

My original design had simple MutableStateFlows that were manually set. This was quite complex to manage and made everything much more annoying than it needed to be.

I quickly got fed up with that and moved to reactive states that automatically react to the changes. We simply take in a reference to the desiredState and the Engine does everything by itself. collectLatest automatically handles cancellations for us. Perfect.

Drop(1)

In the init block, we are dropping the first desiredState to prevent us from moving from the initial state when it is already there. Without this, it will run the transition delay thinking it is moving from the current state to the current state (which, of course, should never happen).

Delays

I've randomized the delays a little. This is unrealistic, but makes it a little more exciting to use. We're using magic constants for these delays, but I think the meaning is pretty obvious from the context.

EngineUiState

Again, I'll start with the solution and work back. 
data class EngineUiState(
val reportedState: EngineState,
val desiredState: EngineState,
val availableAction: EngineAction
)

ViewModel

Let's jump ahead to our VM next.
class MviDesiredStateViewModel: ViewModel() {
private val engineManager = EngineManager(scope = viewModelScope)
val engineUiState = engineManager.uiState
fun onAction(action: EngineAction) = engineManager.onAction(action)
}
Very simple. This is is incredibly easy to work with. I am sure you are now interested to see the EngineManager.

EngineManager

Ok, this is where the complexity really lives. It's quite a lot of code here, but it's really not too complicated when you understand what it is doing.
class EngineManager(scope: CoroutineScope) {
private val _desiredEngineState =
MutableStateFlow<EngineState>(EngineState.Stopped)
val desiredEngineState = _desiredEngineState.asStateFlow()

private val engine = Engine(
_desiredEngineState,
scope
)
val reportedEngineState = engine.engineState

val engineOption: StateFlow<EngineAction> = _desiredEngineState
.map { desired ->
if (desired == EngineState.Stopped)
EngineAction.StartEngine
else EngineAction.StopEngine
}
.stateIn(
scope,
SharingStarted.Eagerly,
EngineAction.StartEngine
)

val uiState: StateFlow<EngineUiState> = combine(
reportedEngineState,
desiredEngineState,
engineOption
) { reported, desired, option ->
EngineUiState(
reported,
desired,
option
)
}.stateIn(scope, SharingStarted.Eagerly,
EngineUiState(
reportedEngineState.value,
desiredEngineState.value,
engineOption.value
)
)

fun onAction(action: EngineAction) {
_desiredEngineState.update {
when (action) {
EngineAction.StartEngine -> EngineState.Running
EngineAction.StopEngine -> EngineState.Stopped
}
}
}
}

EngineManager Link

We hold our desiredEngineState which we link to Engine along with our scope. For Android Compose, we'll be using ViewModelScope, but we don't want to assume.

We take our reportedEngineState from Engine.

Actions

Actions are really simple. onAction takes in an EngineAction (start or stop) and updates the desiredState. As Engine holds a reference to this, it deals with everything as required.

This basically means Engine just knows what we want and we don't care how it gets us to that position. The implementation details are well hidden.

UiState

We are using our EngineUiState defined earlier and so all we need to do here is combine reportedEngineState, desiredEngineState, and engineOption (see below) to produce that.

EngineOption

This simply gives us the opposite of the desiredState. We don't care about what the engine is doing.

I opted to use EngineSate instead of creating a new DesiredEngineState class. This is important for two main reasons:
  • First, we could misuse EngineManager and tell it the desired state is Starting or Stopping. It is not designed to handle this.
  • It simplifies our UI code later which checks to see if the reportedState and desiredState are the same.
As this is all internal to EngineManager, I felt this was the best option. It also keeps the code shorter for this blog post.

The UI

Outside of the layout and formatting, the UI is really simple and hopefully serves as a good MVI example as well as showing everything in action:

EngineUi()

We very simply get out viewModel reference and our engineUiState and pass this to our EnginePanel along with our onEngineAction viewModel reference.
@Composable
private fun EngineUi() {
val viewModel =
viewModel<MviDesiredStateViewModel>()

val engineUiState by viewModel
.engineUiState.collectAsStateWithLifecycle()

EnginePanel(
uiState = engineUiState,
onEngineAction = viewModel::onAction
)
}

EnginePanel()

Mostly layout code here, but we pass our uiState to the display and have our start/stop button (which is taken directly from the uiState.

Notice how the button code is extremely simple. Our toString() override really makes this easier for this demo.
@Composable
fun EnginePanel(
uiState: EngineUiState,
onEngineAction: (EngineAction) -> Unit
) {
Card {
Column(Modifier
.padding(8.dp)
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
EngineStatusDisplay(
uiState.reportedState, uiState.desiredState
)
Button(
{ onEngineAction(uiState.availableAction) }
) {
Text(uiState.availableAction.toString())
}
}
}

}

EngineStatusDisplay()

Most of this is formatting, colors, and layout as almost everything is provided to us already. The colors are based on the state and that requires checking if the desired state and the reported state are the same using our isInSync flag.
  •        Red - Stopped
  •        Yellow - Transition (Starting / Stopping)
  •        Green - Running
@Composable
private fun EngineStatusDisplay(
reportedState: EngineState,
desiredState: EngineState,
) {
val isInSync = reportedState == desiredState

val color = when {
!isInSync -> Color.Yellow
reportedState == EngineState.Running -> Color.Green
else -> Color.Red
}

Text(
text = "Engine",
style = MaterialTheme.typography.headlineLarge
)

val statusText = if (isInSync) {
reportedState.toString()
} else {
"$reportedState ($desiredState)"
}
Text(
text = statusText,
color = color,
style = MaterialTheme.typography.displaySmall
)
}

Final Product

Here is the demo in action:
Animation  showing UI
We simply have a Start / Stop button in the Engine card with the reported state. Of course, it would be far better to do the state with icons, graphics, and animation.

Note: The UI was designed for DarkMode. You would need to change the card colors or the font colors if you wanted to use this on LightMode.

Conclusion

I think this is a solid example and persuasive argument for using this DesiredState pattern for directing and monitoring changes. 

Links


Other Related Posts:
  • TBA
Other Related Likns:
  • Magic Constants - TBA
  • MVI - TBA
  • Actions - TBA
  • UI States - TBA

Comments

Popular Posts