MVI design pattern in mobile development

In the landscape of mobile app development, managing state and business logic efficiently is paramount. This blog post explores a robust implementation of the Model-View-Intent (MVI) pattern combined with Redux principles in a BaseViewModel. This approach helps streamline state management and ensures a unidirectional data flow within your app.

Introduction to Patterns

Model-View-Intent (MVI)

The MVI pattern emphasizes a unidirectional data flow, where:

  • Model: Represents the state of the UI.

  • View: Renders the UI based on the state.

  • Intent: Captures user actions and intents that drive state changes.

MVI is designed to make the state changes predictable and the application behaviour easier to understand and debug. It structures the application in a way that data flows in a single direction, reducing the chances of having inconsistent states.

Unidirectional Data Flow (UDF)

Unidirectional Data Flow ensures that data moves in a single direction, simplifying state management and making it easier to track and debug state changes. In MVI, the view generates intents, which the model processes to produce a new state that the view then renders. This loop creates a clear and predictable data flow.

Focus on MVI

MVI provides a clear structure for managing complex state transitions and user interactions. It reduces the likelihood of inconsistent states and makes the application's behaviour more predictable. By enforcing a strict unidirectional flow, it becomes easier to track state changes and understand the flow of data within the application.

Introducing BaseViewModel

Our BaseViewModel the class encapsulates the business logic and state management for Android applications. Here’s an overview of its components:

package com.newlook.base

import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow

abstract class BaseViewModel<S : UiState, E : UiEvent, A : UiAction>(
    initialState: S,
    private val reducer: Reducer<S, E>
) : ViewModel(), StateMediator<S, A> {

    private val state: StateFlow<S>
        get() = mutableStateFlow.asStateFlow()

    private val mutableStateFlow: MutableStateFlow<S> = MutableStateFlow(initialState)

    override fun getUiState(): StateFlow<S> = state

    abstract override fun onAction(action: A)

    protected open fun onLoadData() {}

    protected fun sendEvent(event: E) =
        reducer.redux(mutableStateFlow.value, event) { nextState -> setState(nextState) }

    private fun setState(newState: S) = mutableStateFlow.tryEmit(newState)
}

Key Components and Concepts

  1. State Management:

    • state: A read-only StateFlow representing the current UI state.

    • mutableStateFlow: A mutable StateFlow that holds and updates the state.

  2. StateMediator Interface:

    • getUiState(): Exposes the current UI state.

    • onAction(action: A): Handles UI actions.

  3. Reducers:

    • sendEvent(event: E): Uses a reducer to process events and update the state.

    • setState(newState: S): Updates the mutableStateFlow with the new state.

  4. Deferred Initialization:

    • onLoadData(): Provides flexibility in loading initial data, allowing developers to choose the optimal timing.

Detailed Breakdown


State Management

The BaseViewModel manages the UI state using MutableStateFlow and StateFlow from Kotlin coroutines. The MutableStateFlow holds the mutable state, while the StateFlow exposes a read-only version of the state to the UI, ensuring that state changes can only occur through the ViewModel.

private val mutableStateFlow: MutableStateFlow<S> = MutableStateFlow(initialState)
private val state: StateFlow<S>
    get() = mutableStateFlow.asStateFlow()

The use of StateFlow ensures that the state can be observed and collected by the UI layer in a lifecycle-aware manner, reducing the risk of memory leaks and ensuring that the UI is always up-to-date.


StateMediator Interface

The StateMediator interface defines the contract for managing UI state and handling actions from the UI. This ensures that the ViewModel adheres to a consistent interface for state management.

interface StateMediator<S, A> {
    fun getUiState(): StateFlow<S>
    fun onAction(action: A)
}

This interface ensures that all state changes and actions flow through a well-defined path, making the application's behavior more predictable and easier to debug.


Reducers

Reducers are responsible for processing events and producing a new state. The sendEvent function uses the reducer to handle events and update the state accordingly.

protected fun sendEvent(event: E) =
    reducer.redux(mutableStateFlow.value, event) { nextState -> setState(nextState) }

Reducers encapsulate the logic for how the state should change in response to events, ensuring that the state transitions are explicit and predictable.


Deferred Initialization

The onLoadData function allows developers to load initial data when they see fit, rather than in the constructor. This provides flexibility in determining the best time to load data, improving performance and user experience.

protected open fun onLoadData() {}

By deferring data loading, developers can optimize when and how data is fetched, reducing unnecessary work and improving the responsiveness of the application.

Reusability in Kotlin Multiplatform (KMM)

Kotlin Multiplatform (KMM)

Kotlin Multiplatform allows developers to share common code between Android, iOS, and other platforms. The BaseViewModel architecture can be used in a multiplatform project to centralize and reuse business logic and state management across different platforms.

Benefits of KMM

  1. Code Reusability: Write the business logic once and share it across Android, iOS, and potentially other platforms.

  2. Consistency: Ensure consistent behavior and state management across different platforms.

  3. Reduced Development Time: By sharing code, development time is reduced, and maintenance is simplified.

Implementing BaseViewModel in KMM

In a Kotlin Multiplatform project, you can define the BaseViewModel and related interfaces in the shared module:

// Shared module
interface UiState
interface UiEvent
interface UiAction

