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
State Management:
state
: A read-onlyStateFlow
representing the current UI state.mutableStateFlow
: A mutableStateFlow
that holds and updates the state.
StateMediator Interface:
getUiState()
: Exposes the current UI state.onAction(action: A)
: Handles UI actions.
Reducers:
sendEvent(event: E)
: Uses a reducer to process events and update the state.setState(newState: S)
: Updates themutableStateFlow
with the new state.
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
Code Reusability: Write the business logic once and share it across Android, iOS, and potentially other platforms.
Consistency: Ensure consistent behavior and state management across different platforms.
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
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.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.
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
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.
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.
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.
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
Unidirectional Data Flow: Simplifies state management and makes state transitions explicit and predictable.
Separation of Concerns: Clearly separates state management from UI rendering.
Scalability: Easily handles complex state transitions and business logic.
Testability: Simplifies unit testing of state transitions and business logic.
Reusability: With Kotlin Multiplatform, share the same logic across different platforms.
Cons
Boilerplate Code: MVI can introduce a lot of boilerplate code, making initial setup verbose.
Learning Curve: Developers unfamiliar with MVI and Redux may need time to learn and adapt.
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.