HI all ... basic question, as I'm still fairly new...
# coroutines
b
HI all ... basic question, as I'm still fairly new to coroutines ... I've setup a
SimplePoller
class that will run a
block: suspend () -> Unit
on a given interval. it looks like this (code condensed a bit for posting in Slack):
Copy code
class SimplePoller(private val interval: Long, private val block: suspend () -> Unit) {

    private var job: Job? = null

    fun start(scope: CoroutineScope) {
        if (job == null || job?.isCompleted == true) {
            job = scope.launch {
                while (isActive) {
                    try {
                        block()
                        delay(interval)
                    } catch (ex: Exception) { cancel() } // stop on errors
                }
            }
        }
    }

    fun stop() {
        job?.let { if (it.isActive) it.cancel() }
    }
}
Most of the time, it's used to poll the
block
while some Android
Lifecycle
is in a certain state. For example:
Copy code
val myPoller = SimplePoller(60_000) {
    //do some work that needs to be repeated like call a network api, etc
}

lifecycleScope.launch { 
    whenResumed { 
        myPoller.start(this)    
    }
}
Inside the poller, I launch a new job in the passed in scope so that I can "manually" (via a call to
stop()
) stop the polling if necessary. Does this seem like a good approach? It seems to work pretty well. In testing the poller runs while the lifecycle is resumed, and suspends (because the passed in scope uses a magical Android "PausingDispatcher") when lifecycle < resumed. It also seems flexible enough that I could really use it in other, non-android-lifecycle situations:
Copy code
someScope.launch {
   myPoller.start(this)
}

// And then sometime later:
myPoller.stop()
I don't see anything immediately wrong, but as I mentioned, I'm still new to coroutines, so... 🤷
a
It looks like it's doing too much and duplicating a lot of what the launch API already offers. You could reduce this to just:
Copy code
class SimplePoller(
  private val interval: Long,
  private val block: suspend () -> Unit
) {
  suspend fun poll() {
    while (isActive) {
      block()
      delay(interval)
    }
  }
}
and get this class out of the business of scope and job management altogether, especially with how you're using it in the usage example. But it also begs other questions: why not use a
flow {}
instead? Is this periodic poller only executed for its side effects? What are those side effects? Using a flow with well-defined outputs in situations like this often helps make the overall system more deterministic, less tightly coupled, and easier to test.
(Silently eating errors is also a short term benefit traded for long term frustration)
âž• 1
on the topic of the pausing dispatcher in general, I regret that we ever shipped that in a library. It breaks a lot of valid suspending code and requires deep knowledge of what it's doing internally to use safely outside of trivial use cases
it's also super easy to create long-lived leaks with it since it breaks timely coroutine cancellation
u
To motivate not duplicating the launcher API I want to point out that your start/stop pair is racy. What if your job is canceling while someone calls
start
? Most of us would get that wrong or suboptimal. That’s why it is better to resort on the existing infrastructure.
âž• 1
a
yeah that too. Getting that right generally involves either locks or CAS loops
b
Thanks @Adam Powell! A few follow ups: To answer a primary question, yes 99% of the time it's use is as a periodic poller for side effects. Mainly ... fetch some data from a network api, and update a table(s) in a Room database. 1. I originally didn't have the extra job and passed in scope as in your refactor. I added it because there are occasions where I need to stop it "manually" (i.e. before the lifecycle it's attached to is destroyed or is still active). How could I accomplish that? 2. yes, my "full" implementation doesn't attempt to silently exit on error. I just put that bit in to condense the code for posting here. 3. I know what you mean on that pausing dispatcher. I presumed that under the hood,
whenResumed
would use an observer to launch a corouting when entring the resumed state and cancel it when the lifecycle "leaves" the resumed state. I was quite surprised to find on my first attempt that when the lifecycle when to STOPPED, that the
whenResumed
scope didn't get just canceled! What?!?!?! I had to dig through the source to find the pausing dispatcher and realize what was going on.
I guess for "manual" cancelation, I could just cancel the higher level context?
Copy code
val pollingJob = someScope.launch {
    myPoller.start(60_000)
}

//and later...
pollingJob.cancel()
At that point, I don't even need a class for this, a simple function will do:
Copy code
suspend fun poll(interval: Long, block: ()->Unit) {
    while (isActive) {
      block()
      delay(interval)
    }
  }
a
Yeah, exactly.
The abstraction of the class isn't really pulling its weight
b
Awesome. Thanks!