I have collect flow from shared viewmodel in fragment :
private val viewModel: MyViewModel by sharedViewModel()
private fun observeViewModelStateFlowData() {
job = lifecycleScope.launch {
viewModel.stateFlowData.collect {
when (it) {
is ViewStates.Success -> handleSuccess(it.data)
}
}
}
}
in ViewModel :
private val _stateFlowData = MutableStateFlow<ViewStates<Model>>(ViewStates.Idle)
val stateFlowData: StateFlow<ViewStates<Model>> get() = _stateFlowData
but when I go to next fragment and back to this fragment again, flow collect again.
I cancel the job in onStop() lifecycle method of fragment :
override fun onStop() {
job?.cancel()
super.onStop()
}
but not cancel and collect again!!!
This happens even when I leave the activity (when the viewmodel is cleared) and come back to activity again!!!
How can I do this so that I can prevent the collecting of flow ?
Well you have to know something about coroutine. If we just call cancel, it doesn’t mean that the coroutine work will just stop. If you’re performing some relatively heavy computation, like reading from multiple files, there’s nothing that automatically stops your code from running.
You need to make sure that all the coroutine work you’re implementing is cooperative with cancellation, therefore you need to check for cancellation periodically or before beginning any long running work. Try to add check before handling a result.
job = lifecycleScope.launch {
viewModel.stateFlowData.collect {
ensureActive()
when (it) {
is ViewStates.Success -> handleSuccess(it.data)
}
}
}
}
For more info take a look on this article https://medium.com/androiddevelopers/cancellation-in-coroutines-aa6b90163629
Related
There is ParentFragment that shows DialogFragment. I collect a dialog result through SharedFlow. When result received, dialog dismissed. Should I stop collect by additional code? What happens when dialog closed, but fragment still resumed?
// ParentFragment
private fun save() {
val dialog = ContinueDialogFragment(R.string.dialog_is_save_task)
dialog.show(parentFragmentManager, "is_save_dialog")
lifecycleScope.launch {
dialog.resultSharedFlow.collect {
when (it) {
ContinueDialogFragment.RESULT_YES -> {
viewModel.saveTask()
closeFragment()
}
ContinueDialogFragment.RESULT_NO -> {
closeFragment()
}
ContinueDialogFragment.RESULT_CONTINUE -> {
// dont close fragment
}
}
}
}
}
class ContinueDialogFragment(
#StringRes private val titleStringId: Int,
#StringRes private val messageStringId: Int? = null
) : DialogFragment() {
private val _resultSharedFlow = MutableSharedFlow<Int>(1)
val resultSharedFlow = _resultSharedFlow.asSharedFlow()
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
return activity?.let { context ->
AlertDialog.Builder(context)
.setTitle(getString(titleStringId))
.setMessage(messageStringId?.let { getString(it) })
.setPositiveButton(getString(R.string.dialog_yes)) { _, _ ->
_resultSharedFlow.tryEmit(RESULT_YES)
}
.setNegativeButton(getString(R.string.dialog_no)) { _, _ ->
_resultSharedFlow.tryEmit(RESULT_NO)
}
.setNeutralButton(getString(R.string.dialog_continue)) { _, _ ->
_resultSharedFlow.tryEmit(RESULT_CONTINUE)
}
.create()
} ?: throw IllegalStateException("Activity cannot be null")
}
companion object {
const val RESULT_YES = 1
const val RESULT_NO = 0
const val RESULT_CONTINUE = 2
}
}
When a Flow completes depends on its original source. A Flow built with flowOf or asFlow() ends once it reaches the last item in its list. A Flow built with the flow builder could be finite or infinite, depending on whether it has an infinite loop in it.
A flow created with MutableSharedFlow is always infinite. It stays open until the coroutine collecting it is cancelled. Therefore, you are leaking the dialog fragment with your current code because you are hanging onto its MutableSharedFlow reference, which is capturing the dialog fragment reference. You need to manually cancel your coroutine or collection.
Or more simply, you could use first() instead of collect { }.
Side note, this is a highly unusual uses of a Flow, which is why you're running into this fragile condition in the first place. A Flow is for a series of emitted objects, not for a single object.
It is also very fragile that you're collecting this flow is a function called save(), but you don't appear to be doing anything in save() to store the instance state such that if the activity/fragment is recreated you'll start collecting from the flow again. So, if the screen rotates, the dialog will reappear, the user could click the positive button, and nothing will be saved. It will silently fail.
DialogFragments are pretty clumsy to work with in my opinion. Anyway, I would take the easiest route and directly put your behaviors in the DialogFragment code instead of trying to react to the result back in your parent fragment. But if you don't want to do that, you need to go through the pain of calling back through to the parent fragment. Alternatively, you could use a shared ViewModel between these two fragments that will handle the dialog results.
I believe you will have a memory leak of DialogFragment: ParentFragment will be referencing the field dialog.resultSharedFlow until the corresponding coroutine finishes execution. The latter may never happen while ParentFragment is open because dialog.resultSharedFlow is an infinite Flow. You can call cancel() to finish the coroutine execution and make dialog eligible for garbage collection:
lifecycleScope.launch {
dialog.resultSharedFlow.collect {
when (it) {
ContinueDialogFragment.RESULT_YES -> {
viewModel.saveTask()
closeFragment()
cancel()
}
ContinueDialogFragment.RESULT_NO -> {
closeFragment()
cancel()
}
ContinueDialogFragment.RESULT_CONTINUE -> {
// dont close fragment
}
}
}
}
So when I press a button I need to wait 3 seconds before executing another method, I worked that out with the followin
val job = CoroutineScope(Dispatchers.Main).launch(Dispatchers.Default, CoroutineStart.DEFAULT) {
delay(THREE_SECONDS)
if (this.isActive)
product?.let { listener?.removeProduct(it) }
}
override fun onRemoveProduct(product: Product) {
job.start()
}
now, if I press a cancel button right after I start the job I stop the job from happening and that is working fine
override fun onClick(v: View?) {
when(v?.id) {
R.id.dismissBtn -> {
job.cancel()
}
}
}
The problem is that when I execute again the onRemoveProduct that executes the job.start() it will not start again, seems like that job.isActive never yields to true, why is this happening ?
A Job once cancelled cannot be started again. You need to do that in a different way. One way is to create a new job everytime onRemoveProduct is called.
private var job: Job? = null
fun onRemoveProduct(product: Product) {
job = scope.launch {
delay(THREE_SECONDS)
listener?.removeProduct(product) // Assuming the two products are same. If they aren't you can modify this statement accordingly.
}
}
fun cancelRemoval() { // You can call this function from the click listener
job?.cancel()
}
Also, in this line of your code CoroutineScope(Dispatchers.Main).launch(Dispatchers.Default, CoroutineStart.DEFAULT),
You shouldn't/needn't create a new coroutine scope by yourself. You can/should use the already provided viewModelScope or lifecycleScope. They are better choices as they are lifecycle aware and get cancelled at the right time.
Dispatchers.Main is useless because it gets replaced by Dispatchers.Default anyways. Dispatchers.Default is also not required here because you aren't doing any heavy calculations (or calling some blocking code) here.
CoroutineStart.DEFAULT is the default parameter so you could have skipped that one.
And you also need not check if (this.isActive) because
If the [Job] of the current coroutine is cancelled or completed while delay is waiting, it immediately resumes with [CancellationException].
I have a UseCase and remote repository that return Flow in a loop and I collect the result of UseCase in the ViewModel like this:
viewModelScope.launch {
useCase.updatePeriodically().collect { result ->
when (result.status) {
Result.Status.ERROR -> {
errorModel.value = result.errorModel
}
Result.Status.SUCCESS -> {
items.value = result.data
}
Result.Status.LOADING -> {
loading.value = true
}
}
}
}
the problem is when the app is in the background (minimized) flow continues working. so can I pause it when the app is in the background and resume it when the app comes back to the foreground?
and also I don't want to observe the data in my view (fragment or activity).
I'd play around with the stateIn operator and the way I'm currently consuming the flow in the view.
Something like:
val state = useCase.updatePeriodically().map { ... }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed, initialValue)
And consume it from the View like:
viewModel.flowWithLifecycle(this, Lifecycle.State.STARTED)
.onEach {
}
.launchIn(lifecycleScope)
For other potential ways on how to collect flows from the UI: https://medium.com/androiddevelopers/a-safer-way-to-collect-flows-from-android-uis-23080b1f8bda
EDIT:
If you don't want to consume it from the view, you still have to signal for the VM that your View is in the background currently.
Something like:
private var job: Job? = null
fun start(){
job = viewModelScope.launch {
state.collect { ... }
}
}
fun stop(){
job?.cancel()
}
Even if the viewModelScope is cancelled, the flow will continue to collect because it is not cooperative to cancellation.
To make a flow cancellable, you can do one of the following things:
In the collect lambda, call currentCoroutineContext().ensureActive() to make sure the context in which the flow is being collected is still active. This will however throw a CancellableException, which you will need to catch, if the coroutine scope was cancelled already (viewModel scope for your case.)
You can use cancellable() operator as follows:
myFlow.cancellable().collect { //do stuff here.. }
And you can call cancel() whenever you want to cancel the flow.
For official documentation on cancelling the flow see:
https://kotlinlang.org/docs/flow.html#flow-cancellation-checks
I believe you want something like this
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
state.collect {
}
}
}
Here's an execellent article on repeatOnLifecyle: https://medium.com/androiddevelopers/repeatonlifecycle-api-design-story-8670d1a7d333
I have a class that launches coroutines and allows them to be cancelled when the Activity/Fragment they are called from is destroyed. However it is not working like I expect. When I back out of the fragment while the operation is running, the coroutine cancel does not take, and I get an NPE when trying to access a View that does not exist anymore.
open class CoroutineLauncher : CoroutineScope {
private val dispatcher: CoroutineDispatcher = Dispatchers.Main
private val supervisorJob = SupervisorJob()
override val coroutineContext: CoroutineContext
get() = dispatcher + supervisorJob
fun launch(action: suspend CoroutineScope.() -> Unit) = launch(block = action)
fun cancelCoroutines() {
supervisorJob.cancelChildren() //coroutineContext.cancelChildren() has same results
}
}
here is the usage
class MyFragment : Fragment {
val launcher = CoroutineLauncher()
fun onSomeEvent() {
launcher.launch {
val result = someSuspendFunction()
if (!isActive) return
// CAUSES CRASH
myTextView.text = result.userText
}
}
override fun onDestroyView() {
super.onDestroyView()
launcher.cancelCoroutines()
}
}
I added log lines to ensure onDestroyView and cancelCoroutines are both being called before the crash. I feel like I'm missing something obvious but what I'm doing seems to be inline with the recipes suggested here: https://proandroiddev.com/android-coroutine-recipes-33467a4302e9
Any ideas?
Ok I figured it out. onSomeEvent was being invoked after cancelCoroutines was called. Since we call cancelChildren on the SupervisorJob instead of cancel, the launcher does not refuse new Jobs, and since the cancel already happened, the new coroutine runs like normal and crashes. I fixed this by checking if the fragment is visible before calling launcher.launch and bailing out of the method if the fragment is not visible.
This could also be fixed by calling supervisorJob.cancel() instead of supervisorJob.cancelChildren(), though that has some other side effects that I didn't want
I am trying to stop a coroutine if user presses a button. However, when I do:
GlobalScope.launch(Dispatchers.Main) {
//code
}
button.setonclicklistener(){
GlobalScope.cancel()
}
The app crashes. How can I fix this?
Change it like this
var job: Job? = null
job = GlobalScope.launch(Dispatchers.Main) {
//code
}
button.setonclicklistener(){
job?.cancel()
}
Here is the sample you can use and modify as per your code
val job =GlobalScope.launch(Dispatchers.Main) {
try {
//code
} finally {
println("job: I'm running finally")
}
}
job.cancelAndJoin() // cancels the job and waits for its completion
println("main: Now I can quit.")
First, I would like to point out that you should not use the GlobalScope. Instead, you should make your local scope bound to your component fragment/activity/presenter, etc. lifecycle. here's why https://elizarov.medium.com/the-reason-to-avoid-globalscope-835337445abc
Now, once that is taken care of, you can create a local scope like this (assuming you need MainScope)
class MyFragment: Fragment(), CoroutineScope by MainScope() {
var job: Job? = null
....
Next, like #Francesc suggested, you'll need to grab a Job reference. Since every coroutine launch returns a Job, you can keep the reference and cancel it whenever you need. Or in this case, if the fragment dies, the coroutine will be canceled automatically as it's bound to the fragment now (which is most of the time the desired behavior).
job = launch {
// your code
}
button.setOnClickListener() {
job?.cancel()
}
Also please note that now you don't have to mention the scope and context before launching.