把 FFI 理解成「更快的 MethodChannel」,会在指针释放、线程和异常这三个地方把进程打崩。Channel 的模型是消息:拷贝、异步、失败是 PlatformException。FFI 的模型是 C ABI 上的直接函数调用:同一进程、同一地址空间、默认同一条线程、没有编解码、没有队列、GC 看不见 C 堆。性能来自中间层消失,use-after-free 也来自中间层消失。
读完你应该能独立回答三个问题:Pointer.asTypedList 的生命周期谁保证、为什么重计算仍会掉帧、C++ 异常为什么不能翻过 extern "C"。
1. 先画边界:调用模型完全不同
| Platform Channel | FFI | |
|---|---|---|
| 单位 | 编码后的消息 | C 函数 + 指针 |
| 线程 | 必经 Platform Runner | 默认就在调用者线程 |
| 内存 | 拷贝 | 共享(你自己证明安全) |
| 失败 | 异常对象 | 未定义行为 / 进程死 |
| 对象 | 只能传 Codec 认识的值 | 传布局匹配的 struct / 指针 |
| 系统 UI | 适合 | 不适合(去调 UIKit 是你自己的 UB) |
dart:ffi 让 Dart 拿到一个函数指针,按平台 C ABI 压栈、跳转、取值。没有方法名字符串,没有 StandardMessageCodec。lookup 失败发生在加载期;调用期的错误,C 不会替你变成 Dart 异常。
final dylib = DynamicLibrary.open('libcodec.so');
final decode = dylib.lookupFunction<
Int32 Function(Pointer<Uint8>, Int32),
int Function(Pointer<Uint8>, int)>('codec_decode');
lookupFunction 的两个类型参数分别是 C 侧签名和 Dart 侧签名。整数宽度必须对齐:C int 在 LP64 上是 32 位,对应 Int32,不是 Dart int 的 64 位幻觉。指针在 64 位上是 Pointer<…>,不要先转 Int32。
Dart 3 的 @Native 注解把 lookup 收进编译期,原理不变:仍是 C ABI,仍是你管内存。
2. 内存:两块堆,一种视图
Dart 堆上的 Uint8List 会被 GC 移动(除非是外部 typed data)。C 堆上的 malloc 字节不会。FFI 的零拷贝来自 让 Dart 直接看 C 的那一段:
final p = calloc<Uint8>(n);
final view = p.asTypedList(n);
// view[i] = ... 直接写 C 堆
nativeProcess(p, n);
calloc.free(p); // 此后 view 就是悬空的
asTypedList 造的是外部 typed data:Dart 侧像 Uint8List,底层指针仍是 p。不变量:
- 视图不得活过
free。 GC 不会在free时把视图作废。你要自己把 view 的最后一个引用和free排好序。 - 不要对可能移动的 Dart 堆取指针再交给异步 C。
Uint8List的.asPointer()一类扩展若存在,也只在「C 函数同步用完就返回」时安全。异步回调里用这份指针是 UAF。 - Dart 对象指针不能当 C 指针传。 Isolate 的对象头、GC 移动、压缩指针,C 一概不懂。要跨回调保住 Dart 对象,用
NativeCallable或SendPort,不要传Object的地址。
NativeFinalizer 把「C 资源的释放」绑到 Dart 对象不可达上:
final finalizer = NativeFinalizer(dylib.lookup('codec_free'));
class CodecBuffer implements Finalizable {
CodecBuffer(this.ptr) {
finalizer.attach(this, ptr.cast(), detach: this);
}
final Pointer<Uint8> ptr;
}
它解决的是「忘了 free」。它不解决「还在用就 free」——detach 再手动释放,或保证 C 侧不再持有。Finalizer 在哪个线程跑,也不保证是分配时的线程;C 的 free 一般可以,C++ 对象带锁的析构要自己确认。
Arena(package:ffi)适合「一次调用范围内的临时分配」:函数返回就释放。不要把 Arena 分配的指针存进全局给下一帧用。
3. 线程:零切换是默认,不是优点本身
FFI 调用发生在 当前 Dart Isolate 的 OS 线程 上。根 Isolate 绑 UI Runner。所以:
- 在
build/ 手势 /onTap里调 20ms 的 C 函数,和写 20ms 的 Dart 循环一样掉帧。没有 Channel 那种「至少把重活甩到主线程以外」的意外保护——Channel 反而可能把活甩到 Platform 线程(然后 ANR)。FFI 更直接:谁调用谁阻塞。 - 正确的重活路径:
Isolate.run/compute/ 长期 worker Isolate 里调 FFI。C 库若自己开线程,必须和 Dart 约定「回调怎么回去」。 NativeCallable.listener可以从任意 C 线程把调用投进 Isolate 事件循环。NativeCallable.isolateLocal只能在创建它的 Isolate 线程调。选错会静默丢回调或断言。isLeaf: true(leaf FFI)告诉 VM「这个 C 函数不会再调 Dart、不会再进 VM」。于是 VM 可以少做过渡工作,调用更快。C 里若回头调Dart_PostCObject或任何 Dart API,就是未定义行为。短、纯、不回调的函数才标 leaf。
不变量:C 线程 ≠ UI Runner ≠ Platform Runner。 从 C 线程直接碰 JNIEnv、UIKit、dart:ui 对象,三种都是错。要碰 Android 系统 API,回到 Channel 或自己 AttachCurrentThread 且不碰 Flutter JNI 表。要碰 Flutter 纹理,走 Texture registry 的文档路径。
引擎自己的 Raster 线程更不能跑你的 FFI——你也调不到,除非你把指针偷过去。不要偷。
4. ABI、结构体、C++ 的边界
FFI 只保证 C ABI。C++ 的 std::string、虚表、name mangling、异常表,一律不在契约里。
- 导出用
extern "C"。符号名才能lookup到。 - 结构体按
Struct写字段,注意对齐。Packed才对应__attribute__((packed))。一边 pack 一边不 pack,字段会错位,表现为「随机数值」而不是立刻崩溃。 - 不要把
std::vector当结构体传。传data()指针 +size。 - C++ 异常必须在
extern "C"边界捕掉,变成错误码。翻过边界进入 Dart 是未定义行为,移动端上常见于「偶发 native crash,堆栈在libdart」。 - 虚函数:传对象指针加一套 C 函数表(自己做 vtable),或只暴露 C API。
// codec_api.h
#ifdef __cplusplus
extern "C" {
#endif
typedef struct { uint8_t* data; int32_t len; } CodecSpan;
int32_t codec_decode(CodecSpan in, CodecSpan* out);
void codec_free(void* p);
#ifdef __cplusplus
}
#endif
out 指向的内存谁分配,必须写进函数文档。常见约定:callee malloc,caller codec_free。两边 free 或两边都不 free,都是事故。
动态库加载:Android 用 System.loadLibrary 后再 DynamicLibrary.open,注意 ABI split(armeabi-v7a / arm64-v8a)。iOS 常用静态链进 Runner,然后 DynamicLibrary.process()。符号被 strip 掉会 lookup 失败。Release 的 strip 配置要给导出符号留口。
5. 生命周期:指针、Isolate、库卸载
一份典型热路径会跨越三种寿命:
- 调用栈寿命: 参数指针只在此次 C 调用内有效。
- Dart 对象寿命:
NativeFinalizer绑定的 buffer。 - Isolate 寿命: worker 死了,还在跑的 C 线程必须停止往回 post。否则向已死 Isolate 发消息会丢,或 native 还持有 isolate 的 callback 指针 → 崩溃。
RootIsolateToken / NativeApi.initializeApiDL 是 C 调回 Dart 的前提。没初始化就 Dart_PostCObject,VM 不认识你。每个 Isolate 要单独保证 callback 的可调用性;不要假设「在主 Isolate 建的 NativeCallable 能在 worker 里调」。
库卸载:热重载不卸载 .so。真正的进程退出才卸。长期存活的 C 全局状态会在热重载后和「新的 Dart 世界」共存——Debug 里表现为单例对不上。C 侧单例按进程活,Dart 侧单例按 Isolate 活。多 Engine、多 Isolate 时这是 bug 源。
6. 和 Channel、Isolate 怎么拼
三种跨界手段经常一起出现,职责必须互斥:
- Channel: 权限、UI、系统服务、一次性配置。主线程、消息、安全失败。
- FFI: 编解码、加密、物理、图像处理算法。同一线程或 worker,共享缓冲。
- Isolate: 把 FFI 重活从 UI 挪走。Isolate 之间仍然不能共享可变 Dart 对象;但 可以共享 C 堆指针(同一进程)。这是真正的零拷贝跨 Isolate:worker 写 C 缓冲,把地址当
int发给主 Isolate,主 Isolate 包成Pointer再上传纹理。前提是所有权协议:谁 free、何时 free、主 Isolate 是否还在读。
不要把 C 指针塞进 Channel 当 int 发给 另一个进程。Channel 能出进程边界(理论上),指针不能。同进程插件用 Channel 传指针也是坏味道:类型是假的,生命周期更假。
7. 设计取舍
- 零中间层: 纳秒级调用、零拷贝视图。代价是 UB 回到工程现场。
- 默认同线程: 没有意外的线程跳。代价是 UI 调用即卡顿。
- C ABI 最小公约: 任何语言都能出 C API。代价是 C++ / Rust 都要做一层薄包装。Rust 用
extern "C"+repr(C)是目前最干净的搭档。 - GC 不管 C 堆: 没有扫描开销。代价是泄漏和 UAF 要自己的工具(ASan、leak tracer、
NativeFinalizer计数)。
「零开销」是相对 Channel 的编码和队列而言。它不是相对正确的算法而言。一份没向量化的 C 循环,不会因为从 Dart 能直接调用就变快。先保证 leaf、同缓冲、少分配,再谈 FFI 本身的纳秒。
8. 排障
- 随机 crash 在
asTypedList之后: 已经free。把free和最后一个 view 使用放到同一次所有权转移里,或改NativeFinalizer且禁止手动free。 - UI 掉帧但 C 函数「很快」: 量实际耗时。20ms 的「很快」在 60Hz 是整帧。搬到 Isolate。
Timeline.startSync包一层就能在 DevTools 里看见。 lookup失败: 符号名 mangled、被 strip、Android ABI 目录不对、iOS 没加进Other Linker Flags。nm看导出表。- 数值全错、不崩: 对齐、
Int32vsInt64、endian(极少数)、Packed。先sizeof对一下 DartsizeOf<T>()和 Csizeof。 - C++ 异常 /
std::terminate: 边界没 catch。用错误码。 - 多 Isolate 双 free: 指针当
int传来传去,两边都觉得自己是 owner。所有权只写在一个方向:callee alloc / caller free,或相反,永不对称。 - Debug 正常 Release 崩: leaf 标错、LTO 把符号吃了、C 依赖未定义行为被优化放大。
ASan 编一份 debug .so 比在 Dart 里加 log 有效得多。FFI 的 bug 多数不是 Dart 逻辑错。
9. 工程上真正的热路径长什么样
一次图像滤镜的正确形状:IO 线程或 worker Isolate 读文件 → C 堆分配输出缓冲 → leaf FFI 处理 → 把指针交给 UI Isolate → decodeImageFromPixels 或直接 Texture 上传 → NativeFinalizer 在 Image 释放后 free。全程没有 Channel,没有 Uint8List.fromList,没有在 UI Isolate 上跑滤镜。
错误形状:Image.memory(await channel.invokeMethod('filter', bytes))。这里有三次拷贝(去、回、解码),两次线程跳,还可能在主线程滤镜。
10. 结构体、数组、ffigen:布局必须对得上 sizeof
Struct 的字段顺序、对齐、padding 必须和 C sizeof / offsetof 一致。Dart 的 sizeOf<T>() 在运行时给你一份数字,CI 里用 assert 和 C 侧打印的 sizeof 对一下,能消灭一类「某机型数值全错」。
final class Vec2 extends Struct {
@Float()
external double x;
@Float()
external double y;
}
final class PathHeader extends Struct {
@Int32()
external int count;
external Pointer<Vec2> points;
}
@Array(16) 做内嵌数组。指针数组是 Pointer<Pointer<Vec2>>,不是 Pointer<Vec2> 的 Dart List。从 C malloc(n * sizeof(Vec2)) 拿到的连续缓冲,用 ptr.asTypedList 做的是字节视图;要按结构走,用 structPtr[i] 这种 Pointer 下标(步长是 sizeOf)。
Packed 对应 C 的 packed。网络协议、文件头才需要。CPU 上未对齐访问在部分 ARM 上慢或 trap。能不 pack 就不 pack。
ffigen 从头文件生成这些 Struct 和 lookupFunction。它消灭的是手写签名漂移,不消灭 ABI 本身。C++ 头要有 extern "C" 的那一层。宏、位域、未命名 union、va_list,ffigen 会放弃或生成不可用代码。位域改成整型 + mask。
@Native<Int32 Function(Pointer<Uint8>, Int32)>(isLeaf: true) external int codecDecode(Pointer<Uint8> p, int n); 把符号解析推迟到编译期/加载期,调用点更像普通函数。asset 路径可把 .so 当 Flutter asset 打包。原理没变:leaf 仍禁止回调 Dart。
不支持的东西要记成边界:变参函数、C++ 异常、成员函数、线程局部和 Dart Isolate 的映射。不要用 FFI 调 printf 这类变参。封装成固定参数的 C 函数。
11. 从 C 调回 Dart:消息循环才是安全点
C 线程上的回调想进 Dart,只有几条合法路:
NativeCallable.listener: 创建时绑在某 Isolate。C 任何线程调这个函数指针,VM 把调用投进该 Isolate 的事件循环。参数必须是 FFI 能拷的(整数、指针),不能是 Dart 对象。Dart_PostCObject/SendPort.nativePort: C 组一份Dart_CObject发到 port。适合流式数据。要Dart_InitializeApiDL。Isolate 死后 port 无效,C 必须能停。- 自己的无锁队列 + Dart 侧
Timer/scheduleMicrotask轮询: 最土也最可控。适合音频回调这种不能阻塞、不能等 VM 的实时线程。
非法路:在 C 音频回调里直接碰 JNIEnv 调 FlutterJNI、在 Raster 线程 SendPort.send、把 Dart 对象指针存进 C 全局。崩的堆栈会在 libdart.so 里,看起来像 VM bug。
// 合法:只投递指针和长度,不碰 Dart 堆
void on_frame(uint8_t* p, int n) {
Dart_CObject type;
type.type = Dart_CObject_kInt64;
type.value.as_int64 = (int64_t)p;
Dart_PostCObject_DL(g_port, &type);
}
所有权:C 认为 post 之后 Dart 会 free,Dart 认为 C 还会写这块缓冲——这是 use-after-free 的剧本。协议写成「双缓冲,C 写 A 时 Dart 读 B,用序号握手」或「Dart 拷走再回 ack,C 才能 reuse」。实时系统用双缓冲;普通解码用 ack。
Rust 侧 extern "C" + #[repr(C)] + no_mangle 是目前最不容易在析构和异常上翻车的搭档。panic 必须 catch_unwind 成错误码,不能进 Dart。flutter_rust_bridge 生成的是 FFI + 一套所有权约定,它没有取消 ABI,只是把 free 和错误码写成了代码。出了 UAF 仍然是你的指针问题,不是「桥」的锅。
分配器、对齐、以及 Dart_InitializeApiDL 的版本
malloc 不对齐清零;calloc 清零。结构体里未赋值的指针字段是随机值,C 一解引用就崩。用 calloc 或显式赋 nullptr。posix_memalign / aligned_alloc 在 SIMD 和某些 GPU 上传路径上需要。Allocator 接口可以换成 arena。不要混用:malloc 的指针 delete,或 Arena 释放后还 free。
64 位上 size_t 是 8 字节,对应 Size / Uint64,不是 Int32。文件长度、缓冲大小用错宽度,大文件截断。
Dart_InitializeApiDL 必须用当前 VM 的 NativeApi.majorVersion/minorVersion。.so 和 Flutter 引擎版本不匹配时,API DL 初始化失败,C 调回看起来像「偶尔成功」——其实你用了错误偏移的函数表。插件的 .so 要和 Flutter 版本一起发,不要假设用户设备上的 libflutter.so 永远和编译时一致(embedder 场景尤其如此)。
热路径上的拷贝清单
一次「零开销」滤镜仍可能悄悄拷贝:
- Dart
Uint8List.fromList从网络来的数据——拷。 - 为了拿指针,
calloc+memcpy——拷。 - C 输出到新缓冲——合理的一份。
Uint8List包装再decodeImageFromPixels——可能再拷进ui.Image。- 上传 GPU——设备相关,常是 DMA,不算你的堆。
能消掉的是 1 和 2:网络直接读进 C 堆或 ImmutableBuffer,FFI 原地处理。decodeImageFromPixels 的像素格式、行对齐必须和 C 输出一致,否则颜色错还要再转一遍。
isLeaf: true 的函数里做 malloc 可以(不进 VM),但 leaf 里不要等锁太久——你堵的是 UI 或 worker 线程。锁竞争不是 FFI 层能消的,是 C 库自己的线程模型。
调试符号与崩溃归类
Release .so 无符号时,Crashlytics 里只有偏移。保留一份 unstripped 给符号化。崩溃在 Dart_Invoke / Precompiled... 不一定是 Dart 逻辑错,可能是 C 写越界破坏了堆,过一会儿 GC 才炸。ASan 编的 debug so、以及在 C 边界做 canary,比在 Dart 里加 try/catch 有用——FFI 调用默认 不 把 C 的 SIGSEGV 变成 Dart 异常。
一份可维护的 FFI 封装形状
不要在 Widget 里 lookupFunction。做成:
codec_api.h:纯 C,文档写清谁 alloc/free、线程、错误码。- 薄
.cc:捕 C++ 异常,转错误码,extern "C"。 ffigen出codec_bindings.dart。- 手写
CodecBuffer:持有Pointer、NativeFinalizer、长度,暴露Uint8List视图方法但不把视图存成字段(避免视图活过 buffer)。 CodecWorker:长期 Isolate,SendPort 收任务,内部调 FFI,回传「指针整型 + 长度」或拷贝后的小结果。- UI Isolate 只收「已完成的 ui.Image / 路径」,不收生指针——除非你有严格的帧同步。
错误码不要用「返回 nullptr 表示失败」又同时让 nullptr 表示空输入。int codec_last_error() 或 out 参数。Dart 侧转成异常。异常发生在 Dart,C 已经回到稳定状态(无锁、无半初始化单例)。
测试:VM 里用 dart:ffi 跑纯 Dart 测试即可,不必起 Flutter。用黄金输入输出字节。ASan 构建单独 CI job。不要只在真机「看起来画面对」——那测不到越界。
和 dart:ui 对象的边界
ui.Image、ui.Scene、ui.Path 不是 FFI 结构体,不能把它们的 Dart 句柄当 C 指针解。Engine 暴露的 C API(FlutterEngine embedder API)是另一套,给 嵌入者 用,不是给 App FFI 用。App 里想把像素变成 ui.Image,用 decodeImageFromPixels / ImmutableBuffer。想把纹理给 Flutter 显示,用 Texture registry。想自己调 Metal,你已经在写引擎扩展,不是业务 FFI。
Pointer.fromAddress(int) 可以从 SendPort 传来的整数恢复指针。整数在 Dart 里是 64 位,足够装指针。不要截成 32 位再传。Isolate 之间传 int 是拷贝一个整数,不是拷贝缓冲——这就是零拷贝的那条缝。安全完全取决于整数还指不指着有效内存。
错误码、半失败、以及重入
C 函数返回「写了 3 个输出里的 2 个然后失败」,Dart 侧很难清理。约定:失败则所有 out 指针要么不变,要么 callee 已 free。成功才转移所有权。文档写 on error, no ownership transfer。Dart 只在 rc == 0 时 attach NativeFinalizer。
重入:C 回调里再调回 Dart,Dart 又调这个 C 函数。非 leaf 允许,但 C 库要能重入或明确禁止。禁止的话用线程局部「in_call」断言。音频回调线程禁止任何会锁的 Dart 往返——只投递。
动态库查找路径
Android System.loadLibrary("codec") 依赖 jniLibs 或 Prefab。DynamicLibrary.open("libcodec.so") 在 load 之后才能。只 open 不 load,旧设备可能找不到。iOS DynamicLibrary.process() 要求符号被链接进主二进制或 -force_load。静态库里的符号被 dead-strip 掉是 Release 独有事故:Debug 还在,Release lookup 失败。__attribute__((used)) 或显式导出列表。
macOS 桌面还要注意沙盒和 @rpath。Windows 注意 codec.dll 的运行库(MT/MD)和 Flutter 的 CRT 一致,否则跨 CRT malloc/free 崩。
性能对照实验怎么写才公平
对比 Channel vs FFI:同一份输入、同一线程、预热后测稳态,再测含首次 lookup 的冷路径。FFI 的 lookup 和 asFunction 有一次性成本,应放在 isolate 启动,不要放进每帧。Channel 的 codec 也有一次性。数字要分冷/热。热路径 FFI 赢是预期;冷路径上一次 100 字节配置 Channel 更简单,别为基准测试的漂亮数字把权限检查改成 FFI。
FFI 工程化的核心是把「指针」变成「带类型的资源对象」,让业务代码几乎看不见 Pointer。业务只看见 CodecBuffer / DecodedFrame,它们的析构(Finalizer 或显式 dispose)是唯一释放点。谁 clone 谁就多一次 dispose。这和 ui.Image 的契约故意对齐,降低心智。暴露 Pointer 给 Widget 层,三个月后必有 UAF。
线程策略写成库的 README 第一段:哪些函数 leaf、可在 UI 调(必须 < 1ms);哪些必须 worker;哪些 C 内部自开线程、回调如何回 Isolate。没有这段,调用方会在 onTap 里解码 4K。审查 PR 先看调用线程,再看算法。
二进制发布:.so / .a 与 Flutter 引擎、NDK、C++ STL 版本钉在 lockfile。CI 编 ASan 与 Release 两份。符号表归档。崩溃匹配偏移。没有符号化的 native 崩溃,在 FFI 项目里等于没有崩溃报告。
不要用 FFI 调用 Objective-C / JNI。那是把运行时和异常模型再引入一遍,且极易在错误线程碰 UI。需要系统 API 走 Channel;需要算力走 FFI。中间状态(既要 UIKit 又要零拷贝)拆成:FFI 算出像素,Channel 或 Texture registry 交给视图。硬用 FFI 调 UIImage 是在借未来的崩溃。
最后一条不变量:Dart GC 扫描不到的每一字节,都要有一个 Dart 对象当监护人。 监护人消失,Finalizer 释放。没有监护人的裸指针,只允许活在一次同步调用栈上。跨 await、跨 isolate、跨帧,必须升级为有监护人的对象。这条比任何工具都能减少 UAF。工具用来抓漏网的。监护人策略用来让漏网变少。
FFI 与安全编译选项必须进构建:-fstack-protector、-D_FORTIFY_SOURCE=2、Android 上 FORTIFY、尽可能 -fno-exceptions 因为异常已在边界捕掉。C++ 仍开 RTTI 只会增肥。Rust panic=abort 在移动端可接受,前提是 abort 前把错误日志写出去,否则用户只看见闪退。
32 位设备(若仍支持)上指针 4 字节,Dart int 仍 64。Pointer.fromAddress 没问题,但 C int 长度 32,文件 offset 用 int 会在 2GB 截断。移动端图片缓冲很少到 2GB,资产文件可能。用 int64_t 传长度。ffigen 对 off_t 的映射要人工检查。
模拟器和真机 ABI 不同:iOS 模拟器是 arm64 或 x86_64,.a 要 lipo。Android 模拟器 x86_64 经常被团队忘了编 so,表现为「只有模拟器 FFI 失败」。CI 矩阵含模拟器 ABI 或明确不支持并在启动时检测。
文档里给一份「禁止清单」比允许清单短:禁止 FFI 调 UI;禁止跨 await 裸指针;禁止 leaf 回调 Dart;禁止 C++ 异常出边界;禁止在 UI Isolate 跑 >1ms 的 FFI;禁止双 owner free。代码审查对着清单打勾。清单外的才需要讨论。FFI 的复杂性来自例外,不来自 lookupFunction 那三行。
与 Channel 共存时的启动顺序:先 NativeApi.initializeApiDL(若需要),再 loadLibrary,再 lookup,再 NativeFinalizer 绑定,最后业务可用。顺序反了会「偶发 null function pointer」。把顺序写成 CodecRuntime.ensure() 单例(Isolate 级),main 和 worker 入口都调。不要散落在各个页面 initState。页面 initState 太晚,也太多次。
把 FFI 库的发布当成 native SDK 发布:semver、changelog、最小系统版本、NDK 版本。Dart 包装版本和 so 版本一起涨。出现「Dart 包装 2.0 + 旧 so」要用符号 codec_abi_version 启动时比对,不匹配就拒绝调用并打错误,而不是随机崩溃。ABI 版本是整数,放 .so 导出符号,Dart lookup 得到。这比靠文件名约定可靠。
内存工具:Android 用 heapprofd 看 native alloc,iOS 用 Allocations 过滤非 Dart 堆。FFI 泄漏在这些图上是持续上涨的 malloc,而 Dart DevTools 是平的。两张图一起看才能证明「不是 Dart 的锅」。证明完再进 C 代码,不要反过来在 Dart 里加 delay 碰运气。
最后一个工作流:改 C 边界必须同时改黄金字节测试、ffigen、Dart 包装、文档谁 free。四份 diff 缺一不合并。边界变更是 FFI 里最贵的变更,故意让它贵,才能保持少变。少变是零开销能持续的前提——否则你每周都在修 UAF,性能数字再好看也上不了线。
跨语言字符串:C 要 const char* 以 \0 结尾,Dart toNativeUtf8 分配的是临时块,必须 free。生命周期仍是同步调用栈。把 Pointer<Utf8> 存起来跨帧,C 读到的是已释放或被复用的堆。路径、错误信息这种短字符串,宁可每次拷,不要玩零拷贝。零拷贝留给像素和 PCM,不要留给字符串显得专业。专业体现在所有权写清楚,不体现在每一字节都不拷。
FFI 落地时还要把构建系统当成产品:Android 用 CMake + Prefab 或 jniLibs,iOS 用 CocoaPods vendored 或 xcframework。符号可见性默认 hidden,只导出 codec_*。隐藏符号减少冲突,也减少别人乱 lookup 你的内部函数。内部函数一旦被业务 lookup,你就没法改布局。导出面小,是 ABI 稳定的前提。
调试器:NDK gdb/lldb 能在 Dart 调 C 的地方下断点。学会在 Android Studio 里 attach 到带 so 的进程。只会 Dart 调试器的团队,FFI 问题会拖成「偶现 native crash」无限期。技能清单要写进组内 onboarding。
与 Isolate 的 CPU 亲和:worker Isolate 默认普通线程,可能和 UI 抢大核。重任务可在 C 里用线程池,Dart 只投递。但线程池大小要考虑和 Flutter IO Runner、编解码器抢核。池太大,低频掉帧;太小,吞吐不够。数字用 trace 看调度延迟,不要抄「CPU 核数×2」。
版权与安全:引入的 C 库许可证、CVE。FFI 让你把别人的 C 堆进进程,漏洞也进进程。依赖扫描覆盖 so,不只覆盖 pubspec。这和「零开销」无关,和「零中间层所以零隔离」有关。Channel 调另一个进程还能有进程隔离(少见);FFI 从来没有。选 FFI 就是选同一命运。认清这一点,审核会认真一点。
FFI 与产品灰度:so 随 App 发,不能像 Dart 热更新那样随便换算法。要热更算法,把算法放 Dart 或放可下发的脚本,C 只留稳定原语(卷积、FFT)。原语稳定,产品逻辑灵活。反过来把产品逻辑写进 C,发版周期被商店绑死。选 FFI 的边界也是选发版边界。把会变的留在 Dart,把稳的、热的留在 C。这条产品约束和内存所有权一样,决定你两年后还敢不敢改这块代码。敢改,是因为 C 面小;不敢改,往往是因为当初图快把业务塞进了 so。
把「监护人对象」落实到代码模板:每个导出的分配函数都有对应的 Dart class,class 创建即 attach Finalizer,dispose 可提前 detach。模板放在仓库 ffi/README。没有对应 class 的 Pointer 不允许出现在 lib/ 业务目录,只允许出现在 lib/ffi/raw/。目录墙比规范有效。墙的另一侧是生成代码和 raw binding,允许丑;业务侧只看见 class。丑和安全被隔开,审查才审查得完。完了,零开销才不是零纪律。
至此 FFI 的地图可以叠在专栏第一篇的 Engine 图上:它不经过 Platform Runner,不经过 Codec,直接在当前 Isolate 的线程上跳进 C。因此它既跳过了 Channel 的保护,也跳过了 Channel 的成本。保护要用监护人、错误码、leaf 承诺自己补;成本要用 worker 把重活从 UI Runner 挪走。补完这两头,零开销才是可上线的零开销,而不是演示里的零开销。演示总是同步、短、不释放。线上总是异步、长、有人忘释放。模板和目录墙是为线上写的。演示代码进不了 lib/。
上线前用 ASan 跑一遍黄金输入,用符号化验证崩溃管道。管道不通,等于飞行无黑匣子。有黑匣子,UAF 才是可修的事故而不是传说。传说不能迭代。事故可以。FFI 允许事故进进程,所以必须允许事故被看见。看见,是零开销互操作对工程的最后一项要求。
12. 小结
FFI 是同一进程里的 C 调用。零开销来自没有消息和没有队列;正确性来自你能证明指针、线程、异常三条边界。和 Channel 的分工是一致性模型不同:一个把失败变成对象,一个把失败变成崩溃。热路径用 FFI,系统 API 用 Channel,重活离开 UI Isolate。三条同时成立,才是「零开销互操作」,而不是把崩溃从 Java 换到 C。