When Flutter needs a boost
Your Flutter app renders a beautiful gallery, but the moment you tap "Enhance," the UI freezes for three seconds. The Dart event loop is choking on a heavy image filter. You need raw CPU power, but rewriting the whole app in C++ feels like stepping back in time. Rust offers a middle ground: compile the heavy logic to a native library and call it from Dart. The UI stays buttery smooth while Rust crunches the numbers in the background.
This setup uses Foreign Function Interface, or FFI. FFI is the protocol that lets two different languages talk. Rust compiles to a dynamic library. Flutter loads that library and calls functions across the boundary. The bridge is C. Rust speaks C. Dart speaks C. They meet in the middle.
The FFI bridge
FFI relies on the C Application Binary Interface, or ABI. The ABI defines how functions pass arguments, how they return values, and how they clean up the stack. Rust has its own calling convention that optimizes for Rust types. C has a simpler convention that works everywhere. When you expose a function to Flutter, you must use the C convention.
Rust also mangles function names by default. The compiler transforms rust_compute into something like _ZN12my_crate12rust_compute17h3a4b5c6d7e8f9g0hE to encode type information and support overloading. C linkers expect plain names. You need to stop the mangling and force the C convention.
Two attributes do the work. extern "C" sets the calling convention. #[no_mangle] preserves the function name. Without both, the linker cannot find your function, and the app crashes at startup.
Minimal example: integers
Start with a simple calculation. Integers cross the boundary cleanly. No encoding issues. No memory management headaches. This example shows the signature pattern you will use for every FFI function.
/// Computes a checksum from a byte slice.
/// Returns 0 if the input is null or empty.
#[no_mangle]
pub extern "C" fn rust_compute(input: *const u8, len: usize) -> u64 {
// SAFETY: Caller must ensure `input` is valid for `len` bytes.
// 1. `input` must not be null if `len > 0`.
// 2. `input` must point to allocated memory of at least `len` bytes.
if input.is_null() || len == 0 {
return 0;
}
// Convert raw pointer to a safe slice.
// This does not copy data. It borrows the memory.
let slice = unsafe { std::slice::from_raw_parts(input, len) };
// Compute a simple checksum.
// Using wrapping_add prevents overflow panics.
slice.iter().fold(0u64, |acc, &b| acc.wrapping_add(b as u64))
}
The function takes a raw pointer and a length. Raw pointers are the currency of FFI. They carry no lifetime information. They carry no safety guarantees. The unsafe block acknowledges that you are trusting the caller to provide valid memory. The null check protects against the most common mistake.
The convention here is to check for null before dereferencing. The community treats raw pointers as untrusted input. Even if the caller is your own Dart code, defensive checks save you from hard-to-debug crashes.
The signature is the contract. Break it, and the linker screams.
Realistic example: strings and memory
Strings are where FFI gets tricky. Dart uses UTF-16 encoding. Rust uses UTF-8. C uses null-terminated UTF-8. You cannot pass a Dart string directly to Rust. You must convert it to a C string, process it, and convert the result back.
Memory ownership is the bigger challenge. Rust manages memory automatically. C and Dart do not. When Rust allocates a string to return to Dart, Rust must give up ownership. Otherwise, Rust frees the memory while Dart is still reading it. The solution is a transfer ceremony. Rust allocates, leaks the pointer intentionally, and Dart calls a free function when done.
use std::ffi::CString;
use std::os::raw::c_char;
/// Processes a C-string and returns a newly allocated C-string.
/// Caller must free the result using `rust_free_string`.
#[no_mangle]
pub extern "C" fn rust_process_string(input: *const c_char) -> *mut c_char {
// SAFETY: Caller must provide a valid, null-terminated C-string.
// 1. `input` must point to a valid C-string.
// 2. The string must be valid UTF-8, or the function returns an error string.
let c_str = unsafe { std::ffi::CStr::from_ptr(input) };
// Convert to Rust string. Handle invalid UTF-8 gracefully.
let rust_str = c_str.to_str().unwrap_or("Invalid UTF-8");
// Process the string.
let result = format!("Rust processed: {}", rust_str);
// Convert to C-string. This allocates on the heap.
let c_result = CString::new(result).expect("Result contains null byte");
// Transfer ownership to the caller.
// `into_raw` leaks the memory from Rust's perspective.
// Dart now owns the pointer and must call `rust_free_string`.
c_result.into_raw()
}
/// Frees a string allocated by `rust_process_string`.
#[no_mangle]
pub extern "C" fn rust_free_string(ptr: *mut c_char) {
// Early return for null pointers.
if ptr.is_null() {
return;
}
// SAFETY: `ptr` must have been returned by `rust_process_string`.
// 1. `ptr` must not be used after this call.
// 2. `ptr` must not be freed twice.
unsafe {
// Reclaim ownership. The CString destructor frees the memory.
let _ = std::ffi::CString::from_raw(ptr);
}
}
The into_raw method looks scary. It is the standard way to cross the boundary. The community calls this the "leak-to-transfer" pattern. It is safe as long as the other side calls the free function. If Dart forgets to call rust_free_string, the memory leaks. If Dart calls it twice, the allocator panics. Ownership crosses the boundary. If you leak the pointer, you leak the memory.
On the Dart side, you use the dart:ffi library. It provides types that map to C types. You load the dynamic library, look up the function by name, and call it. The ffi package from pub.dev helps with memory allocation on the Dart side.
import 'dart:ffi';
import 'package:ffi/ffi.dart';
// Define the Rust function signatures.
// Uint64 maps to u64. Pointer<Utf8> maps to *const c_char.
typedef RustProcessString = Pointer<Utf8> Function(Pointer<Utf8>);
typedef RustProcessStringDart = Pointer<Utf8> Function(Pointer<Utf8>);
typedef RustFreeString = Void Function(Pointer<Utf8>);
typedef RustFreeStringDart = void Function(Pointer<Utf8>);
class RustBridge {
// Load the library. The name varies by platform.
// Use conditional imports or a helper to select the right file.
final DynamicLibrary _lib = DynamicLibrary.open('librust_lib.so');
// Bind the functions.
final RustProcessStringDart _process = _lib
.lookupFunction<RustProcessString, RustProcessStringDart>('rust_process_string');
final RustFreeStringDart _free = _lib
.lookupFunction<RustFreeString, RustFreeStringDart>('rust_free_string');
// High-level API for Dart code.
String process(String input) {
// Allocate a C-string from the Dart string.
Pointer<Utf8> cInput = input.toNativeUtf8();
try {
// Call the Rust function.
Pointer<Utf8> cResult = _process(cInput);
try {
// Convert the result back to a Dart string.
String result = cResult.toDartString();
// Free the Rust-allocated string.
_free(cResult);
return result;
} finally {
// Ensure the result is freed even if conversion fails.
if (cResult != nullptr) {
_free(cResult);
}
}
} finally {
// Free the input string allocated by Dart.
calloc.free(cInput);
}
}
}
The toNativeUtf8 method allocates a C-string on the Dart side. The calloc.free call cleans it up. The try/finally blocks ensure memory is freed even if an error occurs. This pattern is essential. Memory leaks in FFI code accumulate silently until the app runs out of memory.
Convention aside: the community prefers flutter_rust_bridge for production apps. It generates this boilerplate automatically. It handles string encoding, memory management, and async calls. Manual FFI is educational and works for tiny interfaces, but the generated bridge saves weeks of debugging.
Pitfalls and compiler errors
FFI code lives in the unsafe zone. The compiler cannot verify your invariants. You must verify them yourself.
Passing a null pointer to CStr::from_ptr triggers undefined behavior. The compiler does not stop you because the pointer comes from unsafe. You must check is_null() before dereferencing. If you forget, the app crashes in ways that make no sense. The crash might happen seconds later when the allocator detects corruption.
If you try to dereference a raw pointer outside an unsafe block, the compiler rejects you with E0133 (dereference of raw pointer requires unsafe). This error is a feature. It forces you to acknowledge the risk.
Type mismatches cause E0308 (mismatched types). If your Rust function expects usize but Dart passes int32, the signature lookup fails. Ensure the Dart types match the Rust types exactly. u64 maps to Uint64. i32 maps to Int32. *const c_char maps to Pointer<Utf8>.
Encoding errors are silent killers. If you pass a Dart string containing characters outside the BMP, UTF-16 encoding splits them into surrogate pairs. C strings do not understand surrogates. The result is garbled text. Always convert to UTF-8 before crossing the boundary. The toNativeUtf8 method handles this conversion.
Raw pointers are wild animals. Leash them with unsafe and checks.
Decision: manual FFI or bridge tools
Use flutter_rust_bridge when you need a robust, generated bridge that handles string encoding, memory management, and async calls automatically. It saves weeks of boilerplate and reduces memory leak risk. The tool generates Dart and Rust code from a single interface definition.
Use manual FFI with extern "C" when you are building a tiny, stable interface with a single function and want zero dependencies. Keep the API surface minimal. Manual FFI is fine for a math library with a few number-crunching functions.
Use dart:ffi directly on desktop and web when you want to skip the platform channel overhead and call the Rust library from Dart without native glue code. This works on macOS, Windows, Linux, and WebAssembly. The dart:ffi API is available on all these platforms.
Reach for platform channels when you are already writing native Kotlin or Swift code and just need to forward the call to Rust. The overhead is negligible for most UI interactions. Platform channels are the legacy approach for Android and iOS. They require more native code but integrate well with existing native projects.
Pick the tool that matches your pain. Boilerplate kills projects faster than bugs.