DEV Community

Cover image for Android Presentation Patterns: MVVM
Adam Świderski
Adam Świderski

Posted on • Originally published at asvid.github.io

Android Presentation Patterns: MVVM

Evolution

There is a clear trajectory in presentation pattern evolution - decreased coupling. In MVC, components were closely situated, with numerous direct interactions. MVP addressed this by introducing a standard interface structure. It's now possible to test some interactions without relying on UI (although it may sound somewhat peculiar when discussing presentation patterns, I admit). Nevertheless, there are still some challenges, such as maintaining view state or artificial awareness of view lifecycle in MVP. Does the Model-View-ViewModel pattern resolve these issues?

View-View-What?

Model-View-ViewModel (MVVM) is a design pattern that originated from Microsoft's WPF (Windows Presentation Foundation) framework and gained popularity in Android development due to its effectiveness in handling UI-related concerns. MVVM emerged as an evolution of the traditional MVC (Model-View-Controller) pattern, tailored specifically for the needs of modern UI development. The roles of Model and View have remained unchanged since MVP, or even MVC. The most significant practical change in the ViewModel approach is actually not explicitly stated in the pattern's name. It's the State, and how it's utilized.

Image description

State

That's a very serious name for a fairly simple concept. It's a container for data that a View needs to display. State will have fields for text input values, booleans controlling button display or not, lists of items to show in UI, etc.

data class State(
    val items: List<Int> = emptyList(),
    val inputError: String? = null,
    val currentInput: String? = null,
    val isLoading: Boolean = false
)
Enter fullscreen mode Exit fullscreen mode

How is that different from the View itself? Well, a View typically doesn't have a single place for all the data that it is using. Data is scattered over multiple fields or arguments, and doesn't create a consistent state. In MVC or MVP, View elements are changed independently, so the Controller or Presenter needs to be aware of how to set up the View to represent a particular state.

In MVVM, the philosophy is a bit different.

ViewModel

Alright, so how does the ViewModel manage state? First of all, the State is immutable. We don't want multiple places to update it without synchronization.

But how can we modify an immutable state then?

We don't.

Instead, we create a new state based on the existing one. Then, we push it to a flow that the View observes. You might want to have a private mutable flow so that the ViewModel can update the current state freely, and an immutable version of it so that the observer cannot inadvertently modify the flow.

Here's an example:

private val _state: MutableStateFlow<State> = MutableStateFlow(State())
val state: StateFlow<State> = _state
Enter fullscreen mode Exit fullscreen mode

To update the State, you can take the current version of the state, make necessary changes, and then push it to the flow. Here's an example:

_state.update { currentState ->
    currentState.copy(inputError = exception.message)
}
Enter fullscreen mode Exit fullscreen mode

Using a data class to model the State allows you to use the .copy() function easily.

By updating the State in this manner, each subsequent update will consider the previous state, helping to prevent race conditions and ensuring that the state is composed of multiple incremental updates.

The ViewModel operates independently of the View, which is quite beneficial. Its primary role is to generate new states based on data or events from the Model. The ViewModel does not have any reference to the View in any manner.

Testing

In my opinion, the greatest benefit of using MVVM is how incredibly simple it is to test. The ViewModel is completely independent of Android or UI specifics; its sole purpose is to respond to UI events and generate new State. This isolation makes it a perfect black box :) Most tests will follow this pattern:

  1. Set up the system under test (model data, initial state).
  2. Trigger a UI event.
  3. Verify if the state update meets expectations.

And that's all there is to it.

Lifecycle awareness

One challenge with MVP was that the Presenter often lacked awareness of when the View was displayed or destroyed. As a result, it could make unnecessary UI changes when they were no longer required. The typical workaround was to incorporate methods like attachView and detachView and ensure everything was properly linked.

ViewModel, on the other hand, addresses this issue seamlessly with viewModelScope. As outlined in the documentation:

This scope will be canceled when ViewModel will be cleared, i.e ViewModel.onCleared is called.

This method will be called when this ViewModel is no longer used and will be destroyed.
It is useful when ViewModel observes some data and you need to clear this subscription to prevent a leak of this ViewModel.

Even though the ViewModel is independent of the View, it will be notified when it is no longer required.

Databinding

In the past, during the era of XML views, there existed a method called Databinding. It enabled the creation of a stream of updates to View elements, allowing the data from the Model to be directly bound to UI elements. This eliminated the need to expose separate methods in the View for each element and manually update them whenever changes occurred in the Model. While there were native approaches to achieve this, as well as some Rx libraries, I was never particularly fond of establishing a direct link between the Model and the View with intermediary tools.

In my opinion, as of 2024, with the advent of Jetpack Compose, there is no longer a need for traditional databinding. Utilizing StateFlow with a straightforward State object that is consumed by @Composable functions to construct the UI is significantly simpler. I am uncertain whether databinding is even feasible with Jetpack Compose; perhaps it would involve utilizing multiple data streams, each corresponding to a specific UI element you wish to bind.

In conclusion, I would advise against attempting databinding and instead embrace Jetpack Compose, leveraging a single State flow, and immensely enjoying the convenience of the @Preview annotation.

View

In contrast, the View maintains a reference to the ViewModel because it must observe the State and transmit UI events to it.

Upon receiving each new State gathered by the View, the UI is updated to reflect the current state produced by the ViewModel. The State serves as the ultimate source of truth; the View should not retain its own data and then combine it with state changes to determine what should be displayed.

The same principle applies to the logic as well – all operations related to UI handling should be encapsulated within the ViewModel, while business logic should reside in the Model.

UI Events

The View is responsible for informing the ViewModel about UI interactions. This can be achieved by simply invoking public methods of the ViewModel, as the View holds a reference to it.
Therefore, the ViewModel can expose a method such as:

fun addButtonClicked() {
    viewModelScope.launch {
        // Functionality
    }
}
Enter fullscreen mode Exit fullscreen mode

And the View will directly call this method:

Button(
    onClick = {
        addButtonClicked()
    },
)
Enter fullscreen mode Exit fullscreen mode

However, this approach may not scale well. The ViewModel methods could become bloated with repetitive code for managing viewModelScope, handling potential errors, and updating state.

A cleaner solution is to have the ViewModel manage events through a single public method.

sealed class Event {
    data object AddButtonClicked : Event()
    class InputChanged(val input: String) : Event()
    class RemoveItem(val item: Int) : Event()
}

------
// Inside ViewModel:        
fun handleEvent(event: Event) {
    when (event) {
        Event.AddButtonClicked -> addButtonClicked() // calling private method
        is Event.InputChanged -> inputChanged(event.input)
        is Event.RemoveItem -> removeItem(event.item)
    }
}
Enter fullscreen mode Exit fullscreen mode

In general, all UI events should be directed to the ViewModel for decision-making and state updates. However, there can be exceptions, and the official decision tree from the Android Developer website provides a good illustration of when it might be acceptable to have some logic in the View.

Another aspect to consider is whether the ViewModel should handle all UI logic or if certain more straightforward tasks can be managed by the View itself. When working with Jetpack Compose, there might be a temptation to incorporate some logic into @Composable functions since they are authored in Kotlin, similar to ViewModel code. My approach usually aligns with a decision-making process similar to the one depicted in the mentioned decision tree.

For instance, if the State includes a list of items, but the UI only needs to display the number of items, I would leave this calculation in the View instead of introducing another State field solely to count the elements in the list every time it is updated. Similarly, if the task involves filtering elements within the list without altering the list itself, I would handle it in the View. However, for more complex filtering scenarios that require testing it might be better to place the logic in the ViewModel. Ultimately, the approach taken depends on the specific situation, but as a general guideline, I strive to centralize most logic in the ViewModel, except for trivial UI data that has no impact on the State.

Side Effects

Not every UI element needs to be displayed by updating the State. Temporary UI components like Snackbar or Toast, which disappear quickly and typically convey information about an event such as an error or success, don't necessarily need to be tied to the State. It is important for the State to be aware of whether an error was displayed or is no longer present (to prevent duplicate displays, for instance), but these events are often of the fire-and-forget nature, requiring no specific action from the user.

These types of events are referred to as Side Effects and can be managed as a separate stream observed by the View:

