ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Flutter插件通信:MethodChannel与FFI的边界设计实战

Flutter插件通信:MethodChannel与FFI的边界设计实战

1. 插件通信的本质与边界图谱

1.1 三条通信链路缺一不可

做 Flutter 插件开发的人,早晚会在 MethodChannel 和 FFI 之间纠结一次。很多人最开始接触的插件通信就是 MethodChannel:Dart 调一个方法,原生端处理完,结果再走回调回来。这套模型对系统能力调用是够用的,可一旦你开始处理图像、音视频、点云、科学计算这类重数据负载,MethodChannel 的编解码和线程切换成本会被迅速放大,这时候你自然会想:能不能把数据直接交给 C/C++ 去算?于是 FFI 就进入视野了。

但 Flutter 插件的真实通信边界,通常不是 Channel 和 FFI 二选一,而是三条链路同时存在:

  • Dart 与平台层之间的 MethodChannel / EventChannel,负责系统能力,比如相册、蓝牙、传感器、系统设置。
  • Dart 与 C/C++ 层之间的 dart:ffi,负责高性能计算、数据处理、第三方本地库的对接。
  • 平台层与 C/C++ 层之间的原生桥,Android 上是 JNI,iOS 上是 Objective-C++ 直接调用,负责把平台侧的系统事件和底层数据喂给本地内核。

很多团队做插件架构时只盯着前两条,第三条桥经常被忽略,结果就是原生相机拿到的一帧图像非要先传回 Dart,再通过 FFI 传给 C++,绕了一大圈。真正需要考虑的不是选哪条路,而是这三条路怎么衔接,数据从哪一层产生,就在哪一层消费,跨层只传递小尺寸的句柄和轻量消息。

1.2 边界设计的核心矛盾

插件通信之所以要单独设计,是因为跨边界时三个系统在互相打架:类型体系不一致、线程模型不同、内存生命周期完全独立。

Dart 侧有 int、double、String、Uint8List,Java/Kotlin 有 long、byte[]、Bitmap,C 世界则只有数值、指针和结构体。每次跨边界都逃不掉类型映射。线程模型上,MethodChannel 有主线程约束,Android 平台通道方法默认在主线程执行;FFI 则完全不讲道理,哪个线程调进来就在哪个线程跑,回调甚至可能来自 C++ 自己创建的工作线程。内存生命周期更麻烦,Dart 有 GC,Java 有 GC,C 层却要你手动 malloc 和 free,三套时间轴互不相关。

边界设计一旦混乱,最典型的现象就是数据量上来之后全线卡顿。一个小数组通过 Channel 传没什么感觉,图像分辨率一提高,一张位图走 MethodChannel,Dart 侧等回调可能要几百毫秒,UI 直接掉帧。反过来,以为换成 FFI 就能高枕无忧,结果 C 层返回的指针没人释放,Native 内存一路涨到应用被杀。

所以做 Flutter 插件通信架构,第一步不是写代码,而是把边界想清楚:每份数据在哪一层分配,在哪一层释放,哪个线程负责执行,出了问题谁兜底。

2. 选型逻辑:什么场景走 Channel,什么场景走 FFI

2.1 MethodChannel 的适用场景与隐藏成本

MethodChannel 是 Flutter 提供的官方互操作机制,它解决的问题是 Dart 与平台 SDK 的通信,适合低频、小数据量的系统能力调用。比如获取电池电量、读取系统版本号、调起相机、打开系统分享面板,这些场景用 Channel 写起来顺手,理解成本也低。

但 Channel 的隐藏成本比想象中大。第一是编解码成本,StandardMethodCodec 会把每个参数序列化成二进制,再反序列化出来,传一个 byte[] 至少要经历一次完整拷贝。第二是线程切换成本,一次调用从 Dart 到原生要穿越多个上下文,高频调用时损耗会被放大。第三是主线程约束,Android 平台通道方法默认在主线程执行,你在原生那边做耗时操作,界面交互会直接卡顿,甚至触发 ANR。

我实测过的经验是:MethodChannel 传几十 KB 的小配置、触发一次原生动作,性能完全没问题。但如果你要在 60 帧的刷新率下每帧传一屏摄像头帧或点云数据,它就不是为这个设计的。判断标准很简单,单次数据量超过几百 KB,或者每帧都要通信,就该考虑让主体数据绕开 Channel。

