BLOG · #Engineering

通道、Pigeon 与 FFI:跨到原生,但不按跳付费

Flutter 提供三种触达原生代码的方式,成本和失败形态都不同。选对主要取决于一个数字(你跨越的频率)和一条规则(按帧批量,绝不按跳)。

English version: Channels, Pigeon, and FFI: crossing to native without paying per tick。这是交易类 App 架构的 Flutter 实现篇,另见跨平台的边界。

从 Dart 出去有三条路,而它们并不能互相替代。选得好不好,与其说取决于性能跑分,不如说取决于机制是否匹配你的跨越频率,以及你在另一侧需要什么。

三种机制

平台通道(MethodChannel、EventChannel)把消息经引擎送到平台代码。它是异步的,参数会经过编解码器序列化,并且会切换线程。这让它非常适合平台能力:权限、相机、生物识别、分享面板,这些调用本来就不频繁,而且工作本来就发生在平台那一侧。它的成本由每次调用的固定开销主导,而不是参数大小。

Pigeon 是通道之上的代码生成。你只声明一次接口,它生成带类型的 Dart 代码和平台代码。运行时成本和手写通道一样;你换来的是消灭了一整类缺陷:方法名拼错、参数形状不匹配、类型被静默转换。通道天然会招来这类问题,因为它两侧都是以字符串标识、且没有类型的。只要你的通道方法超过两三个,就该用它。

dart:ffi 直接调用 C。它是同步的,没有序列化,并且可以通过类型化数据与 Dart 共享内存。当你需要直接对接一个原生库的 API,或者需要低成本地搬运大块缓冲区时,它是唯一选择。它的坑恰好就是它的优点:同步调用运行在发起它的那个 isolate 上,所以一次慢的 FFI 调用会阻塞该 isolate;如果那是 UI isolate,你就掉帧了。截至 Flutter 3.47 / Dart 3.13(2026 年 8 月),打包原生侧的官方方式是 FFI package 里的构建钩子(hook/build.dart)——flutter create --template=package_ffi,自 Flutter 3.38 起即为推荐做法——它取代了以前为 C 代码逐平台写插件脚手架的方式。

决定一切的那个数字

跨越的成本是按次调用支付的。仅这一个事实就决定了大部分设计。

一条每秒 5000 条消息的数据流,如果逐条经通道封送,就是每秒 5000 次序列化往返加线程切换。它会比全部用 Dart 做还慢,尽管原生代码“更快”。你为了省下一些解码,付出了几千次跨越开销。

同一条流,如果由原生解码并合并,Dart 每帧读取一次快照,那就是每秒 60 次跨越,每次带一批数据。同样的数据量,边界成本少了两个数量级。

所以规则就是管线那篇里那条,用 Flutter 的说法重述一遍:按帧跨越,绝不按跳跨越。 把原生接口设计成少数几个搬运批量数据的粗操作,而不是一串单条事件,也不是一组属性读取。

高频数据源的一个具体形态

如果你确实要做原生接收,行得通的接口长这样:原生持有套接字,负责解码、检测丢包、维护一张当前值表;Dart 只问“自我上次查看以来变了什么”。

final class QuoteBridge {
  QuoteBridge(this._lib);
  final NativeLib _lib;

  /// 把变化的行情拷进一个可复用缓冲区。每帧跨越一次。
  int drainInto(Float64List out) => _lib.drain(out.address, out.length);
}

这里有两个性质很关键。缓冲区是复用的,不是每次调用都分配,所以你不会每秒制造 60 次垃圾。而且这个调用返回的是写入预分配区域的数量,而不是跨边界构造对象,因为从原生代码逐元素地分配 Dart 对象,会把你本想避开的按条成本又请回来。

然后,只要 FFI 调用有可能变慢,就把它挪出 UI isolate:

// 长时间或阻塞的工作属于它自己的 isolate。
final decoded = await Isolate.run(() => heavyDecode(bytes));

如果原生代码需要反过来调用 Dart,比如一个完成回调、或数据源的一次推送,使用 NativeCallable。它给你一个能安全重入 Dart isolate 的函数指针。不要绕过它,从任意原生线程直接调用 Dart。

内存归属必须写清楚

FFI 去掉了序列化边界,同时也去掉了你原本隐式依赖的自动内存管理。对每一个跨线的缓冲区,都要决定并写明:谁分配,谁释放。

站得住的模式有两种:

  • Dart 分配,原生填充。 Dart 在整个生命周期内持有一个类型化数据缓冲区并传下去。这是最简单的,也是我的默认选择,因为生命周期没有任何歧义。
  • 原生分配,Dart 显式释放。 把它包装成“释放不是可选项”的形式:作用域分配用 using((arena) { ... }),或者挂一个 NativeFinalizer,让被丢弃的 Dart 对象自动释放它的原生对应物。

这里的失败不是你在测试中能抓到的崩溃,而是一次缓慢的泄漏,表现为内存曲线在使用一小时后持续爬升,而那恰好是你从不测试的会话长度。

EventChannel 没有背压

这一点值得单独说,因为它常常出乎意料:一个 EventChannel,如果喂入速度快过 Dart 的消费速度,就会排队。它没有内置机制让消费者说“慢一点”,所以一个快速的原生生产者会变成无上限的缓冲,然后变成内存问题。(这个症状早在 flutter/flutter#33640 就有记录:原生侧持续向 EventChannel 喂数据,内存以稳定速率上涨。)

如果原生是高频流的生产者,就要在原生一侧做合并,让 Dart 主动拉取;或者按你控制的节奏推送(每帧一次),而不是按数据到达的节奏推送。在这里施加背压是生产者的职责,因为传输层不会替你做。

简短的选择建议

  • 偶发的平台能力 → 用 Pigeon 生成的通道。类型安全是白得的,调用频率无关紧要。
  • 只有少数几个调用,不想引入代码生成 → 直接用 MethodChannel 也可以,把方法面保持得小一些。
  • 大块缓冲区,或必须直接对接的原生库 → 用 FFI,归属写明确,慢操作放到辅助 isolate。
  • 高频流 → 原生做合并,Dart 经 FFI 每帧拉取一次。绝不要逐条消息走通道。
  • 任何能在 Dart 里以可接受成本完成的事 → 就在 Dart 里做。 那条边界是一笔长期的税,交给调试、崩溃上报和构建复杂度,它应当由一次测量来证明其合理,而不是由“原生更快”的直觉。

最后一条是最常见的错误。调用栈会在边界处中断,崩溃上报需要为两侧分别上传符号表,而一次复现从此牵涉两套工具链。等性能剖析告诉你该跨的时候再跨,不要因为原生听起来更快就跨。

参考

API 与渲染器相关断言按 Flutter 3.47.0 / Dart 3.13(2026 年 8 月)核对。


上一篇:Flutter 面对高频数据 · 下一篇:图表与自绘