当我们需要在 Flutter 中引入高性能、内存安全且具备强大异步生态的语言时,Rust 成了最佳选择。本文将从最简单的同步调用出发,逐步深入到基于 Tokio 的完整异步 Runtime 架构,通过多种不同的 Flutter-Rust FFI 交互模式进行深度对比与性能测试。
0. 环境构建:Native Assets 与 Build Hooks
利用 Dart 的 Build Hooks(hook/build.dart)与 Code Assets 机制,我们无需再编写复杂的 CMakeLists / Makefile 或手动复制 .so / .dylib 文件,可以实现 Rust 源码与 Dart 包的无缝整合:
rust/src/*.rs
│ cargo build (build.rs 运行 cbindgen 生成 C 头文件)
▼
rust/include/rust_ffi_demo.h
│ dart run tool/generate_bindings.dart (ffigen 自动生成 Dart 绑定)
▼
lib/src/bindings.g.dart @Native 外部函数绑定至 Code Asset
▲
│ hook/build.dart 自动构建并将动态库输出为 CodeAsset
librust_ffi_demo.so / .dylib / .dll
hook/build.dart 会在运行 flutter build 或 flutter test 时自动触发 Cargo 编译,将 Dart target 映射到 Rust 三元组(Target Triple),并将编译出的动态库作为 DynamicLoadingBundled 资源装载,同时监控源码变更,实现真正的增量热重构。
需要注意的是,声明依赖时不能只监控 rust/src/**:Cargo.toml、Cargo.lock、build.rs 都必须一并加入 output.addDependencies(...),否则升级依赖或修改构建脚本不会触发重新编译。
注意:现时(Flutter 3.44)需要 flutter config –enable-native-assets 开启 Code Assets。
1. 轻量的同步调用
同步调用是跨越 FFI 边界最快的方式:没有 Isolate 切换、没有事件循环投递、没有数据编解码。
Rust 端实现
use std::panic::{catch_unwind, AssertUnwindSafe};
/// 所有可能 panic 的 extern "C" 导出函数都应包裹一层 panic 捕获防护 Guard
pub fn guard<T>(fallback: T, body: impl FnOnce() -> T) -> T {
catch_unwind(AssertUnwindSafe(body)).unwrap_or(fallback)
}
#[no_mangle]
pub extern "C" fn rfd_add(a: i64, b: i64) -> i64 {
a.wrapping_add(b) // wrapping_add 不会 panic,这里无需 guard
}
Dart 端绑定
// 直接通过自动生成的 bindings 调用
int add(int a, int b) => bindings.rfd_add(a, b);
对于不阻塞、不回调 Dart、不触发 GC 的纯计算函数,务必给 @Native 标注 isLeaf: true:
@Native<Int64 Function(Int64, Int64)>(symbol: 'rfd_add', isLeaf: true)
external int rfd_add(int a, int b);
性能表现
| 测试项 | 耗时 | 吞吐量 |
|---|---|---|
rfd_add |
22.5 ns/call | ~44.4M calls/s |
rfd_add (isLeaf: true) |
8.3 ns/call | ~120.2M calls/s |
内存传递注意事项(Dart 传 Buffer 给 Rust)
Dart 的 TypedData 存放在 GC 堆上,指针不能跨越调用边界长期持有:GC 随时可能移动对象。但自 Dart 3.5 起,TypedData.address 可以直接作为参数传给标注了 isLeaf: true 的 @Native 函数,编译器保证在该次调用期间对象不会被移动,实现真正的零拷贝:
@Native<Int64 Function(Pointer<Uint8>, Size)>(symbol: 'rfd_sum_buffer', isLeaf: true)
external int _sumBuffer(Pointer<Uint8> data, int len);
int sumBuffer(Uint8List data) {
if (data.isEmpty) return 0;
// 零拷贝:仅在这一次叶子调用期间有效
return _sumBuffer(data.address, data.length);
}
约束是:该指针不得被 Rust 侧存储,也不能用于非叶子调用或异步调用。一旦调用本身不再是单次叶子调用,就必须退回到“在 C 堆上分配并复制”的老办法:
int sumBufferCopied(Uint8List data) {
if (data.isEmpty) return 0;
final buffer = malloc<Uint8>(data.length); // 注意:malloc(0) 可能返回 nullptr
try {
buffer.asTypedList(data.length).setAll(0, data);
return bindings.rfd_sum_buffer(buffer, data.length);
} finally {
malloc.free(buffer); // Dart 拥有此段分配的内存,负责在 finally 中释放
}
}
2. 同步调用的陷阱:UI 冻结
同步调用没有让出执行权的机制。无论 Rust 调用占用多少时间,调用该 API 的 Dart Isolate 就会被卡住相同的时长:
#[no_mangle]
pub extern "C" fn rfd_blocking_sleep(millis: u64) -> u64 {
guard(0, || {
let start = std::time::Instant::now();
std::thread::sleep(std::time::Duration::from_millis(millis));
start.elapsed().as_millis() as u64
})
}
如果在 Flutter 的 UI Isolate 上调用此函数,在这期间 Flutter 将丢掉所有的帧。
3. Dart Isolate:转移阻塞而非消除阻塞
Rust 层的同步函数无法自行解决阻塞问题,但 Dart 可以在非 UI Isolate 上运行它。
// 注意:闭包会被发送到新 Isolate,捕获的变量必须是可发送的,否则会抛出 "Illegal argument in isolate message"。
Future<int> runInIsolate(Duration duration) =>
Isolate.run(() => blockingSleep(duration));
Isolate.run 每次都会派生新 Isolate,每次开销约 0.2ms。对于耗时几秒的任务非常划算,但在高频场景下会急剧劣化,仅 100 req/s 时 p99 延迟就已升至 3.44ms。
4. Native Ports:无需 Isolate 的原生异步
要实现高效的异步,应让 Rust 在其自己的线程中等待,并将结果异步投递回 Dart。
这就是 Native Port 的工作原理:Dart 端创建 ReceivePort,将其 nativePort(int64 标识符)传给 Rust,Rust 使用 Dart_PostCObject API 将数据发回 Dart 的事件循环。
初始化 API
if (_bindings.initialize_dart_api(dartffi.NativeApi.initializeApiDLData) != 0) {
throw StateError(
'failed to initialize Dart DL C API: version mismatch. '
'must update include/ to match Dart SDK version',
);
}
/// 返回 0 表示成功,非 0 表示失败。
/// # Safety
/// `api_data` 必须是 `NativeApi.initializeApiDLData` 传入的有效指针。
#[no_mangle]
pub unsafe extern "C" fn initialize_dart_api(
api_data: *mut ::core::ffi::c_void,
) -> ::core::ffi::c_int {
Dart_InitializeApiDL(api_data)
}
这个函数每个进程只需成功调用一次。在 Flutter 中要留意:热重启(hot restart)会重建 Dart Isolate,但 Rust 动态库及其全部静态状态都不会被卸载——Tokio Runtime 还在跑,旧的 session 还活着,在途任务手里攥着的 port id 却已经失效了。生产代码应当为此准备一个显式的 reset 入口,否则热重启后大量任务会静默丢失。
模式 1:每个请求派生一个 Rust OS 线程
#[no_mangle]
pub extern "C" fn rfd_thread_sleep(port: i64, request_id: i64, millis: u64) {
guard((), || {
std::thread::spawn(move || {
// guard 必须放在 worker 闭包**内部**
let result = guard(Err("panic in worker".to_string()), || {
let start = std::time::Instant::now();
std::thread::sleep(std::time::Duration::from_millis(millis));
Ok(start.elapsed().as_millis() as i64)
});
// 无论成功失败,都必须回传一次,向指定 native port 发送 [requestId, status, value]
match result {
Ok(v) => post_ok(port, request_id, v),
Err(e) => post_err(port, request_id, &e),
}
});
});
}
这里有个极易踩空的坑:guard 包住 spawn 是没有意义的。spawn 本身几乎不会 panic,真正可能 panic 的是 worker 闭包体。而线程 panic 默认只会让该线程静默死亡(不会终止进程),于是 post_ok 永远不会执行——Dart 侧对应 request_id 的 Completer 永不完成,Future 永久挂起,pending 表里的条目也永久泄漏。所以防护层必须下沉到闭包内部,且失败路径也必须回传一条消息。
即便如此,Dart 侧仍应为每个请求配一个 timeout 兜底:Rust 侧任何未预料的路径(abort、库被卸载、port 提前关闭)都会让承诺永远无法兑现。
模式 2:Rust 侧使用 Tokio 阻塞线程池(spawn_blocking)
通过在 Rust 侧引入单例 Tokio Runtime(用 OnceLock 惰性初始化即可),将阻塞任务丢入 Tokio 的 Blocking Thread Pool:
#[no_mangle]
pub extern "C" fn rfd_blocking_sleep_pool(port: i64, request_id: i64, millis: u64) {
guard((), || {
runtime().spawn_blocking(move || {
// 同上:guard 下沉到任务体内,失败路径同样要回传
let result = guard(Err("panic in blocking task".to_string()), || {
let start = std::time::Instant::now();
std::thread::sleep(std::time::Duration::from_millis(millis));
Ok(start.elapsed().as_millis() as i64)
});
match result {
Ok(v) => post_ok(port, request_id, v),
Err(e) => post_err(port, request_id, &e),
}
});
});
}
与模式 1 的关键差别在于线程数有上界:Tokio blocking 池默认上限 512,可通过 max_blocking_threads 调整,超出部分排队而非无限扩张。用可控的排队延迟换掉了 OOM 风险。
5. Async Rust 与 Port 复用策略压测
如果 Native 库本身支持 async/await,则可利用 Tokio 的异步 Task 实现极致并发:
#[no_mangle]
pub extern "C" fn rfd_tokio_sleep(port: i64, request_id: i64, millis: u64) {
guard((), || {
runtime().spawn(async move {
let start = std::time::Instant::now();
tokio::time::sleep(std::time::Duration::from_millis(millis)).await;
post_ok(port, request_id, start.elapsed().as_millis() as i64);
});
});
}
异步任务同样存在“panic 即挂起”问题。由于 catch_unwind 无法直接包裹 .await,异步路径推荐改用 JoinHandle 兜底:
let handle = runtime().spawn(async move { /* ... 业务逻辑 ... */ });
runtime().spawn(async move {
if let Err(e) = handle.await {
// 任务 panic 或被取消
post_err(port, request_id, &e.to_string());
}
});
数据流与 Stream 模式
Port 是一个队列,不仅能发送单次响应,还能多次发送数据,非常适合映射为 Dart 的 Stream:
for index in 0..count {
tokio::time::sleep(interval).await;
// 当 Dart 端取消 Stream 并关闭 Port 时,post 会返回 false
if !post(port, vec![request_id, STATUS_PROGRESS, index as i64]) {
return; // Dart 端已取消,立即停止 Rust 端的生产,防止内存泄露
}
}
post(port, vec![request_id, STATUS_DONE, count as i64]);
这个写法有两个必须知道的局限。
其一, post 只在 port 已关闭时才返回 false;只要 port 还开着,Rust 就会以自己的最快速度生产,而 Dart 侧的 port 队列是无界的。生产快于消费时队列会持续膨胀直至 OOM,中途没有任何信号。真正的流控需要一个反向通道,让 Dart 主动发放配额:
// Dart 侧每消费 N 条就通过 rfd_stream_credit(request_id, n) 补充配额
// Rust 侧在配额耗尽时 await 信号量,而不是继续闷头生产
credits.acquire().await;
post(port, vec![request_id, STATUS_PROGRESS, index as i64]);
其二,取消是被动的。只有在下一次 post 时才能发现对端已经关闭。
6. NativeCallable 回调机制
除了 Port,Dart 提供了 NativeCallable 机制,允许直接将 Dart 函数转换为 C 函数指针供 Rust 调用:
| 特性比较 | NativeCallable.isolateLocal |
NativeCallable.listener |
|---|---|---|
| 可调用线程 | 仅限创建它的 Dart Isolate 线程 | 任何 Native 线程 |
| 是否有返回值 | 支持 | 否(仅支持 void 返回类型) |
| 执行时机 | 同步、立即在当前 FFI 调用中执行 | 异步投递至 Dart 事件循环执行 |
| 底层开销 | 较低,但并非一次裸函数指针调用 | 相当于一个 Native Port 消息 |
| 是否保持 Isolate 存活 | 否(默认) | 是,直到调用 close() |
isolateLocal 虽然是同步直调,但从 native 侧进入 Dart 需要完成栈帧与状态切换,实测在百纳秒量级,比普通 C 函数指针调用贵得多。它的优势在于“同步 + 有返回值”,而不是“接近零成本”。
isolateLocal 示例(用于同步回调/比较器/进度回调)
final callable = NativeCallable<Int64 Function(Int64)>.isolateLocal(
callback,
exceptionalReturn: 0, // 编译期常量!当 Dart 发生未捕获异常时的默认返回值
);
try {
return bindings.rfd_map_with_callback(callable.nativeFunction, seed, iterations);
} finally {
callable.close(); // 必须显式关闭!
}
不能在 Rust 的后台工作线程中调用
isolateLocal创建的函数指针, 这会导致 Dart VM Crash。如果在异步/后台线程回调,必须使用NativeCallable.listener。
listener 的两个生命周期陷阱
一是它会保持 Isolate 存活。 NativeCallable.listener 默认持有对宿主 Isolate 的引用,只要不调用 close(),该 Isolate 就永远不会退出——这是 listener 最常见的泄漏形态,在后台 Isolate 中尤其隐蔽(表现为 Isolate 数量只增不减)。请务必在生命周期结束处显式 close(),必要时可通过 keepIsolateAlive: false 调整该行为。
二是不能照搬 isolateLocal 的 try/finally 写法。listener 的回调是异步投递的,如果在 FFI 调用返回后立刻 close(),尚在途中的回调会被直接丢弃:
// 错误:FFI 调用返回不代表回调已经执行完
try {
bindings.rfd_run_async(callable.nativeFunction);
} finally {
callable.close(); // 回调还没来得及投递就被关掉了
}
正确做法是由回调自身来决定关闭时机——例如约定最后一条消息为终止标记,收到后再 close() 并完成对应的 Completer。
7. 错误处理与 Panic 恢复
在 Rust FFI 中,Panic 绝不能跨越 FFI 边界抛到 C/Dart 一侧。自 1.81 起,unwind 逃出 extern "C" 函数已被明确定义为 abort(进程终止)。进程直接死掉,且无法被 Dart 的 try-catch 捕获。
可以使用如下 guard 保护层处理错误:
use std::cell::RefCell;
use std::panic::{catch_unwind, AssertUnwindSafe};
thread_local! {
static LAST_PANIC: RefCell<Option<String>> = const { RefCell::new(None) };
}
pub fn guard<T>(fallback: T, body: impl FnOnce() -> T) -> T {
match catch_unwind(AssertUnwindSafe(body)) {
Ok(val) => val,
Err(err) => {
let msg = if let Some(s) = err.downcast_ref::<&str>() {
s.to_string()
} else if let Some(s) = err.downcast_ref::<String>() {
s.clone()
} else {
"Unknown Rust panic".to_string()
};
LAST_PANIC.with(|slot| *slot.borrow_mut() = Some(msg));
fallback
}
}
}
8. 内存所有权与 GC 交互的 4 种模式
每当一块内存跨过 FFI 边界,则需要回答三个问题:谁分配?谁释放?释放的时机由谁决定? 四种模式的区别就在这三个答案上。
| 模式 | 分配方 | 释放方 | 释放时机 |
|---|---|---|---|
| 1. 显式释放 | Rust | Dart 调 free | 确定 |
2. NativeFinalizer |
Rust | GC 线程 | 不确定 |
| 3. 原地视图 | Rust | 持有者析构时 | 跟随持有者 |
| 4. External Typed Data | Rust | Dart GC | 不确定 |
前三种模式里内存的主人始终是 Rust,Dart 只是借用者;只有模式 4 是真正的所有权转移。
模式 1:Rust 分配,Dart 显式释放
什么时候用:生命周期一眼看得到头的缓冲区,申请、读取、释放能写在同一个函数体里。
#[repr(C)]
#[derive(Copy, Clone)]
pub struct RfdBuffer {
pub ptr: *mut u8,
pub len: usize,
pub cap: usize, // 必须携带 capacity,原因见下
}
impl RfdBuffer {
const fn empty() -> Self {
Self { ptr: std::ptr::null_mut(), len: 0, cap: 0 }
}
fn from_vec(mut v: Vec<u8>) -> Self {
let (ptr, len, cap) = (v.as_mut_ptr(), v.len(), v.capacity());
std::mem::forget(v); // 交出所有权:Rust 不再负责释放这块内存
Self { ptr, len, cap }
}
}
/// 分配一块交由 Dart 负责释放的缓冲区。
#[no_mangle]
pub extern "C" fn rfd_buffer_new(len: u32, fill: u8) -> RfdBuffer {
guard(RfdBuffer::empty(), || {
RfdBuffer::from_vec(vec![fill; len as usize])
})
}
/// # Safety
/// `buffer` 必须来自 `rfd_buffer_new`,且只能被释放一次。
#[no_mangle]
pub unsafe extern "C" fn rfd_buffer_free(buffer: RfdBuffer) {
guard((), || {
if buffer.ptr.is_null() { return; }
// 用原始的 (ptr, len, cap) 三元组还原 Vec,layout 才对得上
drop(Vec::from_raw_parts(buffer.ptr, buffer.len, buffer.cap));
});
}
Dart 侧不要把这对函数裸露给业务代码,包一层:
final class RustBuffer {
RustBuffer._(this._raw);
factory RustBuffer.allocate(int length, {int fill = 0}) {
final raw = bindings.rfd_buffer_new(length, fill);
if (raw.ptr == nullptr && length > 0) {
throw StateError('rfd_buffer_new 返回了空指针');
}
return RustBuffer._(raw);
}
final bindings.RfdBuffer _raw;
bool _freed = false;
/// 零拷贝视图:直接映射 Rust 内存,free() 之后立即失效
Uint8List get bytes {
if (_freed) throw StateError('RustBuffer 已被释放');
if (_raw.ptr == nullptr) return Uint8List(0);
return _raw.ptr.asTypedList(_raw.len);
}
/// 幂等:重复调用不会造成 double free
void free() {
if (_freed) return;
_freed = true;
bindings.rfd_buffer_free(_raw);
}
}
// 调用方必须自己保证 free 一定发生
final buffer = RustBuffer.allocate(1024, fill: 7);
try {
process(buffer.bytes);
} finally {
buffer.free();
}
为什么必须带 cap? 一个常见的错误写法是:
// 危险:仅当 cap == len 时才正确
drop(Box::from_raw(std::slice::from_raw_parts_mut(buffer.ptr, buffer.len)));
Box<[u8]> 释放时使用的是大小为 len 的 layout。而只要这块内存来自一个 capacity != len 的 Vec<u8>(凡是用 push、extend、with_capacity 构造的几乎都是如此),就会以错误的 layout 调用 dealloc——这是未定义行为,在部分分配器上直接表现为堆损坏后的随机崩溃。
如果不想在结构体里多带一个字段,可以在构造时使用 v.into_boxed_slice()(该方法会把 capacity 收缩到 len),此时 Box::from_raw 才是安全的。
风险:Dart 侧若忘记 finally { buffer.free(); },内存将永久泄露,且没有任何信号提醒你。
模式 2:Rust 分配,Dart NativeFinalizer 挂钩 GC 自动释放
什么时候用:要交到 API 使用者手里、生命周期不由你控制的长期 handle(session、连接、解码器)。
Rust 侧把对象装箱后以不透明指针交出,cbindgen 会把 RfdSession 导出为不完整类型,Dart 只能拿到 Pointer<RfdSession>,碰不到字段:
use std::ffi::c_void;
pub struct RfdSession {
state: u64,
buffer: Vec<u8>,
}
#[no_mangle]
pub extern "C" fn rfd_session_new(seed: u64) -> *mut RfdSession {
guard(std::ptr::null_mut(), || {
Box::into_raw(Box::new(RfdSession {
state: seed,
buffer: vec![0u8; 64 * 1024],
}))
})
}
/// # Safety
/// `session` 必须来自 `rfd_session_new`,且只能被释放一次。
#[no_mangle]
pub unsafe extern "C" fn rfd_session_free(session: *mut c_void) {
guard((), || {
if session.is_null() { return; }
drop(Box::from_raw(session as *mut RfdSession));
});
}
Dart 侧:
final _sessionFinalizer = NativeFinalizer(
bindings.addresses.rfd_session_free.cast(),
);
final class RustSession implements Finalizable {
RustSession({int seed = 0, this.bufferLength = 64 * 1024})
: _handle = bindings.rfd_session_new(seed) {
if (_handle == nullptr) throw StateError('rfd_session_new 返回了空指针');
// 把 Dart 对象的生命周期与 Native 指针绑定:
// 本对象不可达时,VM 会以 _handle 为参数调用 rfd_session_free
_sessionFinalizer.attach(this, _handle.cast(),
detach: this, externalSize: bufferLength);
}
final int bufferLength;
final Pointer<bindings.RfdSession> _handle;
bool _closed = false;
int next() {
if (_closed) throw StateError('RustSession 已关闭');
return bindings.rfd_session_next(_handle);
}
/// 提前释放,不等 GC。幂等。
void close() {
if (_closed) return;
_closed = true;
_sessionFinalizer.detach(this); // 必须先 detach,否则将来会二次释放
bindings.rfd_session_free(_handle.cast());
}
}
这段代码里有三处是承重的,少一处都会出问题:
implements Finalizable不能省。 它保证对象在“引用了它的方法体”执行完之前不会被回收。没有它,优化器可能在_handle被读出的那一刻就认为包装对象已死,于是 finalizer 可能在 Rust 正在使用这个 session 的过程中把它释放掉——这种崩溃只在内存压力下偶发。- 显式释放前必须
detach。 否则 finalizer 迟早会在一个已释放的指针上再跑一次。attach时传的detach: this就是为了让这一步能定位到该条注册。 externalSize值需要正确设置。 一个 40 字节的 Dart 对象背后拖着 8MB 原生内存,不告诉 GC 的话,它可能会错误评估回收时机。
另外,NativeFinalizer 的回调运行在 GC 线程上,因此 rfd_session_free 必须是纯 native 的清理逻辑:不得回调 Dart、不得访问 Dart 对象,也不应执行耗时操作。
模式 3:Rust 分配,Dart 原地零拷贝读取(Zero-Copy In-Place View)
什么时候用:大块数据只读一次,且读取全程持有者确定存活,比如从解码器里取出当前帧。
Rust 侧返回的是一个借用,而不是所有权。
/// 借来的视图:只有 ptr 与 len,没有 cap——因为调用方无权释放它。
#[repr(C)]
#[derive(Copy, Clone)]
pub struct RfdBufferView {
pub ptr: *const u8,
pub len: usize,
}
/// 填充 session 内部缓冲区并返回其视图:不分配、不拷贝。
///
/// # Safety
/// `session` 必须是存活的 session 指针;返回的视图仅在该 session 存活期间有效。
#[no_mangle]
pub unsafe extern "C" fn rfd_session_fill(session: *mut RfdSession, seed: u8) -> RfdBufferView {
guard(RfdBufferView { ptr: std::ptr::null(), len: 0 }, || {
let Some(session) = session.as_mut() else {
return RfdBufferView { ptr: std::ptr::null(), len: 0 };
};
for (index, byte) in session.buffer.iter_mut().enumerate() {
*byte = seed.wrapping_add(index as u8);
}
RfdBufferView { ptr: session.buffer.as_ptr(), len: session.buffer.len() }
})
}
Dart 侧:
final class RustSession implements Finalizable {
// ...
/// 必须是 Finalizable 类的**实例方法**,不能写成接收裸指针的顶层函数
Uint8List fillBuffer(int seed) {
if (_closed) throw StateError('RustSession 已关闭');
final view = bindings.rfd_session_fill(_handle, seed);
if (view.ptr == nullptr) return Uint8List(0);
// 直接映射 Rust 内存
return view.ptr.asTypedList(view.len);
}
}
这里“返回的是视图不是拷贝”可以直接验证:拿到视图后再调一次 fillBuffer(10),先前那个 Uint8List 里的字节也会跟着变,因为两个 Dart 对象指向的是同一段原生内存。
// 危险:session 对象可能在调用执行到一半时被 GC
Uint8List fillBuffer(Pointer<RfdSession> session, int seed) { ... }
// 调用方:fillBuffer(mySession._handle, 0);
Finalizable 的存活保证是“对象在引用了它的方法体执行完毕前不会被回收”。一旦把裸指针取出来传给顶层函数,mySession 本身就不再被任何活跃代码引用,GC 完全可以在 rfd_session_fill 执行到一半时判定它不可达并触发 finalizer——Rust 侧的 session 在自己被使用的过程中被释放掉。因此所有接触 _handle 的代码都必须待在该 Finalizable 类的实例方法内。
即便如此,这个视图仍只在两个条件同时成立时有效:session 存活,且底层缓冲区未被重分配(例如 Vec 扩容会换掉整块内存的地址,此时旧视图立刻悬空)。Session 销毁后继续读取该视图,是无法被 try-catch 捕获的 Use-After-Free。
模式 4:外部 Typed Data 跨 Port 转移所有权
什么时候用:需要把一大块数据从 Rust “搬”到 Dart,尤其是搬到另一个 Isolate——这是唯一不付拷贝代价的办法。
通过 Native Port 将 Rust 的 Vec<u8> 作为 External Typed Data 投递给 Dart。下面的 ZeroCopyBuffer 与元组形式的 post 来自 allo-isolate crate,它封装了 Dart_PostCObject_DL 与各类 Dart_CObject 的构造。
#[no_mangle]
pub extern "C" fn rfd_post_zero_copy(port: i64, request_id: i64, len: u32) -> bool {
guard(false, || {
let bytes = vec![0u8; len as usize];
// ZeroCopyBuffer 会把 Vec 转成 kExternalTypedData 类型的 Dart_CObject:
// data = Vec 的指针
// peer = 被 leak 出去的 Box<Vec<u8>>
// callback = VM 回收对应 Uint8List 时调用的释放函数
post(port, (request_id, ZeroCopyBuffer(bytes)))
})
}
Future<Uint8List> receiveZeroCopyBuffer(int length) async {
final port = ReceivePort();
try {
final reply = port.first;
if (!bindings.rfd_post_zero_copy(port.sendPort.nativePort, 0, length)) {
throw StateError('投递失败:port 已关闭或 Dart API 未初始化');
}
final message = await reply as List<Object?>;
// 拿到的是真正的 Uint8List,背后却是 Rust 那块内存,没有发生拷贝
return message[1] as Uint8List;
} finally {
port.close();
}
}
原理:Rust 把 Vec<u8> 的指针、长度与一个释放回调打包发给 Dart VM,VM 直接在这块已有的分配上包出一个 Uint8List,并在 GC 回收该列表时调用那个回调把内存还给 Rust。零拷贝、GC 托管释放——只要投递成功,所有权就真的转移了。四种模式里,只有这一种让 Dart 真正成为了内存的所有者。