sealed class SideEffect {
    class ShowError(val text: String) : SideEffect()
    class ShowToast(val text: String) : SideEffect()
}
---
// inside ViewMode:
private
val _sideEffect = Channel<SIDE_EFFECT>(Channel.BUFFERED)
val sideEffect: Flow<SIDE_EFFECT> = _sideEffect.receiveAsFlow()
---
// inside View:
lifecycleScope.launch {
    lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
        launch {
            viewModel.sideEffect.collect {
                handleSideEffect(it)
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

By using SideEffects, the View gains the flexibility to determine how to display specific events. While a ShowToast side effect may be straightforward, handling a ShowError event could vary based on factors like the data in the event or the current UI state. It enables the View to decide whether to show the error as a Toast message, within a Snackbar, or by highlighting an input error, based on the context. This approach shifts the decision-making from the ViewModel to the View, simplifying future modifications and enhancing the modularity of the codebase.

For Side Effects, I utilized Channel instead of MutableStateFlow. Given that there is no initial side effect to populate the stream, and there's no need to retain events in the stream for late observers, opting for Channel seems more suitable than StateFlow.

Previews

Utilizing State as an input to the @Composable function simplifies the process of creating Jetpack Compose previews. When breaking down the View into smaller @Composables, it is advisable to pass only the necessary data fields from the entire State. This helps reduce dependencies on changes and encourages the creation of concise methods that generate UI elements. Moreover, it facilitates previewing the UI in the IDE, enabling the verification of multiple value variants (such as long numbers, extensive text, dark mode, etc.) without needing to build the entire application.

Unidirectional Dataflow

The term "unidirectional dataflow" frequently arises in discussions about MVVM. But what does it mean? It signifies that data moves in a single direction :) While there may be multiple data flows and the Producer may also observe another flow as an Observer, the flow serves as the sole communication interface. The View does not expose methods to manipulate its elements; instead, it observes the State. Similarly, the ViewModel does not reveal its internal methods but instead awaits UI Events to execute operations and update the State. There are no callbacks or waiting for responses. The system operates by sending events in one direction and anticipating updates to the State.

For further information, visit the Android Developer Portal

Model

Similar to MVC and MVP, the Model in MVVM encompasses the business logic tailored to your specific scenario. While the ViewModel manages UI-related logic exclusively, any functionality unrelated to the UI should reside within the domain model code.

Advantages of MVVM

  • Encapsulation: Each layer in MVVM is responsible for specific aspects of the application's functionality. The model handles data and business logic, the view manages UI components and layout, and the view model orchestrates the interaction between the model and the view. This clear separation makes it easier to understand and maintain the codebase.

  • Modularity: MVVM encourages modular development, allowing developers to focus on implementing and testing individual components independently. This modularity makes it easier to add new features, fix bugs, and refactor existing code without affecting other parts of the application.

  • Isolation of Concerns: The separation of concerns in MVVM makes it easier to write unit tests for each component in isolation. Developers can write tests for the view model layer without needing to interact with the view or the model directly, leading to more focused and efficient testing.

  • Mocking and Dependency Injection: MVVM simplifies the process of mocking dependencies and injecting test doubles into unit tests. View models can be tested with mock data sources or repositories, allowing developers to simulate different scenarios and edge cases easily.

  • Reduced Boilerplate Code: MVVM reduces the amount of boilerplate code required for UI-related tasks by leveraging features like data binding and Android Architecture Components. This reduction in boilerplate code decreases the cognitive load associated with writing and maintaining tests, making it easier to ensure code quality and reliability.

  • Lifecycle Awareness: The ViewModel, being a Jetpack component, is lifecycle-aware and handles configuration changes and lifecycle events in Android activities and fragments automatically. This streamlines the management of UI state and helps prevent memory leaks.

Conclusion

Given the numerous advantages that MVVM offers to Android development, it is highly recommended as the default pattern for most scenarios. Its clear separation of concerns facilitates the maintenance of a clean and structured codebase, enhancing readability, extensibility, and debugging capabilities. The improved testability and maintainability provided by MVVM streamline the development process, leading to faster iterations and higher code quality. Additionally, its seamless integration with Android Jetpack components and support for reactive programming paradigms make MVVM ideal for modern Android app development. This empowers developers to create responsive, scalable, and maintainable applications with ease.

Top comments (0)