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 exposesFoo
singletonschannel
property as a property of itself namedfoo
Foo
singletoninit
method consumeschannel
as flow and chains withmapLatest
mapLatest
should cancel innerjob
if a new value is "offered" to the channelmapLatest
implementation will run awhile
loop if the value istrue
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 singletonsshouldRunJob
to thebar.foo
(which is the same instance asFoo.channel
)Foo
singletonsmapLatest
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, beginwhile
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