Handling ViewModel events in Android

Helder Pinhal
Helder Pinhal
Mar 3 2023
Posted in Engineering & Technology

A comprehensive guide to dealing with ViewModel events in Android.

Handling ViewModel events in Android

Dealing with one-shot events from a ViewModel has been a common requirement for a long time. Since Google introduced the Architecture Components, applications have been incrementally adopting them. Fast forwarding to the present, this has become the cornerstone of Android development.

With developers pursuing ever cleaner and better-structured code, the Android community proposed several solutions to the same problem over the course of time. The most robust and adopted solution involved using Kotlin Channels exposed as Flows. This solved several requirements, like buffering events until an observer becomes available.

However, as good as the possible solutions were, none were without their pitfalls. The Kotlin Channel approach could guarantee every event would be delivered to an observer, but it could not ensure it was processed. Furthermore, the developer must have a deep knowledge of its intricacies to prevent unforeseen scenarios.

Google's guidance

Google always had and shared its strong opinion of how developers should structure their Android applications. But one can argue if anyone knows best, it's Google.

Advocating for better, albeit heavily opinionated, development practices, Google explains at great lengths how to structure your app and solve the majority of challenges associated with that kind of Android development.

Within Google's app architecture guide lies a good explanation of how to deal with ViewModel events.

Before diving into that, let's review the one-shot event mechanics requirements.

A quick recap on the requirements

  • Events must be observed once and only once.
  • New events cannot overwrite unobserved events.
  • Events must buffer until an observer becomes available to consume them.

A simple implementation

Google shares most of the code to convey this principle in their guides, but let's go through all the bits.

For this example, let's assume we're implementing a view that shows a user's profile and saves potential changes. Saving should present a confirmation to the user — the one-shot event.

Defining the UI state

The first part is knowing and accepting that everything related to the view must be represented in the UI state. For more information, take a look at the UDF section.

data class UserProfileUiState(
	val email: String = "",
	val name: String = "",
	// ...
)

Defining the ViewModel

The ViewModel should act as a single source of truth, holding the UI state and dealing with any necessary business logic.

class UserProfileViewModel: ViewModel() {
	private val _uiState = MutableStateFlow(UserProfileUiState())
	val uiState: StateFlow<UserProfileUiState> = _uiState.asStateFlow()

	// TODO: load the user profile.

	fun saveChanges() {
		// TODO: save any pending changes.

		// ...
	}
}

Observing UI changes

In your view, observe any changes to the UI state for the relevant view states. The repeatOnLifecycle function helps ensure the UI state is consumed while the view is in a state capable of processing it.

class UserProfileActivity: AppCompatActivity() {
	override fun onCreate(savedInstanceState: Bundle?) {
		lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.uiState.collect { uiState ->
                    // Render the UI state.
                }
            }
        }
	}
}

Bringing the events into the fold

As Google states, events should always result in a UI state update. To conform to this principle, we should update our UserProfileUiState.

// 1. Create an object describing an event.
sealed class UserMessage {
	val uniqueId: String = UUID.randomUUID().toString()

	class ProfileSaved: UserMessage()
}

// 2. Include a collection of events in the UI state.
data class UserProfileUiState(
	// ...
	val userMessages: List<UserMessage> = listOf(),
)

You may have noticed the uniqueId property and wondered about its purpose. Later, the view must inform the view model the event was processed and can be removed from the state.

We can now adjust the view model to emit the event.

class UserProfileViewModel: ViewModel() {
	// ...

	fun saveChanges() {
		// ...

		_uiState.update { currentState ->
			currentState.copy(
				userMessages = currentState.userMessages + UserMessage.ProfileSaved(),
			)
		}
	}

	fun userMessageShown(message: UserMessage) {
		_uiState.update { currentState ->
			currentState.copy(
				userMessages = currentState.userMessages.filterNot { it.uniqueId == message.uniqueId }
			)
		}
	}
}

Another essential detail in this puzzle is using the MutableStateFlow.update function, which ensures an atomic update and prevents concurrent writes from losing some updates.

Ultimately, the events must be observed and processed by the view.

class UserProfileActivity: AppCompatActivity() {
	override fun onCreate(savedInstanceState: Bundle?) {
		lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.uiState.collect { uiState ->
                    // Render the UI state.

                    val message = uiState.userMessages.firstOrNull()
                    if (message != null) {
                        // Process the event.
                        viewModel.userMessageShown(message)
                    }
                }
            }
        }
	}
}

Conclusion

This pattern is not without its quirks, namely the mandatory callback to the view model. However, handling this sort of event with a minimal margin for error becomes dead simple.

Furthermore, it aligns perfectly with the unidirectional data flow principles bringing another level of consistency to the application's architecture. Overall, this pattern is a very welcomed improvement.

As always, we hope you liked this article, and if you have anything to add, we are available via our Support Channel.

Keep up-to-date with the latest news