interface StateMediator<S, A> {
    fun getUiState(): StateFlow<S>
    fun onAction(action: A)
}

abstract class BaseViewModel<S : UiState, E : UiEvent, A : UiAction>(
    initialState: S,
    private val reducer: Reducer<S, E>
) : ViewModel(), StateMediator<S, A> {

    private val state: StateFlow<S>
        get() = mutableStateFlow.asStateFlow()

    private val mutableStateFlow: MutableStateFlow<S> = MutableStateFlow(initialState)

    override fun getUiState(): StateFlow<S> = state

    abstract override fun onAction(action: A)

    protected open fun onLoadData() {}

    protected fun sendEvent(event: E) =
        reducer.redux(mutableStateFlow.value, event) { nextState -> setState(nextState) }

    private fun setState(newState: S) = mutableStateFlow.tryEmit(newState)
}

Then, platform-specific implementations can extend this shared ViewModel to add platform-specific behavior if needed.

Interaction with Compose and SwiftUI

Jetpack Compose and SwiftUI

Jetpack Compose for Android and SwiftUI for iOS are modern UI toolkits that leverage declarative programming paradigms. They work exceptionally well with unidirectional data flow patterns like MVI.

Benefits with MVI

  1. Reactive UI Updates: Both Compose and SwiftUI are designed to react to state changes efficiently. When the state in the BaseViewModel changes, the UI automatically re-renders, ensuring that the UI is always in sync with the current state.

  2. Simplified State Management: Using MVI with these toolkits simplifies managing the UI state, as the entire UI is a function of the state, and the state is managed centrally in the ViewModel.

  3. Consistency: Ensures that the UI behaves consistently across different platforms, reducing bugs related to state management.

Example with Jetpack Compose

@Composable
fun MyScreen(viewModel: MyViewModel) {
    val uiState by viewModel.getUiState().collectAsState()

    when (uiState) {
        is UiState.Loading -> LoadingScreen()
        is UiState.Success -> SuccessScreen((uiState as UiState.Success).data)
        is UiState.Error -> ErrorScreen((uiState as UiState.Error).message)
    }
}

Example with SwiftUI

struct ContentView: View {
    @ObservedObject var viewModel: MyViewModel

    var body: some View {
        switch viewModel.uiState {
        case .loading:
            LoadingView()
        case .success(let data):
            SuccessView(data: data)
        case .error(let message):
            ErrorView(message: message)
        }
    }
}

Comparison with MVVM

Model-View-ViewModel (MVVM)

MVVM is another popular architectural pattern used in Android development. It separates the UI (View) from the business logic (ViewModel), with the ViewModel serving as an intermediary that manages the UI-related data.

Differences between MVI and MVVM

  1. Data Flow:

    • MVI: Enforces a unidirectional data flow where the state is immutable, and changes are triggered by events and intents.

    • MVVM: Allows bidirectional data binding where the ViewModel exposes observable data that the View binds to directly.

  2. State Management:

    • MVI: Uses a single immutable state to represent the UI, reducing the risk of inconsistent states.

    • MVVM: Often uses mutable LiveData to represent different pieces of the UI state.

  3. Complexity:

    • MVI: Can introduce more boilerplate code and a steeper learning curve due to its strict structure.

    • MVVM: Generally simpler to implement but can lead to tightly coupled code if not managed properly.

  4. Testability:

    • MVI: Easier to test due to the explicit state transitions and unidirectional flow.

    • MVVM: Can be harder to test due to the direct data binding and mutable state.

Pros and Cons of MVI

Pros

  1. Unidirectional Data Flow: Simplifies state management and makes state transitions explicit and predictable.

  2. Separation of Concerns: Clearly separates state management from UI rendering.

  3. Scalability: Easily handles complex state transitions and business logic.

  4. Testability: Simplifies unit testing of state transitions and business logic.

  5. Reusability: With Kotlin Multiplatform, share the same logic across different platforms.

Cons

  1. Boilerplate Code: MVI can introduce a lot of boilerplate code, making initial setup verbose.

  2. Learning Curve: Developers unfamiliar with MVI and Redux may need time to learn and adapt.

  3. Performance Overhead: In some cases, the additional layers can introduce performance overhead, particularly if not managed efficiently.

State Mediator in BaseViewModel

The StateMediator interface in the BaseViewModel ensures that the state can only be modified through specific actions, maintaining a clear and controlled flow of data. This interface defines methods to get the current UI state and handle actions from the UI:

interface StateMediator<S, A> {
    fun getUiState(): StateFlow<S>
    fun onAction(action: A)
}

Conclusion

The BaseViewModel class offers a structured and efficient way to manage state and business logic in Android applications. By leveraging the MVI-Redux pattern, developers can ensure a clean, maintainable, and scalable codebase. Start incorporating BaseViewModel into your projects to experience the benefits of unidirectional data flow and robust state management.

For further reading and official guidelines, check out the following resources:

By implementing these patterns and principles, you'll be well-equipped to manage complex state and business logic in your Android applications, leading to more maintainable and scalable projects.

Previous
Previous

JavaScript Injection in Android WebViews

Next
Next

Decoding the Magic of Predicting Italian Lottery Numbers with AI