Blog Infos
Author
Published
Topics
, , , ,
Author
Published

Inthis series of articles, we dive into best practices for utilizing Android ViewModels, emphasizing essential dos and don’ts to enhance code quality. We’ll cover the role of ViewModels in managing UI state and business logic, strategies for lazy dependency injection, and the importance of reactive programming. Additionally, we discuss common pitfalls to avoid, such as improper state initialization and exposing mutable states, providing a comprehensive guide for developers.

Understanding ViewModels

According to Android’s documentation, the ViewModel class acts as a business logic or screen-level state holder. It exposes the state to the UI and encapsulates related business logic. Its principal advantage is caching the state and persisting it through configuration changes. This means your UI doesn’t have to fetch data again when navigating between activities or following configuration changes, such as when rotating the screen.

Key Discussion Points for This Series
  1. Avoid initializing state in the init {} block.
  2. Avoid exposing mutable states.
  3. Use update{} when using MutableStateFlows:
  4. Lazily inject dependencies in the constructor.
  5. Embrace more reactive and less imperative coding.
  6. Avoid initializing the ViewModel from the outside world.
  7. Avoid passing parameters from the outside world.
  8. Avoid hardcoding Coroutine Dispatchers.
  9. Unit test your ViewModels.
  10. Avoid exposing suspended functions.
  11. Leverage the onCleared() callback in ViewModels.
  12. Handle process death and configuration changes.
  13. Inject UseCases, which call Repositories, which in turn call DataSources.
  14. Only include domain objects in your ViewModels.
  15. Leverage shareIn() and stateIn() operators to avoid hitting the upstream multiple times.

Let’s get started with the first item on the list!

#1-Avoid initializing state in the init {} block:

Initiating data loading in the init {} block of an Android ViewModel might seem convenient for initializing data as soon as the ViewModel is created. However, this approach has several downsides, such as tight coupling with ViewModel creation, testing challenges, limited flexibility, handling configuration changes, resource management, and UI responsiveness. To mitigate these issues, it’s recommended to use a more deliberate approach to data loading, leveraging LiveData or other lifecycle-aware components to manage data in a way that respects the Android lifecycle.

Tight Coupling with ViewModel Creation:

Loading data in the init{} block couples data fetching tightly with the ViewModel’s lifecycle. This can lead to difficulties in controlling the timing of data loads, especially in complex UIs where you might want more granular control over when data is fetched based on user interactions or other events.

Testing Challenges:

Testing becomes harder because the data loading starts as soon as the ViewModel is instantiated. This can make it difficult to test the ViewModel in isolation without also triggering network requests or database queries, complicating test setup and potentially leading to flaky tests.

Limited Flexibility:

Automatically starting data loading upon ViewModel instantiation limits your flexibility in handling different user flows or UI states. For example, you might want to delay fetching data until certain user permissions are granted or until the user navigates to a specific part of your app.

Handling Configuration Changes:

Android ViewModels are designed to survive configuration changes, such as screen rotations. If data loading is initiated in the init{} block, a configuration change might lead to unexpected behavior or unnecessary data re-fetching if not carefully managed.

Resource Management:

Instant data loading might lead to inefficient use of resources, especially if the user does not need the data immediately upon entering the app or screen. This can be particularly problematic for applications that consume a significant amount of data or use costly operations to fetch or process this data.

UI Responsiveness:

Initiating data load in the init{} block can impact UI responsiveness, especially if the data load operation is lengthy or blocks the main thread. It’s generally a good practice to keep the init{} block lightweight and offload heavy or asynchronous operations to a background thread or use LiveData/Flow to observe data changes.

To mitigate these issues, it’s often recommended to use a more deliberate approach to data loading, such as triggering it in response to specific user actions or UI events and leveraging LiveData or other lifecycle-aware components to manage data in a way that respects the Android lifecycle. This can help ensure that your app remains responsive, is easier to test, and makes more efficient use of resources.

Let’s explore some examples of this anti-pattern:

Example #1:

 

class SearchViewModel @Inject constructor(
private val searchUseCase: dagger.Lazy<SearchUseCase>,
private val wordsUseCase: GetWordsUseCase,
) : ViewModel() {
data class UiState(
val isLoading: Boolean,
val words: List<String> = emptyList()
)
init {
getWords()
}
val _state = MutableStateFlow(UiState(isLoading = true))
val state: StateFlow<UiState>
get() = _state.asStateFlow()
private fun getWords() {
viewModelScope.launch {
_state.update { UiState(isLoading = true) }
val words = wordsUseCase.invoke()
_state.update { UiState(isLoading = false, words = words) }
}
}
}

In this SearchViewModel, data loading is triggered immediately within the init block, tightly coupling data fetching to ViewModel instantiation and reducing flexibility. Exposing a mutable state _state inside the class and not handling potential errors or varying UI states (loading, success, error) can lead to a less robust and harder-to-test implementation. This approach undermines the benefits of ViewModel’s lifecycle awareness and the efficiency of lazy initialization.

How can we make it better?

Improve #1:
class SearchViewModel @Inject constructor(
private val searchUseCase: dagger.Lazy<SearchUseCase>,
private val wordsUseCase: GetWordsUseCase,
) : ViewModel() {
data class UiState(
val isLoading: Boolean = true,
val words: List<String> = emptyList()
)
val state: StateFlow<UiState> = flow {
emit(UiState(isLoading = true))
val words = wordsUseCase.invoke()
emit(UiState(isLoading = false, words = words))
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), UiState())
}

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

, ,

Migrating to Jetpack Compose – an interop love story

