MVI in Skroutz android app

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

img

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 issuing Intents.
  • The Intent is being translated to a new instance of the Model.
  • The Model, which is being observed by the View, ends up in changing how the View looks.
Key points (as found both in bibliography and community’s implementations)

The component that handles the Intents:

  • Has a very simple API. A single method or stream for accepting the Intent’s and a stream for emitting the Model instances.
  • It does not hold any instances of the View.
  • It holds an instance of the Model that is currently used by the View.
  • It uses a Reducer to create the Model. For the creation the Reducer uses the current Model’s instance and the Intent that was issued.

Our goal

To get to what we aimed to achieve we must first mention what we wanted to avoid:

  1. 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.
  2. 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.
  3. 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.
  4. 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

img

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 called Action and it represents something that the user/system did/wants. It is being handled by the ActionsHandler (1).
  • The Model is called ScreenState and it represents what the screen needs to look like. The ScreenState is being created and emitted by the ActionsHandler (2) and consumed by the View (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 Actions, a shared flow for emitting ScreenStates and a shared flow for emitting SideEffects.

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.