DEV Community

loading...
Cover image for From Swing to Compose Desktop #7: Concurrency

From Swing to Compose Desktop #7: Concurrency

tkuenneth profile image Thomas Kuenneth Updated on ・4 min read

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")
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Modify ui state on that thread
  2. 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")
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

To use them we just need to add a dependency like this:

dependencies {
  implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.4.2")
}
Enter fullscreen mode Exit fullscreen mode

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)
      }
    },
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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

Discussion (0)

pic
Editor guide