louiscad
05/05/2025, 9:55 PMImageComposeScene
renders one frame late, or just renders wrong?
I am using this composable where I call render
repeatedly on the same ImageComposeScene
, but with ever increasing nanoTime
values, and I only get the correct render if I call render
a second time, which is quite of a dirty workaround.
@Composable
fun JustFrameNumberComposable() {
val frameNumber by produceState(0L) {
while (true) withFrameMillis { frameTimeMillis ->
value = frameTimeMillis * 60L / 1000L
}
}
Text("Frame number: $frameNumber")
}
Alexander Maryanovsky
05/06/2025, 8:03 AMAlexander Maryanovsky
05/06/2025, 8:04 AMlouiscad
05/06/2025, 8:04 AMlouiscad
05/06/2025, 8:07 AMAlexander Maryanovsky
05/06/2025, 8:09 AMlouiscad
05/06/2025, 8:10 AMAlexander Maryanovsky
05/06/2025, 8:11 AMlouiscad
05/06/2025, 8:12 AMlouiscad
05/06/2025, 8:12 AMAlexander Maryanovsky
05/06/2025, 8:13 AMlouiscad
05/12/2025, 12:07 AMApplication.desktop.kt
?
private object YieldFrameClock : MonotonicFrameClock {
override suspend fun <R> withFrameNanos(
onFrame: (frameTimeNanos: Long) -> R
): R {
// We call `yield` to avoid blocking UI thread. If we don't call this then application
// can be frozen for the user in some cases as it will not receive any input events.
//
// Swing dispatcher will process all pending events and resume after `yield`.
yield()
return onFrame(System.nanoTime())
}
}
To me, the yield()
should come after onFrame(…)
in a finally
block, or after with the result of onFrame
being put in a local val
.Alexander Maryanovsky
05/12/2025, 6:45 AMAlexander Maryanovsky
05/19/2025, 10:33 AMsetContent
call already runs the 1st composition (the one with frameNumber=0).
Then each ImageComposeScene.render
call correctly calls the withFrameMillis
lambda and then recomposes.
This code seems to work as expected:
fun main() {
val scene = ImageComposeScene(640, 480)
scene.setContent {
val frame by produceState(0L) {
while (true) {
withFrameNanos {
value = it
}
}
}
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.White),
contentAlignment = Alignment.Center
) {
Text("Frame: $frame")
}
}
for (frameNumber in 1..10) {
val image = scene.render(frameNumber.toLong())
image.encodeToData()?.let { data ->
val file = File("frame-$frameNumber.png")
file.writeBytes(data.bytes)
}
}
scene.close()
}
i.e. each frame-N.png
file contains a picture with “Frame: N” text.Alexander Maryanovsky
05/19/2025, 11:52 AMwithFrameNanos/Millis
is actually called first, before recomposition.Alexander Maryanovsky
05/19/2025, 12:21 PMAlexander Maryanovsky
05/19/2025, 12:24 PMMainUIDispatcher
).louiscad
05/19/2025, 6:45 PMproduceState
builder (from a parent, visible composition)?Alexander Maryanovsky
05/19/2025, 6:49 PMAlexander Maryanovsky
05/19/2025, 6:53 PMlouiscad
05/19/2025, 7:28 PMrunBlocking(MainUiDispatcher) { ... }
or alike?Alexander Maryanovsky
05/19/2025, 7:31 PMlouiscad
05/19/2025, 7:37 PMImageScene
and the Compose machinery, as I get the ImageScene.render
function to sometimes output another composable that is in the app.louiscad
05/19/2025, 7:41 PMlouiscad
05/19/2025, 7:41 PMlouiscad
05/19/2025, 7:42 PMMainUiDispatcher
)Alexander Maryanovsky
05/19/2025, 7:50 PMAlexander Maryanovsky
05/19/2025, 7:50 PMlouiscad
05/19/2025, 7:51 PMAlexander Maryanovsky
05/19/2025, 7:51 PMlouiscad
05/19/2025, 7:51 PMlouiscad
05/19/2025, 7:51 PMlouiscad
05/19/2025, 7:52 PM@RequiresOptIn
guard for ImageComposeScene
, because I really didn't expect that gotcha, that one discovers far into the project…Alexander Maryanovsky
05/19/2025, 7:55 PMAlexander Maryanovsky
05/19/2025, 7:56 PMlouiscad
05/19/2025, 7:56 PMlouiscad
05/19/2025, 7:57 PMAlexander Maryanovsky
05/19/2025, 7:57 PMAlexander Maryanovsky
05/19/2025, 7:57 PMAlexander Maryanovsky
05/19/2025, 7:58 PMlouiscad
05/19/2025, 7:58 PMAlexander Maryanovsky
05/19/2025, 7:58 PMlouiscad
05/19/2025, 7:58 PMImageComposeScene
gets updated to work just like a window, it'd avoid those issues too, wouldn't it?Alexander Maryanovsky
05/19/2025, 7:59 PMlouiscad
05/19/2025, 8:00 PMImageComposeScene
was used from MainUiDispatcher
blob thinking fastlouiscad
05/19/2025, 8:01 PMcoroutineContext
to coroutineContext = coroutineContext[ContinuationInterceptor]!!
, which is now MainUiDispatcher
too.Alexander Maryanovsky
05/19/2025, 8:01 PMlouiscad
05/19/2025, 8:02 PMrender
on Dispatchers.Default
louiscad
05/19/2025, 8:12 PMImageComposeScene
is instantiated (and closed) on MainUiDispatcher
AND render
is called there too.
Maybe just calling render
there is enough blob thinking fast
Anyway, that's fixing my issue, and performance doesn't seem to suffer significantly, which makes sense since the call to render
was the fastest thing in the pipeline (the slowest things being encoding the image to WEBP, and generating a video from those)louiscad
05/19/2025, 8:13 PMImageComposeScene
throw when not invoked from the right thread could get a chance of being merged?Alexander Maryanovsky
05/19/2025, 8:56 PMlouiscad
05/19/2025, 10:16 PM@RequiresOptIn
that warns about that too?
Otherwise, it's very likely to go unnoticed, and you know that in Kotlin, we're not used to these kinds of gotchas from Kotlin made libraries/primitives, especially first party ones.Alexander Maryanovsky
05/20/2025, 5:06 AMAlexander Maryanovsky
05/20/2025, 5:13 AMImageComposeScene
constructor is marked as @ExperimentalComposeUiApi
for some reason, but the secondary constructor, and renderComposeScene
aren’t.
Not sure why the 2nd constructor is even needed. I added it here.Igor Demin
05/20/2025, 10:43 AMIs that the appropriate annotation for something like that? @Igor Demin What do you think?It can be
DelicateComposeUiApi
(similar to DelicateCoroutinesApi
).
But I would rather fix the issue of multi-threading. Because adding this annotation isn't easy - we should add to AOSP, as ComposeScene is planned to be upstreamed, and it will cause source-incompatibilityAlexander Maryanovsky
05/20/2025, 10:49 AMAlexander Maryanovsky
05/20/2025, 10:50 AMlouiscad
05/21/2025, 9:18 AMExperimentalComposeUiApi
would be more accurate than DelicateComposeUiApi
?louiscad
05/21/2025, 9:20 AMAlexander Maryanovsky
05/21/2025, 9:22 AMlouiscad
05/21/2025, 9:27 AMImageComposeScene
wasn't available on Android, but only on other platforms that JetBrains supports blob thinking upside downlouiscad
05/21/2025, 9:28 AM@ExperimentalComposeUiApi
annotation on the other constructors, no?Alexander Maryanovsky
05/21/2025, 9:28 AMAlexander Maryanovsky
05/21/2025, 9:29 AMAlexander Maryanovsky
05/21/2025, 9:29 AMlouiscad
05/21/2025, 9:29 AMlouiscad
05/21/2025, 9:33 AM@ExperimentalComposeUiApi
is already used in this file, why not use it for other constructors, and also for render
?
I mean, that's probably the simplest kind of change one can make to Compose, besides improving the KDoc. Just doing it looks faster than debating the possibility of introducing another annotation, and then ask other people who have other priorities than specifics of what is essentially a warning label.Igor Demin
05/21/2025, 11:22 AMlouiscad
05/22/2025, 8:25 PM@DelicateSomething
annotation instead?Igor Demin
05/22/2025, 8:59 PMExperimentalComposeUiApi
, because it doesn't tell much, users still miss the threading issue if they don't read the doc, and it requires deprecation of the old API
I don't prefer DelicateSomething
, because in the end the issue is just bugs we need to fix someday.