Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Flow onEach/collect gets called multiple times when back from Fragment

I'm using Flow instead of LiveData to collect data in my Fragment. In Fragment A I observe (or rather collect) the data in my fragment`s onViewCreated like this:

lifecycleScope.launchWhenStarted {
            availableLanguagesFlow.collect {
                languagesAdapter.setItems(it.allItems, it.selectedItem)
            }
        }

Problem. Then when I go to Fragment B and then comes back to Fragment A, my collect function gets called twice. If I go the Fragment B again and back to A - then collect function is called 3 times. And so on.

like image 215
Andrew Avatar asked May 06 '21 16:05

Andrew


People also ask

Are both flow s being collected at the same time?

In the example below, you’d think that both Flow s are being collected at the same time, but flow1 is collected until the Flow finishes emitting, and then flow2 is collected until it is finished emitting.

Why use Launchin instead of collect in a flow?

This is the equivalent logic as above, but using launchIn. This is less code to write, but more importantly it’ll get you out of some hard to debug situations when collecting from Flow s. The non obvious thing to understand is that collect () will block the coroutine until the flow has finished emitting.

What is the difference between Launchin() and collect() methods in Java?

The practical difference then is, that you can call collect () method only from another suspending function or from a coroutine. For example like this: whereas .launchIn () can be called like this in any regular function: With the .launchIn () method, you also get a Job as return value, so you could cancel the flow by cancelling the job:

Why does my flow run twice in one Test?

It runs great for me in the dev environment. It runs twice, because an update of Opportunities in the flow triggers it to run again. The field that was updated in the first run is examined and the flow is aborted on that second run. The problem is, something is triggering the flow multiple times in the test instance.


2 Answers

Reason

It happens because of tricky Fragment lifecycle. When you come back from Fragment B to Fragment A, then Fragment A gets reattached. As a result fragment's onViewCreated gets called second time and you observe the same instance of Flow second time. Other words, now you have one Flow with two observers, and when the flow emits data, then two of them are called.

Solution 1 for Fragment

Use viewLifecycleOwner in Fragment's onViewCreated. To be more specific use viewLifecycleOwner.lifecycleScope.launch instead of lifecycleScope.launch. Like this:

viewLifecycleOwner.lifecycleScope.launchWhenStarted {
            availableLanguagesFlow.collect {
                languagesAdapter.setItems(it.allItems, it.selectedItem)
            }
        }

Solution 2 for Activity

In Activity you can simply collect data in onCreate.

lifecycleScope.launchWhenStarted {
            availableLanguagesFlow.collect {
                languagesAdapter.setItems(it.allItems, it.selectedItem)
            }
        }

Additional info

  1. Same happens for LiveData. See the post here. Also check this article.
  2. Make code cleaner with Kotlin extension:

extension:

fun <T> Flow<T>.launchWhenStarted(lifecycleOwner: LifecycleOwner) {
    lifecycleOwner.lifecycleScope.launchWhenStarted {
        [email protected]()
    }
}

in fragment onViewCreated:

availableLanguagesFlow
    .onEach {
        //update view
    }.launchWhenStarted(viewLifecycleOwner)

Update

I'd rather use now repeatOnLifecycle, because it cancels the ongoing coroutine when the lifecycle falls below the state (onStop in my case). While without repeatOnLifecycle, the collection will be suspended when onStop. Check out this article.

fun <T> Flow<T>.launchWhenStarted(lifecycleOwner: LifecycleOwner)= with(lifecycleOwner) {
    lifecycleScope.launch {
        repeatOnLifecycle(Lifecycle.State.STARTED){
            try {
                [email protected]()
            }catch (t: Throwable){
                loge(t)
            }
        }
    }
}
like image 116
Andrew Avatar answered Oct 23 '22 22:10

Andrew


Use SharedFlow and apply replayCache to it.

Resets the replayCache of this shared flow to an empty state. New subscribers will be receiving only the values that were emitted after this call, while old subscribers will still be receiving previously buffered values. To reset a shared flow to an initial value, emit the value after this call. more information

private val _reorder = MutableSharedFlow<ViewState<ReorderDto?>>().apply {
    resetReplayCache()
}
val reorder: SharedFlow<ViewState<ReorderDto?>>
    get() = _reorder
like image 31
amid aliaghazadeh Avatar answered Oct 23 '22 21:10

amid aliaghazadeh