2.2 FFI 的定位与硬约束

dart:ffi 进入稳定版后,跨平台本地插件的玩法发生了很大变化。FFI 允许直接调用 C ABI 动态库的导出函数,没有 StanardMethodCodec 那套编解码,它的核心优势是零拷贝:把指针传给 C 函数后,C 层可以直接读写同一块内存,不需要来回倒数据。图像处理、音视频解码、物理引擎、科学计算这类场景,这是 Channel 永远做不到的。

但 FFI 有几个硬约束必须接受。第一,它只能调用 C 函数,不能直接访问 Android 的 Java API 或 iOS 的 Objective-C API,系统能力仍然需要 Channel 补位。第二,FFI 调用默认是同步的,如果 C 函数跑一个 100ms 的重计算,你在 UI isolate 里直接调就会卡住界面,必须配合后台 isolate 使用。第三,内存安全完全由开发者负责,Dart 侧创建的 Pointer 传给 C 后,如果 C 层没有释放就泄漏了;反过来 C 层分配的内存,Dart 层用完也要释放。

基于这些限制,我在项目里给 FFI 的定位是:接管重数据和重计算,而不是接管系统能力。Channel 管能力,FFI 管数据,两者是互补关系,不是替代关系。

2.3 混合架构:能力走平台,数据走本地

我推荐的标准形态是:平台相关能力走 MethodChannel,数据搬运和计算走 FFI,C/C++ 层通过原生桥接收平台事件。以一个图像滤镜插件为例,Dart 层第一步要打开系统相册选图片,这个动作必须走 Channel,因为它要调用系统能力。拿到文件路径后,Dart 层把路径字符串转成 UTF-8 指针,通过 FFI 交给 C++ 内核,内核直接读取文件做滤镜,结果写回文件,再返回给 Dart。整个过程中大块像素数据没有经过 Dart 堆,只在本地内核和文件系统之间流动,性能自然好。

处理异步逻辑时,我会在 Dart 层包一个薄壳,把 FFI 的同步调用放到 Isolate.run 里,对外暴露 Future 接口。UI 层只调用这个异步方法,底层重计算不会卡住界面。用户视角下它和普通 Channel 调用没有区别,但数据路径已经被优化掉了多次拷贝和编解码。

3. 实操:从 Channel 迁移到 FFI 的完整流程

3.1 插件项目结构怎么搭才不混乱

如果你准备做一个同时使用 Channel 与 FFI 的插件,建议先用 flutter create --template=plugin 创建基础工程。默认结构会把 C/C++ 源码散落在各平台目录里,比如 android/src/main/cpp、ios/Classes、linux/src,这样维护起来非常痛苦,同一个算法库要在五个平台目录各自放一份,改一个 bug 要同步所有平台。

我习惯把 C/C++ 内核源码统一放在插件根目录的 src/ 下,各平台构建脚本通过头文件搜索路径和源码引用去链接它。目录结构大致是这样:

my_plugin/ lib/ # Dart 公共 API,对外只保留这一层 src/ # C/C++ 内核,统一维护 android/ ios/ linux/ windows/ macos/

Android 的 CMakeLists.txt 里把 ../src 设为 include 路径,iOS 的 podspec 把 ../src 下的源码加入 source_files。算法内核只有一份,平台目录只是薄薄的胶水层。

另一个建议是:Dart 的 FFI 绑定代码单独放一个文件,不要和对外 API 混在一起。比如 lib/src/ffi_bindings.dart 只负责所有 DynamicLibrary 加载和 lookupFunction,lib/my_plugin.dart 只暴露封装好的类和方法。这样之后用工具重新生成绑定,不会碰乱对外接口。

3.2 各平台构建配置的共性与差异

FFI 动态库在每个平台的加载方式和构建配置不一样,这是最容易踩坑的地方。

Android 上推荐用 CMake 构建 .so 库,然后在 android/build.gradle 里配置 externalNativeBuild。以 CMakeLists.txt 为例,核心配置大概是:

cmake_minimum_required(VERSION 3.10) project(my_plugin_lib) set(CMAKE_CXX_STANDARD 17) add_library(my_plugin_engine SHARED ../../src/engine.cpp ../../src/geometry.cpp ) target_include_directories(my_plugin_engine PRIVATE ../../src ) find_library(log-lib log) target_link_libraries(my_plugin_engine ${log-lib})

