hi all, i’m an android developer exploring compose...
# compose-web
j
hi all, i’m an android developer exploring compose for web. is it correct to say that compose for web at this time does not support compose ui (e.g. Box, Modifier, etc.), but compose for desktop does? also if this is the case, are there any plans for supporting compose ui for web in the near future? thank you kindly
👍 1
f
Hi, fundamentally there are two different approaches to web frontend development using Compose. • DOM (tree of good old HTML tags) - this is currently the polished way similar to React.js and other declarative frameworks in JS. This uses Compose, but not Compose UI (so you can only have multi-platform code sharing between Android and Desktop) • Canvas - this approach draws the whole UI onto web canvas and lets you write code using Compose UI (and share it between platforms). AFAIK this is still experimental, so not everything is supported, things are not well documented and performance is not great (this may inherently be the problem even after it hits GA)
j
got it. great. thank you.
m
@fmasa What makes you believe that the performance is not great (this may inherently be the problem even after it hits GA)? Actually I find the performance already quite impressive. On my Mac these examples work like a charm. Of course there is always room for improvement. https://koalaplot.github.io/koalaplot-samples/index.html https://mpmediasoft.de/test/PolySpiralMpp/ (I got one feedback from someone on Linux where the performance was not so great (haven’t seen it myself yet) but that is not so unusual for Linux and one should not judge any new GUI technology by its initial performance on Linux.)
h
Also with Dom based you can still share your code: everything except your UI, but database, business logic, network, view models with Flows/States
f
@Michael Paus Yeah, this was unnecessary generalization. It will very much come down to the type of apps you develop. Visualizations etc. are great (these are usually done on canvas anyway even when not using Compose on web). For general web apps (think Github, Slack, not Figma), I've yet to see a webapp fully rendered on canvas that isn't janky (usually when scrolling). The snippets you have posted are actually just a tiny bit of what you would see on a busy app screen. However, that does not mean that this is impossible to overcome (we have canvas off-screen rendering and other tools that could help). This is not the only problem of canvas-based solution though (accessibility and text input comes to mind). This is a great way to quickly turn Android app into a webapp, but right now separate DOM-based implementation would in most cases be more performant and accessible than former.
m
That’s why we have two options right now.
f
As to the main question. Browsers are really good at rendering DOM while applying performant CSS effects. It's due to years of optimization for this use case. When using canvas, all these effects now have to run in user code. So making highly performant webapps using canvas is certainly possible, but it may not be feasible without significant improvements in both Compose and browser APIs.
o
This generalization does not hold up in practice: Browsers can be good at rendering certain DOM/CSS stuff. Have you ever tried to make a browser render a DOM table with 400x400 cells (no matter if
<table>
or
<div>
with CSS grid) and animate cells via CSS? This will quickly convince you how slow browser-based layout and rendering can be despite being around for decades. While I haven't checked the current state of affairs, Compose is highly optimized and with Skia running on WebAssembly I wouldn't be surprised if it is capable of significantly outperforming DOM/CSS, depending on the scenario.
l
@Oliver.O It would be so much fun to create some examples/benchmarks in canvas-based compose, showing that decades of browsers micro-optimizations have to be worse than compose fundamental optimizations thanks to snapshot system (like automatic fine-grained skipping of compositions, skipping layouts, skipping drawing etc.)
o
I have some stuff which I'l probably publish soon (possibly even today). It is aimed at discovering specific current limitations, so I would not call it a benchmark. (Capturing meaningful real-world scenarios in a general benchmark rarely works, so I'd always prefer to create a custom benchmark tailored to the actual use case.)
f
@Oliver.O That't the thing with generilazations, you can always find exceptions. As I mentioned, performant canvas based solution is certainly possible. And since we don't see many complex Compose web canvas apps, the jury is still out. Other similar solutions (Flutter Web comes to mind) are still janky even for relatively simple screen. Don't get me wrong, I very much look forward to "simple" way to bring my Android app to web, but the road is still bumpy (not only on performance front).
Anyway I look forward to your findings. I'd love to change my mind on the performance and start using the canvas-based approach more
o
I'm not sure that my findings – being tied to specific limitations – will make a difference there when compared with DOM. The example I'm working on is something DOM can do well enough and Compose on Canvas currently has a limitation with redrawing (you'll see). On the other hand, with specific table layouts, browsers still have problems with DOM size and layout reflows. In such cases, Compose may outperform DOM right now.
m
One specific problem I currently see is the performance of resizing the canvas. This definitely needs a closer look. If you just try my example from above (https://mpmediasoft.de/test/PolySpiralMpp/) you’ll notice that the rendering is quite fast but when you try to change the window size at the same time this breaks down completely. I have not done any measurements but as far as I can see the rendering of the desktop variant of this code is equally fast as the web variant. The big difference however is the resizing behaviour which pulls down the whole experience. It would also be interesting to compare the performance on different operating systems and browsers.
l
@fmasa But flutter doesn't have anything as good as general snapshot system, right? Also I wonder if maybe google docs/spreadsheets are already using some internal flavor of compose web on canvas (I remember they moved to canvas based rendering like a year ago)
f
As to Flutter, I don't know. It just comes to mind as the most polished contender right now.
o
After some extra experimentation, I discovered that the recomposition/layout system was not really stressed by top-level forced recompositions. Compose managed to skip too much below. So I have added an option to force row-level recomposition (which, being a layout, does not change things when compared to top-level recomposition). But forced cell-level recomposition, which I have also added, lowers the frame rate by 50% or more. Conclusion: Avoiding recomposition can make a difference with lots of components – even with Compose for Desktop. Changes pushed to GitHub.
And one more thing: I have improved UI responsiveness when toggling options for recomposition highlighting and animations. With animations, it's a bit strange: It takes really long to provision a large number of cells with animations, in particular when changing the grid while it is displayed. So I found a way to hide it for some time while provisioning occurs. The solution is quirky and I'd appreciate it if someone comes up with a better idea to tell Compose, "hey please throw away this existing composition tree and rebuild it anew with the updated state".
l
@Oliver.O The
key
should do it:
Copy code
key(Configuration.animationsEnabled) {
                Grid(grid, topLevelRecompositionTrigger, rowLevelRecompositionTrigger, cellLevelRecompositionTrigger)
            }
But I checked it and its still slow. Still my understanding is that
key
should make compose throw away old grid and build a new one. Maybe to make it really efficient it's necessary to make every cell always take the same amount of space in slot table regardless configuration. Then
key
or
if (showGrid)..
condition could be removed.
d
FYI I'm working on a framework that sits on top of Web Compose called Kobweb (https://github.com/varabyte/kobweb), which adds a few methods taken from Android Compose (including Row, Column, and Box) which internally delegate to traditional HTML DOM approaches.
f
Isn't this something JetBrains pursued before targeting canvas?
This works fine, until you want to add support for various modifiers etc
d
Not familiar if they did, but I definitely think it doesn't make sense in the core library because Android and Web have some fundamental differences. Putting it one layer above seems like it's a bit better.
My classes are, erm, Android-inspired but not 100% the same, because of platform differences.
h
Yes, they tried and switched to canvas because modifier problem and no ui sharing. You would still need to use actual/expect to share the ui with web.
but kobweb has more features, mainly the generation of static html files
@Page
and static routing 🙂
d
Yeah, Kobweb aims to be a couple of orthogonal features. Widgets are just one slice of the pie.
o
@langara
Still my understanding is that
key
should make compose throw away old grid and build a new one.
The changing key would indicate that recomposition of
Grid
is necessary. That, however, doesn't mean that the entire Composables tree below
Grid
will be rebuilt as Compose can skip recomposition selectively: https://www.jetpackcompose.app/articles/donut-hole-skipping-in-jetpack-compose
l
@Oliver.O But the
key
change is not only for triggering recompositions. It changes identity of the subtree, so (if I'm not mistaken) it should make the old subtree leave the composition and the new one enter the composition. As in Figure 1 here: https://developer.android.com/jetpack/compose/lifecycle#lifecycle-overview
o
@Oliver.O
key
usually makes sense when the items of some collection could move within its container. The thing is when Compose runs recomposition and when it sees
key(value)
it doesn’t know yet if this key (the content represented by this key) should be thrown away or it could’ve moved somewhere within a container (it can throw it away only after it completes the recomposition of that particular container). I used this snippet in your project:
Copy code
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
                Button(onClick = { selectedGrid.value = null }) {
                    Text("Back")
                }
                Button(onClick = { startOrStop() }) {
                    Text(if (running) "Stop" else "Start")
                }
                Button(onClick = { grid.clear() }) {
                    Text("Clear")
                }
                Button(onClick = { k.value += 1 }) {
                    Text("Inc key=${k.value}")
                }
                Button(onClick = { configurationVisible = !configurationVisible }) {
                    Text(if (configurationVisible) "Hide Configuration" else "Show Configuration")
                }
            }
            Info(grid, startMoment, configurationVisible)

            key(Configuration.animationsEnabled.value) {
                Grid(grid, topLevelRecompositionTrigger, rowLevelRecompositionTrigger, cellLevelRecompositionTrigger)
            }
        }
Tested with this:
Copy code
@Composable
private fun CellText(cellContent: Int) { 
    if (cellContent != 0) {
        Text("$cellContent", style = MaterialTheme.typography.h6)
    }
    DisposableEffect(Unit) { // or even better to add DisposableEffect in Cell
        println("Cell created")
        onDispose {
            println("Cell disposed")
        }
    }
}
And when Configuration.animationsEnabled.value changes, every cell gets disposed and created again (so no smart move of content from the old Grid to a new one).
if (showGrid) { Grid() }
is better because Compose knows for sure, that there is no need to temporary keep the old content (the content with the old key). As for the need for `if (showGrid) { Grid }`… My understanding is probably not detailed enough and could lack some accuracy: It’s related to SlotTable write operations. Compose writes into SlotTable much faster when there is no need to change the position (to move the Gap here and there) - when it can write continuously one part after another. This happens when we create a new group and add all the new content into it (e.g. when showGrid changes from false to true, it adds everything related to Grid). But when the write operations happen at many different positions, it requires moving the Gap back and forth. It happens when the content of many cells changes at one moment. I think someone in #compose could explain the operations in SlotTable much better 🙂
o
Thanks for experimenting and your explanations. Yes, I was aware that
key
identifies a Composable whenever positional identification is insufficient. If my observations were correct, the interesting thing is: It is not just that
if (showGrid) { Grid() }
helps, but also that the duration of hiding the grid matters. For larger grid sizes, a longer period seems beneficial. If it's just the slot table, I'd expect no change in results between
if (showGrid)
and
key(...)
if in both cases the old grid and its components are thrown away completely before creating new slot table entries for the new grid and its components. Or did I miss something?
o
there is a difference between
if(showGrid) {}
and
key(…)
with if: 1) create initial Grid , 2) showGrid changes to false -> initial Grid gets disposed 3) showGrid changes to true -> new Grid gets created and composed with key: 1) create initial Grid, 2) key value changes -> new Grid gets created and composed 3) initial Grid gets disposed The order of create/dispose is different. This still doesn’t really explain why
key
performs worser than
if(showGrid)
, but at least now we know there is a difference, which could mean different states of SlotTable (needs digging into it).
but also that the duration of hiding the grid matters. For larger grid sizes, a longer period seems beneficial.
it appears that insertion of
AnimatedContent
makes the difference. It’s slow not only when we set true to
Enabled animations
state on a GridScene, but also when we enable the animations beforehand on GridChoiceScene and then open GridScene. Turning the animations off (while on GridScene) goes faster than turning it on. If we remove the AnimatedContent, it makes changes to “enabled animations” state equally fast/slow (either turn on or turn off):
Copy code
if (Configuration.animationsEnabled.value) {
            @OptIn(ExperimentalAnimationApi::class)
            CellText(cell.content)
            // AnimatedContent(cell.content, transitionSpec = {
            //     slideInVertically { height -> height } with
            //         slideOutVertically { height -> -height }
            // }) {
            //     CellText(it)
            // }
        } else {
            CellText(cell.content)
        }
