Given this simple method which is triggered on but...
# coroutines
l
Given this simple method which is triggered on button click in Compose:
Copy code
// In ViewModel
    fun toggleScan() {
        when {
            isScanning.value -> stopScan()
            else -> {
                startScan()
                presenterScope.launch(Dispatchers.Default) {
                    delay(DISCOVERY_TIMEOUT)
                }
            }
        }
    }
What is an elegant way to avoid creating a new coroutine on every button click?
z
Why are you trying to avoid that? If you need to launch a coroutine on click to do some longer-running work asynchronously, that’s a perfectly reasonable thing to do.
👍 1
l
When I click the button, it is animated with a shimmer effect and the disovery process for nearby bt devices (
startScan()
) is running until the timeout runs out. Correct me if I'm wrong but when I click the button again following happens: --startScan--------------------------------------------------stopScan -----------------startScan----------------------------------------x----------stopScan ---------------------------------startScan----------------------------------------x---------------stopScan On x, the state is no longer consistent. In the past I have experienced unwanted behavior with not canceling the active coroutine
z
On x, the state is no longer consistent
Because
isScanning.value
isn’t updated?
l
Sorry for the late reply. I have to recheck that but anyway, isn't it a problem to launch everytime a new coroutine? When I click the button, I do'nt need the active one. It's just running until timeout with no benefit or did I miss something. The rest of the logic:
Copy code
// Also in ViewModel 

   fun startScan() {
        presenterScope.launch { discoverBtDevicesTask.startDiscovery() }
        isScanning.value = true
    }

    fun stopScan() {
        presenterScope.launch { discoverBtDevicesTask.stopDiscovery() }
        isScanning.value = false
    }
a
if you're worrying performance-wise, coroutines are cheap. There shouldn't be any real penalty for spawning unnecessary coroutines. From your description, it seems like you need to cancel previously running coroutine if it's still "running" (delaying). You can manage it with structured concurrency or by cancelling job directly
l
That's my current approach:
Copy code
lateinit var timerJob: Job

    fun toggleScan() {
        if (isScanning.value) {
            timerJob.cancel()
            stopScan()
        } else {
            startScan()
            // Scan for [DISCOVERY_TIMEOUT] seconds
            timerJob = presenterScope.launch(Dispatchers.Default) {
                delay(DISCOVERY_TIMEOUT)
            }

            timerJob.invokeOnCompletion { stopScan() }
        }
    }
But it doesn't feel right. Is there room for improvement?
l
An elegant way is to wait for clicks before starting the scan/operation, and keep the button disabled while a scan/operation is ongoing.
Shameless plug: I made a library that contains a function named
awaitOneClick()
(there are ways to get something similar in Compose with
MutableState
), and it allows just that, disabling the button after click, until it's called again by default. https://splitties.louiscad.com/modules/views-coroutines/#content Here's the permalink to the code if you are interested but don't feel like adding the dependency, or are just curious about the implementation: https://github.com/LouisCAD/Splitties/blob/1ea7e072ae7fba5b989f226dd8371cd22d0916e[…]idMain/kotlin/splitties/views/coroutines/VisibilityAndClicks.kt
l
To clarify the use case: I don't want to disable the button. The discovery should be cancelable at every time. So when I click the button first time, discovery is started,
isScanning
is true. When clicked again, discovery is stopped,
isScanning
is false, when not clicked,
isScanning
is false after timeout. When I don't cancel the active coroutine following should happen in theory (explains the strange behavior I experienced): First click,
isScanhning
is true, coroutine A is runnung Second click,
isScanning
is false, coroutine A + B is running Third click,
isScanning
is true, coroutine A + B + C is running. This time I wait for timeout but since A is still running,
isScanning
is changed to false too early.
I had a look into the implementation but tbh I don't get the intent. What's the purpose of disabling the button at the end. When does the button being enbaled again? Also I wouldn't know how to implement it in compose. That's what I currently work with:
Copy code
ExtendedFloatingActionButton(
  onClick = { onToggleScan() },
)
z
I would probably just use the scanning job as the source of truth. If there’s a job, and the job is active, scanning is in progress. If the job is null or not active, then there’s no scan.
💯 1
2
d
Just to add another tool to your belt @Lilly consider the option to model button clicks as
Flow<Unit>
, that way you can
debounce()
them and flat-map to the discovery e.g:
Copy code
private val clicksFlow = MutableSharedFlow<Unit>()
fun onClicked() = clicksFlow.tryEmit(Unit)

val discoveredFlow: Flow<DiscoveryResults> =
    clicksFlow
        .debounce(100)
        .flatMapLatest() {
            beginDiscoveryProcess()
        }
...meaning that if the button is clicked again, any ongoing discovery process is 'automatically' cancelled (that's the
-Latest
part) and a new one started.
l
@Zach Klippenstein (he/him) [MOD], @darkmoon_uk Good point Chris, I like the approach, especially the
-Latest
part. Are there any other constructs, that cancel and start a coroutine like
-Latest
?
d
Well there is also plain
mapLatest
which may be more suitable depending on your scanning API