I'm writing an Android app with Compose and a Room database. I've got a working solution, but I'm unsure if I'm using best practices. I'm collecting two flows in my ViewModel's init so I can create the UI state that is being used in the ViewModel's composable:
class MyViewModel(
savedStateHandle: SavedStateHandle,
context: Context
) : ViewModel() {
// Some code omitted
var uiState by mutableStateOf(MyUiState())
init {
viewModelScope.launch {
combine(
myRepo.getMovie(movieId).filterNotNull(),
myRepo.getActors(movieId)
) { movie, actors ->
uiState.copy(
movie = movie,
actorList = actors
)
}.collect { newState ->
uiState = newState
}
}
}
}
From what I've researched, calling collect() in init may be questionable, but I struggle to find any hard documentation saying don't do it. And I don't know any other way of updating uiState automatically whenever an actor is added or deleted without calling collect(). Any advice on a better solution to updating the uiState would be greatly appreciated.
The general idea is to just transform the flows in the view model, the collection should only be done in the UI. That way the UI can subscribe and unsubscribe from the flows as needed which saves resources.
In your example uiState
should be a flow, specifically, it should be a StateFlow
:
val uiState: StateFlow<MyUiState> = transformedFlow(movieId)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = MyUiState(),
)
A Kotlin StateFlow
is conceptually similar to a Compose State
in that it represents a single value that can be observed for changes. In your composables you can retrieve the uiState like this:
val uiState: MyUiState by viewModel.uiState.collectAsStateWithLifecycle()
You need to add the dependency androidx.lifecycle:lifecycle-runtime-compose
to your gradle files for this. See https://medium.com/androiddevelopers/consuming-flows-safely-in-jetpack-compose-cde014d0d5a3 for more.
The transformedFlow
will be the combined flow from your repo:
private fun transformedFlow(movieId: Int) = combine(
myRepo.getMovie(movieId).filterNotNull(),
myRepo.getActors(movieId),
) { movie, actors ->
MyUiState(
movie = movie,
actorList = actors,
)
}
To address the comment: Everything that your uiState is dependent on should be provided as a flow and combined with the other flows. That way, whenever anything changes, the resulting StateFlow is always up-to-date.
As an example how this could be done, let's assume your movieId could change over time. As it is now, transformedFlow(movieId)
is only called once when the view model is initialized. Changes to the movieId won't result in getMovie
or getActors
being executed again. Now, consider adding this to your view model:
private val selectedMovieId: MutableStateFlow<Int?> = MutableStateFlow(null)
fun selectMovie(movieId: Int) {
selectedMovieId.value = movieId
}
The movie id is now a flow itself and can be changed by calling selectMovie
. Your uiState
can then be based on that flow like this:
@OptIn(ExperimentalCoroutinesApi::class)
val uiState: StateFlow<MyUiState> = selectedMovieId
.flatMapLatest(::uiStateFlowOf)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = MyUiState(),
)
private fun uiStateFlowOf(movieId: Int?): Flow<MyUiState> =
if (movieId == null)
flowOf(MyUiState())
else
combine(
myRepo.getMovie(movieId).filterNotNull(),
myRepo.getActors(movieId),
) { movie, actors ->
MyUiState(
movie = movie,
actorList = actors,
)
}
If there are other data sources, their flows need to be combined with the other flows. If they are dependending on the movie id just add them to the existing combine
, if they are something different entirely combine them with the resulting flow. The stateIn
always comes last, the combining must be done beforehand. Make sure to extract the transformation logic into separate functions, otherwise it will be very hard to understand what is going on. After all, the entire transformation logic has its root in the declaration of uiState
and is of a functional nature, not imperative. You could place everything there in one monolithic statement. Don't do that, extract independent logic into separate functions instead.
If your data sources do not provide flows they can probably be wrapped by flows quite easily: See https://developer.android.com/kotlin/flow, especially the section about callbackFlow
. If you still use LiveData
somewhere, there is a handy asFlow()
method to convert them to flows.