Most of you are familiar with Jetpack Compose and its benefits. If you’re able to start anew and create a Compose-only app, you’re on the right track. But this talk might not be for you…
Watch Video

Migrating to Jetpack Compose - an interop love story

Simona Milanovic
Android DevRel Engineer for Jetpack Compose
Google

Migrating to Jetpack Compose - an interop love story

Simona Milanovic
Android DevRel Engin ...
Google

Migrating to Jetpack Compose - an interop love story

Simona Milanovic
Android DevRel Engineer f ...
Google

Jobs

The refactoring removes data fetching from the ViewModel’s initblock, instead relying on collection to initiate data loading. This change significantly improves flexibility in managing data fetching and reduces unnecessary operations upon ViewModel instantiation, directly addressing issues of premature data loading and enhancing the ViewModel’s responsiveness and efficiency.

Example #2:
class SearchViewModel @Inject constructor(
private val searchUseCase: SearchUseCase,
@IoDispatcher val ioDispatcher: CoroutineDispatcher
) : ViewModel() {
private val searchQuery = MutableStateFlow("")
private val _uiState = MutableLiveData<SearchUiState>()
val uiState = _uiState
init {
viewModelScope.launch {
searchQuery.debounce(DEBOUNCE_TIME_IN_MILLIS)
.collectLatest { query ->
Timber.d("collectLatest(), query:[%s]", query)
if (query.isEmpty()) {
_uiState.value = SearchUiState.Idle
return@collectLatest
}
try {
_uiState.value = SearchUiState.Loading
val photos = withContext(ioDispatcher){
searchUseCase.invoke(query)
}
if (photos.isEmpty()) {
_uiState.value = SearchUiState.EmptyResult
} else {
_uiState.value = SearchUiState.Success(photos)
}
} catch (e: Exception) {
_uiState.value = SearchUiState.Error(e)
}
}
}
}
fun onQueryChanged(query: String?) {
query ?: return
searchQuery.value = query
}
sealed class SearchUiState {
object Loading : SearchUiState()
object Idle : SearchUiState()
data class Success(val photos: List<FlickrPhoto>) : SearchUiState()
object EmptyResult : SearchUiState()
data class Error(val exception: Throwable) : SearchUiState()
}
companion object {
private const val DEBOUNCE_TIME_IN_MILLIS = 300L
}
}

Launching a coroutine within the init block of SearchViewModel for immediate data processing ties data fetching too closely to the ViewModel’s lifecycle, potentially leading to inefficiencies and lifecycle management issues. This approach risks unnecessary network calls and complicates error handling, especially before the UI is ready to handle or display such information. Moreover, it assumes an implicit return to the main thread for UI updates, which may not always be safe or efficient, and it makes testing more challenging by initiating data fetching immediately upon ViewModel instantiation.

And we can refactor it to the following:
class SearchViewModel @Inject constructor(
private val searchUseCase: dagger.Lazy<SearchUseCase>,
) : ViewModel() {
private val searchQuery = MutableStateFlow("")
val uiState: LiveData<SearchUiState> = searchQuery
.debounce(DEBOUNCE_TIME_IN_MILLIS)
.asLiveData()
.switchMap(::createUiState)
private fun createUiState(query: @JvmSuppressWildcards String) = liveData {
Timber.d("collectLatest(), query:[%s]", query)
if (query.isEmpty()) {
emit(SearchUiState.Idle)
return@liveData
}
try {
emit(SearchUiState.Loading)
val photos = searchUseCase.get().invoke(query)
if (photos.isEmpty()) {
emit(SearchUiState.EmptyResult)
} else {
emit(SearchUiState.Success(photos))
}
} catch (e: Exception) {
emit(SearchUiState.Error(e))
}
}
fun onQueryChanged(query: String?) {
query ?: return
searchQuery.value = query
}
sealed class SearchUiState {
data object Loading : SearchUiState()
data object Idle : SearchUiState()
data class Success(val photos: List<FlickrPhoto>) : SearchUiState()
data object EmptyResult : SearchUiState()
data class Error(val exception: Throwable) : SearchUiState()
}
companion object {
private const val DEBOUNCE_TIME_IN_MILLIS = 300L
}
}

The revised implementation avoids launching a coroutine directly within the init block to observe searchQuery changes, instead opting for a reactive setup that transforms searchQuery into LiveData outside of the coroutine context. This eliminates potential issues related to lifecycle management and coroutine cancellation, ensuring that data fetching is inherently lifecycle-aware and more resource-efficient. By not relying on the init block to start observing and processing user input, it also decouples the ViewModel’s initialization from its data fetching logic, leading to a cleaner separation of concerns and a more maintainable code structure.

Summary:

We’ve delved into the reasons why initiating data loading within the init{} block can hinder our progress and have explored more intelligent, streamlined methods to orchestrate our app’s UI and logic via ViewModels. Throughout, we’ve discussed straightforward solutions and essential tactics to evade frequent pitfalls.

If you found this guide insightful, please show your appreciation by applauding 👏 . Keep an eye out for further updates designed to refine your Android development skills! 🎉”

Harry Potter Lol GIF by Sky - Find & Share on GIPHY

This article is previously published on proandroiddev.com

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
It’s one of the common UX across apps to provide swipe to dismiss so…
READ MORE
blog
Hi, today I come to you with a quick tip on how to update…
READ MORE
blog
Automation is a key point of Software Testing once it make possible to reproduce…
READ MORE
blog
Drag and Drop reordering in Recyclerview can be achieved with ItemTouchHelper (checkout implementation reference).…
READ MORE

Leave a Reply

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

Fill out this field
Fill out this field
Please enter a valid email address.

Menu