Home » Reduce your Android view model!

Reduce your Android view model!

Let’s imagine you are starting a new Android project from scratch. You can choose an app architecture, build beautiful features, and write unit tests so that nothing blows up when you refactor code, just perfect! But after some time, you notice that there’s that one particular screen in the app, which is completely overloaded with features and a Product Owner is pushing you to add more there. Even though you extracted all business logic to the domain layer and all network calls to the data layer, there’s still a lot of UI code to handle. Your view model is becoming like an annoying person that you don’t want to visit anymore, because every change just increases complexity and makes it less readable. Of course, someone might say that you could split the logic between a few view models and create one parent view model. That’s one of the possibilities of how to resolve it, but let’s face it, it’s convenient to have one view model for a whole screen. In this blog post, I will show you my approach to organizing UI logic with View Model + Reducers.

In case you want to see the source code with example usage right away, you can find it in the link below.

View Model + Reducers

Below you can see a full concept:

It’s an extension of UDF (Unidirectional Data Flow), inspired by Redux. Actions originating from the UI (for example, a user clicking a button) are sent to the ViewModel, where the action is delegated to a proper Reducer, which knows how to handle it. Reducer can update the state directly in the View Model, and this state is exposed later to the UI to be properly rendered to the user.

Here’s a code of all the components.

abstract class ReducerViewModel<State : Any, Action : Any>(
    initialState: State,
    private val reducers: Reducers<Action>,
) : ViewModel() {

    val state = MutableStateFlow(initialState)

    fun state(newState: (State) -> State) {
        state.update(newState)
    }

    fun action(newAction: Action) {
        viewModelScope.launch {
            with(reducers[newAction]) {
                reduce(newAction)
            }
        }
    }
}

ReducerViewModel is a base class for all view models and holds a state that can be updated from outside. It also takes actions, which are delegated later to a specific reducer for each action.

class Reducers<Action : Any> @Inject constructor(
    private val reducers: Map<Class<out Action>, @JvmSuppressWildcards Provider<Reducer<*, *>>>,
) {

    @Suppress("UNCHECKED_CAST")
    operator fun get(action: Action): Reducer<ReducerViewModel<*, *>, Action> =
        reducers[action::class.java]!!.get() as Reducer<ReducerViewModel<*, *>, Action>
}

Reducers is a wrapper to hide some nasty code. It takes as a constructor parameter action classes mapped to their reducers. Provider is a Dagger interface, to lazily construct reducer only when it’s needed. Reducers has only one function, to get the proper Reducer for a given action.

interface Reducer<VM : ReducerViewModel<out Any, out Any>, T : Any> {
    suspend fun VM.reduce(action: T)
}

Last but not least, Reducer is an interface with one function, which holds the logic for handling a specific action. It has a ReducerViewModel as a receiver, to conveniently access the state and other properties that our view model can have.

Example usage

Here’s a simple view model that extends ReducerViewModel. There’s also a definition of state and action. I’ve put them in the same file, but usually, it’s a good idea to put them in separate ones.

@HiltViewModel
class SampleViewModel @Inject constructor(
    reducers: Reducers<SampleAction>,
) : ReducerViewModel<SampleState, SampleAction>(SampleState(), reducers)

data class SampleState(
    val text: String = "",
)

sealed class SampleAction {
    data object ButtonClick : SampleAction()
}

There’s one action ButtonClick that we need to handle somehow, so here’s a Reducer for that.

class ButtonClickReducer @Inject constructor(
    private val repository: SampleRepository,
) : Reducer<SampleViewModel, ButtonClick> {

    override suspend fun SampleViewModel.reduce(action: ButtonClick) {
        val text = repository.loadText()
        state { it.copy(text = text) }
    }
}

SampleRepository.loadText() could be anything that just loads some text asynchronously. I just wanted to demonstrate here, that you can easily inject any dependencies which are only needed in the scope of this reducer, so you don’t need to inject them into the view model.

Dependency injection

As you probably noticed, I’m using here Hilt for dependency injection. So there are some additional things we need to define, to have the reducers map injected into Reducers. Below you can see the module in which we are binding reducers into a map.

@InstallIn(ViewModelComponent::class)
@Module
abstract class SampleModule {

    @Binds
    @IntoMap
    @SampleActionKey(ButtonClick::class)
    abstract fun bindButtonClickReducer(reducer: ButtonClickReducer): Reducer<*, *>
}

The last thing is a definition of SampleActionKey annotation.

@MapKey
annotation class SampleActionKey(val value: KClass<out SampleAction>)

The purpose of having a custom map key annotation is to inject a map of reducers related only to the specific view model. Otherwise, imagine an app where you have hundreds of view models, and each of them has injected a map of all available reducers, not very efficient!

If you are thinking now: Hmm there’s quite a lot of boilerplate code around it… Then you are absolutely right. I had a similar feeling, but luckily there are tools to help us out.

Boilerplate? KSP to the rescue

We can easily delegate generating the boilerplate code to the KSP. We will use already existing HiltViewModel annotation and create our own HiltReducer, which will be used on our reducer classes.

@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.SOURCE)
annotation class HiltReducer(val value: KClass<out Any>)

Below you can see a simple diagram of what files are generated based on the annotations.

For HiltViewModel, KSP is generating an ActionKey already described in the previous section, and also a ViewModelModule which consists only of binding to an empty map of reducers, to make the code compile in case we didn’t provide any reducers. For our custom HiltReducer, KSP is generating a ReducerModule, which binds only this particular reducer into a map of reducers.

You can find the custom KSP processor implementation in the GitHub repo. For details on how KSP works in general you can visit the documentation.

Conclusion

To sum up, let’s look into some benefits of using an approach of View Model + Reducers:

  • No more view models with a lot of responsibilities, quickly becoming unreadable and unmaintainable.
  • View models have a simple purpose now, to hoist the state and delegate actions.
  • Easily extend the functionality of a screen by adding new actions and reducers to handle them.
  • With this approach, it’s easier to split working on one screen between multiple developers. They will be working on different reducers in different files, so no merge conflicts!

Of course, this approach might be an overengineering for some simple screens with not so much UI logic. For these cases, I would probably stick to the classic view model implementation. But if you encountered the problems that I mentioned in the beginning, I would encourage you to try it out. If you liked the concept, leave a comment or give it a star on GitHub 🙂 If you don’t like it, let me also know in the comments section!

In the upcoming blog post, I will show how to create your own DSL for testing reducers, stay tuned!

Full source code available here:

Leave a Comment

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.