JetBrains / compose-multiplatform

Compose Multiplatform, a modern UI framework for Kotlin that makes building performant and beautiful user interfaces easy and enjoyable.
https://jetbrains.com/lp/compose-multiplatform
Apache License 2.0
15.9k stars 1.16k forks source link

Low Framerate on iOS when drawing to the canvas #3847

Open hgourvest opened 10 months ago

hgourvest commented 10 months ago

Describe the problem

I experiencing low frame rate

Affected platforms

iOS

Versions

Sample code

import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.calculatePan
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import kotlinx.coroutines.coroutineScope

@Composable
fun buggy() {
    var pos by remember { mutableStateOf(0f) }
    Canvas(modifier = Modifier.fillMaxSize().background(Color.DarkGray).pointerInput(true){
        coroutineScope {
            awaitEachGesture {
                do {
                    awaitFirstDown(requireUnconsumed = false)
                    do {
                        val event = awaitPointerEvent()
                        val panChange = event.calculatePan()
                        if (panChange != Offset.Zero) {
                            pos -= panChange.x
                        }
                        val canceled = event.changes.any { it.isConsumed }
                    } while (!canceled)
                } while (false)
            }
        }
    }){
        drawRect(color = Color.Gray, size = Size(size.width - pos, size.height))
    }
}

Reproduction steps

move your finger from right to left

Video

https://youtu.be/NkknOuQljDo

Profiling data

I don't have that

Additional information

Use this component, which forces the frame rate to the maximum, to see the difference with the expected behavior.

@Composable
fun EatFrames() {
    var count by mutableStateOf(0)
    var mark by mutableStateOf(TimeSource.Monotonic.markNow())
    var rate by mutableStateOf(0)
    val textMeasurer = rememberTextMeasurer()
    val precision = 120
    Canvas(Modifier.width(32.dp).height(24.dp).background(Color.Green)) {
        if (count == precision) {
            rate = (1000 * precision / mark.elapsedNow().toLong(DurationUnit.MILLISECONDS)).toInt()
            count = 0
            mark = TimeSource.Monotonic.markNow()
        } else
            count++
        drawText(textMeasurer = textMeasurer, text = "$rate")
    }
}
dima-avdeev-jb commented 10 months ago

Thanks! Is it reproduced only on physical device? (or also on simulator) Is it reproduced on iPhone as well?

hgourvest commented 10 months ago

I only have a 9th generation iPad, and I'm developing on an Apple Mini M1. On the simulator I'm up to 50 frame seconds, which isn't too bad. But on the iPad I'm stuck at 30 frame seconds instead of 60.

Here's a video showing the expected rendering. https://youtu.be/8lltz_cENXY

And here's what I use to count frames. You have to move your finger very quickly

import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.calculatePan
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.rememberTextMeasurer
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.coroutineScope
import kotlin.time.DurationUnit
import kotlin.time.TimeSource

@Composable
fun buggy() {
    var pos by remember { mutableStateOf(0f) }

    var count = 0
    var mark = TimeSource.Monotonic.markNow()
    var rate = 0
    val precision = 120

    Canvas(modifier = Modifier.fillMaxSize().background(Color.DarkGray).pointerInput(true){
        coroutineScope {
            awaitEachGesture {
                do {
                    awaitFirstDown(requireUnconsumed = false)
                    do {
                        val event = awaitPointerEvent()
                        val panChange = event.calculatePan()
                        if (panChange != Offset.Zero) {
                            pos -= panChange.x
                        }
                        val canceled = event.changes.any { it.isConsumed }
                    } while (!canceled)
                } while (false)

            }
        }
    }){
        if (count == precision) {
            rate = (1000 * precision / mark.elapsedNow().toLong(DurationUnit.MILLISECONDS)).toInt()
            count = 0
            mark = TimeSource.Monotonic.markNow()
        } else
            count++
        println("$rate")
        drawRect(color = Color.Gray, size = Size((size.width - pos).coerceAtLeast(0f), size.height))
    }
}
elijah-semyonov commented 10 months ago

Can you measure FPS when adding withFrameNanos with infinite loop inside LaunchedEffect?

hgourvest commented 10 months ago

It doesn't seem effective to me. It doesn't give me frames when I move fast.

hgourvest commented 10 months ago

If it helps, the animations don't seem to be affected by this bug.

hgourvest commented 10 months ago

I tested with Kotlin 1.9.20 and Compose 1.5.10. There is an improvement, it now run at 40 FPS. Unfortunately, the problem is still visible.

hgourvest commented 10 months ago

I found a workaround, by changing the state value in a coroutine

@Composable
fun buggy() {
    var pos by remember { mutableStateOf(0f) }

    Canvas(modifier = Modifier.fillMaxSize().background(Color.DarkGray).pointerInput(true){
        coroutineScope {
            awaitEachGesture {
                do {
                    awaitFirstDown(requireUnconsumed = false)
                    do {
                        val event = awaitPointerEvent()
                        val panChange = event.calculatePan()
                        if (panChange != Offset.Zero) {
                            // iOS workaround
                            launch {
                                pos -= panChange.x
                            }
                        }
                        val canceled = event.changes.any { it.isConsumed }
                    } while (!canceled)
                } while (false)

            }
        }
    }){
        drawRect(color = Color.Gray, size = Size((size.width - pos).coerceAtLeast(0f), size.height))
    }
}
okushnikov commented 2 months ago

Please check the following ticket on YouTrack for follow-ups to this issue. GitHub issues will be closed in the coming weeks.