Architecture
Two halves, one narrow boundary
Rust does not call Compose. It cannot: GraalVM's @CEntryPoint only passes
primitives and word-sized values, so a Modifier or a MutableState
could never cross. Instead Rust describes a UI tree as values, and Kotlin runs an
interpreter over a fixed schema. Redwood and Jetpack Glance work the same way.
Host and Renderer
┌──────────────── Host (Rust) ─────────────────┐
│ your components (rsx!, hooks, signals) │
│ dioxus-core VirtualDom │
│ dioxus-compose renderer: Mutations → bytes │
└──────────────────┬───────────────────────────┘
│ synchronous direct calls on the UI thread
│ + one batch buffer per call
┌──────────────────┴──── Renderer (Kotlin) ────┐
│ generated shims (@CEntryPoint / @CName / …) │
│ protocol decoder → node table (snapshot) │
│ schema interpreter: @Composable RenderNode │
│ Compose Desktop (AWT) / Compose iOS (UIKit) │
└──────────────────────────────────────────────┘
- Host
- The Rust process. Owns your components, your domain logic and the VirtualDom.
- Renderer
- The AOT-compiled Kotlin/Compose native library, including the schema interpreter.
- Schema
- The closed set of widget types, properties, modifiers and events the renderer understands.
- Mutation
- One tree-change command:
Create,SetProp,SetModifier,Insert,Move,Remove,SetText,AppendText. - Event
- A notification travelling the other way, addressed as
(node_id, handler_id, payload).
The C ABI surface
The boundary is deliberately tiny, and adding to it requires a specification change. The model is JSI's, not the old React Native bridge's: same thread, direct calls, synchronous return values.
int32_t dioxus_compose_host_init(const uint8_t* handshake, uint32_t len, MutationBatch* out);
int32_t dioxus_compose_host_dispatch_event(const uint8_t* event, uint32_t len, MutationBatch* out);
int32_t dioxus_compose_host_render_frame(uint64_t frame_time_nanos, MutationBatch* out);
void dioxus_compose_host_release_batch(MutationBatch* batch);
void dioxus_compose_host_shutdown(void);
int32_t dioxus_compose_renderer_run(void); /* blocking; LoopMode::Renderer only */
void dioxus_compose_renderer_request_frame(void); /* thread-safe */
That is the entire surface. Two rules follow from it:
- Only primitives, pointers and lengths cross. No objects, no closures.
- The Rust side links with
-undefined,dynamic_lookup, and so does the renderer library: each half resolves the other's symbols when the process loads.
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 Rust status codes are
0 for success and negative values for protocol errors, uninitialised state,
double initialisation and caught panics, nothing unwinds across the boundary, because a
protocol error has to become a ProtocolError event, never a process abort.
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 record is
tag: u16,len: u16, then fixed fields (node_id: u32and so on). Little-endian, 4-byte aligned. - Strings live in the same arena and are referenced as
(offset: u32, len: u32), UTF-8. The renderer only materialises a KotlinStringat the moment it hands the text to Compose. - The batch itself is
#[repr(C)] struct MutationBatch { ptr, len, result }, pointing into a Host-owned arena that is reused every frame.resultcarries the handler's synchronous return value, for a key event, whether it was consumed.
A batch is applied inside a single snapshot transaction, so an intermediate tree state can never be drawn. The renderer applies the batch in the same call stack and releases it immediately; no batch is ever queued.
The measured target for a text-change event is two boundary calls
(dispatch_event and release_batch) and at most one heap allocation
in the renderer: the Compose String.
The frame model
- The renderer receives input and calls
dispatch_eventon its UI thread. - The Host runs your handler, diffs the VirtualDom, encodes the mutations into the arena and returns the batch plus a result value.
- The renderer applies the batch in one snapshot, releases it, and reports the result to
Compose, for example as
truefromonKeyEvent. - Worker threads that changed state cause a frame request; requests coalesce into one
render_frameinside the next Compose frame clock tick.
The VirtualDom runs on the renderer's UI thread, not on a thread of its own, which is what removes the need for locks and for a queue. The single piece of cross-thread communication in the whole design is the frame-request wake. A frame requested while a handler is executing is held until the handler returns, so the renderer is never re-entered from inside a dispatch.
Schema codegen
The C ABI only carries bytes, so type safety is layered on top of it. Widgets, properties, modifiers and event payloads are defined once in Rust; the Kotlin types and codecs are generated from that definition, and a checked-in test fails if the generated output is stale. A schema hash compares the two sides at handshake time. Nobody writes JNI or cinterop glue by hand.
The macOS runtime
Window ownership was deliberately left to Compose Desktop's AWT path. Taking it over would
mean wiring NSTextInputClient, TSF and ibus/fcitx by hand, exactly the text
quality this project refuses to risk. ComposeWindow is an AWT
JFrame, and ahead-of-time compilation does not change which code paths run, so
the AWT IME path survives into the native image.
Statically linked AWT on macOS still resolves three things by file path, which is why the
distribution is a single lib/ directory:
| File | Why it is there |
|---|---|
libawt_lwawt.dylib | libawt's initialisation loads it by path. The JNI functions already resolve inside the image, so a placeholder fills the slot. |
libjawt.dylib | Skiko dlopens it from <java.home>/lib. Ours forwards to JAWT_GetAWT inside the image. |
JNI_OnLoad_osxui | A symbol every statically linked JNI library must define; NIK's archive does not, so the build defines it. |
libskiko-macos-<arch>.dylib | Skia, which Skiko loads by path. |
java.desktop has always bound Java and native code together with JNI, including
when Compose runs on an ordinary JVM. None of it touches the Host↔Renderer boundary, which
is pure C ABI. The project's ban on hand-written JNI is about the Android boundary.
One more macOS fact shapes the process: AppKit demands the main thread. The renderer runs on
a secondary thread while the main thread creates and runs NSApplication itself,
which puts AWT into the embedded mode it uses under SWT or JavaFX hosts. If AWT is allowed to
own the loop, it re-enters [NSApp run] and control never returns to the Host
after the window closes. Accordingly dioxus_compose_renderer_run must be called
on the process main thread; otherwise it returns RUN_NOT_MAIN_THREAD.