javakotlinswingkotlin-coroutineskotlin-flow

How to convert Java Swing events to Kotlin Flow with maximal performance


I am trying to convert Java Swing events, normally obtained with event listeners, to Kotlin Flow of these events. I checked code of many projects and found more than one way of doing it. I was wondering if someone can give me reasons to use one style over another, in respect to the performance / reliability / resource usage:

fun JButton.actionEvents1(): Flow<ActionEvent> = callbackFlow {
  val listener = ActionListener { e ->
    trySend(e)
  }
  addActionListener(listener)
  awaitClose {
    removeActionListener(listener)
  }
}

fun JButton.actionEvents2(): Flow<ActionEvent> = callbackFlow {
  val listener = ActionListener { e ->
    trySend(e).isSuccess
  }
  addActionListener(listener)
  awaitClose {
    removeActionListener(listener)
  }
}

fun JButton.actionEvents3(): Flow<ActionEvent> = callbackFlow {
  val listener = ActionListener { e ->
    trySendBlocking(e)
  }
  addActionListener(listener)
  awaitClose {
    removeActionListener(listener)
  }
}

fun JButton.actionEvents4(
  scope: CoroutineScope
): Flow<ActionEvent> = callbackFlow {
  val listener = ActionListener { e ->
    scope.launch { send(e) }
  }
  addActionListener(listener)
  awaitClose {
    removeActionListener(listener)
  }
}

The last example is assuming that we have access to the CoroutineScope instance, which in my case would be the same scope from which each action events flow is obtained and collect is called. Should I avoid launching a coroutine for each event?


Solution

  • Which one to use depends on how you want to handle situations where ActionEvents are issued faster than they can be handled.

    The callbackFlow uses a channel internally to decouple sending and receiving the events, acting as a blocking queue. Events from the ActionListener are sent to the channel and are stored there until they are received by the collector of the flow. When the flow collects slower than new events are produced, back-pressure is building up on the channel which is absorbed by the channel's buffer. The buffer, however, is limited (to 64 elements by default), and when the buffer is full new events cannot be sent anymore.

    You need to specify what should happen in this situation. There are several options:

    1. Block the current thread until space becomes available.
    2. Suspend the current thread until space becomes available.
    3. Skip the current event.
    4. Drop the oldest event from the buffer to make space for the new event.

    Before going into more detail, you can always increase the buffer size, preventing problematic back-pressure situations to arise in the first place. Use .buffer(100) (or whatever buffer size seems appropriate to you) on the callbackFlow { ... }. Beware, though: If you constantly issue events faster than they are handled this will only delay the problem. Only increase the buffer size if you can successfully absorb spikes where a lot of events are issued in bursts.

    Option 1 (Block): The trySendBlocking of your actionEvents3 function blocks the main thread that was used to issue the event until the channel isn't full anymore. While the main thread is blocked your UI is unresponsive. You should use this only if there won't be much back-pressure on the channel (i.e., events are usually handled fast enough before the next event is produced).

    Option 2 (Suspend): The send of your actionEvents4 function suspends the current thread until the channel isn't full anymore. Suspending the main thread would be good because it allows other code to run on the main thread, keeping your UI responsive. However, it may not even be the main thread that is suspended here because the only thing you do on that thread is launching a new coroutine. That coroutine is what is suspended by send, and that coroutine is launched in the scope you provided. Which may or may not use the main thread, it doesn't matter.
    This option is the safest, but it is also the most complicated. If you have serious issues with the back-pressure and cannot skip or drop any events, this may still be the cleanest solution.

    Option 3 (Skip): The trySend of your actionEvents1 function backs off from sending the event if the channel is full, effectievly skipping the new event. If this is an appropriate behavior depends on the nature of the events. Repeatedly clicking the same button, so fast that the click event cannot be handled before the next click occurs may be an acceptable situation where the newest click can be ignored.
    Your actionEvents2 function behaves identical to actionEvents1. The only difference is that you access isSuccess, determining if the new event was sent successfully. But you do not do anything with that information and immediately end the lambda, so it doesn't matter if it actually was successful or not. You shouldn't use actionEvents2.

    Option 4 (Drop): Append .buffer(onBufferOverflow = BufferOverflow.DROP_OLDEST) to callbackFlow { ... } (you can also specify the buffer size here) and use trySend to send new events. If the channel is full the oldest event will be dropped so the new event can be immediately inserted. This can be useful if you do not need old events and only want to handle the newest events. You may want to actually reduce the buffer size in this case to force all old events to be dropped, instead of keeping old events as they fit in the buffer. Also see if you want to conflate the flow in this situation.