当我们需要在 Flutter 中引入高性能、内存安全且具备强大异步生态的语言时,Rust 成了最佳选择。本文将从最简单的同步调用出发,逐步深入到基于 Tokio 的完整异步 Runtime 架构,通过多种不同的 Flutter-Rust FFI 交互模式进行深度对比与性能测试。


0. 环境构建:Native Assets 与 Build Hooks

利用 Dart 的 Build Hookshook/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 buildflutter test 时自动触发 Cargo 编译,将 Dart target 映射到 Rust 三元组(Target Triple),并将编译出的动态库作为 DynamicLoadingBundled 资源装载,同时监控源码变更,实现真正的增量热重构。

需要注意的是,声明依赖时不能只监控 rust/src/**Cargo.tomlCargo.lockbuild.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,将其 nativePortint64 标识符)传给 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_idCompleter 永不完成,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 调整该行为。

二是不能照搬 isolateLocaltry/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 != lenVec<u8>(凡是用 pushextendwith_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 真正成为了内存的所有者。