2023-02-24
Using Flutter Rust bridge in 2023
Flutter Rust bridge is super useful project that allows you to call Rust code from Flutter. Instead of manually writing FFI methods and type conversions, bridge will generate all that glue code for you.
Intro🔗
Before we begin let's list all the dependencies we will need:
- Flutter
- Rust
- Flutter Rust bridge codegen:
cargo install -f flutter_rust_bridge_codegen@1.65.0
Library authors have created an excellent guide to set up the bridge on all platforms: see more. Also I have previously written how to setup Flutter Rust bridge on Fedora.
For the purpose of this post we won't go into platform details. Instead we will focus on finding out what is possible with the bridge and how to use the features.
In this post we will reference two files:
api.rs
that will contain our public Rust methodsmain.dart
that will contain Dart code calling into Rust
Basic example🔗
First thing that everybody needs is how to call a function. Let's define it:
Now let's generate glue code. Flutter Rust bridge codegen will go through all public methods in api.rs
and generate FFI methods for each.
# This generates bindings for all platforms.
# `native/src/api.rs` is our Rust file with public methods.
Finally, we can call our square function from Dart:
// Import generated glue code.
final _dylib = /* load library */;
final Native native = NativeImpl(_dylib);
Future<int> result = native.square(4);
By default Flutter Rust bridge executes a FFI call on a thread pool and returns a Future so as not to hang Dart's main thread. If you really want to make your call synchronous you can return
SyncReturn<u32>
from Rust.
Features🔗
Returning structs🔗
In our basic example we used primitive types. In real world we rely on a lot more types: structs, datetime, enums, etc.
Structs usage is self-explanatory. They work just as you would expect. Fields could be primitive types, other structs, Option or chrono::DateTime. Bridge project is very active and with each release there are less and less restrictions.
Person saved = await native.savePerson(p: Person(name: "Tom", age: 23));
There is one catch with structs though: you cannot use structs from external crate directly. Bridge needs to know the fields of the struct and can't do that with structs defined in external crates.
You have to redefine the struct in api.rs
or in any other file in the same crate. Our struct usage becomes more complex:
This gets quite hectic if you need to convert all of your types. Luckily, Flutter Rust bridge comes with a solution to this problem. They call it mirroring. "Mirroring" still requires your to re-define a struct but it removes the need for writing manual conversion methods.
With "Mirroring" our example becomes:
/// Important to re-export Person import.
pub use Person;
// Conversions are no longer needed
Based on my experience I couldn't switch fully to "mirroring" because there are some use cases where writing a custom conversion method is helpful.
Log streaming🔗
When creating an application it is very helpful to be able to access all logs from somewhere. In my case I wanted to display all logs in the UI. With the bridge and Rust's tracing it is straightforward. Flutter Rust bridge allows us to return a stream of events from Rust side.
We will create our own sink for Rust logs and forward all lines to Dart.
use Write;
use ;
use StreamSink;
use ;
/// Wrapper so that we can implement required Write and MakeWriter traits.
/// Write log lines to our Flutter's sink.
/// Public method that Dart will call into.
Dart side is way simpler.
Stream<String> nativeLogs = native.setupLogs();
nativeLogs.handleError((e) {
print("Failed to set up native logs: $e");
}).listen((logRow) {
print("[native] $logRow");
});
Now all calls to tracing::info!()
, tracing::error!()
, ... will forward logs to our Flutter application.
In my application I use the same stream pattern to dispatch events from native module to Flutter.
Global state and Tokio🔗
So far we have only looked at how to call the pure functions, functions that don't reference a global state. In practice we often need a global state. Be it a database connection, an async runtime, or something else.
SQLite🔗
Let's start with the essentials. How about having an SQLite connection?
use ;
/// Keep a global database reference
static DB: = new;
/// First we need to initialize our db connection
/// Now we can use established connection to save people
You could see the pattern here: Dart code tends to be short and clean.
await native.connect();
Person saved = await native.savePerson(name: "Tom", age: 23);
Tokio runtime🔗
Now comes the big question: How do you call async functions?
First thing I would suggest is to check if you could use blocking API (e.g reqwest provides blocking clients). There is nothing terribly wrong with blocking. Also check more potential solutions from bridge's docs.
There is no silver bullet when it comes to using async with Flutter Rust bridge. All solutions have trade-offs. Async work is tracked here.
If none of the previous solutions suit your needs or you need your runtime to be running all the time then let's continue here.
use Runtime;
static RUNTIME: = new;
Dart:
await native.start();
Person saved = await native.savePerson(name: "Tom", age: 23);
There is one downside to this approach though. Since we lock the runtime for the duration of the call only one UI action could be processed at a time.
Due to this limitation I am using an event-based approach in my app. In example when I want to download a file I call async method to start download and do not wait till the file is downloaded. Instead I wait for a download complete event that is sent from the native module.
Project references🔗
I have created a cross-platform Flutter application that uses Flutter Rust bridge, Bolik Timeline. If you want to see some "real life" examples check out the links:
- Set up native module: Listen to logs and start a runtime.
- Sample method call: Dart and Rust
And finally, if you have any questions feel free to get in touch with me.