0

My aim is to have a long lived singleton (that survives fragments being destroyed and that will be used by various view models in my app).

This singleton will expose a Channel<Boolean>, which I want to send values to. This singleton will be responsible for doing work based on if the sent value to this channel is true|false (i.e. - do a job if the channel is sent a value true else cancel the existing job).

There will be multiple fragments that will ultimately access this singleton and send true|false to this channel. However, it is given that there will only be one fragment interacting with this singleton at any given time. In addition, calls to offer on the Channel will all be made from the main thread and should therefore be synchronous. I will set my Channel to use Channel.BUFFERED for the capacity such that "quick" calls to the offer function will become buffered rather than discarding the previous "offered" values.

In my solution, I am using DI and Room DB, however I have been able to reproduce my issue with some simple code without the complexity of Room/DI. This seems to be a misunderstanding of coroutines/flows on my end.

I have hit upon an issue which I cannot make sense of. The code I include in this question gives simplest verifiable example of my problem (i.e. excluding Room/DI). It is simply a fragment, a singleton and a class which references the singleton, which itself is referenced by the fragment.

See the code below, my paragraph afterwards will explain what I think it should be doing, however I observe that it is not doing what I expected it (which I will also explain after the code below):

class ProblemFragment : Fragment() {

    lateinit var binding: FragmentBinding

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {

        binding = FragmentFireBinding.inflate(inflater, container, false)

        val bar = Bar()

        binding.root.setOnClickListener {
            Foo.shouldRunJob = !Foo.shouldRunJob
            bar.foo.offer(Foo.shouldRunJob)
        }

        return binding.root
    }
}

object Foo {
    val channel = Channel<Boolean>()
    var shouldRunJob = false

    private val scope = CoroutineScope(Dispatchers.IO)

    init {
        channel
            .consumeAsFlow()
            .mapLatest { shouldRun ->
                if (shouldRun) {
                    while (scope.isActive) {
                        delay(1000L)
                        Log.d("Foo", "Recieved")
                    }
                }

            }.launchIn(scope)
    }
}

class Bar {
    val foo = Foo.channel
}

So, here is what I think this code should be doing conceptually:

  • Bar class exposes Foo singletons channel property as a property of itself named foo
  • Foo singleton init method consumes channel as flow and chains with mapLatest
  • mapLatest should cancel inner job if a new value is "offered" to the channel
  • mapLatest implementation will run a while loop if the value is true else will do nothing

And procedurally:

  • Application fires up and fragment is created
  • Fragment creates bar instance
  • When user "clicks" the root view, toggle the singletons shouldRunJob state
  • offer the singletons shouldRunJob to the bar.foo (which is the same instance as Foo.channel)
  • Foo singletons mapLatest is invoked, with latest value (i.e. the one just "offered" from the fragment) which cause the inner job to be cancelled
  • if shouldRun is true, begin while loop, delaying by 1 second, before printing "Recieved"

So, I observe a few problems with my code:

#1 If I click the root once, i.e. setting Foo.shouldRunJob=true and calling bar.foo.offer(true) then I observe that I start seeing logs like:

"23:06:44.280 Foo: Recieved" "23:06:45.280 Foo: Recieved" ...

But, after approximately 5-6 log lines, the logs "stop" and then "resume" again after a few seconds. Almost like the flow is "blocked" by something. I expected to see consistent logs with approx 1 second between each log, indefintely. Guessing this is something to do with the downstream consumer causing "backpressure" on the flow? But the downstream consumer is simply logging to logcat so I don't really understand where the bottleneck is.

Note: As expected, if I click the root view again which toggles the Foo.shouldRunJob=false and invokes bar.foo.offer(false) this causes "inner" job (i.e. the code inside in the mapLatest lambda) to be cancelled. Toggling the Foo.shouldRunJob back to true results in the same behaviour as described in problem #1.

#2 Even more confusingly, if I trigger problem #1 and don't "cancel" the job (i.e. don't click the root view again) and then navigate away from the fragment; I continue to see the logs (which I expected - due the fact Foo is singleton), however, if I then return to the fragment (which therefore creates a new bar instance, and click the root again (which now toggles the Foo.shouldRunJob to false and invokes bar.foo.offer(false) then I observe the logs continue to print. Given that Foo is singleton, and therefore the channel property is also part of the singleton, I expect that the logs should stop printing because mapLatest chain call on the channel should cancel the inner job.

Can anyone provide some advice?

Further notes:

My real implementation is more complicated than this (i.e. I'm getting singleton using Dagger annotations, and using VM's etc etc) but I have been able to reproduce my issues using just the code above - i.e. Minimum Verifiable Example

Thomas Cook
  • 4,371
  • 2
  • 25
  • 42
  • 1
    1# LogCat will suppress identical messages - do you get "chatty" in the logcat? 2# `while (scope.isActive)` once triggered will run for the entire scope of the Singleton, mapping true, false, true will mean you'll get double logging, true, false, true, false, true - triple and so on ... – Mark Jun 11 '21 at 22:49
  • @MarkKeen - oh really? Well, in my example - yes I get "chatty" as you can see I keep printing the same message "Received"... but this is first time I've heard abou this. If true, that's *awful* feature of the logcat implementation in my opinion. – Thomas Cook Jun 11 '21 at 22:51
  • If you pass the "scope" to the singleton, and it holds a reference to it, it will leak the scope, viewmodel and fragment (until that reference is changed/invalidated). why not just have start and stop functions on the class i.e startTimer, stopTimer - using a "channel" and subscribing to it seems overkill as its only relevant to the the inernals of this singleton .. – Mark Jun 11 '21 at 22:57
  • @MarkKeen - Hmmm, so on #2, how come mapLatest doesn't cancel inner job but *only* in the case where I leave fragment and come back? The inner job (i.e. the code inside the `mapLatest` lambda) is running in a "child" scope of the outer scope - so it should cancel when new value is emitted from upstream - and it is, if I don't leave the fragment and simply click the root *again* after tapping it the first time - I observe the logs stop. So why the difference if I leave the fragment and come back? Mem leak maybe? Something funky going on in my opinion – Thomas Cook Jun 11 '21 at 22:58
  • 1
    Your "scope" is never canceled - hence it is always active - you never call `CoroutineScope::cancel`. everytime you "map" and its `true` it kicks off another while loop. – Mark Jun 11 '21 at 23:02
  • Ok that makes sense, but putting that aside, `mapLatest` is supposed to cancel the code inside it's lambda when a new value is emitted from the upstream (which btw, I observe if I stay on the fragment and click the root toggling from true->false and so on), I only notice my issue #2 when I leave the fragment and come back... – Thomas Cook Jun 11 '21 at 23:03
  • `mapLatest` code docs: " * Returns a flow that emits elements from the original flow transformed by [transform] function. * When the original flow emits a new value, computation of the [transform] block for previous value is cancelled." – Thomas Cook Jun 11 '21 at 23:04
  • So, even *if* the condition for the while loop is nonsense, even if I had while(true) - this code should be "cancelled" when the upstream emits again (and it does, if I stay on the fragment - but not if I leave and come back...) – Thomas Cook Jun 11 '21 at 23:05
  • 1
    Thanks, that's cleared it up for me I think - got my solution working now by launching a new job via `scope.launch { withContext(..) { while (true) { ... } }` inside my `mapLatest` lambda and keeping a reference to the returned job, which I'm cancelling at the start of `mapLatest` lambda. – Thomas Cook Jun 11 '21 at 23:30
  • Looking at mapLatest source - it actually does call `job::cancel` internally on each emission - this happens in `ChannelFlowTransformLatest::flowCollect` - so you were correct it does "cancel" the child - it just handles this internally (which is handy to know!) – Mark Jun 12 '21 at 00:11

0 Answers0