Inthe second part of this series of articles, we will continue discussing best practices for using Android ViewModels.
In the previous part, we discussed 🔄🔄🔄
- Avoid initializing state in the
init {}
block
Find the detailed information in the post below:
Let’s look at the Key discussion points for this series and refresh our memories, In this part we’ll discuss #2 and #3 in the list below
Key Discussion Points for This Series
- Avoid initializing state in the
init {}
block. ✅ (read here) - Avoid exposing mutable states.
- Use update{} when using MutableStateFlows:
- Lazily inject dependencies in the constructor.
- Embrace more reactive and less imperative coding.
- Avoid initializing the ViewModel from the outside world.
- Avoid passing parameters from the outside world.
- Avoid hardcoding Coroutine Dispatchers.
- Unit test your ViewModels.
- Avoid exposing suspended functions.
- Leverage the
onCleared()
callback in ViewModels. - Handle process death and configuration changes.
- Inject UseCases, which call Repositories, which in turn call DataSources.
- Only include domain objects in your ViewModels.
- Leverage
shareIn()
andstateIn()
operators to avoid hitting the upstream multiple times.
#2- Do not expose mutable state ❌:
Exposing a MutableStateFlow
directly from Android ViewModels can introduce several issues related to app architecture, data integrity, and the overall maintainability of your code. Here are some of the main concerns:
Violates Encapsulation:
The primary issue with exposing aMutableStateFlow
is that it breaks the encapsulation principle of object-oriented programming. By exposing a mutable component, you allow external classes to modify the state directly, which can lead to unpredictable app behavior, hard-to-track bugs, and a violation of the ViewModel’s responsibility to manage and control its own state.
Data Integrity Risks:
When external classes can modify the state directly, maintaining data integrity becomes challenging. The ViewModel can no longer ensure that its state transitions are valid, potentially leading to illegal or inconsistent states within the app. This can complicate state management and increase the risk of errors.
Increased Complexity:
Allowing direct modification of state from outside the ViewModel can lead to more complex codebases. It becomes harder to track where and how state changes are initiated, making the codebase harder to understand and maintain. This can also make debugging more difficult, as it’s less clear how the app reached a particular state.
Concurrency Issues:
MutableStateFlow
is thread-safe, but managing concurrency becomes more complicated when multiple parts of the app can update the state concurrently. Without careful coordination, you might end up with race conditions or other concurrency issues that lead to unstable app behavior.
Testing Challenges:
Testing the ViewModel becomes more challenging when its internal state can be modified externally. It’s harder to predict and control the ViewModel’s state in tests, which can make tests less reliable and more complex.
Architectural Clarity:
Exposing mutable states directly can blur the lines between different layers of your app’s architecture. The ViewModel’s role is to expose data and handle logic for the UI to observe and react to, not to provide a mutable data source that can be changed from anywhere. This can lead to a less clear separation of concerns, making the architecture harder to understand and follow.
Lack of Control Over Observers:
When the state can be modified externally, it’s harder to control how and when observers are notified of changes. This can lead to unnecessary UI updates or missed updates if the state is changed without properly notifying observers.
Below shows an example of how can we expose a mutable state as a bad practice.
class RatesViewModel constructor( | |
private val ratesRepository: RatesRepository, | |
) : ViewModel() { | |
val state = MutableStateFlow(RatesUiState(isLoading = true)) | |
} |
OK, Show me how not to expose a mutable state 🤔
To mitigate these issues, it’s generally recommended to expose the state from ViewModels as read-only, using StateFlow
or LiveData
. This approach maintains encapsulation and allows the ViewModel to manage its state more effectively. Changes to the state can be made through well-defined methods in the ViewModel, which can validate and process changes as needed. This helps to ensure data integrity, simplify testing, and maintain a clear architecture.
class RatesViewModel constructor( | |
private val ratesRepository: RatesRepository, | |
) : ViewModel() { | |
private val _state = MutableStateFlow(RatesUiState(isLoading = true)) | |
val state: StateFlow<RatesUiState> | |
get() = _state.asStateFlow() | |
} |
Job Offers
In the example above we have an internal private state to the ViewModel which can be updated internally and then we expose an immutable state using asStateFlow()
extension function.
#3- Use update{}
when using MutableStateFlows 📜:
Using MutableStateFlow
in Kotlin, especially within the context of Android development, offers a reactive way to handle data that can change over time. When you need to update the state represented by a MutableStateFlow
, there are indeed several approaches you can take. Let’s explore these methods and why using .update{}
is often the recommended way to go.
Option 1: Direct Assignment
mutableStateFlow.value = mutableStateFlow.value.copy()
This method involves directly setting the value of the MutableStateFlow
by creating a copy of the current state with the desired changes. This approach is straightforward and works well for simple state updates. However, it’s not atomic 🛑🛑🛑, meaning that if multiple threads are updating the state simultaneously, you might end up with race conditions.
Option 2: Emitting a New State
mutableStateFlow.emit(newState())
Using .emit()
allows you to send a new state into the MutableStateFlow
. While .emit()
is thread-safe and can be used for concurrent updates, it’s a suspending function. This means it should be called within a coroutine and is designed for situations where you might need to wait for the state to be consumed. This can be more flexible but also introduces complexity when used within synchronous code blocks or outside of coroutines.
Option 3: Using .update{}
mutableStateFlow.update { it.copy(// state modification here) }
Why .update{}
is Often the Preferred Approach:
– Atomicity: .update{}
ensures that the update operation is atomic, which is crucial in a concurrent environment. This atomicity guarantees that each update is applied based on the most current state, avoiding conflicts between concurrent updates.
– Thread-Safety: It manages thread safety internally, so you don’t have to worry about synchronizing state updates across different threads.
– Simplicity and Safety: It provides a simple and safe way to update the state without the overhead of managing coroutines explicitly, as would be the case with .emit()
for non-synchronous updates.
In summary, while direct assignment and .emit()
have their use cases, .update{}
is designed to offer a thread-safe, atomic way to update MutableStateFlow
values. This makes it an excellent choice for most scenarios where you need to ensure consistent and safe updates to your reactive state in a concurrent environment.
Example Usage
Imagine you have a MutableStateFlow
holding a state of type User
, which is a data class
data class User(val name: String, val age: Int)
val userStateFlow = MutableStateFlow(User(name = "John", age = 30))
If you want to update the age of the user, you could do:
userStateFlow.update { currentUser ->
currentUser.copy(age = currentUser.age + 1)
}
This code atomically updates the userStateFlow
‘s current state, incrementing the age
by 1. The currentUser
inside the lambda represents the current state.
🛑 make sure you are using the recent version of coroutines to be able to use this extension function:
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0"
Summary 📝:
So far we’ve uncovered advanced techniques critical for efficient app development. We’ve highlighted the pitfalls of directly exposing mutable state from ViewModels and discussed the associated risks. To address these challenges, we’ve recommended solutions such as embracing read-only state and leveraging the update{}
function for safer state updates, ensuring our codebase remains robust and maintainable.
Key Takeaway:
From what we’ve learned, it’s clear that sticking to good practices in how we use ViewModels is super important. By avoiding common mistakes and using the right techniques, we can make our apps stronger, keep our data safe, and make testing easier. So, let’s remember to follow these tips to build better Android apps!
Great job on making it to the end of this article! ✨✨✨
- Follow my YouTube channel for video tutorials and tips on Android development
- If you need help with your Android project, Book a 1:1 or a Pair-Programming meeting with me, 100% free! book a time here
If you like this article please can you do me a little favour and hit the 👏clap button as many times! I appreciate your kindness x❤️👊
This article is previously published on proandroiddev.com