What is the recommended way of managing coroutines...
# compose-desktop
r
What is the recommended way of managing coroutines in a Compose Desktop app? At the moment I'm trying the following: • Get an application-level scope using
MainScope()
from my
application
block and pass it around • My ViewModel instances receive the application
CoroutineScope
as a constructor parameter and setup a view model scope from that using
CoroutineScope(coroutineScope.coroutineContext + SupervisorJob())
• My ViewModel instances launch coroutines to the data layer using
scope.launch
or
scope.async
builders. • At application shutdown, the application coroutine scope has it's
cancel
method called like
appCoroutineScope.cancel("Application Shutdown")
I originally thought this was sufficient to make sure all jobs were correctly cancelled on shutdown, but I haven't been able to convince myself things are actually shutting down properly.
👀 1
a
I guess on the application shutdown the process is destroyed anyway, and all threads are killed. But if your ViewModels are scoped to screens, then they should be managed with navigation.
r
At the moment the application is only a single window (single screen also) hence tying it all to the application lifecycle. Here is the basic code for my ViewModel base class:
Copy code
open class ViewModel(coroutineScope: CoroutineScope) {
    protected val job = SupervisorJob()
    protected val scope: CoroutineScope = CoroutineScope(coroutineScope.coroutineContext + job)
}
Is creating a new scope within the application scope like this not enough to link them together?
c
A Desktop app won’t have the same terrible lifecycle issues that an Android app has, so you would probably be fine scoping your ViewModels to whatever screen’s Composable function they’re a part of with
rememberCoroutineScope()
. The ViewModel would live as long as that function is in scope, which is probably what you want for a UI ViewModel. Any work that lives longer than a Composable function would then need to be managed on a scope that lives outside of the Compose world, most likely. So launch those jobs onto
MainScope()
, or create custom scopes as-needed per application logic
r
Thanks, I appreciate your insight. I steered clear of
rememberCoroutineScope
since I already have trouble keeping context and scope straight in terms of when to use which since it's named
rememberCoroutineScope
but takes a function that produces a
CoroutineContext
lol
c
To over-simplify things here, a
CoroutineScope
is made up of
CoroutineContext
elements, and you don’t need to worry about it much beyond that for typical usage. So don’t get too caught up on the actual signature of that function, it’s meant to be used like this:
Copy code
class Screen1ViewModel(private val viewModelScope: CoroutineScope) {
    fun doWork() = viewModelScope.launch {
        // do a thing
    }
}

@Composable
fun Screen1() {
    val scope = rememberCoroutineScope { Dispatchers.Default }
    val viewModel = remember(scope) { Screen1ViewModel(scope) }
}
a
What do you want to inherit from
coroutineScope.coroutineContext
? When you cancel a scope, what is cancelled is its job, but since you are using a new job for each view model scope, I don’t think those scopes will be cancelled when you cancel the application scope.
r
Maybe I have it all backwards then. I have been thinking of CoroutineContext as "larger than" CoroutineScope as in
Copy code
+--------------- Context -----------------+
|                                         |
|   +------ Scope ---------+              |
|   |  + launch            |              |
|   |  + async             |              |
|   |  + etc...            |              |
|   |                      |              |
|   |                      |              |
|   |                      |              |
|   |                      |              |
|   +----------------------+              |
|                                         |
|                                         |
+-----------------------------------------+
@Albert Chang - the thinking was that on application shutdown I would propagate cancellation to any view model scopes, and from there down to any jobs those view models had launched and I thought that to achieve this I should be combining scopes in the way I mentioned in the original post
So that I'd have:
Copy code
Application Scope
  |- ViewModel1 Scope
    |- ViewModel1 Job 1
    |- ViewModel1 Job 2
    |- ViewModel1 Job 3
    ...
  |- ViewModel2 Scope
    |- ViewModel2 Job 1
    |- ViewModel2 Job 2
    |- ViewModel2 Job 3
    ...
