How Flutter-Rust FFI Communication Works in AppFlowy: A Deep Dive into the Bridge Architecture
AppFlowy implements Flutter-Rust FFI communication through a Foreign Function Interface layer where Dart's DynamicLibrary loads compiled Rust shared objects, enabling zero-copy protobuf serialization across the boundary with async responses posted back to Dart isolates via the allo_isolate crate.
The AppFlowy-IO/AppFlowy repository embeds performance-critical business logic—such as document storage, database operations, and real-time collaboration—inside a Rust core library. This architecture requires a robust, low-latency bridge to the Flutter UI layer, implemented through a thin FFI abstraction that maintains thread safety and platform independence.
Architecture of the FFI Communication Layer
The Three-Tier Pipeline
The communication stack operates across three distinct layers. On the Flutter side, Dart's DynamicLibrary loads the platform-specific shared object (libdart_ffi.so, .dylib, or .dll). This library exposes C-ABI functions marked with #[no_mangle] extern "C" in the Rust source, such as init_sdk, async_event, and set_stream_port. These functions serve as the entry points to the AppFlowy Core, where AFPluginRuntime and AppFlowyCore instances handle plugin events, data persistence, and networking on a dedicated Tokio runtime.
Key Components for Cross-Platform Messaging
- FFIRequest / FFIResponse: Protobuf-generated types that convert between raw byte buffers used by the FFI boundary and internal Rust types (
AFPluginRequestandAFPluginEventResponse). - allo_isolate: A Rust crate that enables posting messages back to specific Dart isolates using numeric port identifiers, creating an async channel across the language boundary.
- Port-Based Channels: The UI creates an
Isolateand registers its port viaset_stream_port. Rust stores this in a thread-safeRwLock<Option<Isolate>>(specificallyLOG_STREAM_ISOLATE), allowing background threads to post responses without blocking the Flutter UI.
Step-by-Step FFI Communication Flow
1. Loading the Native Library
The Dart side initializes the bridge by loading the compiled Rust library into memory.
final DynamicLibrary _dart_ffi_lib = _open(); // loads libdart_ffi.so / .dylib / .dll
2. Initializing the SDK
Flutter calls init_sdk to bootstrap the Rust environment, passing a JSON configuration string that specifies application version, device ID, and platform type.
int init_sdk(int port, Pointer<Utf8> data) => _init_sdk(port, data);
On the Rust side (in frontend/rust-lib/dart-ffi/src/lib.rs), this function parses the JSON, initializes environment variables, creates an AppFlowyCore instance, and stores the runtime handle for subsequent requests.
3. Establishing the Async Response Channel
Before sending commands, the UI registers a port for receiving async responses. The set_stream_port function stores the Dart isolate in Rust's LOG_STREAM_ISOLATE, a global RwLock that enables thread-safe access from background tasks.
int set_stream_port(int port) => _set_stream_port(port);
4. Dispatching Async Events
When the UI needs to perform operations like creating a document, it constructs an FFIRequest protobuf message, serializes it to bytes, and passes a raw pointer to async_event.
void async_event(int port, Pointer<Uint8> input, int len) => _invoke_async(port, input, len);
The Rust implementation of async_event (in lib.rs) converts the raw buffer into an AFPluginRequest and dispatches it through DART_APPFLOWY_CORE.dispatch, which routes the command to the appropriate plugin on a Tokio background thread.
5. Processing and Responding
The AFPluginDispatcher::boxed_async_send_with_callback executes the requested operation (e.g., database queries or network synchronization). Upon completion, the system invokes post_to_flutter, which converts the AFPluginEventResponse into an FFIResponse, serializes it to bytes, and posts it to the Dart isolate.
async fn post_to_flutter(response: AFPluginEventResponse, port: i64) {
let ffi_resp = FFIResponse::from(response);
let bytes = ffi_resp.into_bytes().unwrap().to_vec();
let isolate = allo_isolate::Isolate::new(port);
isolate.catch_unwind(async { isolate.post(bytes) }).await;
}
6. Receiving Data in Flutter
The Dart isolate listens for incoming byte buffers, deserializes them using the generated FFIResponse class, and forwards the payload to the UI layer for rendering.
Implementation Examples
Initializing the Native SDK in Dart
The following pattern from frontend/appflowy_flutter/packages/appflowy_backend/lib/ffi.dart demonstrates proper initialization and memory management:
Future<void> initAppFlowy() async {
final config = {
'app_version': '0.6.0',
'device_id': 'flutter-device',
'platform': Platform.isIOS ? 'ios' : Platform.isAndroid ? 'android' : 'desktop',
};
final jsonStr = jsonEncode(config);
final ptr = jsonStr.toNativeUtf8();
const port = 5000;
init_sdk(port, ptr);
set_stream_port(port);
calloc.free(ptr);
}
Sending Async Events from Flutter
This snippet shows how to build a protobuf request and transmit it across the FFI boundary:
Future<void> createDocument(String docId) async {
final request = FFIRequest()
..event = 'create_document'
..payload = jsonEncode({'doc_id': docId}).codeUnits;
final bytes = request.writeToBuffer();
final ptr = malloc.allocate<Uint8>(bytes.length);
final native = ptr.asTypedList(bytes.length);
native.setAll(0, bytes);
async_event(5000, ptr, bytes.length);
malloc.free(ptr);
}
Receiving Responses via Isolates
Dart registers a callback to handle bytes posted from Rust:
void startResponseListener(int port) {
IsolateNameServer.registerPortWithName(port, (Uint8List data) {
final response = FFIResponse.fromBuffer(data);
final payload = utf8.decode(response.payload);
print('Received response: $payload');
});
}
Handling Requests in Rust
The entry point in frontend/rust-lib/dart-ffi/src/lib.rs demonstrates zero-copy conversion:
#[no_mangle]
pub extern "C" fn async_event(port: i64, input: *const u8, len: usize) {
let request: AFPluginRequest = FFIRequest::from_u8_pointer(input, len).into();
DART_APPFLOWY_CORE.dispatch(request, port, None);
}
Key Source Files
Understanding the following files is essential for working with the AppFlowy FFI layer:
frontend/appflowy_flutter/packages/appflowy_backend/lib/ffi.dart: Contains Dart-side FFI bindings, includinginit_sdk,async_event, andset_stream_portwrappers.frontend/rust-lib/dart-ffi/src/lib.rs: Exposes C-ABI functions, manages theAppFlowyCoreruntime, and implementspost_to_flutterfor returning data to Dart.frontend/rust-lib/dart-ffi/src/model/ffi_request.rs: Handles conversion of raw FFI byte buffers into internalAFPluginRequesttypes.frontend/rust-lib/dart-ffi/src/model/ffi_response.rs: Converts Rust response types into protobuf bytes for Dart consumption.frontend/rust-lib/dart-ffi/src/notification/mod.rs: ImplementsDartNotificationSenderfor real-time event streaming separate from standard request-response cycles.
Summary
- Zero-copy serialization: AppFlowy uses raw byte buffers and protobuf messages (
FFIRequest/FFIResponse) to minimize data copying overhead across the language boundary. - Isolate-based async communication: The
allo_isolatecrate enables Rust background threads to safely post responses to specific Dart isolates using numeric ports, preventing UI blocking. - Thread-safe architecture: All heavy processing (database operations, networking, indexing) runs on a dedicated Tokio runtime in Rust, while the Flutter thread remains responsive.
- Platform abstraction: The same Dart code targets Android, iOS, macOS, Linux, and Windows by loading the appropriate shared object format through
DynamicLibrary.
Frequently Asked Questions
How does AppFlowy handle async responses from Rust to Flutter?
AppFlowy uses a port-based channel system. During initialization, Dart calls set_stream_port to register an isolate identifier with Rust, which stores it in a thread-safe RwLock. When an async operation completes, Rust uses allo_isolate::Isolate::new(port).post(bytes) to deliver the serialized response back to the specific Dart isolate listening on that port.
What serialization format does AppFlowy use for FFI messages?
The system uses Protocol Buffers (protobuf). Dart constructs FFIRequest messages and serializes them to Uint8List buffers before passing pointers to Rust. Conversely, Rust constructs FFIResponse objects from internal AFPluginEventResponse types and serializes them to bytes for Dart deserialization.
Why does AppFlowy use a dedicated Tokio runtime for the Rust core?
The dedicated Tokio runtime ensures that all blocking operations—such as SQLite queries, file I/O, and HTTP requests—execute on background threads. This design keeps the Flutter UI thread unblocked, providing a responsive user experience while leveraging Rust's performance for compute-intensive tasks.
How is the native library loaded across different platforms?
The Dart DynamicLibrary automatically selects the correct binary format based on the operating system: .so for Linux/Android, .dylib for macOS/iOS, and .dll for Windows. The ffi.dart file contains platform detection logic that loads libdart_ffi with the appropriate extension, enabling a single Dart codebase to target all supported platforms.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →