Architecture¶
Three components¶
graph LR
plugin["<b>Gradle plugin</b><br/>build-logic/gradle-plugin"]
bindgen["<b>Bindgen</b><br/>bindgen/"]
runtime["<b>Runtime</b><br/>runtime/"]
plugin -->|"cargo install, then runs it"| bindgen
bindgen -->|"generated code imports uniffi.runtime.*"| runtime
plugin -->|"adds as commonMain dependency"| runtime
| Component | Responsibility |
|---|---|
| Gradle plugin | Installs the bindgen, builds the Rust crate per target, runs the bindgen, and wires generated sources, headers and libraries into the Kotlin source sets. |
| Bindgen | A uniffi_bindgen::BindingGenerator plus askama templates. Writes Kotlin for four source sets and C headers. |
| Runtime | Kotlin shared by every crate, plus a small Rust library of its own. That library allocates every RustBuffer Kotlin passes to Rust, so a buffer can go to any crate. This is what makes multi-module builds work. In addition, the common code lives here too: FfiConverter, RustBuffer, UniffiHandleMap, the cleaner, the async helpers, converters for primitive types. Published as ch.ubique.uniffi:runtime. |
The plugin and the runtime are released together with the same version. The bindgen is installed
from source by cargo install, see Gradle plugin.
The build pipeline¶
flowchart TD
meta["cargo metadata<br/><i>configuration time</i>"]
install["installBindgen<br/>cargo install"]
host["cargoBuild<Host>Debug<br/>host cdylib"]
bindings["buildBindings<br/>bindgen --library <cdylib>"]
out["build/uniffi/bindings/<br/>commonMain · jvmMain · androidMain · nativeMain<br/>nativeInterop/cinterop/headers"]
cargo["cargoBuild<Target><Profile><br/>one per Rust target"]
merge["mergeUniffi*<br/>JVM resources, Android jniLibs"]
def["generateDefFileFor<Target>"]
cinterop["cinterop uniffi-cinterop"]
meta --> host
install --> bindings
host --> bindings
bindings --> out
cargo --> merge
cargo --> def
out --> def
def --> cinterop
- Configuration. The plugin runs
cargo metadatato find the package and library name and Cargo's target directory. - Generate.
installBindgeninstalls the generator. A debug build of the crate for the host produces a dynamic library, andbuildBindingsruns the generator on it in UniFFI's library mode: the interface description is read from metadata embedded in the binary. WithgenerateFromUdl, the UDL file is passed instead and the host build is skipped. - Compile Rust per target. One
cargoBuild<Target><Profile>task per Rust target, building a dynamic library for JVM/Android and a static library for Kotlin/Native. - Wire. The generated directories are added as Kotlin source dirs. Dynamic libraries are merged
into one directory tree for JVM resources or Android
jniLibs. For each native target, a.deffile points cinterop at the static library and the generated headers.
In debug builds, the host build in step 2 and the JVM build for the host in step 3 are the same task, so the crate is not compiled twice. Details are in Gradle plugin.
Generated source sets¶
For a crate with namespace ns, the bindgen writes:
build/uniffi/bindings/
├── commonMain/kotlin/<package>/ns.common.kt
├── jvmMain/kotlin/<package>/ns.jvm.kt
├── androidMain/kotlin/<package>/ns.android.kt
├── nativeMain/kotlin/<package>/ns.native.kt
└── nativeInterop/cinterop/headers/
├── ns/ns.h
└── common/common.h
The split follows one constraint: only the platform source sets can call Rust. The FFI library
object (UniffiLib) is a JNA Library on JVM/Android and a set of cinterop functions on Native,
so it can't exist in commonMain.
| Declaration | commonMain |
Platform source sets |
|---|---|---|
| Top-level functions | expect fun |
actual fun with the FFI call |
| Objects | interface FooInterface + expect open class Foo |
actual open class Foo, FfiConverterTypeFoo |
| Records, enums, errors | the full data class / enum class / sealed class |
FfiConverterType… |
| Methods on records and enums | member function calling an internal expect fun shim |
actual shim with the FFI call |
| Callback interfaces | interface Foo |
vtable, FfiConverterTypeFoo |
Disposable, use, NoHandle, UniffiWithHandle |
declared in the generated file | — |
Records are not expect classes, because a data class needs to be declared in full to keep copy
and componentN available in common code. That is why their methods go through a shim.
Disposable and the marker objects are declared per package rather than imported from the
runtime. Every generated object implements Disposable, so importing it from the runtime would make
the runtime part of every binding's public API.
The plugin adds -Xexpect-actual-classes to the whole project, because expect/actual classes
are still a Beta feature in Kotlin.
JVM and Android share a template¶
jvmMain and androidMain are rendered from the same templates (templates/generic/android+jvm/).
The template is rendered twice, with identical output, and written to both source sets. The plugin adds JNA
as a jar on the JVM and as an aar on Android.
One call, end to end¶
add(2, 2)
commonMaindeclaresexpect fun add(a: Int, b: Int): Int.- The JVM
actuallowers each argument with itsFfiConverter, and callsUniffiLib.INSTANCE.uniffi_<crate>_fn_func_add(a, b, status)insideuniffiRustCall. - JNA calls the
extern "C"scaffolding function that#[uniffi::export]generated in Rust. - Rust writes the result and a
RustCallStatus.uniffiRustCallthrows if the status reports an error. Otherwise the result is lifted back into a Kotlin value.
The first access to UniffiLib.INSTANCE loads the library, checks the contract version and
checksums, and registers callback vtables. See Objects and handles
for calls on objects, and Async for suspend functions.