iOS 上更简单,在 podspec 里配置 source_files 指向 src 下的源码,Flutter 会把源码编译进最终二进制,不需要单独的 framework。

s.source_files = 'Classes/**/*', '../src/**/*.{h,cpp}' s.xcconfig = { 'HEADER_SEARCH_PATHS' => '../src' }

Windows 上用 CMake 生成 DLL,Linux 上生成 .so,macOS 和 iOS 类似。Dart 侧加载时要注意文件名差异:Android 用 libmy_plugin_engine.so,Linux 用 libmy_plugin_engine.so,Windows 用 my_plugin_engine.dll,macOS/iOS 如果符号已经链接进主二进制,可以直接用 DynamicLibrary.process() 查找。

这一步虽然是编译配置,但它是所有 FFI 调用的地基,配置错一个符号加载都会失败,而且报错信息很隐晦。

3.3 dart:ffi 绑定的最小可运行写法

在 C++ 侧先写一个最简单的函数,比如对整数数组求和:

extern "C" __attribute__((visibility("default"))) int sum_ints(const int* data, int length) { int sum = 0; for (int i = 0; i < length; ++i) { sum += data[i]; } return sum; }

extern "C" 是为了避免 C++ 名字修饰导致 Dart 查不到符号,visibility("default") 是确保符号被导出,这两个修饰在生产代码里都别省。

Dart 侧声明:

import 'dart:ffi'; typedef SumIntsNative = Int32 Function(Pointer<Int32> data, Int32 length); typedef SumIntsDart = int Function(Pointer<Int32> data, int length); final DynamicLibrary _lib = DynamicLibrary.open('libmy_plugin_engine.so'); final SumIntsDart _sumInts = _lib .lookupFunction<SumIntsNative, SumIntsDart>('sum_ints');

调用时不能直接把 Dart 的 List 当指针用,FFI 需要 NativeMemory 或通过 allocate 分配的内存。标准做法是把数据拷贝进新分配的内存块:

import 'package:ffi/ffi.dart'; int callSumInts(List<int> values) { final ptr = calloc<Int32>(values.length); for (var i = 0; i < values.length; i++) { ptr[i] = values[i]; } try { return _sumInts(ptr, values.length); } finally { calloc.free(ptr); } }

第一次跑通这个过程后,你就能理解 FFI 的本质:Dart 和 C 之间没有魔法转换,所有数据都要落在同一块内存上,谁分配谁释放,边界就是这几行代码的职责。

3.4 内存所有权与释放时机

FFI 边界的核心问题是这块内存到底是谁的。常见情况有三种。

第一种,Dart 分配,传给 C 只读使用,调用完由 Dart 释放,比如上面的 calloc 场景。第二种,Dart 分配,传给 C 后 C 会持有引用并在后续异步线程使用,那 Dart 不能立即释放,必须等 C 层的完成通知。第三种,C 层分配并返回给 Dart,比如返回一个 char* 字符串,Dart 用完后需要调用 C 层配套的 free 函数释放,不能用 Dart 的 GC 处理。

我遇到过一个很隐蔽的泄漏:C++ 内核返回错误信息字符串,Dart 侧用 fromUtf8 转成 String 后,顺手把 Pointer 变量置空,结果每次调用都泄漏一块 C 层内存。原因是那个 char* 由 C 层 malloc 出来,必须调用一个 release_error_string 函数释放。

处理这类问题,我习惯把指针生命周期收口到封装对象里。比如一个句柄类,创建时持有 Pointer,dispose 时统一释放。Dart 的 finalizer 可以兜底,但核心原则永远是:谁分配谁释放,分配与释放函数成对出现。

如果只是数据拷贝,没必要让指针长期驻留,Dart 侧临时分配用完即释放就好。如果 C 层要保存内部状态,建议在 C++ 侧维护句柄表,给 Dart 返回一个整数 id,每次调用通过 id 查找内部对象。这样 Dart 侧只持有 int,不持有真实指针,内存生命周期完全由 C++ 掌控,边界安全得多。

4. 边界上的类型映射与序列化细节

4.1 Channel 的序列化到底贵在哪

