Search code examples
kotlinandroid-jetpack-composeandroid-viewmodel

Collecting Flows in the ViewModel


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.


Solution

  • 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.