State Ex Machina is a MVI-like library written in Kotlin for Android.
Why do we need another MVI library?
Because we simply couldn't find one that was easy to start working with, lightweight and that would cover enough use cases.
Core concepts
- state machine built on kotlin
Flow
to handle and store state changes - reactive entities at the base for both states and intents
- clear DSL with few core directives, open to customisation
- lightweight
Here's a simple example to show off the fundaments on which the library is based on.
The user wants to add two numbers and see the result of the sum.
Lastly saving the total sum with a network call.
// settings.gradle
dependencyResolutionManagement {
repositories {
// ...
mavenCentral()
}
}
// build.gradle
// MVI foundation
implementation("io.github.gionni2d:state-ex-machina-foundation:<latest-version>")
// Jetpack Compose MVI extensions
implementation("io.github.gionni2d:state-ex-machina-ext-compose:<latest-version>")
// Android ViewModel MVI extensions
implementation("io.github.gionni2d:state-ex-machina-ext-viewmodel:<latest-version>")
Intents represent user intentions, for example the intention to type a number.
sealed interface SumIntent : Intent {
data class TypeFirstNumber(val firstNumber: String) : SumIntent
data class TypeSecondNumber(val secondNumber: String) : SumIntent
object Sum : SumIntent
object SaveSum : SumIntent
}
State represents a photo of all the dynamic information needed to present the view and for the model to interact with the domain (and update itself)
data class SumState(
val firstNumber: Int = 0,
val secondNumber: Int = 0,
val sum: Int = 0
) : State
Reducers are pure functions that takes in input the old state and return a new state. In these functions is where you want to define the state update logic. ReducerFactory is an abstraction on reducers that we're adopting to try to divide as much as possible the state update logic from the Model, using High Order Functions.
interface SumReducersFactory {
fun updateFirstNumber(n: Int): Reducer<SumState>
fun updateSecondNumber(n: Int): Reducer<SumState>
val updateSum: Reducer<SumState>
}
class SumReducersFactoryImpl : SumReducersFactory {
override fun updateFirstNumber(
n: Int
) = Reducer<SumState> { s ->
s.copy(firstNumber = n)
}
override fun updateSecondNumber(
n: Int
) = Reducer<SumState> { s ->
s.copy(secondNumber = n)
}
override val updateSum = Reducer<SumState> { s ->
s.copy(sum = s.firstNumber + s.secondNumber)
}
}
The model holds the representation of the state and updates it with the reducers, it's the layer responsible for most of the business logic.
- Create a model that implements the MVI
Model
and override the functionsubscribeTo
, this is the scope where you can update the state and call coroutines - Use
on
to react to user intents - You can update the state with
updateState
or usesideEffect
to elaborate data from a repository and more - More functions are present in the library to cover most common use-cases (eg:
launchedEffect
always execute code when the functionsubscribeTo
of the Model is called)
Model is immutable, every function or variable declared inside its scope should only be called in subscribeTo
import state.ex.machina.foundation.Model
import state.ex.machina.dsl.stateMachine
import state.ex.machina.dsl.updateState
class SumModel(
private val coroutineScope: CoroutineScope
) : Model<SumState, SumIntent> {
private val reducers: SumReducersFactory = SumReducersFactoryImpl()
private val repository: SumRepository = SumRepository()
private val _uiEffect: MutableSharedFlow<SumUIEffect> = MutableSharedFlow()
val uiEffect: Flow<SumUIEffect> = _uiEffect.toSharedFlow()
override fun subscribeTo(intents: Flow<SumIntent>) = stateMachine(
initialState = SumState(),
intents = intents,
coroutineScope = coroutineScope,
) {
on<SumIntent.TypeFirstNumber>() updateState { reducers.updateFirstNumber(it.firstNumber.toInt()) }
on<SumIntent.TypeSecondNumber>() updateState { reducers.updateSecondNumber(it.secondNumber.toInt()) }
on<SumIntent.Sum>() updateState reducers.updateSum
on<SumIntent.SaveSum>() sideEffect {
repository.saveSum(currentState.sum)
_uiEffect.emit(SumUIEffect.ShowSumSavedNotification)
}
}
}
With the library extension for Android ViewModel we can utilize the stateMachine
that calls for viewModelScope
as coroutine scope.
import state.ex.machina.viewmodel.stateMachine
class SumModel : ViewModel(), Model<SumState, SumIntent> {
override fun subscribeTo(intents: Flow<SumIntent>) = stateMachine(
initialState = SumState(),
intents = intents
) {
//
}
}
@Composable
fun SumScreen(model: Model<SumState, SumIntent>) {
val (stateFlow, onIntent) = rememberMviComponent(model)
val state by stateFlow.collectAsState()
SumScreen(
state = state,
onTypeFirstNumber = { SumIntent.TypeFirstNumber(it).let(onIntent) },
onTypeSecondNumber = { SumIntent.TypeSecondNumber(it).let(onIntent) },
onSum = { SumIntent.Sum.let(onIntent) },
onSaveSum = { SumIntent.SaveSum.let(onIntent) },
)
}
@Composable
private fun SumScreen(
state: SumState,
onTypeFirstNumber: (Int) -> Unit,
onTypeSecondNumber: (Int) -> Unit,
onSum: () -> Unit,
onSaveSum: () -> Unit,
) {
// render UI using data from 'state' and wire intents to UI components actions
}