MethodChannel 使用的 StandardMethodCodec 对参数有固定编码规则:每种类型带一个前缀标记,字符串按 UTF-8 编码,二进制大对象直接拷贝进消息缓冲区。整个过程看似简单,但至少产生两次内存拷贝:第一次是 Dart 对象序列化到二进制 buffer,第二次是原生端反序列化成 Java/Kotlin 或 Objective-C 对象。如果传输的是图片或音频,还伴随额外的字节数组堆分配。

跨线程的成本更明显。Dart 侧通过 UI isolate 的二进制 messenger 发送消息,原生端收到后可能要先切到主线程,处理完再切回来。真正执行业务逻辑的时间只占一小部分,大部分都花在线程切换和编解码上。帧率敏感的应用很容易感知到这种延迟波动。

更麻烦的是,Channel 没有随机访问能力。你想让原生端读取一个 100 MB 文件的一部分,也得先把整个文件读成 Uint8List 传过去,或者先把路径传过去让原生自己读。后者虽然避免了大数据传输,但把文件 IO 逻辑散落在平台层,维护成本很高。这也是我更倾向对文件类数据直接走 FFI 的原因:C++ 层自己处理文件读取和内存映射,Dart 侧只传一个路径字符串。

4.2 FFI 类型映射速查

FFI 类型映射的规则其实很死板,但容易踩的坑在细节。整理一个常用对照表:

Dart 类型C/C++ 类型说明
Int32int32_t / int注意 C 的 int 不一定 32 位
Int64int64_t / long long显式使用定宽类型更稳
Doubledouble对应 C double
Floatfloat对应 C float
Pointerchar*字符串,用 toNativeUtf8 创建
Struct 子类struct内存布局需要对应
Pointervoid*通用指针,可互相转换
Uint8uint8_t / unsigned char常用于字节数组

C 的 bool 需要注意,C++ 里 bool 大小在不同 ABI 下可能不一样,建议接口层统一用 Uint8 或显式 Bool 类型,避免隐患。

字符串转换也比较容易出错。Dart 侧要用 toNativeUtf8 生成 char*,用完必须 free;C 侧返回的 char* 要用 fromUtf8 读取,读完后要确认是否需要调用 C 层释放函数。不要只转 String 就丢指针,否则会有泄漏风险。

4.3 结构体与对齐问题

FFI 支持直接传递结构体,只要在 Dart 侧声明一个继承 Struct 的类:

final class Point extends Struct { @Double() external double x; @Double() external double y; }

这样可以把 Pointer 传给 C 函数,双方读写同一块内存。字段顺序必须与 C 结构体定义一致,字段类型宽度也要一致。嵌套结构体或数组可以用 @Array 注解,或者声明为 Pointer 指向另一块连续内存。

结构体最容易出问题的是内存对齐。C 编译器默认按成员最大对齐数做 padding,Dart 的 Struct 也会自动对齐,正常情况下没问题。但如果你在 C++ 里用了 #pragma pack(1) 压缩结构体,Dart 侧必须加 @Packed() 关闭自动对齐,否则字段全部错位,读取的值会乱七八糟。跨边界传递结构体时,要么双方都显式控制对齐,要么不要依赖结构体拷贝,直接传指针逐字段处理,后一种做法更省心。

5. 异步回调与跨线程的消息路由

5.1 FFI 回调的线程边界

FFI 函数不限制在哪个线程调用,但正因为这样,C/C++ 库的异步回调经常发生在你完全无法控制的线程里。C++ 内核在后台线程跑完推理,直接调用你传入的函数指针,这个回调线程既不是 Dart 的 UI isolate,也不是平台主线程。如果在这个回调里直接碰 Dart 对象,多半会崩,或者出现无法预料的竞态。

正确做法是:回调线程不能碰 Dart 对象,只能把结果投递到 Dart 侧的 ReceivePort。经典套路是在 Dart 侧创建 ReceivePort,把它的 sendPort 通过 FFI 传给 C++ 层,C++ 在任意线程里向 SendPort 发送消息。Dart isolate 的 ReceivePort 内部会处理跨线程投递,Dart 回调最终会在绑定的 isolate 上执行。

代码框架大致是:

