ThanosFisherman
ThanosFisherman

Reputation: 5859

Collecting Flows in ViewModel. Is repeatOnLifeCycle needed?

Up until now I used to collect my flows either in activity/fragment or in ViewModel like so

Activity/Fragment

lifecycleScope.launch {
    myViewModel.readTokenCredentials().collect { data -> /* do something */ }
}

ViewModel

viewModelScope.launch {
    prefsRepo.readTokenCredentials().collect { data -> /* do something */ }
}

Now Google devs tell us that this is not a safe way to collect flows because it could lead to memory leaks. Instead they recommend wrapping the collection in lifecycle.repeatOnLifecycle for flow collection in Activities/Fragments.

lifecycleScope.launch {
    lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
        myViewModel.readTokenCredentials().collect { data -> /* do something */ }
    }   
}

My question is:

Why can't I use repeatOnLifecycle with viewModelScope when collecting flows inside the view model? Of course I know view model is not lifecycle aware but is perhaps viewModelScope less likely to introduce memory leaks during flow collection?

Upvotes: 20

Views: 21012

Answers (2)

Constructor
Constructor

Reputation: 534

TLDR:

In ViewModel, use stateIn:

someFlow.stateIn(
            scope = viewModelScope,
            initialValue = , // set initial value here
            started = SharingStarted.WhileSubscribed(5000)
        )

This is likely the best method if configuration change needs to be taken care of.

The same, if you are willing to add an unnecessary LiveData into the mix, can actually be achieved with arguably less code using someFlow.asLiveData() which also defaults to timeoutInMs = 5000. (See additional note that bottom)

OR

In Activity onCreate/Fragment onCreateView, use: repeatOnLifecycle.

(All main conclusions in big font below)


I was able to clarify many concepts after going through the full video explanation (link to relevant part of "Flow in Android UI") and the companion documentation. I recommend everyone to watch and read it, pause and read more than once if necessary. Going beyond the original question, here are my notes:

There are two main things to consider.

  1. The first one is about not wasting resources when the app is in the background, and
  2. the second one is about configuration changes

First, the UI should collect items only when needed by using life cycle-aware alternatives. Concretely, this means:

In ViewModel: use Flow<T>.asLiveData().

The asLiveData flow operator converts the flow to live data that observes items only while the UI is visible on the screen. This conversion is something we can do in the view model class. In the UI, we just consume the LiveData as usual.

This approach takes advantage of the intrinsic property of LiveData, but introduces LiveData as another technology, which does not appear elegant.

In Activity's onCreate, use repeatOnLifecycle(Lifecycle.State.STARTED)

This is the recommended way to collect flows from the UI layer. Example:

class LatestNewsActivity : AppCompatActivity() {
    private val latestNewsViewModel = // getViewModel()

    override fun onCreate(savedInstanceState: Bundle?) {
        ...
        // Start a coroutine in the lifecycle scope
        lifecycleScope.launch {
            // repeatOnLifecycle launches the block in a new coroutine every time the
            // lifecycle is in the STARTED state (or above) and cancels it when it's STOPPED.
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                // Trigger the flow and start listening for values.
                // Note that this happens when lifecycle is STARTED and stops
                // collecting when the lifecycle is STOPPED
                latestNewsViewModel.uiState.collect { uiState ->
                    // New value received
                    when (uiState) {
                        is LatestNewsUiState.Success -> showFavoriteNews(uiState.news)
                        is LatestNewsUiState.Error -> showError(uiState.exception)
                    }
                }
            }
        }
    }
}

This API is lifecycle aware, as it automatically launches a new coroutine with a block pass to it when the lifecycle reaches that step. Then, when the lifecycle falls below that state, the ongoing coroutine is canceled. Inside the block, we can call collect, as we are in the context of a coroutine. As repeatOnLifecycle is a suspend function, it also needs to be called in a coroutine. As you are in an activity, we can use lifecycleScope to start one. As you can see, the best practice is to call this function when the lifecycle is initialized, for example, in onCreate in this activity. RepeatOnLifecycle's restartable behavior takes into account the UI lifecycle automatically for you.

Note if you need to collect from multiple flows, please see the video/documentation for details.

When only one flow needs to be collected, an alternative is flowWithLifecycle(lifecycle, State.STARTED)

See the video for details of why old API (launch, launchWhenX) can be wasteful/dangerous if not manually stopped.

In Fragment, viewLifecycleOwner.lifecycleScope.launch and viewLifecycleOwner.repeatOnLifecycle can be used.


Second, handling configuration changes. Specifically, when an activity/fragment goes through its life cycle, the ViewModel will persist on its own lifecycle. (More details in the video) How do we handle the ViewModel in this case?

The solution is to use StateFlow in LiveData.

This is very similar to a LiveData, but one key difference is it is opinionated about requiring an initial value.

There are two ways of implementing StateFlow.

The first not recommended way is to use a (backing) MutableStateFlow and manually update it stateFlow.value = newValue. But this is not ideal because this is not a "reactive" solution: When there are no UI subscribers to this StateFlow, it will still update.

The recommended way is to use the stateIn intermediate operator.

val result: StateFlow<Result<T>> = someFlow
    .stateIn(
            scope = viewModelScope,
            initialValue = , // set initial value here
            started = SharingStarted.WhileSubscribed(5000)
        )

The 5000 ms sounds somewhat arbitrary: it basically means when it will stop the flow after 5000 ms if there is no subscriber, but if there is a configuration change (e.g. rotation), the flow is resumed within 5000 ms, so the same flow is kept alive.

Additional note on difference between StateFlow and LiveData:

Note, however, that StateFlow and LiveData do behave differently:

  • StateFlow requires an initial state to be passed in to the constructor, while LiveData does not.
  • LiveData.observe() automatically unregisters the consumer when the view goes to the STOPPED state, whereas collecting from a StateFlow or any other flow does not stop collecting automatically. To achieve the same behavior,you need to collect the flow from a Lifecycle.repeatOnLifecycle block.

Upvotes: 9

Tenfour04
Tenfour04

Reputation: 93629

It's not possible to have a repeat on lifecycle since the ViewModel doesn't have a repeating lifecycle. It starts once and is destroyed once.

I don't think memory leak is an accurate term for what's happening when a Flow continues being collected while a Fragment is off-screen. It's just causing its upstream Flow to keep emitting for no reason, but the emitted items will be garbage collected. It's simply a waste of activity. The danger comes if you are also updating UI in the collector because you can accidentally update views that are off-screen.

In a ViewModel, you have the same risk of collecting from Flows for no reason. To avoid it, you can use stateIn or shareIn with a WhileSubscribed value. Then it will stop collecting when there is nothing downstream collecting. And if you're using repeatOnLifecycle in your Activities and Fragments that collect from these SharedFlows and StateFlows, then everything's taken care of.

For example:

val someFlow = prefsRepo.readTokenCredentials()
    .map { data -> // doSomething }
    .shareIn(viewModelScope, SharingStarted.WhileSubscribed(5000L), 1)

And collect it in the UI layer. If there's nothing for the UI to collect, then why does the Flow exist in the first place? I can't think of a good counter-example. ViewModel is intended for preparing the model for viewing, not doing work that's never been seen.

Upvotes: 21

Related Questions