Then on shutdown a simple
applicationScope.cancel
would propagate cancellation down to its whole tree
a
As I said each view model has their own job instead of inheriting the job of application scope, the cancellation of application scope won’t be propagated.
r
I suppose I thought that
CoroutineScope(coroutineScope.coroutineContext + job)
would essentially add the view model's job as a child of the application scope job
a
That overrides the job.
r
Ohhhhhh that's not what I was going for at all lol
c
It sounds like you should probably study coroutines a bit more before attempting anything too crazy with custom scopes and things like that. However, with Compose, as long as you only use
rememberCoroutineScope()
, I think things will generally work as you expect without too much fuss. Each point that you call
rememberCoroutineScope()
will be cancelled if that point in the Compose hierarchy gets removed, so effectively it gives you a way to run your coroutines as long as that portion of the Compose UI hierarchy is visible. And the same will hold true in a multi-window application using the
application { }
builder, where anything launched from the scope of the application root will live beyond any single
window { }
, but anything launched within the
window
will only be active if the window itself is open.
Copy code
fun main() = application {
    val applicationCoroutineScope = rememberCoroutineScope()
    val applicationViewModel = remember(applicationCoroutineScope) { ApplicationViewModel(applicationCoroutineScope) }

    window {
        val windowCoroutineScope = rememberCoroutineScope()
        val windowViewModel = remember(windowCoroutineScope) { WindowViewModel(windowCoroutineScope) }
        val showScreen1 by windowViewModel.showScreen1.collectAsState()

        if(showScreen1) {
            screen1 {
                // if `showScreen1` becomes false, this block will go away and `screen1CoroutineScope` will be
                // cancelled, along with anything launched into its scope
                val screen1CoroutineScope = rememberCoroutineScope()
                val screen1ViewModel = remember(screen1CoroutineScope) { Screen1ViewModel(screen1CoroutineScope) }
            }
        }

        screen2 {
            // if `showScreen1` becomes false, this block will continue to stick around and its jobs will continue running
            val screen2CoroutineScope = rememberCoroutineScope()
            val screen2ViewModel = remember(screen2CoroutineScope) { Screen1ViewModel(screen2CoroutineScope) }
        }
    }
}
m
@Casey Brooks I handle coroutines and my view models in exactly the same way with one small difference. I don’t also add the scope to the remember function as you do. I am wondering whether this could ever be needed. Could you please explain your reasoning about that detail?
c
It’s probably not necessary, but I do it out an abundance of caution in following with the guidance that any variable used inside the
remember { }
block should be set as one of its keys
m
I see. I was just concerned that this could lead to a wrong understanding of the lifecycle of the CoroutineScopes here because AFAIK they live exactly as long as your view model will live which is the whole time where this composition is active.
c
The CoroutineScope lives as long as that specific point in the Composition, not the entire time the whole Compose application is alive. And this makes the ViewModel live exactly as long as the UI it is concerned with, which is generally what you want. The fact that Android’s ViewModel classes live longer than the UI is really a hack because of the confusing lifecycle of the Android app, not because it’s a desirable trait of the ViewModel. Realistically, the Android ViewModels live as long as a screen is active, as you would expect, it’s just that the UI may live shorter than you expect due to configuration changes. Desktop apps do not have that problem, so you don’t want your ViewModels to outlive the portion of UI they supply
And with ViewModels scoped to a point in the Compose hierarchy, they should never be passed to a parent Composable function, so you don’t have to worry about confusing ViewModel lifecycles. If you can pass a reference to a ViewModel to a child Composable function, then the ViewModel is active
m
I guess you misunderstood me. I said “where this composition is active” meaning the time span where a screen is shown. Not the time span of the whole application. And as you said “the ViewModel live exactly as long as the UI it is concerned with” means that the CoroutineScope also does not change for this time and therefore there is no reason to put it into the remember function. And I agree with you that Androids handling is not what you really want. I always switch configuration changes off in the manifest which makes my life much easier.