final receivePort = ReceivePort(); receivePort.listen((message) { // 这里已经回到 Dart isolate,可以安全更新 UI }); // 将 receivePort.sendPort.nativePort 传给 C++ 层

C++ 侧把这个原生端口保存下来,在后台线程里调用 port 发送。这个机制本质上是 Dart VM 提供的线程安全 native port 投递,比直接调函数指针安全得多。注意原生端口是个整数句柄,C++ 侧保存时要保证线程安全;多次回调时要带上下文 id 区分是哪次任务。

5.2 回调风暴与背压

异步回调另一个常见问题是回调风暴。C++ 内核在极短时间内发出大量回调,每个都要跨线程投递到 Dart,如果 Dart 侧处理不过来,ReceivePort 的队列会越积越长,内存不断增长。Flutter 层面没有现成的背压控制机制,架构上常见的做法是合并回调:在 C++ 侧做结果缓存,按固定节奏批量发送;或在 Dart 侧用合并窗口,收到 N 个回调后只处理最新结果。

高频传感器场景尤其明显。比如传感器回调 100 Hz,UI 最多以 60 Hz 刷新,中间多余的 40 次回调如果全部入队,就是浪费。我采用过一个方案:C++ 侧只保留最新采样值,Dart 侧定时请求一次,而不是来一个采样推一次。这样通信方式从“推送”变成了“拉取”,数据压力小很多。

EventChannel 也有类似问题,平台侧高频发事件,Dart 侧 sink 吞吐跟不上,事件就会在平台上排队。如果事件源本质是高频数据,别依赖 EventChannel 做节流,应该在数据源头先做采样和降频。

5.3 各线程模型的职责划分

设计插件边界时,把每个线程的职责写清楚非常重要。最终形态总结成一句话:Dart UI isolate 只负责 UI 交互和轻量逻辑,不执行任何 FFI 重计算;需要算的调用放到后台 isolate 里,通过 FFI 进入 C++;C++ 内部自己管理工作线程,完成后通过 SendPort 回调 Dart;平台能力继续走 MethodChannel,由平台主线程处理再回传 Dart。

很多边界问题最后都演化成线程问题。比如在 C++ 工作线程里想去调一个原生平台接口,结果发现原生接口强制要求主线程,又得把任务 post 回主线程,来回切换加锁,复杂度就上来了。所以在搭建边界之前,建议先把线程关系画成草稿:哪个函数在哪个线程被调用、回调可能从哪个线程进来、是否可能并发进入同一个 C++ 对象。这些确定之后,再写代码几乎不会出大错。

6. 常见问题与排查技巧实录

6.1 符号找不到与加载失败

FFI 最常见的报错是 lookupFunction 找不到符号。排查思路一般是先确认动态库里确实有该符号,再确认 Dart 侧加载路径正确。Android 上如果用 CMake 构建,生成的是 libmy_plugin.so,Dart 侧DynamicLibrary.open('libmy_plugin.so')通常没问题。但如果原生代码里也调用了 System.loadLibrary,加载时机和 Dart 侧 open 的时机冲突,符号就可能找不到。稳妥做法是只让 Dart 侧负责加载,原生侧不要重复 load。

iOS 和 macOS 上,因为 Flutter 插件会链接进主二进制,可以优先用DynamicLibrary.process()查找符号,而不是 open 某个动态库文件。这样路径问题少一些,代价是必须保证符号确实被链接进了主二进制。如果被优化器裁掉,可以检查链接参数并保持 visibility default。

工具方面,Android 用nm -D libxxx.so看导出符号,Linux 用nm -D或objdump -T,Windows 用dumpbin /exports。这些命令能快速告诉你符号到底有没有。

6.2 内存错误:崩溃、踩内存、泄漏

FFI 的很多错误不会直接报错,而是直接崩溃,这是它比 Channel 难调试的地方。高发原因通常有四个:Dart 侧分配的内存太小,C 层写入越界,破坏堆结构;结构体对齐不一致导致解析错位;C 层释放了 Dart 侧还在使用的指针,产生悬垂指针;以及忘记释放整数句柄对应的 C++ 对象,静默泄漏。

排查这类问题,我建议在开发期打开地址消毒器。Android 的 CMake 里加-fsanitize=address,iOS 的 Xcode 里开启 Address Sanitizer,崩溃时能拿到具体访问地址和调用链。C++ 代码里的日志输出到 stderr,Android 上用 logcat 看,iOS 上用控制台看。

关键还是提前预防:每一块 malloc 都成对出现 free,每个句柄都有对应的 close 方法。FFI 代码不适合事后补作业,内存卫生要从第一行写起。

6.3 从 Channel 迁移后的回归对比

如果你正打算把某个 Channel 大数据传输迁移到 FFI,建议迁移前先量化基线:记录旧方案的耗时、内存峰值、CPU 占用,迁移后跑同一组测试数据对比。很多人一上来就为了性能上 FFI,结果边界没封装好,后续维护成本反而比 Channel 方案高得多。

对比性能一定要在 Release 模式下做。Debug 模式下 Dart VM 的检查开销会盖住一部分 FFI 的性能收益,跑出来的数字没有参考价值。测试时也要注意 warmup,Flutter 第一帧有各种初始化开销,跑三到五次再取稳定值。

回归测试要覆盖空数据、大对象、并发调用、内存峰值四个维度。FFI 并发调用尤其要小心,多个 Isolate 同时进入同一个 C++ 库时,如果库不是线程安全的,需要在 C++ 侧加锁,或者按 isolate 隔离实例。Channel 迁移时反而不容易暴露这个问题,因为通道天然有串行化,而 FFI 把并发压力直接带到了底层。

7. 把边界封装成可持续维护的 API

7.1 对外不要暴露指针

FFI 边界设计得再好,如果 Dart 对外 API 直接抛 Pointer,用起来体验会非常差。调用者不关心内存块在哪、释放时机是什么,他们只关心传一个 File,返回一个处理结果。所以我会在 lib/ 层做一层纯 Dart 封装:内部文件持有 FFI 绑定和指针管理,对外只暴露基于 Future 的方法,比如Future<Uint8List> processImage(Uint8List input)或Future<void> dispose()。这样即便以后底层从 FFI 换成 Channel 或其他实现,对外接口都不会变。

封装时要处理好错误传播。FFI 调用返回了错误码或空指针时,不要简单当成 null,应该转成专门异常类型,带上 C 层错误信息。如果错误信息是 C 层返回的字符串,记得用 fromUtf8 读完立即释放对应内存。

7.2 平台能力与本地内核的组合套路

一个需要相册权限、又有 C++ 计算内核的插件,典型逻辑是:Dart UI 请求选图,平台 Channel 返回文件路径,Dart 层把路径字符串通过 FFI 传给 C++ 内核,内核用标准库打开文件处理,结果写回文件或内存,Dart 层再封装成对象返回。完整步骤是:

  1. 平台 Channel 完成文件选择,返回本地路径。
  2. Dart 只拿到路径,不读取文件内容。
  3. FFI 接收路径的 UTF-8 指针,内核自己读文件处理。
  4. 结果通过同步返回值或 SendPort 回到 Dart。
  5. 封装成 Future 响应给 UI。

这种做法最大的优势是路径字符串很小,不会造成大数据跨通道传输,文件内容只在本地内核与文件系统之间流动。同样的套路可以推广到数据库加密、日志分析、几何计算等场景。

7.3 工具链建议

手写 FFI 绑定在大项目里容易出错,如果 C/C++ 头文件结构稳定,建议用 ffigen 自动生成绑定。ffigen 读取头文件和 YAML 配置,生成 Dart 代码,符号、结构体和回调都能自动映射。但要提醒一句:自动生成的绑定最好只当底层参考,不要直接作为对外 API 使用,因为它会暴露过多 C 风格细节,缺少内存管理封装。

Rust 场景则可以用 FFI 导出 C ABI,Dart 侧仍然按 C 函数绑定,核心原理完全一致。工具只是辅助,边界设计和内存所有权才是需要长期维护的核心。

CMake 统一构建后,你会发现新增一个平台只是改几条路径,不需要在五个目录各写一份源码。跨平台统一构建对本地内核很友好,但别为了统一把各平台的专属能力硬砍掉。边界清晰、职责单一,才是真正的可持续架构。

我在实际项目里踩过几次坑之后,形成了一条很朴素的经验:Flutter 插件通信架构的门槛不在 API 调用,而在边界意识。MethodChannel 和 FFI 不是互斥的二选一,而是同一个插件里的两只手。系统能力交给平台,重数据交给本地内核,中间用一套清晰的内存所有权规则和线程模型衔接,你自然就不会被性能问题追着跑。

最后分享一个小技巧:如果你不确定某个数据路径该走哪条链路,先按最笨的方案实现再测,但要把数据路径拆分出来,让它具备替换条件。先把 Channel 跑通、量化一下,再逐步把热路径换成 FFI,这种渐进方式最稳。用数据说话,边界设计才能真正落地。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进