Blog Infos
Author
Published
Topics
Published
Topics

Part I. Basic steps to implement MVI with the state-machine pattern

 

Learning strait“Learning strait” to adopt the state-machine MVI architecture by Ulyana Kotchetkova

 

This is the first article of the ‘MVI with state-machine’ series that describes the basic architecture and steps to build your logic. Check the other parts of the series for advanced topics:

The source code for the article and the very basic core library is available on GitHub. The library is totally optional — just in case you want to save some time on not writing your own core.

Introduction

The MVI pattern of architecting modern applications has been getting more and more popular in recent times. There are a lot of articles describing the pattern but to recap let’s see the key points that make up the approach:

  • Model — A model holds the representation of the data state and changes it with reducing logic. Data changes are propagated to the view layer as a stream of complete view-states.
  • View — The view layer observes user actions and UI-state changes from the model. As a result, it sets the intention for the triggered UI gesture passing it to the model to process. Each UI-state fully describes the complete view state!
  • Intent — A representation of user’s gestures that changes the state of the model. View handles widget interactions and provides a stream of gestures to the model through the unified interface.

Key advantages of MVI:

  • Single source of truth — one set of data and logic to define complete view-state
  • Unidirectional data flow
  • Thorough and complete testing of all logic with unit tests
  • Friendly to Jetpack Compose view framework

However (as with any technology) there are some downsides that you may come across along with your app growth:

  • Too much overkill for simple functions like LCE (Load/Content/Error) display
  • Too much reducer logic based on if/else of the current data state that plays badly in complex multi-step scenarios.
  • Steep learning curve to grasp the technology
The aim of the article

While there are plenty of MVI frameworks already available, I’d like to show you how you can implement the MVI architecture with a simple and easy way without external dependencies and framework locks. We’ll try to overcome the above drawbacks to give you more freedom to choose technology and to build a cohesive logic and data processing in a “less-opinionated” way. The samples target Android platform but there is no dependency on Android framework both in core components and implementation code so the logic could be used in Kotlin multiplatform projects as shown in part III of this article. The sample projects use Jetpack Compose at the view layer but it is not a restriction. You could use fragment transactions or direct view operations if you like them better.

State-machine definition

state machine, is an abstract machine that can be in exactly one of a finite number of states at any given time. The machine can change from one state to another in response to some inputs: user gestures, results of asynchronous operations, etc. A state-machine is defined by a list of its states, its initial state, and the inputs that trigger transitions from state to state. The key point for me is that each state may operate only a sub-set of all the gestures that are relevant to it. For example, the music player can’t be paused (gesture) when it is in the stopped state. That gives us the ability to narrow down both the amount of logic and input/output data operated by each state and make low coupled and highly cohesive (thus easily maintainable and testable) code.

The basic task – Load-Content-Error

Let’s see how the MVI architecture and the state-machine pattern could be mixed together. Let’s start with a basic example (the full source code is available in lcefolder of the repository). Imagine we need to implement the classic master-detail view of items with the following screen flow:

Load-Content-Error flowLoad-Content-Error flow of the master-detail application

Let’s break down business requirements…
We have four application logical states which correspond to screen states for this application:

  • Item list – the list of items to load is displayed. User clicks an item to load its contents.
  • Loading item – the network operation is running. User waits for operation to complete.
  • Item content – the loaded item content is displayed. User may return back to the item list.
  • Item load error – the load operation has failed and we have a choice to retry load or to quit the application.
States and transitions

Each machine state may transition to another machine state as a result of Gesture or state’s internal logic. Let’s take a look at which Gestures each machine state processes and how they transition to next machine states:

Logical state Ui-State Gesture/Event Next state Output
ItemList ItemList Back Terminated Finishes activity
ItemClicked Loading Loads requested item
Loading Loading Back Item list Cancels load and returns to list
onContent Content Displays loaded item
onError Error Displays load error
Content Item Back Item list Returns to the item list
Error Error Back Item list Returns to the item list
Retry Loading Retries load operation
Exit Terminated Finishes activity
view raw lcestates.md hosted with ❤ by GitHub

You could also create a state-diagram to represent your states and transitions:

 

LCE state diagram

LCE state diagram

 

The diagram above has two types of inter-state transitions:

  • Red – user Intentions: clicks, swipes and other interactive `Gestures` that are originated by application user.
  • Blue – transitions made by application logic: content display, errors, etc.

