简体   繁体   中英

Coroutine StateFlow stops emitting when CoroutineScope is cancelled

I have a StateFlow coroutine that is shared amongst various parts of my application. When I cancel the CoroutineScope of a downstream collector, a JobCancellationException is propagated up to the StateFlow , and it stops emitting values for all current and future collectors.

The StateFlow :

val songsRelay: Flow<List<Song>> by lazy {
    MutableStateFlow<List<Song>?>(null).apply {
        CoroutineScope(Dispatchers.IO)
            .launch { songDataDao.getAll().distinctUntilChanged().collect { value = it } }
    }.filterNotNull()
}

A typical 'presenter' in my code implements the following base class:

abstract class BasePresenter<T : Any> : BaseContract.Presenter<T> {

    var view: T? = null

    private val job by lazy {
        Job()
    }

    private val coroutineScope by lazy { CoroutineScope( job + Dispatchers.Main) }

    override fun bindView(view: T) {
        this.view = view
    }

    override fun unbindView() {
        job.cancel()
        view = null
    }

    fun launch(block: suspend CoroutineScope.() -> Unit): Job {
        return coroutineScope.launch(block = block)
    }
}

A BasePresenter implementation might call launch{ songsRelay.collect {...} } When the presenter is unbound, in order to prevent leaks, I cancel the parent job. Any time a presenter that was collecting the songsRelay StateFlow is unbound, the StateFlow is essentially terminated with a JobCancellationException , and no other collectors/presenters can collect values from it.

I've noticed that I can call job.cancelChildren() instead, and this seems to work ( StateFlow doesn't complete with a JobCancellationException ). But then I wonder what the point is of declaring a parent job , if I can't cancel the job itself. I could just remove job altogether, and call coroutineScope.coroutineContext.cancelChildren() to the same effect.

If I do just call job.cancelChildren() , is that sufficient? I feel like by not calling coroutineScope.cancel() , or job.cancel() , I may not be correctly or completely cleaning up the tasks that I have kicked off.

I also don't understand why the JobCancellationException is propagated up the hierarchy when job.cancel() is called. Isn't job the 'parent' here? Why does cancelling it affect my StateFlow ?

UPDATE:

Are you sure your songRelay is actually getting cancelled for all presenters? I ran this test and "Song relay completed" is printed, because onCompletion also catches downstream exceptions. However Presenter 2 emits the value 2 just fine, AFTER song relay prints "completed". If I cancel Presenter 2, "Song relay completed" is printed again with a JobCancellationException for Presenter 2's job.

I do find it interesting how the one flow instance will emit once each for each collector subscribed. I didn't realize that about flows.

    val songsRelay: Flow<Int> by lazy {
        MutableStateFlow<Int?>(null).apply {
            CoroutineScope(Dispatchers.IO)
                    .launch {
                        flow {
                            emit(1)
                            delay(1000)
                            emit(2)
                            delay(1000)
                            emit(3)
                        }.onCompletion {
                            println("Dao completed")
                        }.collect { value = it }
                    }
        }.filterNotNull()
                .onCompletion { cause ->
                    println("Song relay completed: $cause")
                }
    }

    @Test
    fun test() = runBlocking {
        val job = Job()
        val presenterScope1 = CoroutineScope(job + Dispatchers.Unconfined)
        val presenterScope2 = CoroutineScope(Job() + Dispatchers.Unconfined)

        presenterScope1.launch {
            songsRelay.onCompletion { cause ->
                println("Presenter 1 Completed: $cause")
            }.collect {
                println("Presenter 1 emits: $it")
            }
        }

        presenterScope2.launch {
            songsRelay.collect {
                println("Presenter 2 emits: $it")
            }
        }

        presenterScope1.cancel()

        delay(2000)
        println("Done test")
    }

I think you need to use SupervisorJob in your BasePresenter instead of Job . In general using Job would be a mistake for the whole presenter, because one failed coroutine will cancel all coroutines in the Presenter. Generally not what you want.

OK, so the problem was some false assumptions I made when testing this. The StateFlow is behaving correctly, and cancellation is working as expected.

I was thinking that between Presenters , StateFlow would stop emitting values, but I was actually testing the same instance of a Presenter - so its Job had been cancelled and thus it's not expected to continue collecting Flow emissions.

I also mistakenly took CancellationException messages emitted in onCompletion of the StateFlow to mean the StateFlow itself had been cancelled - when actually it was just saying the downstream Collector / Job had been cancelled.

I've come up with a better implementation of BasePresenter that looks like so:

abstract class BasePresenter<T : Any> : BaseContract.Presenter<T>, CoroutineScope {

    var view: T? = null

    private var job = Job()

    override val coroutineContext: CoroutineContext
        get() = job + Dispatchers.Main

    override fun bindView(view: T) {
        if (job.isCancelled) {
            job = Job()
        }
        this.view = view
    }

    override fun unbindView() {
        job.cancel()
        view = null
    }
}

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM