Replacing SwingWorker with Kotlin coroutines
Note: code samples in this post are kept in sync with the latest stable Kotlin coroutines release. All the samples have been verified to work with release 1.9.0. Also see the official guide to UI programming with coroutines.
Kotlin 1.3 has been released, and one of the major additions to the language is the official graduation of coroutines from experimental to stable. Let’s take a look at how we can replace the old and venerable SwingWorker with something that is a bit more modern.
In the simplest use case, you do some kind of long-running work in your doInBackground()
and then process the result of that work in done()
:
object : SwingWorker<List<StringValuePair>, Void>() {
@Throws(Exception::class)
override fun doInBackground(): List<StringValuePair>? {
return bar.callback.getLeafs(newPath)
}
override fun done() {
try {
filePanel.setFolder(get())
} catch (exc: Exception) {
}
}
}.execute()
There’s a whole bunch of noise, so to speak, around the core of the logic – calling breadcrumb bar’s callback to get the leaf content for the newly set path, and then populating the folder based on the retrieved content. Let’s see how this code can look like with coroutines:
filePanel.setFolder(GlobalScope.async {
bar.callback.getLeafs(newPath)
}.await())
In this case, we have distilled the core of the logic flow to its essence – asynchronous loading of the leaf content, followed by updating the UI. Another option is to collapse the async / await
block into a single withContext
to change the execution context away from the UI thread:
filePanel.setFolder(withContext(Dispatchers.Default) {
bar.callback.getLeafs(newPath)
})
Note that once you switch to coroutines, you also need a larger context for proper synchronization. In Swing, it means wrapping the entire listener with GlobalScope.launch(Dispatchers.Swing)
:
// Configure the breadcrumb bar to update the file panel every time
// the path changes
bar.model.addPathListener {
GlobalScope.launch(Dispatchers.Swing) {
val newPath = bar.model.items
if (newPath.size > 0) {
// Use the Kotlin coroutines (experimental) to kick the
// loading of the path leaf content off the UI thread and then
// pipe it back to the UI thread in setFolder call.
filePanel.setFolder(withContext(Dispatchers.Default) {
bar.callback.getLeafs(newPath)
})
}
}
}
Now let’s take a look at something a bit more interactive – updating the UI on the ongoing progress of a long-running background task.
Let’s add a button and a label to a sample frame, and kick off a 5-second task that updates the UI every second on the progress using SwingWorker.process()
:
val button = JButton("Start operation!")
val status = JLabel("Progress")
frame.add(button)
frame.add(status)
button.addActionListener {
class MyWorker : SwingWorker<Unit, Int>() {
override fun doInBackground() {
for (i in 1..5) {
publish(i)
Thread.sleep(1000)
}
}
override fun process(chunks: MutableList<Int>?) {
status.text = "Progress " + chunks?.joinToString()
}
override fun done() {
status.text = "Done!"
}
}
val worker = MyWorker()
worker.execute()
}
The first way to convert to coroutines would be with the help of channels:
button.addActionListener {
GlobalScope.launch(Dispatchers.Swing) {
val channel = Channel<Int>()
GlobalScope.launch {
for (x in 1..5) {
println("Sending $x " + SwingUtilities.isEventDispatchThread())
// This is happening off the main thread
channel.send(x)
// Emulating long-running background processing
delay(1000L)
}
// Close the channel as we're done processing
channel.close()
}
// The next loop keeps on going as long as the channel is not closed
for (y in channel) {
println("Processing $y " + SwingUtilities.isEventDispatchThread())
status.text = "Progress $y"
}
status.text = "Done!"
}
}
Note the usage of Dispatchers.Swing
context that is passed to the GlobalScope.launch()
function and the wrapping of the emulated long-running task in another GlobalScope.launch
lambda. Then, as long as that lambda keeps on send
ing content into the channel, the for
loop iterates over the channel content on the UI thread, and then relinquishes the UI thread so that it is no longer blocked.
Now let’s make it a little bit more structured. While code samples found in documentation and tutorials run on the lighter side of things (including this article), real-life apps would have more complexity on both sides of async processing. We’re going to split this logic into two parts – background processing of data and updating the UI on the main thread. Background processing becomes a separate function that returns a data channel:
fun process() : ReceiveChannel<Int> {
val channel = Channel<Int>()
GlobalScope.launch {
for (x in 1..5) {
println("Sending $x " + SwingUtilities.isEventDispatchThread())
// This is happening off the main thread
channel.send(x)
// Emulating long-running background processing
delay(1000L)
}
// Close the channel as we're done processing
channel.close()
}
return channel
}
And UI code consumes the data posted to the channel and updates the relevant pieces:
button.addActionListener {
GlobalScope.launch(Dispatchers.Swing) {
// The next loop keeps on going as long as the channel is not closed
for (y in process()) {
println("Processing $y " + SwingUtilities.isEventDispatchThread())
status.text = "Progress $y"
}
status.text = "Done!"
}
}
Let’s make the background task cancellable so that the currently running operation can be safely canceled without subsequent erroneous UI updates. Background processing returns an object that has two parts – data channel and a job that can be canceled if needed:
class ProcessResult(val resultChannel: ReceiveChannel<Int>, val job: Job)
fun processCancelable() : ProcessResult {
val channel = Channel()
val job = GlobalScope.launch {
for (x in 1..5) {
if (!isActive) {
// This async operation has been canceled
break
}
println("Sending $x " + SwingUtilities.isEventDispatchThread())
// This is happening off the main thread
channel.send(x)
// Emulating long-running background processing
delay(1000L)
}
// Close the channel as we're done processing
channel.close()
}
return ProcessResult(channel, job)
}
And on the UI side of things, we keep track of the last job that was kicked off and cancel it:
var currJob: Job? = null
button.addActionListener {
GlobalScope.launch(Dispatchers.Swing) {
currJob?.cancel()
val processResult = processCancelable()
currJob = processResult.job
// The next loop keeps on going as long as the channel is not closed
for (y in processResult.resultChannel) {
println("Processing $y " + SwingUtilities.isEventDispatchThread())
status.text = "Progress $y"
}
status.text = "Done!"
}
}
Finally, we can move away from the existing concept of data communication “pipe” between the two parts, and start thinking in terms of passing a lambda to be invoked by the data producer when it has completed processing the next chunk of data. In this last example, the producer marks itself with the suspend
keyword and uses the parent context so that cancellation is properly propagated:
suspend fun processAlternative(job : Job, progress: (Int) -> Unit = {}) {
for (x in 1..5) {
// Emulating long-running background processing.
// Use the parent job so that cancellation of the parent propagates in here.
GlobalScope.async(context=job) {
println("Running on " + SwingUtilities.isEventDispatchThread())
delay(1000L)
}.await()
// And calling the callback on the UI thread
println("Sending $x " + SwingUtilities.isEventDispatchThread())
progress(x)
}
}
And the UI side of things supplies a lambda that updates the relevant UI pieces:
var currJob: Job? = null
button.addActionListener {
currJob?.cancel()
currJob = GlobalScope.launch(Dispatchers.Swing) {
// This will run until all the sequential async blocks are done
processAlternative(currJob!!) { progress ->
println("Processing $progress " + SwingUtilities.isEventDispatchThread())
status.text = "Progress $progress"
}
status.text = "Done!"
}
}
If you want start playing with replacing SwingWorker
with coroutines, add org.jetbrains.kotlinx:kotlinx-coroutines-swing
and org.jetbrains.kotlinx:kotlinx-coroutines-core
to your dependencies. The latest version for both of the modules is 1.9.0
.