Our codebase is over 10 years old. Like every other project that started at that period, it adopted Model View Presenter as its architecture. Over the years, both the app and the team grew. This created a need to shift to an architecture resembling ‘clean architecture’. At that point, MVP became the presentation pattern of the app. A decision that served us well all these years.
Until we started using Jetpack Compose. It’s not that we couldn’t match them, but the integration didn’t feel smooth. On the other hand a presentation pattern like MVI, that is unidirectional, makes perfect sense since a composition tree can be fed by a single state object. For what is worth, UDF is also what Google recommends for Compose.
MVI
MVI is a unidirectional pattern where the flow starts and ends from/to the View
.
In a very abstracted and simplified description the flow looks like this:
- The
View
, being the entry point, reacts to what the user/system wants by issuingIntent
s. - The
Intent
is being translated to a new instance of theModel
. - The
Model
, which is being observed by theView
, ends up in changing how theView
looks.
Key points (as found both in bibliography and community’s implementations)
The component that handles the Intent
s:
- Has a very simple API. A single method or stream for accepting the
Intent
’s and a stream for emitting theModel
instances. - It does not hold any instances of the
View
. - It holds an instance of the
Model
that is currently used by theView
. - It uses a
Reducer
to create theModel
. For the creation theReducer
uses the currentModel
’s instance and theIntent
that was issued.
Our goal
To get to what we aimed to achieve we must first mention what we wanted to avoid:
- Another 3rd party library. Our MVP implementation is based on a library called Mosby which is no longer maintained. This makes it a liability for a project that needs to keep itself updated and follow the framework as close as possible.
- Too many abstractions. Over the years we had to extend what Mosby offered ending up having a deep hierarchy of base classes. This makes it hard for new developers to understand the codebase and even senior developers to reason about it.
- Different implementations of the same thing. Even though there was a convention and a couple of helper methods to support it, over the years and as the team grew, the convention was not followed.
- Large and unfocused API surfaces. MVP is a pattern that can get out of hand regarding its public API. And it did in our case. We have presenters exposing numerous public methods, making it difficult to determine their exact purpose.
So, our goal was to create something small, opinionated and straightforward. Something that will be easy to setup and even easier to reason about.
Our tiny MVI framework
The flow and core concepts of our MVI implementation are the same with what we described above:
- The
View
is every activity/fragment/service/whatever component needs a presentation pattern. - The
Intent
is actually calledAction
and it represents something that the user/system did/wants. It is being handled by theActionsHandler
(1). - The
Model
is calledScreenState
and it represents what the screen needs to look like. TheScreenState
is being created and emitted by theActionsHandler
(2) and consumed by theView
(3).
What we added is the concept of a SideEffect
(4). A SideEffect
is something that the consumer needs to do and it is not directly related to the ScreenState
. For example, a navigation command.
ActionsHandler
The heart of our framework is the ActionsHandler
.
A small abstract class that exposes three things:
a public method for handling Action
s, a shared flow for emitting ScreenState
s and a shared flow for emitting SideEffect
s.
abstract class ActionsHandler<Action : Any, ScreenState : Any, SideEffect : Any> {
private val supportedActions = SupportedActions<Action, ScreenState, SideEffect>()
private var currentScreenState: ScreenState? = null
private val emitter = InternalEmitter()
private val internalSideEffect = MutableSharedFlow<SideEffect>()
private val internalScreenState = MutableSharedFlow<ScreenState>()
val sideEffect: SharedFlow<SideEffect> = internalSideEffect
val screenState: SharedFlow<ScreenState> = internalScreenState
init {
with(supportedActions) {
setup()
}
}
suspend fun handle(action: Action) {
val reducer = supportedActions[action::class.java]
reducer(action, currentScreenState, emitter)
}
protected abstract fun SupportedActions<Action, ScreenState, SideEffect>.setup()
}
This is what every developer on our team has to extend in order to create a new component that adopts the MVI pattern.
All three concepts (Action
, ScreenState
, SideEffect
) are generics and are defined during extension so that each usage can have its own triplet of types.
During the extension the developer will have to implement the setup
method too. This is where we match an action with a specific reducer:
override fun SupportedActions<StagingServersAction, StagingServersScreenState, StagingServersSideEffect>.setup() {
on<ScreenOpened>(::fetchAndRenderStagingServers)
on<ScreenReOpened>(::renderCurrentScreenState)
on<UserClickedOnServer>(::toggleUserSelectionsAndPromptForRestart)
on<UserClickedOnClear>(::clearAll)
on<UserSearchedFor>(::search)
}
Reducer
Every time there is an incoming Action
the handler finds the appropriate Reducer
and simply delegates the handling to it.
A Reducer
is just a function that does not return anything and accepts an Action
, the current ScreenState
and an Emitter
:
(Action, ScreenState?, Emitter<ScreenState, SideEffect>) -> Unit
Its purpose is to connect the presentation layer with the domain and at the end construct a new screen state. The new state will be emitted to whoever is listening along with any side effects.
For example:
// code snippet from the toggleUserSelectionsAndPromptForRestart reducer
val newUiItems = // call to the API to get the new items
val newState = ShowingStagingServers(
uiItems = newUiItems,
showClearButton = newUiItems.any { uiItem -> uiItem.selected },
searchQuery = searchQuery
)
emitter.emitScreenState(newState)
emitter.emitSideEffect(PromptUserToRestartApp)
Here the reducer constructs the new screen state (ShowingStagingServers
), emits it to the View
and
then emits a side effect (PromptUserToRestartApp
) that requests from the consumer to prompt the user.
The prompt is implemented as a toast on top of the screen so it is not considered part of the screen state, thus we emit it as a side effect.
Emitter
The Emitter
is a simple abstraction that we provide to every reducer:
interface Emitter<ScreenState : Any, SideEffect : Any> {
suspend fun emitScreenState(state: ScreenState)
suspend fun emitSideEffect(effect: SideEffect)
}
Its actual implementation is a private inner class that only the handler can create. The inner
keyword is what allows the emitter to use the shared flows.
Sugar on top
ActionsHandlerViewModel
One decision that we had to make early on was if there was a benefit in having ActionsHandler
implement ViewModel
.
We decided not to. The concept of having a tiny framework involves having zero to none dependencies. Apart from Kotlin’s flow, we have no other dependencies making the framework more resilient to changes.
But this does not mean that we do not use ViewModel
. We do.
We have an abstract class that, apart from guiding the creation of the handler,
it exposes sugar methods that help into using the handler’s API under a coroutine scope and a provided lifecycle:
abstract class ActionsHandlerViewModel<Action : Any, ScreenState : Any, SideEffect : Any> : ViewModel() {
private val handler: ActionsHandler<Action, ScreenState, SideEffect> by lazy { createActionsHandler() }
fun handle(action: Action): Job {
// implementation
}
fun collectScreenStates(
lifecycle: Lifecycle,
collector: FlowCollector<ScreenState>
) {
// implementation
}
fun collectSideEffects(
lifecycle: Lifecycle,
collector: FlowCollector<SideEffect>
) {
// implementation
}
protected abstract fun createActionsHandler(): ActionsHandler<Action, ScreenState, SideEffect>
}
Having a viewmodel to host the handler means that our framework leverages all benefits the viewmodel provides. For example it is trivial to render the last screen state after a configuration change. The handler has survived the change and emits its current screen state when the view starts collecting again.
SupportedActions
The handler uses an instance of SupportedActions
to match an Action
with a Reducer
.
This component is nothing more than a mutable map garnished with some of Kotlin’s goodies like reified generics and operator overloading:
class SupportedActions<Action : Any, ScreenState : Any, SideEffect : Any> internal constructor() {
@PublishedApi
internal val storage = mutableMapOf<Class<out Action>, suspend (Action, ScreenState?, Emitter<ScreenState, SideEffect>) -> Unit>()
inline fun <reified T : Action> on(noinline reducer: suspend (Action, ScreenState?, Emitter<ScreenState, SideEffect>) -> Unit) {
storage[T::class.java] = reducer
}
operator fun <T : Action> get(action: Class<out T>): suspend (T, ScreenState?, Emitter<ScreenState, SideEffect>) -> Unit {
return storage[action] ?: throw IllegalStateException("Could not find reducer for $action")
}
}
This is what allows us to have a setup full of on that do this
and a handler that queries a map for the appropriate reducer.
Conclusion
Did we achieve our goal? We believe we did.
We wanted a framework that would be small. It is. It has only three components, the handler, the emitter and the reducer.
We wanted a framework that would be opinionated and straightforward. It is. Its API surface has only three points. One for handling the actions and two for consuming the handling results.
We wanted a framework that would be easy to setup and even easier to reason about. Can’t be easier that this. We simply extend a class while getting guided to match actions with reducers. Each reducer is just a function. It can be easily replaced, decorated, provided from other sources.