Composing in the wild
Jetpack Compose is a upcoming declarative UI framework for Android with many new features provided by its compiler plugin and smart runtime. As a long-time Kotlin enthusiast, I was amazed when it was announced a year ago and still discover something new in there every now and then.
However, the Compose story doesn’t end on “just” building UI. Even during the first talks describing magic behind the curtain, the library goal was defined as “to efficiently build and maintain tree-like data structures”. It opens a door to create your custom dynamic hierarchies (not necessarily tied to visuals) with all the benefits of caching and incremental updates.
It is still quite hard to imagine tree structures dynamic enough to require Compose, practical enough to implement, and far enough from UI at the same time. After going back and forth, I decided to experiment with managing the structure of a webpage, similar to how SwiftUI was applied before.
What exactly does it do? It allows you to define a webpage and user interactions in a Compose way, similar to its Android implementation. Of course, it is hard to port many concepts directly, as you can not, for example, use constraints for laying out elements. However, if you squint hard enough, it does look quite similar to how you could write it on mobile.
The snippet above results in the following screen:
So what exactly happening here and how does it work?
Composing a Web page
As execution on the client wasn’t an option, I went for the same approach as SwiftWebUI author did: running it on a server. This is how it works:
- Compose starts with some initial state which results in the server-side representation of a webpage.
- This representation is sent to the client as commands of adding/removing/moving nodes through websocket. The client then changes the structure of the webpage according to commands received from the server.
- When a client event happens (for example click), it is sent to the server. Here, the state is updated with the new changes. It yields a different structure of the DOM, and these updates are sent to the client, refreshing the webpage.
This implementation still puts a lot of constraints on what we can do in terms of interactivity. For the scope of this experiment, however, it is more than enough.
Composing on the server side
Compose is developed as a purely Android library, so many pieces won’t work for the server. Thankfully, developers have separated it into multiple packages and we can pick and choose whatever we want. For example, we have
compose-runtime with under-the-hood machinery,
ui module for basic interaction with Android systems,
ui-material for implementation of material components and themes, and many more. The first one (the runtime) is what we need to adapt to make this project possible.
To launch the runtime in a server environment, we need to substitute everything that links it to Android with server counterparts. UI frameworks are often tied to the main thread and other platform specifics, so it may seem quite hard. In reality, it was super easy, barely an inconvenience: JetBrains is currently collaborating with Google to bring Compose to multiplatform, so most of the required
actual bindings were already there.
You can see the full list of required bindings here. To highlight, we mimic the main thread with a single thread
CoroutineDispatcher and stub some other APIs based on their definition for desktop.
For the server environment, Ktor was the best choice. It is a web server developed by JetBrains which has perfect integration with coroutines and exposes configuration as expressive DSL. An additional package gives us websockets, and we can use feature extension API to integrate Compose into the server seamlessly.
Composing a tree
After the runtime is up and running, it is time to create a representation of a webpage on a server side. Running through the internals of already existing Compose elements, I ended up with the following structure:
This representation of the DOM distinguishes two types of elements:
- Tag, which contains the name of the HTML node and may have some children. Order of child nodes is manipulated through public methods with simple list operations.
- Text, always a leaf node with text values only.
These nodes can now be expressed as
We need an
ApplyAdapter which controls how we add nodes to the tree. Here, we delegate to methods of a
Tag class for all operations. This adapter is then used with
emit function to link
@Composable functions to the tree by interacting with the composer. It has three parameters to control the resulting structure:
- Constructor, describing how the node is created. Every change in the captured parameter here will result in Compose recreating the node.
tagfunction is the perfect example: in the browser, most tags have different representations. For instance,
<input>actually has underlying type of
HTMLInputElement, so, when changing tag to
<div>, we need to create new
HTMLDivElementto replace it.
- Update, defining how the element is updated. It is useful to avoid removing/adding the element every time a simple value update will suffice.
textis a good example of this, we don’t need to replace the whole node every update, changing inner value instead.
- Children, propagating other elements down the tree.
Now we can use these definitions to build a simple structure from tags:
Composing through a WebSocket
After building the tree, the next tricky part is client-server communication. It consists of two parts: passing node updates to the client and sending events back.
Node updates are mostly caused by client events in our case. For example, clicking on a button can display/hide an element:
Compose already does the diffing for tree changes (add/move/remove), so we can hook into
ApplyAdapter methods and dispatch commands to the client from there. It also takes care of the initial state, which is sent to the client as a sequence of additions. Properties (like
Text) are updated without calls to
ApplyAdapter, but we can catch them through a custom setter.
To make this work, every node needs to have access to a websocket connection. It would be quite annoying to pass it through each level of calls, and Compose has a utility just for that.
Ambient allows us to propagate some elements without explicitly keeping them as parameters. It is used in Android to provide
Theme, so we will do a similar thing for the server:
The second part (client events) was a bit harder to tackle. The main problem is to make sure the event is propagated to the right node and there it invokes the right callback. After playing around with different ways of expressing them, I have settled on the following structure:
- Each node now has a new
idfield, which serves as a method of matching them between client and server.
- We introduce a new entity that is similar to
CommandDispatcherbut works the other way around, listening to websocket and distributing updates to events.
- Each event is defined with the following structure:
It consists of three parts:
type serves as an identifier,
Payload provides a way to pass values from the client in a type-safe way, and
Callback invokes lambda with payload to update things in Compose world. All those elements are matched by
descriptor, which ties them together inside the node.
This system is not ideal, but it is extensible (allows you to define custom events) and customizable (those events can be tied to any JS/Server code). You can also check out definitions of other events here or how to process them on the server or client.
Composing it better
With the composition and events running, now it was the time to think about expanding functionality. On this stage, I created a couple of components which defined tags, but the parameters were getting a bit out of control:
Experimenting with inline CSS, I realized that it required a much more flexible system. Looking for inspiration in original libraries, I found out that many similar cases were handled with
Modifier instances, and it worked for me perfectly as well.
Modifiers create a linked list where elements are chained with others using Kotlin extension functions. Using them hides optional parameters from the signature in a type-safe way, converting the example above into this:
This system provides some basic modifiers, and later they can be extended with more concrete ones. For example, all HTML tag attributes are defined as a basic modifier of
attribute(String, String). Later they are refined with more precise definitions, e.g. with
type(String) on input and so on. Taking this further, we can provide scoping (flex parameters inside of
display: flex parents) and type safety (enums or integers instead of strings as parameters).
Jetpack Compose is much more than a UI toolkit made exclusively for Android. I can definitely see how it can be applied in multiplatform to power many Kotlin projects.
At the same time, API surface of the core functionality is very minimal. The compiler plugin takes the route of minimal invasion, providing restartable functions with cacheable calls transparently for the user. This is then leveraged by runtime with all the tree building functionality and other tooling. I am very surprised of all the new possibilities provided with such a small change to the language.
The project was a huge learning experience for me, pushing the boundaries of what Compose can do on its current stage of development. The approach I have taken puts a barrier on many interactions, as it is relying on fast communication between client and server, which is not the case for the majority of users. It can certainly be improved with a browser runtime whenever Compose supports either JS or WASM in full.
The source code is available on Github and is separated into 4 modules:
- runtime with updated actuals,
- server part as a Ktor feature,
- browser runtime,
- and integration module, containing both server and client code with the “business logic” of the demo.
You can try it yourself online, or play with the repo using
deploy branch, which keeps prebuilt jar for compose-runtime.
You can find me on Twitter, where I am sharing random thoughts about Android, Kotlin, and other things I find exciting along the way.