compose-rust guide

Architecture

Two halves, one narrow boundary

Rust does not call the Compose API. It describes a UI tree as values, fixed-layout records, and a general-purpose interpreter on the Kotlin side turns them into a real Compose tree. Cash App's Redwood and Jetpack Glance use the same pattern.

Host and Renderer

the stack
┌──────────────────────── Host (Rust) ────────────────────────┐
│  your code                                                  │
│  the layer that builds the tree (Runtime)                   │
│  compose-rust: schema, fixed-layout records, one batch      │
└──────────────────────────────┬──────────────────────────────┘
                               │
              synchronous, same-thread direct calls
              primitives, pointers and lengths only
                               │
┌──────────────────────────────┴──────────────────────────────┐
│  generated shims (C exports, JNI, wasm)                     │
│  protocol decoder ──► node table                            │
│  schema interpreter ──► Compose                             │
└──────────────────── Renderer (Kotlin, AOT) ─────────────────┘
Host
The Rust side. Owns your code, your domain logic, and the runtime that builds the tree.
Runtime
Whatever builds and changes the tree, behind the Runtime trait. In 1.0.0 that is compose-rust's own recomposition runtime.
Renderer
The ahead-of-time compiled Kotlin and Compose library, including the schema interpreter.
Schema
The closed set of widgets, properties, modifiers, roles and events the renderer understands.
Mutation
One tree-change record, such as Create, SetProp, SetModifier, Insert, Move, Remove, SetText or AppendText.
Event
A record travelling the other way: a click, a key, a range request, a size class change.

The C ABI surface

The boundary is deliberately tiny. The model is JSI's, not the old React Native bridge's: the same thread, direct calls and synchronous return values.

c: Renderer to Host (exported by Rust)
int32_t compose_rust_host_init(const uint8_t* handshake, uint32_t len, MutationBatch* out);
int32_t compose_rust_host_dispatch_event(const uint8_t* event, uint32_t len, MutationBatch* out);
int32_t compose_rust_host_render_frame(uint64_t frame_time_nanos, MutationBatch* out);
void    compose_rust_host_release_batch(MutationBatch* batch);
void    compose_rust_host_shutdown(void);
c: Host to Renderer (exported by Kotlin)
int32_t compose_rust_renderer_run(void);            /* blocking; the renderer owns the loop */
void    compose_rust_renderer_request_frame(void);  /* callable from any thread */

Two rules follow from it.

The handshake exchanges a schema hash, a protocol version and the loop mode. A hash mismatch fails initialisation rather than producing a subtly wrong UI. The status codes are 0 for success and negative values for protocol errors, an uninitialised Host, double initialisation and caught panics. Nothing unwinds across the boundary, because a protocol error has to become a ProtocolError event, never a process abort.

Two loop modes exist. On the desktop and iOS, Rust's main calls compose_rust_renderer_run and the renderer owns the loop from there. On Android and the web, the platform owns the loop and the renderer calls compose_rust_host_init first. The application is the same in both.

The protocol

Both halves are in one process, so the encoding minimises copying rather than bytes on a wire. Records are fixed-layout and read in place. There is no decode step and no serde-style format on the hot path.

A batch is applied inside a single snapshot, so an intermediate tree can never be drawn. The renderer applies it in the same call stack and releases it at once. No batch is ever queued.

The frame model

  1. The renderer receives input and calls dispatch_event on its UI thread.
  2. The Host runs the handler, the runtime writes what changed into the arena, and the Host returns the batch and the result value.
  3. The renderer applies the batch in one snapshot, releases it, and reports the result to Compose, for example as a handled key event.
  4. A worker thread that changed state causes a frame request. Requests coalesce into one render_frame on the next tick of the Compose frame clock.

The runtime runs on the renderer's UI thread, not on a thread of its own. That removes the need for locks and for a queue. The only cross-thread signal in the design is the frame request. A frame requested while a handler runs is held until the handler returns, so the renderer is never re-entered from inside a dispatch.

The measure call Planned

A layer that computes its own layout needs to know how big text will be before it places it. The measure call lets the Host ask the renderer, in the middle of its layout, how much room a piece of text or an already-sent widget takes within given constraints. It is tracked in #106.

c: Host to Renderer, planned
int32_t compose_rust_renderer_measure(const uint8_t* requests, uint32_t len,
                                      uint32_t count, MeasureResult* results);

Application code does not call it. The layer that builds the tree reaches it through a safe Rust API that only works inside the Host's frame work.

Zoom Planned

compose-rust zooms the way VS Code does: the whole window scales, and the starting point is the operating system's text size.

Today the desktop renderers fix the font scale at 1 and ignore the OS text size (#68).

Schema codegen

The C ABI carries only bytes, so type safety is layered on top. Widgets, properties, modifiers, roles and event payloads are defined once in Rust. The Kotlin types, the codecs, the JNI shims for Android and the wasm shims for the web are generated from that definition, and a checked-in test fails when the generated output is stale. Nobody writes JNI or cinterop glue by hand.

What runs where

The renderer is built two ways. Where Compose can be compiled for the platform directly, there is no virtual machine in the result. Where it cannot yet, the renderer is a GraalVM native image with a Java runtime compiled into it. Neither needs Java installed on the user's machine.

Platform0.0.1 shipsNext
macOSNative imageKotlin/Native, linked into the executable; runs in CI (#22)
LinuxNative imageKotlin/Native, linked into the executable; passes a startup test in CI (#22)
WindowsNative imageKotlin/Native (#24)
iOSKotlin/Native static archiveunchanged
AndroidAndroid libraryunchanged
WebKotlin/Wasmunchanged

The difference is most of the weight. The notepad sample on macOS with the Kotlin/Native renderer is one 28.76 MB file with nothing beside it. The same sample built with the native image was 93.1 MB across four files. The extra is not Compose and not compose-rust's own code, but the Java runtime and the metadata a closed world needs.

Linux has no published Compose UI for Kotlin/Native either, but nothing prevents building it, so the pinned Compose fork adds the Linux targets. Windows is harder: Kotlin/Native's target there is MinGW and Skia's build is MSVC, so the two cannot simply be linked.

The application does not know which

The boundary is the same C ABI on every renderer, the records are the same bytes, and the interpreter is the same source. Nothing you write changes.

The Kotlin/Native renderer on macOS

The window is the renderer's own NSWindow, not the one Compose opens for this platform. Compose's window draws correctly, but nothing in it is named: a screen reader sees the title and the three title bar buttons and nothing the application drew. Owning the window makes the contents readable.

The view's backing layer is a CAMetalLayer, and Skia draws into it through Metal. A frame is presented inside the same transaction that resizes the window, so the contents follow the window edge during a drag instead of trailing behind it.

Text goes through the platform rather than around it. The view implements NSTextInputClient, so marked text, the candidate window and Korean, Japanese and Chinese composition are the input method's own work. The menu a selection offers is a real NSMenu.

AppKit demands the main thread. The renderer runs NSApplication on the thread it was called on, and the call returns when the window closes. So compose_rust_renderer_run must be called on the process main thread.