Immutable event based state management framework
Revolver is a Kotlin Multiplatform state management solution that enforces one single immutable state. Information is passed from clients to KMM by emitting RevolverEvents
to a RevolverViewModel
. This RevolverViewModel
will have readonly RevolverState
and RevolverEffect
flows that the clients can subscribe to for updates.
The first thing we need are our sealed classes responsible for communicating data to and from kmm:
sealed class ExampleEvent : RevolverEvent {
object Refresh : ExampleEvent()
}
sealed class ExampleState : RevolverState {
class Loading : ExampleState()
data class Loaded(val result: String) : ExampleState()
}
Avoid using object
for sealed classes it might cause problems with state updates.
sealed class ExampleEffect : RevolverEffect {
data class ShowToast(val message: String) : ExampleEffect()
}
with these 3 in place, we can create our own RevolverViewModel
implementation. This RevolverViewModel
always needs an initial state since all event handling happens asynchroniously.
class ExampleViewModel : RevolverViewModel<ExampleEvent, ExampleState, ExampleEffect>(
initialState = ExampleState.Loading,
)
In this ViewModel we can register one or more EventHandlers
that are responsible for mapping an incomming event to one or more states.
class ExampleViewModel : RevolverViewModel<ExampleEvent, ExampleState, ExampleEffect>(
initialState = ExampleState.Loading,
) {
init {
addEventHandler<ExampleEvent.Refresh>(::onRefresh)
}
suspend fun onRefresh(
event: DealsEvent.Refresh,
emit: Emitter<ExampleState, ExampleEffect>,
) {
emit.state(ExampleState.Loading)
val data = someDataFetchingOperation()
emit.state(ExampleState.Loaded(data))
}
}
As you can see, these event handlers have an Emitter
used for emitting RevolverState
changes or side RevolverEffect
to the clients.
With these 4 things you have your basic state handling flow set up.
A very important consept is that we don't want to expose any Kotlin Multiplatform errors to the clients directly. Therefore the RevolverViewModel
will catch all exceptions that bubble up and allows you to handle them similarly to any other RevolverEvent
. In every RevolverViewModel
you should at least register one ErrorHandler
.
If you want a quick solution you can register one event handler that catches the generic Exception
. but you probably want to define multiple error handlers that catch your custom Errors
class ExampleViewModel : RevolverViewModel<ExampleEvent, ExampleState, ExampleEffect>(ExampleState.Loading) {
init {
addErrorHandler<IllegalStateException>(::onIllegalStateException)
addErrorHandler<Exception>(::onException)
}
private fun onIllegalStateException(
exception: IllegalStateException,
emit: Emitter<ExampleState, ExampleEffect>,
) {
// handle this specific error case
}
private fun onException(
exception: Exception,
emit: Emitter<ExampleState, ExampleEffect>,
) {
// handle generic Exceptions
}
}
Keep in mind that the order of handler registration matters, it wil match a thrown error in the order of handler registration, so if one exception type extends another, make sure the child type is registered first.
It is possible that you don't want to rewrite the same error handling implementation for every viewModel, for example a toast should pop up every time a NoConnectionException
is thrown.
To do this Revolver supports reusable error handling classes. If you don't want to worry about error handling you can use Revolvers build in error handling class to map any exception directly to a state.
class ExampleViewModel : RevolverViewModel<ExampleEvent, ExampleState, ExampleEffect>(ExampleState.Loading) {
init {
addErrorHandler(RevolverDefaultErrorHandler(ExampleState.Error))
}
}
this will result in any exception thrown to emit an ExampleState.Error
.
ofcourse you can implement your own RevolverErrorHandler
to do more complex state mapping.
class ExampleErrorHandler<STATE, EFFECT> : RevolverErrorHandler<STATE, EFFECT, Throwable> {
override suspend fun handleError(exception: Throwable, emit: Emitter<STATE, EFFECT>) {
// log error, call some other external methods, map to reusable states ...
}
}
which you can then register in your viewmodel like any other reused error handler.
if you implement your viewmodels as described above, you will end up with a completly immutable and defined state machine where all actions and responses are mapped. The great thing this allows us to do is test the complete state flow in Kotlin multiplatform unit tests without needing any link to a client application
There are a couple external tools we recommend when writing tests:
using these it's really easy to test your state machine's flow, take this example viewmodel
sealed class ExampleEvent : RevolverEvent {
object Refresh : ExampleEvent()
}
sealed class ExampleState : RevolverState {
object Loading : ExampleState()
data class Loaded(val data: String) : ExampleState()
}
class ExampleViewModel(
private val repository: ExampleRepository,
initialState: ExampleState = ExampleState.Loading,
) : RevolverViewModel<ExampleEvent, ExampleState, EFFECT>(initialState) {
init {
addEventHandler<ExampleEvent.Refresh>(::onRefresh)
}
private suspend fun onRefresh(
event: ExampleEvent.Refresh,
emit: Emitter<ExampleState, EFFECT>,
) {
emit.state(ExampleState.Loading)
val result = repository.fetchData()
emit.state(ExampleState.Loaded(data))
}
}
@OptIn(ExperimentalCoroutinesApi::class)
internal class ExampleViewModelTests {
@Mock
private val exampleRepository = mock(classOf<ExampleRepository>())
@BeforeTest
fun setup() {
Dispatchers.setMain(StandardTestDispatcher())
}
@AfterTest
fun dispose() {
Dispatchers.resetMain()
}
@Test
fun onRefreshEmitsLoadingAndLoadedState() = runTest {
// given
given(exampleRepository).coroutine { fetchData() }.thenReturn("testData")
val initialState = ExampleState.Loading
val viewmodel = ExampleViewModel(exampleRepository, initialState)
viewmodel.state.test {
// when
viewmodel.emit(ExampleEvent.Refresh)
// then
assertIs<ExampleState.Loading>(awaitItem())
val loadedState = assertIs<ExampleState.Loaded>(awaitItem())
assertEquals("testData", loadedState.data)
}
}
}
as you can see we use Mockative to mock our repository, so we can purely focus on testing the viewmodel. Then we use Turbine to test the viewmodel.state
(you can also test viewmodel.effect
).
to use this package in your kotlin multiplatform project you need to add our gradle server to you build.gradle.kts file:
dependencyResolutionManagement {
repositories {
maven {
url = uri("https://maven.pkg.github.com/apegroup/revolver/")
credentials {
username = System.getenv("GH_USERNAME") ?: ""
password = System.getenv("GH_TOKEN") ?: ""
}
}
}
}
Then you can import it in your commonMain just like any other Kotlin multiplatform package
implementation("com.umain:revolver:{LATEST_VERSION}")
This package is very much still in experimental mode and using this in a production environment is at your own risk. Bug reports, feature requests, or contributions are very much appreciated!