Backend Architecture
In this chapter, you will:
- See how each backend (Apple, Android, GTK4, Hydrolysis) maps Rust views to platform widgets
- Understand the shared contract that all backends implement
- Learn the architecture patterns for adding a new backend
- Review the feature matrix across platforms
WaterUI supports multiple rendering backends, each targeting a different platform. All backends share the same FFI contract but differ in how they create and manage platform widgets. Think of each backend as a translator: it reads the same Rust view tree but speaks a different platform language.
The Backend Contract
Every backend must:
- Call
waterui_init()to initialize the Rust runtime. - Install theme signals (color scheme, colors, fonts) into the environment.
- Call
waterui_app(env)to obtain the application’s window tree. - Walk the view tree, dispatching each node by its
WuiTypeId. - Subscribe to reactive signals and update widgets when values change.
- Handle the application lifecycle (window management, event loop).
The waterui-backend-core crate provides shared infrastructure, including
ViewDispatcher – a type-based dispatch table that routes AnyView instances
to backend-specific handlers.
Note: You do not need to understand backend internals to use WaterUI. This chapter is for contributors, backend authors, and the deeply curious.
Apple Backend (Swift)
Location: backends/apple/ (git submodule)
The Apple backend is a Swift Package that integrates with UIKit (iOS/tvOS), AppKit (macOS), and WatchKit (watchOS). It is the most mature backend and serves as the reference implementation.
Architecture
Rust Library (.dylib / .a)
|
C ABI (waterui.h)
|
Swift Package (WaterUIRuntime)
|
UIKit / AppKit Widgets
The Swift side maintains a ViewWatcher that walks the Rust view tree and creates
corresponding UIKit/AppKit views:
| Rust View | iOS Widget | macOS Widget |
|---|---|---|
Text | UILabel | NSTextField |
Button | UIButton | NSButton |
Toggle | UISwitch | NSSwitch |
TextField | UITextField | NSTextField |
ScrollView | UIScrollView | NSScrollView |
NavigationStack | UINavigationController | NSNavigationController (custom) |
GpuSurface | MTKView | MTKView |
Reactive Integration
The Swift backend subscribes to nami signals through FFI watchers. When a
Computed<T> value changes, the Rust side invokes a C callback that the Swift
side registered:
nami::Binding<String> changes
--> Computed<Str> fires
--> C callback invoked
--> Swift closure updates UILabel.text
This happens on the main thread, ensuring UI updates are safe.
Theme Injection
The Swift backend reads system appearance and typography, creating reactive signals:
// Pseudocode
let colorSchemeSignal = waterui_computed_color_scheme_new { watcher in
// Track UITraitCollection.userInterfaceStyle
// Call waterui_call_watcher_color_scheme(watcher, .dark) on change
}
waterui_theme_install_color_scheme(env, colorSchemeSignal)
Each semantic color (foreground, background, accent, etc.) is mapped to the platform’s dynamic color system, so views automatically adapt to light/dark mode.
Build Integration
The Apple backend is a git submodule under backends/apple/. You drive it
through the water CLI – never invoke xcodebuild or swift build yourself.
water run --platform ios cross-compiles the Rust staticlib for the target
triple, hands the path to the Swift package, performs code signing, and deploys
to the chosen simulator or device. If you find a build step that the CLI cannot
yet express, file an issue against cli/ rather than working around it from
your own scripts.
Android Backend (Kotlin/JNI)
Location: backends/android/ (git submodule)
The Android backend uses JNI (Java Native Interface) to bridge Rust and Kotlin. It renders using Android’s native View system.
Architecture
Rust Library (.so)
|
JNI (Java Native Interface)
|
Kotlin Runtime (dev.waterui.android)
|
Android Views / Compose Interop
JNI Bridge
On Android, the FFI macros generate JNI entry points alongside C functions:
// Generated by ffi_view!(TextConfig, WuiText, text)
extern "system" fn Java_dev_waterui_android_ffi_WatcherJni_textId(...) -> jobject;
extern "system" fn Java_dev_waterui_android_ffi_WatcherJni_forceAsText(...) -> jobject;
The JNI module (ffi/src/jni/) provides:
- Class caching: JNI class references are cached at
JNI_OnLoadtime. - Struct-to-Java conversion: Rust
#[repr(C)]structs are converted to Java objects field by field. - Pointer management: Rust pointers are passed as
jlongvalues through JNI.
Initialization
The JNI_OnLoad function (generated by export!()) runs when the native library
loads:
extern "system" fn JNI_OnLoad(vm: *mut c_void, _reserved: *mut c_void) -> i32 {
ndk_context::initialize_android_context(vm, null_mut());
jni::init(vm as *mut JavaVM)
}
This initializes the NDK context (for logging, asset access) and the JNI module
(for cached class references and the JavaVM pointer).
Gradle Project Structure
The Android backend is a Gradle project with modules:
runtime: The Kotlin library that hosts the WaterUI view tree.ffi: JNI bindings generated from Rust.
You do not invoke Gradle or adb directly. The water CLI orchestrates everything:
- Cross-compiling Rust for Android targets (
aarch64-linux-android, etc.). - Copying the
.sointo the Gradle project’sjniLibs/. - Running the embedded Gradle wrapper to build the APK.
- Installing and launching the app on the chosen device or emulator.
If you ever feel tempted to call gradlew or adb install from a script,
extend the CLI instead so the workflow stays reproducible across machines.
Hydrolysis Backend
Location: backends/hydrolysis/ (and backends/hydrolysis-m3/ for the
Material 3 component overrides).
Hydrolysis is the active Rust-side backend. It does not use platform widgets at
all – it renders the entire UI on the GPU using vello for 2D graphics,
parley for text shaping, and wgpu for the device interface. It is also the
backend that drives waterui-testing, because the accessibility tree it
produces is the contract that integration tests assert against.
Architecture
Rust View Tree
|
ViewDispatcher (Rust) ----> AccessKit a11y tree
|
Hydrolysis widgets (text, layout, scroll, gestures, ...)
|
vello + parley scene
|
wgpu device + GPU surface
View Dispatch
Hydrolysis (and any other Rust-side backend) uses ViewDispatcher from
waterui-backend-core to route views to type-specific handlers:
use waterui_backend_core::ViewDispatcher;
use waterui_core::{components::Native, Environment};
let mut dispatcher: ViewDispatcher<State, RenderContext, Widget> =
ViewDispatcher::new();
dispatcher.register::<Native<TextConfig>>(|state, ctx, native, env| {
// Build the Hydrolysis text widget from the config.
});
dispatcher.register::<Native<ButtonConfig>>(|state, ctx, native, env| {
// Build the Hydrolysis button widget.
});
// Dispatch a concrete view; unknown types auto-expand via body().
dispatcher.dispatch(view, &env, context);
The dispatcher’s dispatch method is the render loop:
- Look up the view’s
TypeIdin the handler table. - If a handler is registered, run it.
- Otherwise, evaluate
body()and recurse on the result.
This is the same algorithm the FFI backends implement in Swift or Kotlin, but performed in Rust without crossing a language boundary.
GPU Discipline
Hydrolysis enforces several rules that you must respect when authoring GPU-backed components on top of it:
- GPU only on the runtime path. Never read render targets back to CPU memory in the runtime render path. Offscreen capture for testing has its own dedicated entry points.
- One
GpuViewperGpuSurface.GpuSurface::new(renderer)owns oneGpuViewfor the lifetime of that surface. Persistent GPU resources for that renderer instance live inGpuView::setup(), not in hidden caches outside the surface’s lifetime. - No software fallback in production. Hydrolysis production surfaces
reject software/noop wgpu adapters. Test-only constructors gated behind
#[cfg(test)](and theWATER_HYDROLYSIS_FORCE_FALLBACK_ADAPTERenvironment variable for diagnostics) opt in to compute-capable software adapters so CI can still run accessibility tests where no real GPU exists.
Accessibility as a Build Output
Every Hydrolysis component produces an accesskit accessibility tree as a
first-class artifact, not as an after-thought layered on top of the visual
output. waterui-testing consumes that tree directly, which means a UI
component that fails accessibility coverage is failing its design contract,
not just a CI lint.
Debug Tracing
Set WATERUI_DISPATCH_DEBUG=1 to log the dispatch tree as views are matched
against handlers:
[dispatch] Native<TextConfig>
[dispatch] Metadata<Padding>
[dispatch] Native<ButtonConfig>
Tip: This is invaluable when a view is not rendering as expected. It shows you exactly which view types the dispatcher matched and which ones fell through to
body()recursion.
Other Rust-Side Backends
Two additional backend folders exist in the workspace:
- GTK4 (
backends/gtk/) – An early Linux backend overgtk4-rs. The upstream roadmap marks it as no longer supported, so do not target it for new work; treat it as historical reference. - TUI – Listed as work in progress in
AGENTS.md. There is no stable terminal backend you can ship against today.
If you need a Linux-only target right now, drive Hydrolysis through water run --platform linux --backend hydrolysis.
How to Add a New Backend
If you want to bring WaterUI to a new platform, here is the roadmap:
-
Create the backend crate in
backends/your-backend/. -
Add a dependency on
waterui-backend-corefor theViewDispatcherand shared types. -
Register view handlers for each native view type you support:
dispatcher.register::<Native<TextConfig>>(|state, ctx, text, env| { // Create your platform's text widget }); -
Handle metadata by registering handlers for
Metadata<T>types:dispatcher.register::<Metadata<Opacity>>(|state, ctx, meta, env| { // Apply opacity, then render meta.content }); -
Implement the application lifecycle: window creation, event loop, and signal-driven updates.
-
Install theme signals: Map your platform’s appearance system to WaterUI’s color and font slots.
For backends that go through FFI (like the Apple and Android backends), you instead implement the view walker in the target language, using the C header or JNI functions to call into Rust.
Backend Status
| Backend | Path | Status |
|---|---|---|
| Apple | backends/apple/ | Stable. Reference implementation. |
| Android | backends/android/ | Stable. |
| Hydrolysis | backends/hydrolysis/ | Active Rust-side renderer. Drives waterui-testing. |
| Hydrolysis-M3 | backends/hydrolysis-m3/ | Material 3 component overrides for Hydrolysis. |
| GTK4 | backends/gtk/ | Unmaintained. The roadmap marks it as no longer supported. |
| TUI | (not in tree) | Work in progress. |
Component coverage on each backend is an evolving target. Rather than freezing
a feature matrix here that will rot, run water run --platform <target> against
your view and read the dispatch trace – any view that falls through to its
body() and never reaches a registered handler is a backend gap to file.
What’s Next
With backends covered, the next chapter shifts focus from framework internals to framework extension – how to author reusable WaterUI component libraries that integrate cleanly with the ecosystem.