Welcome to the 7. and final post about my journey of transforming a Java Swing app to Compose for Desktop. Today I will focus on concurrency. To get an idea why this is necessary, please take a look at this clip:
Now, this looks bad. And of course it's all my fault. Look:
import androidx.compose.desktop.Window
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.Button
import androidx.compose.material.Text
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
fun main() = Window {
Box(contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxSize()) {
Button(onClick = {
while (true) {
println("ohoho...")
}
}) {
Text("Hallo")
}
}
}
Once the button has been clicked, the app is stuck in an infinite loop doing nothing but println()
. What we see here is common to many ui frameworks. They are said to be single-threaded, meaning that all work related to the user interface must be done on this particular thread. Swing calls it the Event Dispatch(ing) Thread, on Android it's the main thread. They all have it, and they all need it because creating a ui framework that can safely handle its state no matter on which thread this happens is said to be way too complex and prone to deadlocks.
This gives us two simple rules:
- Modify ui state on that thread
- Do all other work somewhere else
And that's why my example fails so miserably. It's not the fault of Swing, and it's not the fault of Compose for Desktop. We developers need to make sure that any heavy computation is done on a separate thread. But also the seemingly easy stuff, like file or network i/o. Yes that's right. Anything that is not ui related and might block other work for more than a few milliseconds should be done somewhere else.
Now, being Kotlin fans we have coroutines at our disposal. They give us lightweight threads.
package com.thomaskuenneth
import androidx.compose.desktop.Window
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.Button
import androidx.compose.material.Text
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
fun main() = Window {
Box(contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxSize()) {
Button(onClick = {
GlobalScope.launch {
while (true) {
println("ohoho...")
}
}
}) {
Text("Hallo")
}
}
}
To use them we just need to add a dependency like this:
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.4.2")
}
This post is no coroutine tutorial, so I am not going to explain what happens here, besides emphasizing that GlobalScope.launch
usually is not the best practice. A coroutine scope defines a lifecycle, a lifetime, for coroutines. Most of the time GlobalScope
is too broad. On the other hand, this way coroutines can be created most easily. So, I could add it to TKDupeFinder like this:
Button(
onClick = {
if (scanning.value) {
stopScan(currentPos, checksums, scanning)
} else {
scanning.value = true
selected.clear()
checksums.value = emptyList()
startScan(name.value.text, currentPos, checksums, scanning)
}
},
The idea is to start scanning for duplicate files if a button is pressed, and to stop if that button is pressed again.
private fun startScan(baseDir: String, currentPos: MutableState<Int>,
checksums: MutableState<List<String>>,
scanning: MutableState<Boolean>) {
GlobalScope.launch(Dispatchers.IO) {
df.clear()
df.scanDir(baseDir, true)
df.removeSingles()
stopScan(currentPos, checksums, scanning)
}
}
private fun stopScan(currentPos: MutableState<Int>,
checksums: MutableState<List<String>>,
scanning: MutableState<Boolean>) {
currentPos.value = 0
checksums.value = df.checksums.toList()
scanning.value = false
}
stopScan()
updates some state, which triggers a recomposition of the user interface. startScan()
does the actual search. Have you noticed that I pass Dispatchers.IO
? This specifies where a coroutine should be executed. There are several pools. Dispatchers.IO
is great for i/o related work.
Once the search is complete, I invoke stopScan()
. Recall that we are still on Dispatchers.IO
. This begs an interesting question. Shouldn't I make sure (using Dispatchers.Main
) that this is done on the appropriate thread? It seems that this is not necessary. In the end, my code is not actually changing the user interface, but only modifying state.
Using this code, TKDupeFinder works well. ...well, sort of... If I press the button to stop the scan, the ui is updated accordingly. But under the hood the scan continues. This should not surprise us, as in stopScan()
there is no code to cancel the coroutine. launch()
returns a Job
which has cancel()
. Great, right? But why the heck do I not use it, then? Coroutines need to cooperate. They must check regularly if they should continue running using something like while (isActive) {
.
The search is done by my old Java code. As I wanted to update only the user interface and leave the scanner unchanged, invoking isActive
is no option. So for this particular purpose, I decided to wrap the search into a classic java.lang.Thread
.
private var worker: Thread? = null
private fun startScan(baseDir: String, currentPos: MutableState<Int>,
checksums: MutableState<List<String>>,
scanning: MutableState<Boolean>) {
worker = thread {
df.clear()
df.scanDir(baseDir, true)
df.removeSingles()
invokeLater {
stopScan(currentPos, checksums, scanning)
}
}
}
private fun stopScan(currentPos: MutableState<Int>,
checksums: MutableState<List<String>>,
scanning: MutableState<Boolean>) {
if (worker?.isAlive == true) {
worker?.stop()
}
currentPos.value = 0
checksums.value = df.checksums.toList()
scanning.value = false
}
This solution is certainly not ideal, as stop()
has been deprecated for (almost) ever. Also, with thread
I seem to have to invoke stopScan()
on the main thread (using invokeLater()
). But then, it just works. 😂
I hope you enjoyed this little series. I would love to hear your thoughts in the comments. I will continue working on the app. You can find it on GitHub.
From Swing to Jetpack Compose Desktop #1
From Swing to Jetpack Compose Desktop #2
From Swing to Compose Desktop #3
From Swing to Compose Desktop #4
From Swing to Compose Desktop #5
From Swing to Compose Desktop #6
Top comments (1)
The biggest problem I'm having with Jetpack Compose (once getting past all the teething issues with using such a new framework) is figuring out how to implement all the layouts I'm used to in Swing.
For example, simple column and row layouts are really easy in Compose, but I still haven't figured out how to get something like BorderLayout where there is one component which takes up all the space that isn't used by the components around it. I know I can resort to using a custom layout for this, but it really seems like such a common layout shouldn't need me to do that.