But still,
if(showGrid)
is necessary for big grids, but no need to have larger delay for bigger grids.
o
The differences in order might result in a different load pattern on the slot table. I'll see if I can get some hard timing data (interval between changing the animation setting and cell updates continuing) and/or some profiling data covering just that interval. I don't want to put too much load on you as this example might be a strange corner case and there is probably more important stuff in CfW/Skiko to work on right now (e.g. text input and such). I'll report back as soon as I have some data (might take a while). 🙂
j
start a new thread 🙏☺️
o
I have updated the application on GitHub to better measure UI responsiveness (see Changes section in the README). Basically, @Oleksandr Karpovich [JB], you are entirely right pointing to slot table updates being the root cause. The trick I had achieved was just removing the old grid from the composition, then delaying until the next recomposition before inserting the new grid into the composition. This replaced slot table reshuffling with sequential insertions. The exact delay is just a heuristic value, lacking a precise indicator for a point in time when the current composition phase completes. I mentioned that an additional delay seemed beneficial for larger grids. There is some truth to this, but the gains are not significant and can be attributed to a different effect: As CfD redraws the entire composition tree on each frame, hiding the grid longer means less redraw cycles happen while the ripple animation of the 'Enable animations' checkbox runs. I have created a separate cross-linked thread in #compose regarding the slot table update problem here: https://kotlinlang.slack.com/archives/CJLTWPH7S/p1663066133417219