Each machine state should be able to:

  • Update the Ui-State of your application.
  • Process some relevant user interactions — Gestures ignoring irrelevant
  • Hold some internal data state
  • Transition to another machine state passing some of the shared data between

Now that we have our state requirements, let’s define the role of the state-machine:

  • Hold the active machine state
  • Delegate gesture processing to the current state
  • Transition between states
  • Propagate UI-state changes to the outside world
  • Clean-up all resources on shutdown
Base components implementation

Let’s combine the state and the machine to see how do they relate to each other. We generalize gesture and UI-states hierarchies as G and U sealed class hierarchies accordingly.

State-machine class diagram

State-machine class diagram

The state-machine implements a simple interface with the following methods:

  • process(gesture: G) — called by view upon user action. Delegated to current state.
  • clear() — called by view to cleanup resources. Like in onCleared of Jetpack’s ViewModel.
  • setMachineState(machineState: CommonMachineState<G, U>) — called by the active state to transition to the new one.
  • setUiState(uiState: U) — called by the active state to update view.

Optional: the state-machine interface above, being a bridge between two worlds, is divided into MachineOutput and MachineInput interfaces to be called by internal state and outside world. Just in case you want to go with a minimal interface available for your application parts.

Let’s create a basic implementation:

/**
* Common state machine
* @param G UI gesture
* @param U UI state
*/
interface CommonStateMachine<G: Any, U: Any> : MachineInput<G>, MachineOutput<G, U> {
/**
* Base state-machine implementation
* @param init Initial state producer
*/
abstract class Base<G: Any, U: Any>(
private val init: () -> CommonMachineState<G, U>
) : CommonStateMachine<G, U> {
/**
* Active machine state
*/
protected lateinit var activeState: CommonMachineState<G, U>
/**
* Sets active machine state
*/
final override fun setMachineState(machineState: CommonMachineState<G, U>) {
clear()
activeState = machineState
startMachineState()
}
/**
* Starts machine
*/
protected fun start() {
activeState = init()
startMachineState()
started = true
}
/**
* Updates state with UI gesture
* @param gesture UI gesture to proceed
*/
final override fun process(gesture: G) {
activeState.process(gesture)
}
/**
* Cleans-up state-machine
*/
final override fun clear() {
activeState.clear()
}
/**
* Starts machine state
*/
private fun startMachineState() {
activeState.start(this)
}
}
}

State machine base

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

Jobs

The concrete state machine implementation should also provide a way to update the view with a new UI state. For example the FlowStateMachine
exports UI state changes through uiState shared flow:

