View State Management & Coordination with Compose on Android
A Lengthy Exploration of ViewModel to Compose UI Communication & UI State Management with Compose
So I have had a little problem with my open-source Android project, Merlinsbag. The core of this problem is the communication between Compose UI and an Android Architectural Component (AAC) ViewModel
. Up until now, any well-structured architecture on this front had been ignored. You can get far by simply providing UI state & UI events reporting via viewmodel member variables and methods. However, in Merlinsbag, this simple approach has grown a bit cumbersome in ways I hope to describe.
This article is about a refactoring journey to find an architecture suitable for my project’s needs. It is not about revealing the one true architecture to rule them all. It is an article I wish I had for my past self and that I hope is valuable to my future self.
In this article, "viewmodel" is not used as a loaded term in support of MVVM. It is a term I use for the portion of code that manages UI state. In Merlinsbag, this is handled via AAC ViewModels
.
The example code snippets in this post are painfully explicit. In some sense, it feels impossible to demonstrate the pain without full examples. I hope the extensive nature of this article proves to be more helpful than confusing. Don’t hesitate to fling through the code as soon as the pattern makes sense.
Part 1: Starting Point
We will be following the refactoring of a SettingsViewModel
& SettingsScreen
Compose UI function, as I search for a solution to apply to all screens in Merlinsbag. SettingScreen
is not too complicated but it involves state management for various UI elements (some of which are derived from a repository Flow
) and various one-time UI effects (ex: navigation, toasts, in-app reviews).
The starting point is the bare minimum you might expect, but with all state & state-changing actions hoisted out of the composable SettingsScreen
UI function and into the SettingsViewModel
. There is also the merging of related UI state like dropdown menus, alert dialogs, and navigation requests via enums
& sealed interfaces
.
The Merlinsbag application is a single Activity, zero fragment, application that uses Navigation with Compose. Android has an open-source application called "Now in Android" with a similar structure. So far, I am in love with this new way of Android development and I highly recommend checking it out if you have not already. However, you don’t need to be too familiar with it to understand the code.
Below is a picture of three different UI states for the settings screen in Merlinsbag, as seen by the user. The screen consists of one large scrollable column of wide horizontal buttons. A button can trigger navigation, dropdown menus, alert dialogs, toasts, or in-app reviews.
Here’s a diagram of the existing architecture:
And this is what the starting code actually looks like (full source on GitHub):
// enough time to prevent flows from timing out on basic configuration changes | |
const val STATEIN_TIMEOUT_MILLIS = 5_000L | |
// ViewModel | |
@HiltViewModel | |
class SettingsViewModel @Inject constructor( | |
private val purgeRepository: PurgeRepository, | |
private val userPreferencesRepository: UserPreferencesRepository, | |
): ViewModel() { | |
// UI state enums | |
enum class DropdownMenuState { | |
None, | |
DarkMode, | |
ColorPalette, | |
HighContrast, | |
Typography, | |
ImageQuality, | |
} | |
enum class AlertDialogState { | |
None, | |
DeleteAllData, | |
ImageQuality, | |
} | |
data class PreferencesState( | |
// all fields are simple enums defined elsewhere | |
val darkMode: DarkMode, | |
val colorPalette: ColorPalette, | |
val highContrast: HighContrast, | |
val imageQuality: ImageQuality, | |
val typography: Typography, | |
) | |
sealed interface NavigationState { | |
data object TipsAndInfo: NavigationState | |
data object Statistics: NavigationState | |
data class Web(val url: String): NavigationState | |
} | |
// UI state | |
// Note: In Merlinsbag, Event is/was a simple way of | |
// sending transient state (ex: showing Toasts, displaying | |
// Snackbar message, navigation events, in-app review requests) | |
var cachePurged by mutableStateOf(Event<Unit>(null)) | |
var dataDeleted by mutableStateOf(Event<Unit>(null)) | |
var rateAndReviewRequest by mutableStateOf(Event<Unit>(null)) | |
var navigationEventState by mutableStateOf(Event<NavigationState>(null)) | |
var clearCacheEnabled by mutableStateOf(true) | |
var highContrastEnabled by mutableStateOf(true) | |
var dropdownMenuState by mutableStateOf(DropdownMenuState.None) | |
var alertDialogState by mutableStateOf(AlertDialogState.None) | |
val preferencesState = userPreferencesRepository.userPreferences.map { | |
PreferencesState( | |
darkMode = it.darkMode, | |
colorPalette = it.colorPalette, | |
highContrast = | |
if(it.colorPalette != SYSTEM_DYNAMIC) it.highContrast | |
else HighContrast.OFF, | |
imageQuality = it.imageQuality, | |
typography = it.typography, | |
) | |
}.stateIn( | |
scope = viewModelScope, | |
started = SharingStarted.WhileSubscribed(STATEIN_TIMEOUT_MILLIS), | |
initialValue = with(UserPreferences()) { | |
PreferencesState( | |
darkMode = darkMode, | |
colorPalette = colorPalette, | |
highContrast = highContrast, | |
imageQuality = imageQuality, | |
typography = typography, | |
) | |
}, | |
) | |
// UI events | |
fun onClickClearCache() { | |
// Disable clear cache button until viewmodel is recreated | |
clearCacheEnabled = false | |
viewModelScope.launch(Dispatchers.IO) { | |
purgeRepository.purgeCache() | |
cachePurged = Event(Unit) | |
} | |
} | |
fun onClickDarkMode() { /* .. */ } | |
// ...25 more UI event methods | |
} | |
// Compose UI | |
@Composable | |
fun SettingsRoute( | |
navigateToTipsAndInfo: () -> Unit, | |
navigateToStatistics: () -> Unit, | |
settingsViewModel: SettingsViewModel = hiltViewModel(), | |
) { | |
val uriHandler = LocalUriHandler.current | |
LaunchedEffect(settingsViewModel.cachePurged) { | |
settingsViewModel.cachePurged.getContentIfNotHandled()?.let { | |
/* toast */ | |
} | |
} | |
LaunchedEffect(settingsViewModel.dataDeleted) { | |
settingsViewModel.dataDeleted.getContentIfNotHandled()?.let { | |
/* toast */ | |
} | |
} | |
LaunchedEffect(settingsViewModel.rateAndReviewRequest) { | |
settingsViewModel.rateAndReviewRequest.getContentIfNotHandled()?.let { | |
inAppReviewRequest() | |
} | |
} | |
LaunchedEffect(settingsViewModel.navigationEventState) { | |
settingsViewModel.navigationEventState.getContentIfNotHandled()?.let { | |
when(it) { | |
Statistics -> navigateToStatistics() | |
TipsAndInfo -> navigateToTipsAndInfo() | |
is Web -> uriHandler.openUri(it.url) | |
} | |
} | |
} | |
val userPreferences by settingsViewModel | |
.preferencesState | |
.collectAsStateWithLifcycle() | |
SettingsScreen( | |
alertDialogState = settingsViewModel.alertDialogState, | |
dropdownMenuState = settingsViewModel.dropdownMenuState, | |
highContrastEnabled = settingsViewModel.highContrastEnabled, | |
clearCacheEnabled = settingsViewModel.clearCacheEnabled, | |
preferencesState = userPreferences, | |
onClickClearCache = settingsViewModel::onClickClearCache, | |
onClickDarkMode = settingsViewModel::onClickDarkMode, | |
// ...25 more UI event lambdas | |
) | |
} | |
@Composable | |
fun SettingsScreen( | |
alertDialogState: AlertDialogState, | |
dropdownMenuState: DropdownMenuState, | |
highContrastEnabled: Boolean, | |
clearCacheEnabled: Boolean, | |
preferencesState: PreferencesState, | |
onClickClearCache: () -> Unit, | |
onClickDarkMode: () -> Unit, | |
// ...25 more UI event lambdas | |
) { | |
// Convert state to Compose UI and | |
// report UI events to viewmodel via lambda arguments… | |
} | |
// Compose UI Preview | |
@Preview | |
@Composable | |
fun PreviewSettingsScreen() = MerlingsbagTheme { | |
Surface { | |
SettingsScreen( | |
alertDialogState = AlertDialogState.None, | |
dropdownMenuState = DropdownMenuState.None, | |
highContrastEnabled = true, | |
clearCacheEnabled = true, | |
preferencesState = PreferencesState( | |
darkMode = DARK, | |
colorPalette = ROAD_WARRIOR, | |
highContrast = OFF, | |
imageQuality = STANDARD, | |
typography = DEFAULT, | |
), | |
onClickClearCache = {}, | |
onClickDarkMode = {}, | |
// ...25 more UI event lambdas | |
) | |
} | |
} | |
// ViewModel Tests | |
class SettingsViewModelTest { | |
@get:Rule val mockkRule = MockKRule(this) | |
@get:Rule val dispatcherRule = MainDispatcherRule() | |
@MockK lateinit var purgeRepository: PurgeRepository | |
@MockK lateinit var userPreferencesRepository: UserPreferencesRepository | |
lateinit var viewModel: SettingsViewModel | |
@Before | |
fun setup() { | |
every { userPreferencesRepository.userPreferences } | |
returns flowOf(UserPreferences()) | |
viewModel = SettingsViewModel( | |
purgeRepository, | |
userPreferencesRepository, | |
) | |
} | |
@Test | |
fun `Selecting a higher image quality triggers alert dialog`() = runTest { | |
val collectJob = launch(UnconfinedTestDispatcher()) { | |
viewModel.preferencesState.collect() | |
} | |
viewModel.onSelectedImageQuality( | |
ImageQuality.entries[testInitialUserPreferences.imageQuality.ordinal + 1] | |
) | |
assertEquals( | |
AlertDialogState.ImageQuality, | |
viewModel.alertDialogState | |
) | |
collectJob.cancel() | |
} | |
} | |
// Event | |
open class Event<out T>(private val content: T?) { | |
var hasBeenHandled = false | |
private set | |
fun getContentIfNotHandled(): T? { | |
return if(!hasBeenHandled) { | |
hasBeenHandled = true | |
content | |
} else null | |
} | |
} |
Pros:
Using an AAC
ViewModel
means the state is trivially maintained on configuration changes (e.g. switching between portrait and landscape modes)If all UI state and UI state-changing events live in the viewmodel, then all UI state produced via state-changing events can be tested via the viewmodel.
This does not verify the presentation of UI state. One would still need Compose instrumentation tests or Compose Preview Screenshot Testing.
Straightforward. The viewmodel is a class that holds state that is only modified via the class’ own methods. A bare-bones OOP approach.
Although the parameter count for
SettingsScreen
is large and growing without bounds, its UI state & UI events can be easily reasoned about from the function declaration alone.From “Where to hoist state - property drilling” in the official Android Developers documentation:
“Even though exposing events as individual lambda parameters could overload the function signature, it maximizes the visibility of what the composable function responsibilities are. You can see what it does at a glance.Property drilling is preferable over creating wrapper classes to encapsulate state and events in one place because this reduces the visibility of the composable responsibilities. By not having wrapper classes you’re also more likely to pass composables only the parameters they need, which is a best practice.”
This statement applies more to reusable custom composable components (e.g. design system buttons, chips, loading indicators) than to composable functions that represent whole screens or panes. But it is still a valid benefit. In Google’s own Now in Android source code, they organize their composable screen parameters with a mixture of UI state data wrappers and exhaustively explicit parameters.
stateIn()
allows potentially long-running and numerous AACViewModels
on the backstack to unsubscribe fromFlows
when there is no associated foreground screen actively collecting emissions.
Cons:
Quite a bit of boilerplate when it comes to adding new UI state or UI events.
Ex: “
onClickWelcome()
” is a viewmodel method but it also shows up as a composable function parameter and twice as that same composable function’s arguments (one for production use and another for@Preview
code).
Not much separation between viewmodel & UI. The viewmodel defines both the UI state and the UI events.
Using an AAC
ViewModel
to hold all UI state management logic and exposing the UI state via ComposesState<T>
type restricts using the code across multiple platforms.
Notes:
The viewmodel uses
mutableStateOf()
which returns the typeMutableState<T>
, this function and type stem from the Compose Runtime library.This is not necessarily bad practice for Android-specific code. Broader uses of Compose for state management will be expanded upon later in this article.
Event<T>
is a custom class that is not perfect but it allows one-time UI effects to be passed in the same manner as all other state.
Even though I desire a better alternative, I would like to defend the starting position. It is a bit ugly (composable functions with unbounded parameters), but it is not that bad. For starters, it works well, it is testable, and I'd wager a software engineer from any discipline could figure out what it's doing fairly quickly. In the above, the viewmodel’s exposed values are the UI state and its exposed methods are UI events. It’s simple.
However, as a screen’s complexity grows, the unbounded parameters of its Compose UI function becomes unwieldy. In this starting position, we are already looking at 30+ parameters for the SettingsScreen
function. A simple name change in UI state requires changing it in the viewmodel, as well as finding its usage in the composable UI functions and changing the parameter name there as well. And the application will continue to compile if you change the name in one location but not the other, which is annoying. Hence, why I am looking for a better solution. If there existed smarter interfacing between Compose UI and the viewmodel, we could imagine achieving similar results with a simple “Refactor → Rename" in Android Studio.
Part 2: Interfacing
Where I want to go from here is having an externally defined interface for the viewmodel and Compose UI screen to communicate across. What more obvious potential steps could be taken than to use an interface for our UI events and a cohesive data class for our UI state? Let’s see what that looks like.
As a diagram:
And the updated source code (full source on GitHub):
// UI state enums | |
enum class SettingsDropdownMenuState { | |
None, | |
DarkMode, | |
ColorPalette, | |
HighContrast, | |
Typography, | |
ImageQuality, | |
} | |
enum class SettingsAlertDialogState { | |
None, | |
DeleteAllData, | |
ImageQuality, | |
} | |
sealed interface SettingsNavigationState { | |
data object TipsAndInfo: SettingsNavigationState | |
data object Statistics: SettingsNavigationState | |
data class Web(val url: String): SettingsNavigationState | |
} | |
// UI state | |
data class SettingsUIState ( | |
val cachePurged: Event<Unit>, | |
val dataDeleted: Event<Unit>, | |
val rateAndReviewRequest: Event<Unit>, | |
val navigationEventState: Event<SettingsNavigationState>, | |
val clearCacheEnabled: Boolean, | |
val highContrastEnabled: Boolean, | |
val dropdownMenuState: SettingsDropdownMenuState, | |
val alertDialogState: SettingsAlertDialogState, | |
val darkMode: DarkMode, | |
val colorPalette: ColorPalette, | |
val highContrast: HighContrast, | |
val imageQuality: ImageQuality, | |
val typography: Typography, | |
) | |
// UI events | |
interface SettingsUIEventListener { | |
fun onClickClearCache() | |
fun onClickDarkMode() | |
// ...25 more UI event methods | |
} | |
// enough time to prevent flows from timing out on basic configuration changes | |
const val STATEIN_TIMEOUT_MILLIS = 5_000L | |
// ViewModel | |
@HiltViewModel | |
class SettingsViewModel @Inject constructor( | |
private val purgeRepository: PurgeRepository, | |
private val userPreferencesRepository: UserPreferencesRepository, | |
): ViewModel(), SettingsUIEventListener { | |
private data class LocallyManagedState ( | |
val cachePurged: Event<Unit> = Event(null), | |
val dataDeleted: Event<Unit> = Event(null), | |
val rateAndReviewRequest: Event<Unit> = Event(null), | |
val navigationEventState: Event<SettingsNavigationState> = Event(null), | |
val clearCacheEnabled: Boolean = true, | |
val dropdownMenuState: SettingsDropdownMenuState = | |
SettingsDropdownMenuState.None, | |
val alertDialogState: SettingsAlertDialogState = | |
SettingsAlertDialogState.None, | |
) | |
private val locallyManagedState = MutableStateFlow(LocallyManagedState()) | |
// System dynamic color schemes do not currently support high contrast | |
private fun highContrastIsEnabled(colorPalette: ColorPalette) = | |
colorPalette != SYSTEM_DYNAMIC | |
// UI state provider | |
val uiState: StateFlow<SettingsUIState> = combine( | |
userPreferencesRepository.userPreferences, | |
locallyManagedState, | |
) { userPreferences, lms -> | |
val highContrastEnabled = | |
highContrastIsEnabled(userPreferences.colorPalette) | |
cachedImageQuality = userPreferences.imageQuality | |
SettingsUIState( | |
cachePurged = lms.cachePurged, | |
dataDeleted = lms.dataDeleted, | |
rateAndReviewRequest = lms.rateAndReviewRequest, | |
navigationEventState = lms.navigationEventState, | |
clearCacheEnabled = lms.clearCacheEnabled, | |
dropdownMenuState = lms.dropdownMenuState, | |
alertDialogState = lms.alertDialogState, | |
highContrastEnabled = highContrastEnabled, | |
darkMode = userPreferences.darkMode, | |
colorPalette = userPreferences.colorPalette, | |
highContrast = | |
if(highContrastEnabled) userPreferences.highContrast | |
else HighContrast.OFF, | |
imageQuality = userPreferences.imageQuality, | |
typography = userPreferences.typography, | |
) | |
}.stateIn( | |
scope = viewModelScope, | |
started = SharingStarted.WhileSubscribed(STATEIN_TIMEOUT_MILLIS), | |
initialValue = with(locallyManagedState.value){ | |
val defaultPreferences = UserPreferences() | |
SettingsUIState( | |
cachePurged = cachePurged, | |
dataDeleted = dataDeleted, | |
rateAndReviewRequest = rateAndReviewRequest, | |
navigationEventState = navigationEventState, | |
clearCacheEnabled = clearCacheEnabled, | |
dropdownMenuState = dropdownMenuState, | |
alertDialogState = alertDialogState, | |
highContrastEnabled = | |
highContrastIsEnabled(defaultPreferences.colorPalette), | |
darkMode = defaultPreferences.darkMode, | |
colorPalette = defaultPreferences.colorPalette, | |
highContrast = defaultPreferences.highContrast, | |
imageQuality = defaultPreferences.imageQuality, | |
typography = defaultPreferences.typography, | |
) | |
}, | |
) | |
// UI event overrides | |
override fun onClickClearCache() { | |
locallyManagedState.value = | |
locallyManagedState.value.copy(clearCacheEnabled = false) | |
viewModelScope.launch(Dispatchers.IO) { | |
purgeRepository.purgeCache() | |
locallyManagedState.value = | |
locallyManagedState.value.copy(cachePurged = Event(Unit)) | |
} | |
} | |
override fun onClickDarkMode() { /* .. */ } | |
// ...25 more UI event override methods | |
} | |
// Compose UI | |
@Composable | |
fun SettingsRoute( | |
navigateToTipsAndInfo: () -> Unit, | |
navigateToStatistics: () -> Unit, | |
settingsViewModel: SettingsViewModel = hiltViewModel(), | |
) { | |
val uriHandler = LocalUriHandler.current | |
val uiState by settingsViewModel | |
.uiState | |
.collectAsStateWithLifecycle() | |
LaunchedEffect(uiState.cachePurged) { | |
uiState.cachePurged.getContentIfNotHandled()?.let { | |
/* toast */ | |
} | |
} | |
LaunchedEffect(uiState.dataDeleted) { | |
uiState.dataDeleted.getContentIfNotHandled()?.let { | |
/* toast */ | |
} | |
} | |
LaunchedEffect(uiState.rateAndReviewRequest) { | |
uiState.rateAndReviewRequest.getContentIfNotHandled()?.let { | |
inAppReviewRequest() | |
} | |
} | |
LaunchedEffect(uiState.navigationEventState) { | |
uiState.navigationEventState.getContentIfNotHandled()?.let { | |
when(it){ | |
Statistics -> navigateToStatistics() | |
TipsAndInfo -> navigateToTipsAndInfo() | |
is Web -> uriHandler.openUri(it.url) | |
} | |
} | |
} | |
SettingsScreen( | |
uiState = uiState, | |
uiEventListener = settingsViewModel | |
) | |
} | |
@Composable | |
fun SettingsScreen( | |
uiState: SettingsUIState, | |
uiEventListener: SettingsUIEventListener, | |
) { | |
// Convert SettingsUIState to Compose UI | |
// and report user actions to SettingsUIEventListener | |
} | |
// Compose UI Preview | |
@Preview | |
@Composable | |
fun PreviewSettingsScreen() = MerlinsbagTheme { | |
Surface { | |
SettingsScreen( | |
uiState = SettingsUIState( | |
cachePurged = Event(null), | |
dataDeleted = Event(null), | |
navigationEventState = Event(null), | |
rateAndReviewRequest = Event(null), | |
alertDialogState = SettingsAlertDialogState.None, | |
dropdownMenuState = SettingsDropdownMenuState.None, | |
highContrastEnabled = true, | |
clearCacheEnabled = true, | |
darkMode = DARK, | |
colorPalette = ROAD_WARRIOR, | |
highContrast = OFF, | |
imageQuality = STANDARD, | |
typography = DEFAULT, | |
), | |
uiStateChanger = object: SettingsUIEventListener { | |
override fun onClickClearCache() {} | |
override fun onClickDarkMode() {} | |
// ...25 more UI event override methods | |
}, | |
) | |
} | |
} | |
// ViewModel tests | |
class SettingsViewModelTest { | |
@get:Rule val mockkRule = MockKRule(this) | |
@get:Rule val dispatcherRule = MainDispatcherRule() | |
@MockK lateinit var purgeRepository: PurgeRepository | |
@MockK lateinit var userPreferencesRepository: UserPreferencesRepository | |
lateinit var viewModel: SettingsViewModel | |
val testInitialUserPreferences = | |
UserPreferences(imageQuality = VERY_HIGH) | |
@Before | |
fun beforeEach() = runTest { | |
every { userPreferencesRepository.userPreferences } | |
returns flowOf(testInitialUserPreferences) | |
viewModel = SettingsViewModel( | |
purgeRepository, | |
userPreferencesRepository, | |
) | |
} | |
@Test | |
fun `Selecting a higher image quality triggers alert dialog`() = runTest { | |
val collectJob = launch(UnconfinedTestDispatcher()) { | |
viewModel.uiState.collect() | |
} | |
viewModel.onSelectedImageQuality( | |
ImageQuality.entries[ | |
testInitialUserPreferences.imageQuality.ordinal + 1 | |
] | |
) | |
assertEquals( | |
SettingsAlertDialogState.ImageQuality, | |
viewModel.uiState.value.alertDialogState | |
) | |
collectJob.cancel() | |
} | |
} | |
// Event | |
open class Event<out T>(private val content: T?) { | |
var hasBeenHandled = false | |
private set | |
fun getContentIfNotHandled(): T? { | |
return if(!hasBeenHandled) { | |
hasBeenHandled = true | |
content | |
} else null | |
} | |
} |
Added Benefits:
SettingsScreen
composable UI function declaration dramatically reduced from a growing 30+ parameters to a constant 2 parameters.UI state & events become trivial to add, remove, or edit via
SettingsUIState
&SettingsUIEventListener
. The compiler will ensure these fields are implemented everywhere.The relationship between viewmodel and Compose UI is now externally defined, uncoupling them.
Drawbacks:
The “
combine
” function works well in this example but has limitations as the number ofFlows
being
combined
grows.Unlimited
Flows
can be combined, but only up to 5Flows
are supported by thekotlinx.coroutines.flow
library with automatic type casting.Creating additional combine functions for 6+
Flows
is a trivial and valid path forward. See [here].
Compose UI Previews need to update their adherence to the
SettingsUIEventListener
interface. This problem existed previously in a slightly different form.One-time effects (in the form of
Event<T>
) are being passed toSettingsScreen
viaSettingsUIState
, despite always being handled prior in theSettingsRoute
.There is more redundancy in the declaration of UI state in the viewmodel than before. In particular, the definition and usages of the
LocallyManagedState
class and the initial value declaration as part of thestateIn()
function
Notes:
FlowState
is the primary way in whichSettingsViewModel
provides UI state. The Compose Runtime is no longer a dependency in the viewmodel.Compose UI Previews still requires all values in a UI state to be explicitly provided. This is not an issue. Compose Previews are defined by the state they provide (as well as the form factor they emulate) and, as such, should do so explicitly. The compiler helps every step of the way, failing to build Previews if state is not provided.
Composable Preview utility functions with default UI state values can be created to offer the flexibility of abundant, smaller, less-explicit previews.
Part 3: Unidirectional Data Flow (UDF)
So, what now? Inspired by a talk called "Unidirectional State Flow patterns – a refactoring story" by Kaushik Gopal, I am looking to make two improvements:
1) I have found that, in the Merlinsbag codebase, one-time UI effects are always handled in @Composable XxxxRoute()
functions before they ever reach the @Composable XxxxScreen()
UI functions. I could foresee scenarios where this might not always be true but, for now, I would like to make a distinction between one-time UI effects and less-transient UI state.
Examples of existing one-time effects in the Merlinsbag application:
Navigation
Clearly the responsibility of a routing composable function.
Launch Camera, Camera Roll, Web View, App Settings, or In-App Reviews
Similar to navigation.
Toast & Snackbar
Although UI-related, they are not truly part of any particular UI screen and, as such, can be handled external to
XxxxScreen()
.
2) I want the viewmodel to have one point of entry for UI events, in hopes of reducing no-op interface creation & maintenance for Compose UI Previews. And a convenient location in which all UI events can be tracked, logged, and/or debugged.
Here’s the diagram:
And the source (full source on GitHub):
// UI state enums | |
enum class SettingsDropdownMenuState { | |
None, | |
DarkMode, | |
ColorPalette, | |
HighContrast, | |
Typography, | |
ImageQuality, | |
} | |
enum class SettingsAlertDialogState { | |
None, | |
DeleteAllData, | |
ImageQuality, | |
} | |
// UI state | |
data class SettingsUIState ( | |
val clearCacheEnabled: Boolean, | |
val highContrastEnabled: Boolean, | |
val dropdownMenuState: SettingsDropdownMenuState, | |
val alertDialogState: SettingsAlertDialogState, | |
val darkMode: DarkMode, | |
val colorPalette: ColorPalette, | |
val highContrast: HighContrast, | |
val imageQuality: ImageQuality, | |
val typography: Typography, | |
) | |
// UI effects | |
sealed interface SettingsUIEffect { | |
sealed interface NavigationDestination { | |
data object TipsAndInfo: NavigationDestination | |
data object Statistics: NavigationDestination | |
data class Web(val url: String): NavigationDestination | |
} | |
data object CachePurged: SettingsUIEffect | |
data object AllDataDeleted: SettingsUIEffect | |
data object RateAndReviewRequest: SettingsUIEffect | |
data class Navigation(val dest: NavigationDestination) | |
: SettingsUIEffect | |
} | |
// UI events | |
sealed interface SettingsUIEvent { | |
data object ClickClearCache: SettingsUIEvent | |
data class SelectDarkMode(val mode: DarkMode): SettingsUIEvent | |
// ...25 more UI events | |
} | |
// enough time to prevent flows from timing out on basic configuration changes | |
const val STATEIN_TIMEOUT_MILLIS = 5_000L | |
// ViewModel | |
@HiltViewModel | |
class SettingsViewModel @Inject constructor( | |
private val purgeRepository: PurgeRepository, | |
private val userPreferencesRepository: UserPreferencesRepository, | |
): ViewModel() { | |
private data class LocallyManagedState ( | |
val clearCacheEnabled: Boolean = true, | |
val dropdownMenuState: SettingsDropdownMenuState = | |
SettingsDropdownMenuState.None, | |
val alertDialogState: SettingsAlertDialogState = | |
SettingsAlertDialogState.None, | |
) | |
private val locallyManagedState = MutableStateFlow(LocallyManagedState()) | |
private fun highContrastIsEnabled(colorPalette: ColorPalette) = | |
colorPalette != SYSTEM_DYNAMIC | |
private val _uiEffect = | |
MutableSharedFlow<SettingsUIEffect>(extraBufferCapacity = 20) | |
// UI state & effect providers | |
val uiEffect: SharedFlow<SettingsUIEffect> = _uiEffect | |
val uiState: StateFlow<SettingsUIState> = combine( | |
userPreferencesRepository.userPreferences, | |
locallyManagedState, | |
) { userPreferences, lms -> | |
// System dynamic color schemes do not currently support high contrast | |
val highContrastEnabled = | |
highContrastIsEnabled(userPreferences.colorPalette) | |
SettingsUIState( | |
clearCacheEnabled = lms.clearCacheEnabled, | |
dropdownMenuState = lms.dropdownMenuState, | |
alertDialogState = lms.alertDialogState, | |
highContrastEnabled = highContrastEnabled, | |
darkMode = userPreferences.darkMode, | |
colorPalette = userPreferences.colorPalette, | |
highContrast = | |
if(highContrastEnabled) userPreferences.highContrast | |
else HighContrast.OFF, | |
imageQuality = userPreferences.imageQuality, | |
typography = userPreferences.typography, | |
) | |
}.stateIn( | |
scope = viewModelScope, | |
started = SharingStarted.WhileSubscribed(STATEIN_TIMEOUT_MILLIS), | |
initialValue = with(locallyManagedState.value){ | |
val defaultPreferences = UserPreferences() | |
SettingsUIState( | |
clearCacheEnabled = clearCacheEnabled, | |
dropdownMenuState = dropdownMenuState, | |
alertDialogState = alertDialogState, | |
highContrastEnabled = | |
highContrastIsEnabled(defaultPreferences.colorPalette), | |
darkMode = defaultPreferences.darkMode, | |
colorPalette = defaultPreferences.colorPalette, | |
highContrast = defaultPreferences.highContrast, | |
imageQuality = defaultPreferences.imageQuality, | |
typography = defaultPreferences.typography, | |
) | |
}, | |
) | |
// UI event entry point | |
fun onUiEvent(uiEvent: SettingsUIEvent) { | |
when(event){ | |
ClickClearCache -> { | |
// Disable clearing cache until viewmodel is recreated | |
locallyManagedState.value = | |
locallyManagedState.value.copy(clearCacheEnabled = false) | |
viewModelScope.launch(Dispatchers.IO) { | |
purgeRepository.purgeCache() | |
launchUiEvent(CachePurged) | |
} | |
} | |
is SelectDarkMode -> { /* .. */ } | |
// ...25 more UI events | |
} | |
} | |
private fun launchUiEffect(uiEffect: SettingsUIEffect){ | |
if(!_uiEffect.tryEmit(uiEffect)) { | |
error("SettingsViewModel: UI effect buffer overflow.") | |
} | |
} | |
} | |
// Compose UI | |
@Composable | |
fun SettingsRoute( | |
navigateToTipsAndInfo: () -> Unit, | |
navigateToStatistics: () -> Unit, | |
settingsViewModel: SettingsViewModel = hiltViewModel(), | |
) { | |
val lifecycle = LocalLifecycleOwner.current.lifecycle | |
val uriHandler = LocalUriHandler.current | |
LaunchedEffect(Unit) { | |
lifecycle.repeatOnLifecycle(state = Lifecycle.State.STARTED) { | |
settingsViewModel.uiEffect.collect{ uiEffect -> | |
when(uiEffect){ | |
AllDataDeleted -> { /* toast */ } | |
CachePurged -> { /* toast */ } | |
RateAndReviewRequest -> inAppReviewRequest() | |
is Navigation -> when(uiEffect.dest) { | |
Statistics -> navigateToStatistics() | |
TipsAndInfo -> navigateToTipsAndInfo() | |
is Web -> uriHandler.openUri(uiEffect.dest.url) | |
} | |
} | |
} | |
} | |
} | |
val uiState by settingsViewModel | |
.uiState | |
.collectAsStateWithLifecycle() | |
SettingsScreen( | |
uiState = uiState, | |
onUiEvent = settingsViewModel::onUiEvent | |
) | |
} | |
@Composable | |
fun SettingsScreen( | |
uiState: SettingsUIState, | |
onUiEvent: (SettingsUIEvent) -> Unit, | |
) { | |
// Convert SettingsUIState to Compose UI | |
// and report UI events via onUiEvent | |
} | |
// Compose UI Preview | |
@Preview | |
@Composable | |
fun PreviewSettingsScreen() = MerlinsbagTheme { | |
Surface { | |
SettingsScreen( | |
uiState = SettingsUIState( | |
alertDialogState = SettingsAlertDialogState.None, | |
dropdownMenuState = SettingsDropdownMenuState.None, | |
highContrastEnabled = true, | |
clearCacheEnabled = true, | |
darkMode = DARK, | |
colorPalette = ROAD_WARRIOR, | |
highContrast = OFF, | |
imageQuality = STANDARD, | |
typography = DEFAULT, | |
), | |
onUiEvent = {}, | |
) | |
} | |
} | |
// ViewModel tests | |
class SettingsViewModelTest { | |
@get:Rule val mockkRule = MockKRule(this) | |
@get:Rule val dispatcherRule = MainDispatcherRule() | |
@MockK lateinit var purgeRepository: PurgeRepository | |
@MockK lateinit var userPreferencesRepository: UserPreferencesRepository | |
lateinit var viewModel: SettingsViewModel | |
val testInitialUserPreferences = | |
UserPreferences(imageQuality = VERY_HIGH) | |
@Before | |
fun beforeEach() = runTest { | |
every { userPreferencesRepository.userPreferences } | |
returns flowOf(testInitialUserPreferences) | |
justRun { purgeRepository.purgeCache() } | |
viewModel = SettingsViewModel( | |
purgeRepository, | |
userPreferencesRepository, | |
) | |
} | |
@Test | |
fun `Click clear cache triggers UI event`() = runTest { | |
val collectJob = launch(UnconfinedTestDispatcher()) { | |
viewModel.uiState.collect() | |
} | |
viewModel.uiEffect.test { | |
viewModel.onUiEvent(ClickClearCache) | |
assertEquals(CachePurged, awaitItem()) | |
assertFalse(viewModel.uiState.value.clearCacheEnabled) | |
} | |
collectJob.cancel() | |
} | |
} |
Added Benefits:
All UI events pass through
onUiEvent()
, making them trivial to track, log, or step through in a debugger.One-time effects are now separate from less-transient UI state and are provided via a single
uiEffect
Flow
, never being passed to Composable UI screen functions.Compose UI Preview functions no longer require modifications when a new UI event is added, removed, or modified.
The viewmodel has been morphed into an easily testable black box, with one point of entry (
onUiEvent()
) and two points of output (uiState
&uiEffect
).Test scenarios can be written as a simple array of
SettingsUIEvents
passed throughSettingsViewModel.onEvent()
.
Drawbacks:
The "
combine
" Flow function is still a potential annoyance for screens with a higher complexity thanSettingsScreen
.
Notes:
Compose UI is now provided UI state & effects via
StateFlow
&SharedFlow
, respectively.StateFlow
is simply a modifiedSharedFlow
where only the most recent value is pushed to its subscribers. It will also provides the most recent value to any new subscribers or accessors of its "value
" property. As the name implies, it is perfect for holding state.The
SharedFlow
used is modified to allow multiple UI effects to be sequentially passed to subscribers (viaextraBufferCapacity
). This allows multiple UI effects to be in flight, rather than conflating into a single value (as with aStateFlow
). For example, you may want to display a toast and navigate back after some error.
Although this is a great stopping point. Since I am already in an exploratory mood, I would like to dive deeper into alternative methods of UI state management.
As I mentioned earlier, Jetpack Compose can also be utilized for state management. Instead of relying on modifying streams reactively via method chaining, the Compose Runtime allows us to manage streams in a more readable, maintainable, and familiar imperative approach.
Part 4: Compose State Management w/ Molecule (Incomplete)
Jetpack Compose is not only a tool for creating Compose UI. The Compose runtime can also be used for general state management.
Official Google engineers and documentation support such use cases:
“Is Compose a UI library or is it something a little bit more? …the way we are doing things here it is completely agnostic to UI… I’d like to leave this talk today with a challenge for you all to think about what else we could apply such a system like this to…” from “Kotlin Conf 2019: The Compose Runtime, Demystified” by Leland Richardson, a software engineer working on Jetpack Compose at Google.
“[In the section on the Compose Runtime] You might consider building directly upon this layer if you only need Compose’s tree management abilities, not its UI.” from “Jetpack Compose architectural layering” in official Android Developers documentation as pointed out in a noteworthy talk “Compose beyond the UI: Molecule at Swedish Railways” by Erik Westenius
There are also many instances of the Compose Compiler & Runtime being used separately from Compose UI on other projects:
“Reactive UI state on Android, starring Compose” from Reddit Engineering Tech Blog
“Molecule” by Cash App, an open-source library for converting Compose Runtime functions with output values of type
T
toFlow<T>
orStateFlow<T>
.“Circuit” by Slack, an open-source framework for creating app screens that utilize Compose for both the presenter logic and the UI.
For the next step in this architectural discovery, let’s explore Molecule by CashApp.
One of the main aspects of composable functions is that they recompose when the runtime notices that the state they rely for their calculations has changed. This is often thought of as recomposing the UI. However, composable functions can also be utilized to compute and return values. Instead of recomposing UI when the state changes, they recompose some result value.
Molecule is a library that transforms the recomposed results from composable functions into Flows
(via moleculeFlow()
) or StateFlows
(via launchMolecule()
). This allows developers to use the Compose Compiler & Runtime to manage state that can then be consumed by non-composable code. Since Flow
& StateFlow
are broadly accepted interfaces, Molecule allows the Compose Runtime to produce & manage state that can used for most imaginable workflows.
Let’s start by following the sample available in the Molecule GitHub repository. The results are shown below (full source on GitHub):
// UI state enums | |
enum class SettingsDropdownMenuState { | |
None, | |
DarkMode, | |
ColorPalette, | |
HighContrast, | |
Typography, | |
ImageQuality, | |
} | |
enum class SettingsAlertDialogState { | |
None, | |
DeleteAllData, | |
ImageQuality, | |
} | |
// UI state | |
data class SettingsUIState ( | |
val clearCacheEnabled: Boolean, | |
val highContrastEnabled: Boolean, | |
val dropdownMenuState: SettingsDropdownMenuState, | |
val alertDialogState: SettingsAlertDialogState, | |
val darkMode: DarkMode, | |
val colorPalette: ColorPalette, | |
val highContrast: HighContrast, | |
val imageQuality: ImageQuality, | |
val typography: Typography, | |
) | |
// UI effects | |
sealed interface SettingsUIEffect { | |
sealed interface NavigationDestination { | |
data object TipsAndInfo: NavigationDestination | |
data object Statistics: NavigationDestination | |
data class Web(val url: String): NavigationDestination | |
} | |
data object CachePurged: SettingsUIEffect | |
data object AllDataDeleted: SettingsUIEffect | |
data object RateAndReviewRequest: SettingsUIEffect | |
data class Navigation(val dest: NavigationDestination) | |
: SettingsUIEffect | |
} | |
// UI events | |
sealed interface SettingsUIEvent { | |
data object ClickClearCache: SettingsUIEvent | |
data class SelectDarkMode(val mode: DarkMode): SettingsUIEvent | |
// ...25 more UI events | |
} | |
// ViewModel | |
abstract class MoleculeViewModel<UIEvent, UIState, UIEffect>: ViewModel() { | |
private val uiScope = | |
CoroutineScope( | |
viewModelScope.coroutineContext + | |
AndroidUiDispatcher.Main | |
) | |
private val _uiEvents = Channel<UIEvent>(capacity = 20) | |
private val _uiEffects = | |
MutableSharedFlow<UIEffect>(extraBufferCapacity = 20) | |
// UI effect & state providers | |
val uiEffect: SharedFlow<UIEffect> = _uiEffects | |
val uiState: StateFlow<UIState> = | |
uiScope.launchMolecule(mode = ContextClock) { | |
uiState(_uiEvents.receiveAsFlow(), ::launchUiEffect) | |
} | |
@Composable | |
protected abstract fun uiState( | |
uiEvents: Flow<UIEvent>, | |
launchUiEffect: (UIEffect) -> Unit, | |
): UIState | |
// UI event entry point | |
fun onUiEvent(uiEvent: UIEvent) = | |
viewModelScope.launch { _uiEvents.send(uiEvent) } | |
private fun launchUiEffect(uiEffect: UIEffect){ | |
if(!_uiEffects.tryEmit(uiEffect)) { | |
error("SettingsViewModel: UI effect buffer overflow.") | |
} | |
} | |
} | |
@HiltViewModel | |
class SettingsViewModel @Inject constructor( | |
private val purgeRepository: PurgeRepository, | |
private val userPreferencesRepository: UserPreferencesRepository, | |
): MoleculeViewModel<SettingsUIEvent, SettingsUIState, SettingsUIEffect>() { | |
@Composable | |
override fun uiState( | |
uiEvents: Flow<SettingsUIEvent>, | |
launchUiEffect: (SettingsUIEffect) -> Unit, | |
): SettingsUIState = | |
settingsUIState( | |
uiEvents = uiEvents, | |
launchUiEffect = launchUiEffect, | |
purgeRepository = purgeRepository, | |
userPreferencesRepository = userPreferencesRepository | |
) | |
} | |
// UI state management composable function | |
@Composable | |
fun settingsUIState( | |
uiEvents: Flow<SettingsUIEvent>, | |
launchUiEffect: (SettingsUIEffect) -> Unit, | |
purgeRepository: PurgeRepository, | |
userPreferencesRepository: UserPreferencesRepository, | |
): SettingsUIState { | |
var clearCacheEnabled by remember { mutableStateOf(true) } | |
var dropdownMenuState by remember { | |
mutableStateOf(SettingsDropdownMenuState.None) | |
} | |
var alertDialogState by remember { | |
mutableStateOf(SettingsAlertDialogState.None) | |
} | |
val userPreferences by remember { | |
userPreferencesRepository.userPreferences | |
}.collectAsState(UserPreferences()) | |
LaunchedEffect(Unit) { | |
uiEvents.collect { uiEvent -> | |
// UI event handler | |
when(uiEvent) { | |
ClickClearCache -> { | |
// Disable clearing cache until state is recreated | |
clearCacheEnabled = false | |
launch(Dispatchers.IO) { | |
purgeRepository.purgeCache() | |
launchUiEffect(CachePurged) | |
} | |
} | |
is SelectDarkMode -> { /* .. */ } | |
// ...25 more UI events | |
} | |
} | |
} | |
val highContrastEnabled = | |
userPreferences.colorPalette != SYSTEM_DYNAMIC | |
return SettingsUIState( | |
clearCacheEnabled = clearCacheEnabled, | |
dropdownMenuState = dropdownMenuState, | |
alertDialogState = alertDialogState, | |
highContrastEnabled = highContrastEnabled, | |
darkMode = userPreferences.darkMode, | |
colorPalette = userPreferences.colorPalette, | |
typography = userPreferences.typography, | |
imageQuality = userPreferences.imageQuality, | |
highContrast = | |
if(highContrastEnabled) userPreferences.highContrast | |
else HighContrast.OFF, | |
) | |
} | |
// UI | |
@Composable | |
fun SettingsRoute( | |
navigateToTipsAndInfo: () -> Unit, | |
navigateToStatistics: () -> Unit, | |
settingsViewModel: SettingsViewModel = hiltViewModel(), | |
) { | |
val lifecycle = LocalLifecycleOwner.current.lifecycle | |
val uriHandler = LocalUriHandler.current | |
LaunchedEffect(Unit) { | |
lifecycle.repeatOnLifecycle(state = Lifecycle.State.STARTED) { | |
settingsViewModel.uiEffect.collect{ uiEvent -> | |
when(uiEvent){ | |
AllDataDeleted -> { /* toast */ } | |
CachePurged -> { /* toast */ } | |
RateAndReviewRequest -> inAppReviewRequest() | |
is Navigation -> when(uiEvent.dest) { | |
Statistics -> navigateToStatistics() | |
TipsAndInfo -> navigateToTipsAndInfo() | |
is Web -> uriHandler.openUri(uiEvent.dest.url) | |
} | |
} | |
} | |
} | |
} | |
val uiState by settingsViewModel | |
.uiState | |
.collectAsStateWithLifecycle() | |
SettingsScreen( | |
uiState = uiState, | |
onUiEvent = settingsViewModel::onUiEvent | |
) | |
} | |
@Composable | |
fun SettingsScreen( | |
uiState: SettingsUIState, | |
onUiEvent: (SettingsUIEvent) -> Unit, | |
) { | |
// Convert SettingsUIState to Compose UI | |
// and report UI events via onUiEvent() | |
} | |
// Compose UI Preview | |
@Preview | |
@Composable | |
fun PreviewSettingsScreen() = NoopTheme { | |
Surface { | |
SettingsScreen( | |
uiState = SettingsUIState( | |
alertDialogState = SettingsAlertDialogState.None, | |
dropdownMenuState = SettingsDropdownMenuState.None, | |
highContrastEnabled = true, | |
clearCacheEnabled = true, | |
darkMode = DARK, | |
colorPalette = ROAD_WARRIOR, | |
highContrast = OFF, | |
imageQuality = STANDARD, | |
typography = DEFAULT, | |
), | |
onUiEvent = {}, | |
) | |
} | |
} | |
// Compose UI state management tests | |
class SettingsUIStateTest { | |
@get:Rule val mockkRule = MockKRule(this) | |
@get:Rule val dispatcherRule = MainDispatcherRule() | |
@MockK lateinit var purgeRepository: PurgeRepository | |
@MockK lateinit var userPreferencesRepository: UserPreferencesRepository | |
val testInitialUserPreferences = | |
UserPreferences(imageQuality = VERY_HIGH) | |
@Before | |
fun beforeEach() = runTest { | |
every { userPreferencesRepository.userPreferences } | |
returns flowOf(testInitialUserPreferences) | |
justRun { purgeRepository.purgeCache() } | |
} | |
@Test | |
fun `Clear cache disables button and triggers effect`() = runTest { | |
val events = Channel<SettingsUIEvent>() | |
val uiEffects = ArrayList<SettingsUIEffect>() | |
moleculeFlow(mode = Immediate){ | |
settingsUIState( | |
events.receiveAsFlow(), | |
{ uiEffects.add(it) }, | |
purgeRepository = purgeRepository, | |
userPreferencesRepository = userPreferencesRepository, | |
) | |
}.test { | |
skipItems(1) // skip initial state | |
assertTrue(awaitItem().clearCacheEnabled) | |
events.send(ClickClearCache) | |
assertFalse(awaitItem().clearCacheEnabled) | |
assertEquals(1, uiEffects.size) | |
assertEquals(CachePurged, uiEffects[0]) | |
uiEffects.clear() | |
} | |
} | |
} |
Added Benefits:
Thanks to the Compose Compiler & Runtime,
Flows
can be easily managed imperatively. Improving readability and maintainability.No more need for
combine
or its limitations.
Expensive mappings of dynamic collections can be alleviated via Compose’s
key
andremember
API.More about this is discussed in “Reactive UI state on Android, starring Compose” from Reddit Engineering Tech Blog
Managing UI state outside an AAC
ViewModel
allows us to move this logic to a multiplatform-friendly module.This is an added benefit of moving composable UI state-management function outside of the AAC
ViewModel
. Similar results could be achieved without Molecule or the Compose Runtime.
Drawbacks:
SettingsViewModel
'suiState
StateFlow
remains hot even when theSettingsScreen
is no longer in the foreground.This issue was brought up by Stylianos Gakis in the GitHub issue #273 and a fix was submitted by Stylianos Gakis via pull request.
Verified by myself via the Android Studio debugger.
This was not true for previous iterations as
stateIn()
was applied to all UI stateFlows,
which turns a hot flows cold when the subscriber count reaches zero.This is a major potential issue and invalidates this approach for my project. In Merlinsbag, the number of screens that can conceivably exist on the backstack is near limitless. If hot Flows are running for every AAC
ViewModel
on the backstack, there could realistically be huge performance hits and unexpected app behavior.
Although Compose has the benefit of merging
Flows
imperatively, the initial values provided tocollectAsState()
blur the line between loading & loaded states. In many ways this is great. However, it can make it difficult to follow the common practice of usingsealed interfaces
to distinguishLoading
& Success states for UI screens or panes (as seen here in the DuckDuckGo Android project).A solution is to use a special initial value in
collectAsState()
to signify that the data is still being loaded. Here is an example of usingnull
as the special initial loading value. Funnily enough, it uses a custom multi if let function that has the same drawbacks as thecombine
Flow
libray function.
UI events are processed in a
when(uiEvent)
block only after collection from theuiEvents
Flow
. This is undoubtedly slower than when we started with a simple direct function call or the virtual function calls via theSettingsUIEventListener
interface. However, this performance hit won't matter in most circumstances as user’s actions are relatively infrequent in comparison to our frame rate.However, this is not true for all user input. While hoisting a composable
TextField
’s text string to a viewmodel and modifying its state via the UI event pattern described above, theTextField
became unusable due to the text editing cursor being unable to keep up with the changing text. The UI event pattern remained a viable option, but theTextField
’s state had to be moved closer to it’s usage point. I assume similar issues may occur for other high frequency user input components.UI event
Flow
is internally represented as aChannel
with a specified capacity. UI events can be reported to the viewmodel viaonUiEvent()
when there are no active collectors of itsuiState
(themoleculeFlow
responsible for consuming the UI events). For instance, a UI event from a composable function reporting arememberSaveable
TextField
string value after a system-initiated process death might not be immediately collected.
Part 4.1: Compose State Management w/ Molecule (Fixed)
As stated, the hot Flows
on backstack viewmodels are a huge problem. Let's keep as close to the current architecture as possible but with the minor change of ensuring Flows
are shut off when they no longer have subscribers (full source on GitHub):
// UI state enums | |
enum class SettingsDropdownMenuState { | |
None, | |
DarkMode, | |
ColorPalette, | |
HighContrast, | |
Typography, | |
ImageQuality, | |
} | |
enum class SettingsAlertDialogState { | |
None, | |
DeleteAllData, | |
ImageQuality, | |
} | |
// UI state | |
data class SettingsUIState ( | |
val clearCacheEnabled: Boolean, | |
val highContrastEnabled: Boolean, | |
val dropdownMenuState: SettingsDropdownMenuState, | |
val alertDialogState: SettingsAlertDialogState, | |
val darkMode: DarkMode, | |
val colorPalette: ColorPalette, | |
val highContrast: HighContrast, | |
val imageQuality: ImageQuality, | |
val typography: Typography, | |
) | |
// UI effects | |
sealed interface SettingsUIEffect { | |
sealed interface NavigationDestination { | |
data object TipsAndInfo: NavigationDestination | |
data object Statistics: NavigationDestination | |
data class Web(val url: String): NavigationDestination | |
} | |
data object CachePurged: SettingsUIEffect | |
data object AllDataDeleted: SettingsUIEffect | |
data object RateAndReviewRequest: SettingsUIEffect | |
data class Navigation(val dest: NavigationDestination): SettingsUIEffect | |
} | |
// UI events | |
sealed interface SettingsUIEvent { | |
data object ClickClearCache: SettingsUIEvent | |
data class SelectDarkMode(val mode: DarkMode): SettingsUIEvent | |
// ...25 more UI events | |
} | |
// enough time to prevent flows from timing out on basic configuration changes | |
const val STATEIN_TIMEOUT_MILLIS = 5_000L | |
// ViewModel | |
abstract class MoleculeViewModel<UIEvent, UIState, UIEffect>( | |
initialState: UIState, | |
): ViewModel() { | |
private val uiScope = | |
CoroutineScope( | |
viewModelScope.coroutineContext + | |
AndroidUiDispatcher.Main | |
) | |
private val _uiEvents = Channel<UIEvent>(capacity = 20) | |
private val _uiEffects = | |
MutableSharedFlow<UIEffect>(extraBufferCapacity = 20) | |
private var cachedUiState: UIState = initialState | |
// UI state & effect providers | |
val uiEffect: SharedFlow<UIEffect> = _uiEffects | |
val uiState: StateFlow<UIState> = moleculeFlow(mode = ContextClock) { | |
uiState( | |
cachedUiState, | |
_uiEvents.receiveAsFlow(), | |
::launchUiEffect, | |
) | |
}.onEach { uiState -> | |
cachedUiState = uiState | |
}.stateIn( | |
scope = uiScope, | |
started = SharingStarted.WhileSubscribed(STATEIN_TIMEOUT_MILLIS), | |
initialValue = cachedUiState, | |
) | |
@Composable | |
protected abstract fun uiState( | |
initialState: UIState, | |
uiEvents: Flow<UIEvent>, | |
launchUiEffect: (UIEffect) -> Unit, | |
): UIState | |
// UI event entry point | |
fun onUiEvent(uiEvent: UIEvent) = | |
viewModelScope.launch { _uiEvents.send(uiEvent) } | |
private fun launchUiEffect(uiEffect: UIEffect){ | |
if(!_uiEffects.tryEmit(uiEffect)) { | |
error("SettingsViewModel: UI effect buffer overflow.") | |
} | |
} | |
} | |
@HiltViewModel | |
class SettingsViewModel @Inject constructor( | |
private val purgeRepository: PurgeRepository, | |
private val userPreferencesRepository: UserPreferencesRepository, | |
): MoleculeViewModel<SettingsUIEvent, SettingsUIState, SettingsUIEffect>( | |
initialState = with(UserPreferences()){ | |
SettingsUIState( | |
clearCacheEnabled = true, | |
highContrastEnabled = true, | |
dropdownMenuState = SettingsDropdownMenuState.None, | |
alertDialogState = SettingsAlertDialogState.None, | |
darkMode = darkMode, | |
colorPalette = colorPalette, | |
highContrast = highContrast, | |
imageQuality = imageQuality, | |
typography = typography, | |
) | |
} | |
) { | |
@Composable | |
override fun uiState( | |
initialState: SettingsUIState, | |
uiEvents: Flow<SettingsUIEvent>, | |
launchUiEffect: (SettingsUIEffect) -> Unit, | |
): SettingsUIState = settingsUIState( | |
initialState = initialState, | |
uiEvents = uiEvents, | |
launchUiEffect = launchUiEffect, | |
purgeRepository = purgeRepository, | |
userPreferencesRepository = userPreferencesRepository | |
) | |
} | |
// Compose UI | |
@Composable | |
fun settingsUIState( | |
initialState: SettingsUIState, | |
uiEvents: Flow<SettingsUIEvent>, | |
launchUiEffect: (SettingsUIEffect) -> Unit, | |
purgeRepository: PurgeRepository, | |
userPreferencesRepository: UserPreferencesRepository, | |
): SettingsUIState { | |
var clearCacheEnabled by remember { | |
mutableStateOf(initialState.clearCacheEnabled) | |
} | |
var dropdownMenuState by remember { | |
mutableStateOf(initialState.dropdownMenuState) | |
} | |
var alertDialogState by remember { | |
mutableStateOf(initialState.alertDialogState) | |
} | |
val userPreferences by remember { | |
userPreferencesRepository.userPreferences | |
}.collectAsState( | |
UserPreferences( | |
darkMode = initialState.darkMode, | |
colorPalette = initialState.colorPalette, | |
highContrast = initialState.highContrast, | |
imageQuality = initialState.imageQuality, | |
typography = initialState.typography, | |
) | |
) | |
LaunchedEffect(Unit) { | |
uiEvents.collect { uiEvent -> | |
when(uiEvent) { | |
ClickClearCache -> { | |
// Disable clear cache button until state is recreated | |
clearCacheEnabled = false | |
launch(Dispatchers.IO) { | |
purgeRepository.purgeCache() | |
launchUiEffect(CachePurged) | |
} | |
} | |
is SelectDarkMode -> { /* .. */ } | |
// ...25 more UI events | |
} | |
} | |
} | |
val highContrastEnabled = | |
userPreferences.colorPalette != SYSTEM_DYNAMIC | |
return SettingsUIState( | |
clearCacheEnabled = clearCacheEnabled, | |
dropdownMenuState = dropdownMenuState, | |
alertDialogState = alertDialogState, | |
highContrastEnabled = highContrastEnabled, | |
darkMode = userPreferences.darkMode, | |
colorPalette = userPreferences.colorPalette, | |
typography = userPreferences.typography, | |
imageQuality = userPreferences.imageQuality, | |
highContrast = | |
if(highContrastEnabled) userPreferences.highContrast | |
else HighContrast.OFF, | |
) | |
} | |
// Compose UI | |
@Composable | |
fun SettingsRoute( | |
navigateToTipsAndInfo: () -> Unit, | |
navigateToStatistics: () -> Unit, | |
settingsViewModel: SettingsViewModel = hiltViewModel(), | |
) { | |
val lifecycle = LocalLifecycleOwner.current.lifecycle | |
val uriHandler = LocalUriHandler.current | |
LaunchedEffect(Unit) { | |
lifecycle.repeatOnLifecycle(state = Lifecycle.State.STARTED) { | |
settingsViewModel.uiEffect.collect{ uiEvent -> | |
when(uiEvent){ | |
AllDataDeleted -> { /* toast */ } | |
CachePurged -> { /* toast */ } | |
RateAndReviewRequest -> inAppReviewRequest() | |
is Navigation -> when(uiEvent.dest) { | |
Statistics -> navigateToStatistics() | |
TipsAndInfo -> navigateToTipsAndInfo() | |
is Web -> uriHandler.openUri(uiEvent.dest.url) | |
} | |
} | |
} | |
} | |
} | |
val uiState by settingsViewModel | |
.uiState | |
.collectAsStateWithLifecycle() | |
SettingsScreen( | |
uiState = uiState, | |
onUiEvent = settingsViewModel::onUiEvent | |
) | |
} | |
@Composable | |
fun SettingsScreen( | |
uiState: SettingsUIState, | |
onUiEvent: (SettingsUIEvent) -> Unit, | |
) { | |
// Convert SettingsUIState to Compose UI | |
// and report UI events via onUiEvent() | |
} | |
// Compose UI Preview | |
@Preview | |
@Composable | |
fun PreviewSettingsScreen() = NoopTheme { | |
Surface { | |
SettingsScreen( | |
uiState = SettingsUIState( | |
alertDialogState = SettingsAlertDialogState.None, | |
dropdownMenuState = SettingsDropdownMenuState.None, | |
highContrastEnabled = true, | |
clearCacheEnabled = true, | |
darkMode = DARK, | |
colorPalette = ROAD_WARRIOR, | |
highContrast = OFF, | |
imageQuality = STANDARD, | |
typography = DEFAULT, | |
), | |
onUiEvent = {}, | |
) | |
} | |
} | |
// Compose state management test | |
class SettingsUIStateTest { | |
@get:Rule val mockkRule = MockKRule(this) | |
@get:Rule val dispatcherRule = MainDispatcherRule() | |
@MockK lateinit var purgeRepository: PurgeRepository | |
@MockK lateinit var userPreferencesRepository: UserPreferencesRepository | |
val testInitialUserPreferences = | |
UserPreferences(imageQuality = VERY_HIGH) | |
val testInitialUiState = with(testInitialUserPreferences){ | |
SettingsUIState( | |
clearCacheEnabled = true, | |
highContrastEnabled = true, | |
dropdownMenuState = SettingsDropdownMenuState.None, | |
alertDialogState = SettingsAlertDialogState.None, | |
darkMode = darkMode, | |
colorPalette = colorPalette, | |
highContrast = highContrast, | |
imageQuality = imageQuality, | |
typography = typography, | |
) | |
} | |
@Before | |
fun beforeEach() = runTest { | |
every { userPreferencesRepository.userPreferences } | |
returns flowOf(testInitialUserPreferences) | |
justRun { purgeRepository.purgeCache() } | |
} | |
@Test | |
fun `Clear cache disables button and triggers effect`() = runTest { | |
val events = Channel<SettingsUIEvent>() | |
val uiEffects = ArrayList<SettingsUIEffect>() | |
moleculeFlow(mode = Immediate){ | |
settingsUIState( | |
initialState = testInitialUiState, | |
events.receiveAsFlow(), | |
{ uiEffects.add(it) }, | |
purgeRepository = purgeRepository, | |
userPreferencesRepository = userPreferencesRepository, | |
) | |
}.test { | |
assertTrue(awaitItem().clearCacheEnabled) | |
events.send(ClickClearCache) | |
assertFalse(awaitItem().clearCacheEnabled) | |
assertEquals(1, uiEffects.size) | |
assertEquals(CachePurged, uiEffects[0]) | |
uiEffects.clear() | |
} | |
} | |
} |
Added Benefits:
StateFlows
now stop when their associated screen enters the backstack.Verified by myself via the Android Studio debugger.
Providing the initial state to the state-managing compose functions allows for trivial testing of various initial states.
Drawbacks:
The initial value provided to
stateIn()
directly conflicts with the initial values provided tocollectAsState()
in composable functions. The benefit of composable functions is the ability to treat aFlow<T>
as an unwrappedT
value by transforming it intoState<T>
with default values. The initial values supplied tocollectAsState()
allow the composable functions to always return a value without waiting forFlows
to emit (where the function is then recomposed on each emission of theFlows)
. The initial value provided tostateIn()
is immediately overwritten by the initial values defined in the composable. This is misleading at worst and redundant at best.The state-managing compose function is no longer in control of its initial state, complicating UI state validity.
Callers can set the initial state to anything they want. For example, the initial value of
SettingsUIState
might be set to display both a dropdown menu and an alert dialog. This is undesirable state. However, due to the initial state being provided as a parameter, this configuration is easier than ever to achieve.
Part 5: Compose UI State Manager
Let’s make one last attempt at managing the drawbacks by containing the initial state and composable state management function within a “ComposeUIStateManager
“ (apologies, not the best name). The results can be seen here (full source on GitHub):
// UI state enums | |
enum class SettingsDropdownMenuState { | |
None, | |
DarkMode, | |
ColorPalette, | |
HighContrast, | |
Typography, | |
ImageQuality, | |
} | |
enum class SettingsAlertDialogState { | |
None, | |
DeleteAllData, | |
ImageQuality, | |
} | |
// UI state | |
data class SettingsUIState ( | |
val clearCacheEnabled: Boolean, | |
val highContrastEnabled: Boolean, | |
val dropdownMenuState: SettingsDropdownMenuState, | |
val alertDialogState: SettingsAlertDialogState, | |
val darkMode: DarkMode, | |
val colorPalette: ColorPalette, | |
val highContrast: HighContrast, | |
val imageQuality: ImageQuality, | |
val typography: Typography, | |
) | |
// UI effects | |
sealed interface SettingsUIEffect { | |
sealed interface NavigationDestination { | |
data object TipsAndInfo: NavigationDestination | |
data object Statistics: NavigationDestination | |
data class Web(val url: String): NavigationDestination | |
} | |
data object CachePurged: SettingsUIEffect | |
data object AllDataDeleted: SettingsUIEffect | |
data object RateAndReviewRequest: SettingsUIEffect | |
data class Navigation(val dest: NavigationDestination) | |
: SettingsUIEffect | |
} | |
// UI events | |
sealed interface SettingsUIEvent { | |
data object ClickClearCache: SettingsUIEvent | |
data class SelectDarkMode(val more: DarkMode): SettingsUIEvent | |
// ...25 more UI events | |
} | |
// enough time to prevent flows from timing out on basic configuration changes | |
const val STATEIN_TIMEOUT_MILLIS = 5_000L | |
interface ComposeUIStateManager<UIEvent, UIState, UIEffect> { | |
@Composable | |
fun uiState( | |
uiEvents: Flow<UIEvent>, | |
launchUiEffect: (UIEffect) -> Unit, | |
): UIState | |
val cachedState: UIState | |
} | |
abstract class MoleculeViewModel<UIEvent, UIState, UIEffect>( | |
val uiStateManager: ComposeUIStateManager<UIEvent, UIState, UIEffect> | |
): ViewModel() { | |
private val uiScope = | |
CoroutineScope( | |
viewModelScope.coroutineContext + | |
AndroidUiDispatcher.Main | |
) | |
private val _uiEvents = Channel<UIEvent>(capacity = 20) | |
private val _uiEffects = | |
MutableSharedFlow<UIEffect>(extraBufferCapacity = 20) | |
val uiEffect: SharedFlow<UIEffect> = _uiEffects | |
val uiState: StateFlow<UIState> = moleculeFlow(mode = ContextClock) { | |
uiStateManager.uiState( | |
uiEvents = _uiEvents.receiveAsFlow(), | |
launchUiEffect = ::launchUiEffect, | |
) | |
}.stateIn( | |
scope = uiScope, | |
started = WhileSubscribed(STATEIN_TIMEOUT_MILLIS), | |
initialValue = uiStateManager.cachedState, | |
) | |
fun onUiEvent(uiEvent: UIEvent) = | |
viewModelScope.launch { _uiEvents.send(uiEvent) } | |
private fun launchUiEffect(uiEffect: UIEffect){ | |
if(!_uiEffects.tryEmit(uiEffect)) { | |
error("SettingsViewModel: UI effect buffer overflow.") | |
} | |
} | |
} | |
@HiltViewModel | |
class SettingsViewModel @Inject constructor( | |
settingsUIStateManager: SettingsUIStateManager | |
): MoleculeViewModel<SettingsUIEvent, SettingsUIState, SettingsUIEffect>( | |
uiStateManager = settingsUIStateManager | |
) | |
class SettingsUIStateManager @Inject constructor( | |
val purgeRepository: PurgeRepository, | |
val userPreferencesRepository: UserPreferencesRepository, | |
): ComposeUIStateManager<SettingsUIEvent, SettingsUIState, SettingsUIEffect> { | |
override var cachedState = with(UserPreferences()) { | |
SettingsUIState( | |
clearCacheEnabled = true, | |
highContrastEnabled = true, | |
dropdownMenuState = SettingsDropdownMenuState.None, | |
alertDialogState = SettingsAlertDialogState.None, | |
darkMode = darkMode, | |
colorPalette = colorPalette, | |
highContrast = highContrast, | |
imageQuality = imageQuality, | |
typography = typography, | |
) | |
} | |
@Composable | |
override fun uiState( | |
uiEvents: Flow<SettingsUIEvent>, | |
launchUiEffect: (SettingsUIEffect) -> Unit | |
): SettingsUIState { | |
var clearCacheEnabled by remember { | |
mutableStateOf(cachedState.clearCacheEnabled) | |
} | |
var dropdownMenuState by remember { | |
mutableStateOf(cachedState.dropdownMenuState) | |
} | |
var alertDialogState by remember { | |
mutableStateOf(cachedState.alertDialogState) | |
} | |
val userPreferences by remember { | |
userPreferencesRepository.userPreferences | |
}.collectAsState( | |
UserPreferences( | |
darkMode = cachedState.darkMode, | |
colorPalette = cachedState.colorPalette, | |
highContrast = cachedState.highContrast, | |
imageQuality = cachedState.imageQuality, | |
typography = cachedState.typography, | |
) | |
) | |
LaunchedEffect(Unit) { | |
uiEvents.collect { uiEvent -> | |
when(uiEvent) { | |
ClickClearCache -> { | |
// Disable clearing cache until state is recreated | |
clearCacheEnabled = false | |
launch(Dispatchers.IO) { | |
purgeRepository.purgeCache() | |
launchUiEffect(CachePurged) | |
} | |
} | |
is SelectDarkMode -> { /* .. */ } | |
// ...25 more UI events | |
} | |
} | |
} | |
val highContrastEnabled = | |
userPreferences.colorPalette != SYSTEM_DYNAMIC | |
cachedState = SettingsUIState( | |
clearCacheEnabled = clearCacheEnabled, | |
dropdownMenuState = dropdownMenuState, | |
alertDialogState = alertDialogState, | |
highContrastEnabled = highContrastEnabled, | |
darkMode = userPreferences.darkMode, | |
colorPalette = userPreferences.colorPalette, | |
typography = userPreferences.typography, | |
imageQuality = userPreferences.imageQuality, | |
highContrast = | |
if(highContrastEnabled) userPreferences.highContrast | |
else HighContrast.OFF, | |
) | |
return cachedState | |
} | |
} | |
// Compose UI | |
@Composable | |
fun SettingsRoute( | |
navigateToTipsAndInfo: () -> Unit, | |
navigateToStatistics: () -> Unit, | |
settingsViewModel: SettingsViewModel = hiltViewModel(), | |
) { | |
val lifecycle = LocalLifecycleOwner.current.lifecycle | |
val uriHandler = LocalUriHandler.current | |
LaunchedEffect(Unit) { | |
lifecycle.repeatOnLifecycle(state = Lifecycle.State.STARTED) { | |
settingsViewModel.uiEffect.collect{ uiEvent -> | |
when(uiEvent){ | |
AllDataDeleted -> { /* toast */ } | |
CachePurged -> { /* toast */ } | |
RateAndReviewRequest -> { | |
inAppReviewRequest() | |
} | |
is Navigation -> when(uiEvent.dest) { | |
Statistics -> navigateToStatistics() | |
TipsAndInfo -> navigateToTipsAndInfo() | |
is Web -> uriHandler.openUri(uiEvent.dest.url) | |
} | |
} | |
} | |
} | |
} | |
val uiState by settingsViewModel | |
.uiState | |
.collectAsStateWithLifecycle() | |
SettingsScreen( | |
uiState = uiState, | |
onUiEvent = settingsViewModel::onUiEvent | |
) | |
} | |
@Composable | |
fun SettingsScreen( | |
uiState: SettingsUIState, | |
onUiEvent: (SettingsUIEvent) -> Unit, | |
) { | |
// Convert SettingsUIState to Compose UI | |
// and report UI events via onUiEvent() | |
} | |
// Compose UI Preview | |
@Preview | |
@Composable | |
fun PreviewSettingsScreen() = NoopTheme { | |
Surface { | |
SettingsScreen( | |
uiState = SettingsUIState( | |
alertDialogState = SettingsAlertDialogState.None, | |
dropdownMenuState = SettingsDropdownMenuState.None, | |
highContrastEnabled = true, | |
clearCacheEnabled = true, | |
darkMode = DARK, | |
colorPalette = ROAD_WARRIOR, | |
highContrast = OFF, | |
imageQuality = STANDARD, | |
typography = DEFAULT, | |
), | |
onUiEvent = {}, | |
) | |
} | |
} | |
// SettingsUIStateMangerTest | |
class SettingsUIStateManagerTest { | |
@get:Rule val mockkRule = MockKRule(this) | |
@get:Rule val dispatcherRule = MainDispatcherRule() | |
@MockK lateinit var purgeRepository: PurgeRepository | |
@MockK lateinit var userPreferencesRepository: UserPreferencesRepository | |
val testInitialUserPreferences = | |
UserPreferences(imageQuality = VERY_HIGH) | |
lateinit var settingsUIStateManager: SettingsUIStateManager | |
@Before | |
fun beforeEach() = runTest { | |
every { userPreferencesRepository.userPreferences } | |
returns flowOf(testInitialUserPreferences) | |
justRun { purgeRepository.purgeCache() } | |
settingsUIStateManager = SettingsUIStateManager( | |
purgeRepository = purgeRepository, | |
userPreferencesRepository = userPreferencesRepository, | |
) | |
} | |
@Test | |
fun `Clear cache disables button and triggers effect`() = runTest { | |
val uiEvents = Channel<SettingsUIEvent>() | |
val uiEffects = ArrayList<SettingsUIEffect>() | |
moleculeFlow(mode = Immediate){ | |
settingsUIStateManager.uiState( | |
uiEvents = uiEvents.receiveAsFlow(), | |
launchUiEffect = { uiEffects.add(it) }, | |
) | |
}.test { | |
skipItems(1) // skip initial emission | |
assertTrue(awaitItem().clearCacheEnabled) | |
uiEvents.send(ClickClearCache) | |
assertFalse(awaitItem().clearCacheEnabled) | |
assertEquals(1, uiEffects.size) | |
assertEquals(CachePurged, uiEffects[0]) | |
uiEffects.clear() | |
} | |
} | |
} |
Added Benefits:
The initial UI state is tightly coupled with UI state management, once again.
Invalid/undesirable state configurations are less trivial to achieve.
The ComposeUIStateManager allows for the selective caching of state.
In Merlinsbag, caching an alert dialog state be an obvious benefit but caching image file URIs could be undesirable.
Drawbacks:
It is no longer trivial to set the initial UI state for tests.
Maybe we’ve reached the point of over-engineering?
Maybe that point was a long time ago?
Only time will reveal the truth…
Ending Remarks
I am still determining the value of using the Compose Compiler & Runtime with Molecule for state management. However, I am confident that Compose has a bright future beyond UI. The Compose Runtime is fantastic when it comes to managing Flows
imperatively and it may be fruitful to invest early.
The presented solutions should all maintain UI state between simple configuration changes (ex: device rotation). However, they will not inherently respond well to system-initiated process death. The Android OS can kill an app at any time (to reclaim & repurpose its memory). For UI state to be restored after unexpected app termination, you will need to use a combination of rememberSaveable for composable functions, SavedStateHandle for AAC ViewModels, or more persistent storage, like Room persistence library (a fancy Kotlin SQLite wrapper) or Proto DataStore.
With Merlinsbag, it is yet to be determined the exact viewmodel-to-UI communication or state management techniques that will become the convention. But, I have at least determined some good pathways forward.
Thank you for reading! If you know of any open-source Android projects that demonstrate good alternative practices or utilize Slack’s Circuit framework, or if you have any tips on improving state management with Compose, I would love to hear from you!
Anyway, the work is never finished. Back to pushing buttons.
References & Links
“Now in Android” open-source Android application from the official Android GitHub
“Kotlin Conf 2019: The Compose Runtime, Demystified” by Leland Richardson
“Compose beyond the UI: Molecule at Swedish Railways” by Erik Westenius
“Reactive UI state on Android, starring Compose” from Reddit Engineering Tech Blog
"Unidirectional State Flow patterns – a refactoring story" by Kaushik Gopal