open class FlowStateMachine<G: Any, U: Any>(
init: () -> CommonMachineState<G, U>
) : CommonStateMachine.Base<G, U>(init) {
private val mediator = MutableSharedFlow<U>(
replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
init {
start()
}
/**
* ExportedUI state
*/
val uiState: SharedFlow<U> = mediator.asSharedFlow()
final override fun setUiState(uiState: U) {
mediator.tryEmit(uiState)
}
}

The state machine that exports UI updates through the Flow interface

 

The state and the state lifecycle

The base state class works with the state-machine instance and accepts delegated calls from it. The state has three state-machine interaction methods:

  • doProcess(gesture: G) — called by the state-machine to process a UI gesture.
  • setUiState(uiState: U) — call from within your state implementation to update UI State.
  • setMachineState(machineState: CommonMachineState<G, U>) — call from implementation to transition to the new machine state.

and two lifecycle methods:

  • doStart() — called by the state-machine when your state becomes active.
  • doClear() — called by the state machine when your current state is about to be destroyed either by replacing the new state or when state-machine is about to be destroyed.

You can check the basic state implementation here. There is no rocket science there — just some template functions to handle state lifecycle. We’ll get into details a bit later.

The state lives between doStart and doClear calls. You could safely call interaction methods and expect gesture processing calls within that period. Make sure to cleanup all your pending operations in doClear handler. For example, the CoroutineState provides you the stateScope coroutine scope that is being cancelled in doClear:

abstract class CoroutineState<G: Any, U: Any>: CommonMachineState<G, U>() {
protected val stateScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
override fun doClear() {
stateScope.cancel()
}
}

Note on threading: the library doesn’t provide any threading support and not thread-safe. So it is your responsibility to implement correct thread handling so all state changes happen on the desired thread. The CoroutineState above creates it’s scope with Dispatchers.Main.immediate.

App Implementation

Now that we have all basic components in place, let’s implement our application. First of all, let’s create our Gesture and UI-state systems using Kotlin sealed classes.

Gestures — events emitted by UI layer and passed to state-machine:

sealed class LceGesture {
/**
* Item to load clicked
* @property id Item ID to load
*/
data class ItemClicked(val id: ItemId) : LceGesture()
/**
* Retry operation clicked
*/
object Retry : LceGesture()
/**
* Backwards navigation gesture
*/
object Back : LceGesture()
/**
* Terminates activity
*/
object Exit : LceGesture()
}
view raw LceGesture.kt hosted with ❤ by GitHub

LCE app gestures

 

UI-states — data that fully describes what user sees. Emitted by machine:

sealed class LceUiState {
/**
* Item list view
*/
@Immutable
data class ItemList(val items: List<ItemModel>) : LceUiState()
/**
* Loading spinner
*/
object Loading : LceUiState()
/**
* Item detail view
*/
data class Item(val contents: String) : LceUiState()
/**
* Error view
*/
data class Error(val error: Throwable) : LceUiState()
/**
* Terminator
*/
object Terminated : LceUiState()
}
view raw LceUiState.kt hosted with ❤ by GitHub

LCE app UI-states

 

Let’s get to implementing machine states then.

Item list

This is the initializing state of our state-machine. It displays the list of items to load.

class ItemListState : CommonMachineState<LceGesture, LceUiState>() {
private val items = listOf(
ItemId.LOADS_CONTENT to "Item that loads",
ItemId.FAILS_WITH_ERROR to "Item that fails to load"
)
override fun doStart() {
setUiState(LceUiState.ItemList(items.map { ItemModel(it.first, it.second) }))
}
override fun doProcess(gesture: LceGesture) = when(gesture) {
is LceGesture.ItemClicked -> onItemClicked(gesture.id)
is LceGesture.Back -> onBack()
else -> Timber.d("Unhandled gesture: %s", gesture)
}
private fun onItemClicked(id: ItemId) {
setMachineState(LoadingState(id))
}
private fun onBack() {
setMachineState(TerminatedState())
}
}

Item list state

 

The list is hardcoded for this example so we just emit a complete view-state in doStart() template method.

User may click one item or another producing the ItemClicked gesture. We handle it in onItemClicked method by transitioning the machine to the LoadingState passing selected item id to load.

If users presses back button, he produces the Back gesture. The state machine is switched to the TerminatedState which terminates the activity.

Data ‘loading’ state

This state pretends to be running an asynchronous operation by starting a coroutine with delay.

class LoadingState(
@get:VisibleForTesting val id: ItemId,
private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default
) : CoroutineState<LceGesture, LceUiState>() {
override fun doStart() {
setUiState(LceUiState.Loading)
load()
}
private fun load() {
stateScope.launch(defaultDispatcher) {
delay(1000L)
withContext(Dispatchers.Main) {
when (id) {
ItemId.LOADS_CONTENT -> toContent()
ItemId.FAILS_WITH_ERROR -> toError()
}
}
}
}
private fun toContent() {
setMachineState(ContentState("Some item data..."))
}
private fun toError() {
setMachineState(ErrorState(id, IOException("Failed to load item")))
}
override fun doProcess(gesture: LceGesture) = when(gesture) {
LceGesture.Back -> onBack()
else -> Timber.d("Unhandled gesture: %s", gesture)
}
private fun onBack() {
setMachineState(ItemListState())
}
}
view raw LoadingState.kt hosted with ❤ by GitHub

Item loading state

 

We display a loading spinner in onStart() template and run the coroutine.

Given the hardcoded item ID the state finishes either in toContent or toError handler. Thus we transition to either the ContentState passing ‘loaded’ item data or to the ErrorState passing the error and the failed item ID to implement retry (see below).

If user clicks Back while waiting for result the machine is switched back to the item list. We subclass the CoroutineState here so our stateScope is cancelled when state is shut down.

Item contents state

This one is very basic. The state emits the loaded data passed to the constructor and handles Back to return to item-list.

class ContentState(private val contents: String) : CommonMachineState<LceGesture, LceUiState>() {
override fun doStart() {
setUiState(LceUiState.Item(contents))
}
override fun doProcess(gesture: LceGesture) = when (gesture) {
LceGesture.Back -> onBack()
else -> Timber.d("Unhandled gesture: %s", gesture)
}
private fun onBack() {
setMachineState(ItemListState())
}
}
view raw ContentState.kt hosted with ❤ by GitHub

Item contents state

 

Error state

The last state worth mentioning is the error state that displays a popup with retry and fail options.

class ErrorState(private val failed: ItemId, private val error: Throwable) : CommonMachineState<LceGesture, LceUiState>() {
override fun doStart() {
setUiState(LceUiState.Error(error))
}
override fun doProcess(gesture: LceGesture) = when (gesture) {
LceGesture.Back -> onBack()
LceGesture.Retry -> onRetry()
LceGesture.Exit -> onExit()
else -> Timber.d("Unhandled gesture: %s", gesture)
}
private fun onRetry() {
setMachineState(LoadingState(failed))
}
private fun onBack() {
setMachineState(ItemListState())
}
private fun onExit() {
setMachineState(TerminatedState())
}
}
view raw ErrorState.kt hosted with ❤ by GitHub

Item load error state

 

We dispatch relevant user gestures by calling the appropriate handler and switching the state-machine to the new state. Worth to mention the onRetry handler — the new LoadingState is created there that will restart the item loading procedure from scratch.

Wiring with the application

Now that we have all states implemented let’s feed them to the state-machine and connect with the view subsystem. We need some place to retain the state-machine so let’s wrap it to the Jetpack ViewModel:

class LceViewModel : ViewModel() {
private fun initStateMachine(): CommonMachineState<LceGesture, LceUiState> = ItemListState()
private val stateMachine = FlowStateMachine(::initStateMachine)
val state: SharedFlow<LceUiState> = stateMachine.uiState
fun process(gesture: LceGesture) {
stateMachine.process(gesture)
}
override fun onCleared() {
stateMachine.clear()
}
}
view raw LceViewModel.kt hosted with ❤ by GitHub

LCE view-model

 

All we need to do here is:

  • to figure out the initial state that machine will start from
  • to create a state-machine instance
  • to wire ui-state and gesture processing with the outside world

And here is our composable main view that connects to the view-model:

@Composable
fun LceScreen(onExit: @Composable () -> Unit) {
val model: LceViewModel = viewModel()
val state = model.state.collectAsState(LceUiState.Loading)
BackHandler(onBack = { model.process(Back) })
Scaffold(
modifier = Modifier.fillMaxSize(),
backgroundColor = MaterialTheme.colors.background,
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.title)) },
navigationIcon = {
IconButton(onClick = { model.process(Back) }) {
Icon(
imageVector = Icons.Filled.ArrowBack,
contentDescription = "Back",
tint = Color.White
)
}
}
)
}
) {
when (val uiState = state.value) {
LceUiState.Loading -> Loading()
is LceUiState.ItemList -> ItemList(
state = uiState,
onItemClicked = { model.process(ItemClicked(it)) }
)
is LceUiState.Error -> LoadError(
state = uiState,
onRetry = { model.process(Retry) },
onBack = { model.process(Back) },
onExit = { model.process(Exit) }
)
is LceUiState.Item -> ItemDetails(state = uiState)
LceUiState.Terminated -> onExit()
}
}
}
view raw LceScreen.kt hosted with ❤ by GitHub

LCE view

 

So far so good

I hope I’ve managed to showcase the simplicity of the state-machine pattern in implementing the application logic. It produces a clean and easy to grasp step-by-step logic with well-separated concerns and easy and thorough testing ability (see the source code for details). The pattern also attempts to be as non-opinionated as possible. Each state is a black-box with a defined contract and developers may choose the most suitable tools to implement each one without affecting the other.

The example above is a very basic one. However you could do things a lot more clean by using some of the additional abstractions and code organizing. Jump to Part II to learn more!

 

This article was originally published on proandroiddev.com on August 19, 2022

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
This article is a follow-up to one I published in April 2019. You can…
READ MORE
blog
This is the second part of the ‘MVI with state-machine’ series that describes some…
READ MORE
blog
This is the third part of the ‘MVI with state-machine’ series that describes how…
READ MORE
blog
In this story, I’ll talk about why I believe we can strongly improve the…
READ MORE
Menu