把 MethodChannel.invokeMethod 理解成「调了一个 Java/ObjC 函数」,会在大图、每帧传感器、同步返回这三个场景里立刻撞墙。Channel 不是 FFI,不是共享内存,也不是 JNI 的直接包装。它是 一份编过码的字节,被投进跨线程队列,对端再解码。你 await 到的延迟 = 编码 + 两次队列等待 + 原生执行 + 回包编码。没有函数调用语义,所以也没有「传一个对象引用过去」。
读完你应该能独立回答三个问题:一次 invoke 在哪条线程上跑完、Uint8List 会不会拷贝、什么数据绝对不该走 Channel。
1. 先画边界:三层 API,一份字节
日常看见的是三张皮:
MethodChannel:方法名 + 参数 + 成功/失败结构。适合 RPC。EventChannel:原生 push、Dartlisten。适合传感器流、导航事件。BasicMessageChannel:双向任意消息,没有 method 名。适合自定义协议。
三张皮底下是同一个东西:BinaryMessenger。它只认识 ByteData。名字、Map、异常,全是 Codec 的幻觉。
Engine 侧对应 PlatformMessage。Dart 的 PlatformDispatcher.sendPlatformMessage 把字节交给 Engine;Engine 投到 Platform Task Runner;Embedder 再交给插件注册表。回包沿反向走:Platform Runner → UI Runner → Dart 回调。
不变量:
- 消息是值,不是引用。对端改 Map 不会反映回来。
- 默认异步。没有「在同一调用栈上进 Java 再返回」的 Channel。
- 每个
FlutterEngine一份 messenger。多 Engine 时同名 Channel 互不相通,除非你自己桥。 - Codec 必须两端一致。一边
StandardMessageCodec、一边 JSON,解码会直接失败。
const channel = MethodChannel('app.device');
Future<String> model() async {
final result = await channel.invokeMethod<String>('getModel');
return result ?? '';
}
这看起来像函数。它不是。getModel 只是字节里的一段 UTF-8。原生没注册这个名字,你得到的是 MissingPluginException,发生在回包之后,不是编译期。
2. Codec:类型怎么变成字节
StandardMessageCodec 是默认选择。它用 tag byte 标记类型,再跟 payload:
| tag | 类型 | 备注 |
|---|---|---|
| 0 | null | |
| 1 / 2 | true / false | |
| 3 | int32 | |
| 4 | int64 | Dart 的 int 在 64 位上走这条或更大 |
| 5 | float64 | 对齐到 8 字节 |
| 6 | String | UTF-8,长度前缀 |
| 7 | Uint8List | 长度 + 原始字节 |
| 8–10, 13 | typed list | int32/int64/float64/float32 |
| 11 | List | 元素递归编码 |
| 12 | Map | key/value 递归;key 不必是 String |
对齐规则容易被忽略:float64 和部分 list 要 pad 到 8 字节对齐。自己手写解码器如果不 pad,会在「某些长度的字符串后面跟一个 double」时读到垃圾。这就是为什么不要轻易重写 Codec。
JSONMessageCodec 走 UTF-8 JSON。调试友好,热路径不友好:多一次字符串分配、数字全是 JSON number、Uint8List 只能变 base64。StringCodec 只传字符串。BinaryCodec 原样过 ByteData,是 Channel 能做到的最薄封装,仍有队列和拷贝。
StandardMethodCodec 在 message codec 外包一层:成功是 [method, args] 或 [result];失败是 [code, message, details]。PlatformException.code 应该是稳定协议(permission_denied),不要把异常堆栈当 code。
Pigeon 生成的是「固定结构的 StandardMethodCodec」。它消灭的是字符串方法名和动态 Map,不是拷贝,也不是线程跳。生成代码里你看见的 codec.encodeMessage,和手写 Channel 是同一条路。
3. 拷贝发生在哪:没有共享 ByteBuffer 的幻觉
文档偶尔说「共享 ByteBuffer」。实现上,跨 JNI / ObjC 边界时,字节几乎总会被拷一次:
- Dart 堆上的
Uint8List/ByteData编码进 message buffer(可能已经是拷贝)。 - Engine 把 buffer 交到 Platform 线程。有的路径是 move,有的是 copy,取决于实现和是否能转移所有权。
- Android 上 JNI 取
byte[],通常再拷到 Java 堆。iOS 上NSData可能 copy-on-write,但 Dart 侧释放后你不能假设仍指向同一页。 - 回包再走一遍。
所以一张 8MB 的图走 Channel,峰值内存很容易是 8MB × 2~4,再加上编解码临时对象。这不是「Channel 实现差」,是消息语义的直接推论:消息必须在发送方释放后仍能被接收方读。 要满足这点,要么拷贝,要么做复杂的跨堆生命周期,Engine 选了拷贝。
零拷贝大缓冲的正确出口是 FFI 指针、Texture 注册表、或 dart:ui 的 Image / ImmutableBuffer。不要试图用 TransferableTypedData 骗过 Channel——那只在 Dart Isolate 之间有效,出不了 Engine。
4. 线程模型:异步是推论,不是语法糖
四条 Runner 的约束在 Channel 上变成一张时间表:
| 步骤 | 线程 | 你能做什么 | 卡住的后果 |
|---|---|---|---|
invokeMethod 编码 | UI / 调用方 Isolate | 准备 Dart 对象 | 大对象编码打 build 预算 |
| 投递 | Engine 队列 | 无 | 队列堆积表现为延迟,不是抛错 |
| 插件方法 | Platform(主线程) | 调系统 API | Android 上重活 = ANR |
| 回包解码 | UI | setState | 回包太大同样卡帧 |
默认插件代码跑在主线程,因为系统 API(权限、窗口、蓝牙适配器)要求如此。Flutter 后来加了 TaskQueue / background platform channels:你可以声明某个 Channel 的 handler 跑在后台线程。这只是把「原生执行」挪走,编码/解码和 Dart 回调仍在 UI Isolate。
不变量:Dart 回调回到 UI Isolate。 在后台 Isolate 里 invokeMethod 需要 BackgroundIsolateBinaryMessenger.ensureInitialized(rootIsolateToken)。忘了会直接抛。回包也不能在 worker 里 setState。
await channel.invokeMethod 在等待期间 UI Isolate 是空闲的(相对这条调用而言),可以继续处理帧。这是它和「同步 JNI」的关键差别。反过来,你没法用 Channel 实现「在 layout 里同步问原生一个高度」——那会要么死锁,要么至少拖到下一帧。这种问题用 FFI、或把高度缓存进 Dart。
EventChannel 的 listen 在 Dart 侧订阅时给原生发一份 listen 方法调用;cancel 再发 cancel。原生若在错误的线程 sink.success,Embedder 会帮你投递,但高频小包仍会把 Platform 和 UI 队列打满。传感器 1kHz 原样 push 是典型误用,应该原生合批,20~50Hz 再发,或改共享内存。
5. 生命周期:Channel 比 Widget 活得长
MethodChannel 对象只是名字 + codec + messenger 的句柄。真正的注册表在 Engine / 插件侧。典型事故:
- Widget
dispose了,EventChannel的StreamSubscription没cancel,原生还在sink.success,回包进已经拆掉的setState。 - 热重载后 Dart 侧换了 handler,原生侧还是旧的。Debug 里表现为「改了插件没生效」。
- 多 Engine:插件
registerWith用了静态MethodChannel,第二个 Engine 的消息打到第一个的 handler,或反过来。正确做法是 handler 持有FlutterPluginBinding.binaryMessenger,按 engine 实例注册。 - 引擎销毁顺序:先
destroyEngine 再卸插件,队列里的回包会丢。Dart 侧await永远不结束——必须在dispose里用timeout或忽略FlutterError。
class BatteryTap extends State<BatteryLabel> {
static const _ch = EventChannel('app.battery');
StreamSubscription<dynamic>? _sub;
@override
void initState() {
super.initState();
_sub = _ch.receiveBroadcastStream().listen((event) {
if (!mounted) return;
setState(() => _level = event as int);
});
}
@override
void dispose() {
_sub?.cancel();
super.dispose();
}
}
mounted 检查不是可选的。回包可能在 dispose 之后、cancel 被原生处理之前到达。
6. 错误协议与版本
Channel 没有 IDL,除非你用 Pigeon。手写时要把这些当公共 API:
- 方法名、参数 Map 的 key、类型。
PlatformException.code枚举。- 未实现:
MissingPluginExceptionvs 自己返回 null。两者在 Dart 侧分支完全不同。 - 版本:原生先发、Dart 后发时,旧 Dart 遇到新方法应忽略;旧原生遇到新方法应返回明确 code,不要崩溃。
二进制兼容比 JSON 更脆:StandardMessageCodec 加一种 tag 是 Engine 版本问题,你自己的业务字段只能加 Map key。不要把 struct 按位置塞进 List 当协议,插入一个字段会让所有版本一起炸。
安全:Channel 不是进程隔离。任何插件都能听同名 Channel(同 Engine 内)。不要用它传密钥后还假设「用户看不见」——它只是同进程消息。真正的隔离是操作系统进程和 TEE。
7. 何时不该用 Channel
- 需要零拷贝大缓冲: FFI、Texture、
ImmutableBuffer。 - 需要同步返回参与 layout: 做不到。预取、缓存、或把逻辑搬进 Dart。
- 每帧数据: 相机帧、音频 PCM、触摸采样。Channel 的队列和拷贝会先于你的算法成为瓶颈。
- 调用 UIKit / Android View 层级: 必须在主线程且和视图生命周期绑在一起,用插件 + Platform View,不要从 worker Isolate 乱 invoke。
- 只是想「快一点」: 先测。几十字节的配置走 Channel 是微秒到毫秒级,完全够用。过早上 FFI 换来的是内存安全和构建复杂度。
反例也对:权限、取电量、一次性地打开设置页、付费凭证校验(短字符串)——Channel 是正确工具。Pigeon 让它类型安全,不改变它的成本模型。
8. 设计取舍
- 消息而不是共享堆: 插件崩溃可以变成异常而不是 Dart VM 内存损坏。代价是拷贝和异步。
- 默认主线程: 系统 API 好写。代价是一条慢插件拖死所有 Channel 和部分输入。
- 字符串方法名: 动态、可热更新协议。代价是重构靠搜字符串,Pigeon 就是补这个洞。
- 每 Engine 一份 messenger: 多 Flutter 页面互不串。代价是插件作者必须按实例写,不能用 Java
static偷懒。
这些取舍解释了为什么 Flutter 同时保留 Channel 和 FFI。它们解决的是不同的一致性模型:一个要安全和系统集成,一个要热路径。用 Channel 模拟 FFI,或用 FFI 调 Activity,都是在错误的模型里找性能。
9. 排障
MissingPluginException: 先问是哪一个 Engine、是 Debug 热重载还是 Release、插件有没有在GeneratedPluginRegistrant里、iOS 是否进了正确的AppDelegate。多 flavor 最容易漏注册。- 卡住的
await: 原生没调result.success/error,或 Engine 已销毁。用 timeout 包一层,原生用runOnce保证 result 只回一次(回两次会断言)。 - ANR / 主线程卡顿: 插件里做了磁盘、解码、网络。挪到后台线程,回主线程只
result.success。 - 大包延迟: 量
ByteData.lengthInBytes。超过几百 KB 就该换通道。编码本身会在 UI 线程分配。 - 类型错误: Dart
int和 JavaInteger/Long、iOSNSNumber的宽度。StandardMessageCodec在 32 位设备上可能给你 32-bit,别假设永远 64。 - EventChannel 只收到一次: 原生把 sink 存成局部变量,
listen返回后就释放。sink 必须活到cancel。 - 偶发乱码 / 错字段: 自定义 Codec 的对齐;或两端 Flutter 版本不一致导致 tag 集合不同。
线上打点打三件事:方法名、payload 字节数、往返耗时分位数。不要打 payload 内容。
10. 字节布局与自写 Codec 的最小正确实现
StandardMessageCodec 的 writeValue 是一份递归下降:遇到 List/Map 就写长度再逐元素。字符串先写 UTF-8 长度(变长编码:0–253 单字节,254 后跟 uint16,255 后跟 uint32),再写字节。Float64 之前 writeAlignment(8):从当前 write position pad 0 直到 % 8 == 0。这就是「前面字符串长度一变,后面 double 全错」的根。
自己要扩展类型时,正确姿势是 包一层 而不是复制一份 codec:
class DateCodec extends StandardMessageCodec {
static const int _date = 128;
@override
void writeValue(WriteBuffer buffer, Object? value) {
if (value is DateTime) {
buffer.putUint8(_date);
super.writeValue(buffer, value.toUtc().millisecondsSinceEpoch);
return;
}
super.writeValue(buffer, value);
}
@override
Object? readValueOfType(int type, ReadBuffer buffer) {
if (type == _date) {
final ms = super.readValue(buffer) as int;
return DateTime.fromMillisecondsSinceEpoch(ms, isUtc: true);
}
return super.readValueOfType(type, buffer);
}
}
tag 128–255 留给业务。小于 128 的是框架的。两端必须同一天发版,否则一边写 128 一边当 unknown type 抛错。Pigeon 生成的自定义 codec 也走这条路。
WriteBuffer / ReadBuffer 基于 ByteData。大 List<int> 用 typed list tag(7–10、13)而不是 tag 11 的通用 List:后者每个元素一个 tag,百万整数会把编码时间打进 UI 帧。这是「Channel 传了个 int 数组就卡」的常见原因——类型是 List<int> 不是 Int32List。
Android JNI 侧 StandardMessageCodec 的 Java 实现对 byte[] 对应 Dart Uint8List。Java ArrayList 对应 List,HashMap 对应 Map。Integer 和 Long 都会来,Dart 侧写成 as int 没问题,写成 as int 再塞进只接受 32 位的 FFI 就要小心。iOS 上 NSNumber 的 bool / int / double 容易混:JSON 里 true 和 1 不同,Standard codec 里 bool 和 int 也不同,但如果你在原生用 @{@"ok": @1} 而 Dart 当 bool 解,会类型异常。协议要写死。
消息大小没有写在文档里的硬顶,但 Android Binder 对同进程不是瓶颈,跨进程插件(极少见)才是。真正的顶是内存和编码时间。超过 256KB 的 payload 就该问自己是不是走错通道。
11. 后台 Isolate、TaskQueue、与插件注册顺序
BackgroundIsolateBinaryMessenger.ensureInitialized(token) 把 worker Isolate 接到 同一个 Engine 的 messenger。它不是新 Channel 世界,只是允许非 UI Isolate 发消息。回包回到这个 worker 的事件循环,不是 UI。worker 里 invokeMethod 再 sendPort.send 给 UI,才是完整链路。漏 ensureInitialized 的错误信息直白;漏「回包进 worker 后去碰 dart:ui」会变成更隐蔽的崩溃。
Android 插件的 BinaryMessenger.makeBackgroundTaskQueue() 让 handler 跑在后台线程:
TaskQueue queue = binding.getBinaryMessenger().makeBackgroundTaskQueue();
channel = new MethodChannel(
binding.getBinaryMessenger(),
"app.unzip",
StandardMethodCodec.INSTANCE,
queue);
适合解压、哈希、读大文件。不适合碰 Activity、View、Looper.getMainLooper() 上才安全的 API。result.success 可以从后台线程调,Embedder 会切。result 只能用一次;二次调用 Debug 断言、Release 可能静默。把 result 存下来等异步,必须保证成功和失败互斥,取消时也要回一次(error 或 success(null)),否则 Dart await 挂死。
注册顺序:GeneratedPluginRegistrant 在 Engine run 之前。自己手写的 Channel 若在 runApp 之后才在原生注册,Dart 启动期的第一次 invoke 会 MissingPluginException。反过来,Dart 侧 setMethodCallHandler 必须在原生第一次往 Dart 推之前挂上——EventChannel 的 listen 是 Dart 主动的,安全;原生在 Engine 启动瞬间就 sink.success,可能丢。启动事件要可重放或等 listen。
Pigeon 的 @HostApi() / @FlutterApi() 只是生成两边的方法表。FlutterApi 是原生调 Dart,仍然异步。不要在原生 onCreate 同步等 Dart 准备好——没有同步。用「Dart 第一帧后 ping 原生 ready」或队列把早期事件攒到 Dart handler 挂上。
MissingPluginException 的分层含义
同一句 MissingPluginException 至少四种病:
- 插件根本没链进这个产物(flavor、按需交付、iOS 没进
GeneratedPluginRegistrant)。 - 插件链了,但挂在另一份 Engine 的 messenger 上(Group / 多 FlutterView)。
- 方法名拼错或原生用了不同的 channel 名常量。
- Dart 调用发生在原生
onAttachedToEngine之前(启动竞态)。
前两种用「这个 Engine 上 hasPlugin」区分;后两种用日志打出两边的字符串常量(包括隐藏字符)。不要一遇到就 flutter clean。
invokeMethod 的泛型 T 只是 Dart 侧转换,不是 codec 保证。原生回了 int,你写成 invokeMethod<String>,失败发生在解码之后的 cast。用 invokeMapMethod / invokeListMethod 时,元素类型仍是动态。Pigeon 的价值是把这层 cast 变成生成代码里的显式字段。
编码性能:对象图形状比字节数先爆
10000 个 {id: int, name: String} 的 List<Map> 走 Standard codec:每个 map 一个 tag、两个 key 字符串、两个值。key 字符串 id/name 重复一万次。改成列式:{'ids': Int32List, 'names': List<String>} 或直接 FFI。Channel 适合「浅而宽」的配置对象,不适合「深而重复」的 ORM 导出。
字符串:Dart UTF-16,codec UTF-8。中文热路径上转码本身可测。路径、token 这类短字符串无所谓;聊天记录全量同步不要走 Channel。
const StandardMessageCodec() 是无状态的,可全局复用。不要每次 invoke new 一个自定义 codec 的重配置对象,若它还关了文件句柄。
安全与拒绝服务
任何 Dart 代码都能在同 Engine 上 MethodChannel('app.device').invokeMethod('whatever')。插件 handler 必须校验方法名和参数类型,不要把未校验的 path 传给 File。Channel 不是安全边界,是便利边界。敏感操作还要操作系统权限和你自己的鉴权。
恶意或错误的超大 payload:解码会在 UI 或 Platform 线程分配。对 EventChannel 的上游做大小限制。原生往 Dart 推无界日志流,能把帧打掉也能把堆打爆。合批、截断、采样。
EventChannel 的 listen/cancel 状态机
EventChannel 在原生侧不是无限流,是两次 RPC:Dart listen → 原生 onListen;Dart cancel → onCancel。onListen 里开传感器,onCancel 里关。只开不关,页面 pop 后传感器还在跑,这是耗电和往已销毁 Engine 推事件的根源。
onListen 被调用两次:热重载、重复订阅。第二次应先按 cancel 清理再开,或拒绝(error)。receiveBroadcastStream() 每次 listen 都走一遍原生 onListen。把 stream 存在 State 里复用,不要在 build 里每次 receiveBroadcastStream()。
StreamController 在 Dart 插件包装里再套一层时,要 onCancel 链式传到 Channel cancel。漏一层,原生永远收不到 cancel。
二进制心跳:若原生 50Hz push 一个 double,Channel 仍可能够用。若 50Hz push 一帧 JPEG,不够。用频率 × 编码后字节估算:超过每秒 1~2MB 就该换共享内存或 Texture。估算写进设计文档,避免到线上才发现。
Android 主线程与 Dart 的「假并发」
插件方法默认主线程,Dart 回调 UI 线程。两者都是「不能停」的线程,只是职责不同。插件里 Thread.sleep 或同步磁盘,ANR;Dart 回调里 jsonDecode 2MB,掉帧。把重活丢给 Executors / TaskQueue 后,result.success 带大 byte[] 仍会在 回包解码时 打 UI。所以「我已经后台算完了」不等于「Dart 侧便宜」。回包只传路径或 id,让 Dart 用 FFI/文件去取,或让原生直接把结果写成 Texture。
runOnUiThread 在已经在主线程时再 post,会把 success 推迟到下一则 Looper 消息,多一帧延迟。已经在主线程就直接 result.success。
iOS 上同类问题是 DispatchQueue.main。Texture 上传应在 Flutter 规定的线程,乱 async 到随机队列会和 Raster 抢 GL 上下文(若你错误地碰了 GL)。Texture registry 的 API 才是合法入口。
版本协商
启动时一次 getProtocolVersion,小于本地支持的范围就降级方法集。不要每个方法自己 try/catch MissingPlugin。版本号进 Standard codec 的 int 即可。Pigeon 的版本字段同样道理。跨 App 和独立插件进程(极少)才需要兼容多年;同仓库发版的壳和 Flutter 模块,用「壳版本 ≥ 模块要求」更简单。但模块热更新(动态下发 isolate 快照,若你做了)会让 Channel 协议重新变成分布式问题,那时必须有版本。
一次 invoke 的墙钟时间怎么拆
把 Stopwatch 打在 Dart 编码前、原生 handler 入口、原生 result.success 前、Dart 解码后。四段:
- Dart 编码:对象图形状问题。
- 队列等待:Platform 线程忙(其它插件、WebView、IME)。
- 原生执行:你的算法或系统 API。
- 回包解码 + 微任务排队:UI 忙。
只看总 await 耗时会把 2 和 3 混在一起,于是去「优化」一个其实已经很快的 Java 方法。主线程 trace 才能看见 2。Dart Timeline 自定义事件包住 invoke,能看见 1 和 4。三件套都要,缺一就会改错层。
同步方法在 Android 上有 invokeMethod 的 blocking 变体吗?官方 Dart API 没有真正的同步 invoke。JNI 侧有阻塞等回包的嵌入者 API,App 业务不要用:容易和主线程互相等,ANR 或卡死。需要同步语义时,把值缓存在 Dart,Channel 只做刷新。
命名空间与多模块
Channel 名是全局(每 Engine)字符串。app.device 这种太短,插件市场会撞。用反域名 com.company.module/device。Pigeon 的 @ConfigurePigeon 也要设。两个 Flutter 模块打进同一壳,短名字必撞——第二份 handler 覆盖第一份,bug 表现为「偶发调到另一个模块的实现」。
EventChannel 和 MethodChannel 可以同名吗?不要。底层是同一 messenger 的同一 key。分后缀 /method /event。文档里写死。代码审查搜 MethodChannel( 看字符串字面量有没有抽成常量——两边不共享常量文件是漂移源。
二进制兼容实验
加字段:Map 加 key,旧端忽略。删字段:旧端要能缺省。改类型:新 key。不要 reuse 同一 key 换类型。List 按位置的协议几乎无法演进。这和 Standard codec 的 tag 稳定是同一哲学:追加,不重解释。
单元测试:用 codec encodeMessage / decodeMessage 在纯 Dart 测往返,不启动引擎。把黄金字节数组提交进仓库,防止有人「优化对齐」时踩碎线上。Channel 协议一旦进生产,黄金测试比注释可靠。
Channel 协议一旦发布,就应按独立于 Dart 版本的 公有 API 来管理。方法名、Map key、错误 code、EventChannel 的事件形状,全部进 changelog。Flutter 升级、Pigeon 升级、Kotlin 空安全升级,都不允许默默改二进制含义。一次线上事故的典型链是:原生把 status 从 int 改成 string,Dart as int 崩,页面白屏,看起来像「偶发空指针」。防御是 codec 往返黄金测试 + 版本协商 + fromPrimitives 式的宽容解析(Channel 回包同样该宽容)。
再谈背压。EventChannel 没有内建窗口。原生 sink.success 能把 Dart 微任务队列打满,UI 来不及 setState 就下一包又到。实现背压的地方在 生产者:传感器采样降频、合批(20ms 内的 N 个样本合成一条)、或满了就丢旧保新。Dart 侧 Stream 的 pause 能否传到原生,取决于你是否在 onPause 里真的停传感器。默认 pause 只停 Dart 订阅,原生还在 push,缓冲堆在引擎队列。要闭环,pause 必须变成一次 cancel 或一次「pause」方法调用。这和「Channel 是消息不是共享内存」是同一推论:对端不知道你来不及,除非你发消息告诉它。
最后是可观测性。每个 Channel 方法在 Debug 打 payload 大小和耗时;Release 只打方法名、大小桶、耗时分位数、失败 code。不要打 token。大小桶用 0-1KB / 1-16KB / 16-256KB / 更大。一旦「更大」出现在滚动路径,报警。这比事后用感觉说「Channel 慢」更能推动换 FFI 或 Texture 的决策。决策要有数字:连续一周 P95 > 8ms 且 payload > 64KB,就立项换通道。数字写进技术债票,避免口头「以后优化」。
插件的 result.success(null) 和完全不回包,在 Dart 侧一个是完成一个是挂起。code review 对每个分支(权限拒绝、取消、异常、成功)都要看到 result 被调。用封装 onceResult 保证只回一次。JNI 崩溃在 reply already submitted 就是没封装。把这层封装当基础设施,业务 handler 只 return 值或 throw,由封装翻译成 success/error。Channel 的正确性一半在这种无聊的纪律,一半在选对通道。纪律不值钱但不做会值夜班。
把插件当成小型 RPC 服务来运维,还会碰到超时、重试、幂等三个经典问题。Channel 默认没有超时:原生不回包,await 永远挂。业务层必须 timeout。超时后原生可能仍在跑,回头 result.success 会打到已经不关心的 Completer,甚至二次提交崩溃。所以超时也要原生可取消:Dart 发 cancel 或同一 callId 的取消方法。没有 callId 的 Channel 无法取消,这是协议缺陷,新插件应带 callId。
重试:权限类、一次性支付类禁止自动重试。读配置可以重试。重试要幂等,原生用 requestId 去重。用户连点按钮会发出两次 invoke,没有去重就会下两单。去重发生在原生事务层,不发生在「按钮 setState loading」——loading 挡不住双 Engine、挡不住 EventChannel 回放。
幂等和 EngineGroup 交叉:两份 Engine 各 invoke 一次「初始化 SDK」,进程级 SDK 初始化两次可能炸。原生用原子布尔。Dart 的 static 布尔帮不上另一份 Isolate。这又回到「Java static 是进程级」——这里反而要用进程级去重,是少数 static 合法的场景。合法 static 写进注释「进程级幂等」,避免被下一轮「去掉所有 static」的清理误删。
序列化层再补一条:NaN / Infinity 在 Standard codec 的 double 里可以编码,JSON codec 不一定。两端要用同一 codec,且业务要决定 NaN 是错误还是合法。传感器数据出现 NaN 原样传到图表,会让路径变成空洞。Channel 边界上校验有限性,比在图表里防御更早。
包体积与反射:Java 插件用反射找方法,Proguard 会把方法名 strip 掉,Release MissingPluginException、Debug 正常。keep 规则必须进插件 AAR。iOS 的 strip 同理。Channel 字符串方法名让反射/动态分发很诱人,也让混淆很危险。Pigeon 生成的显式方法更抗混淆。选择 Pigeon 不只是类型安全,还是 Release 存活率。
最后,Channel 与 Isolates 的 token:RootIsolateToken 在 Flutter 3 之后才能从 background isolate 发消息。老代码在 worker 里直接 MethodChannel 会抛。升级 Flutter 后搜 Isolate.spawn 附近有没有 Channel,补 ensureInitialized。漏掉的表现是「后台压缩完无法通知原生保存」,只在走 worker 的机型上出现,难复现。把 token 初始化放进 worker 入口模板,不要让每个业务自己记。
协议评审加一项:这个方法的最大 payload、调用频率、失败是否可重试、是否必须主线程。四项写在注释头里。缺一项不合并。Channel 的事故几乎都能追溯到这四项里某一项是空白。空白不是「以后补文档」,是运行时的未定义行为。把四项当签名的一部分,和类型一样硬。
至此 Channel 可以收成一张检查表,但不要当空话:名字带域名、codec 两端同一份、handler 按 Engine 实例、result 只回一次、payload 有大小桶、主线程只做主线程的事、超大或每帧走别的通道。检查表每一项都对应过一次事故。新插件对照着写,比对照着「最佳实践」十原则有用,因为每一项都能指出失败时的异常类型和线程。
对照这张检查表合并插件时,把「主线程只做主线程的事」写成代码结构:handler 里立刻把 call 丢进后台,主线程返回。结构保证比注释保证硬。其余项同理,能写成封装就不要写成提醒。
做完这些,Channel 就不再是「调原生的黑盒」,而是一条有大小、有线程、有版本的消息总线。总线该瘦。瘦了才稳。
12. 小结
Channel 是 Codec 加跨线程消息队列。MethodChannel 只是在字节上印了方法名。拷贝和异步是消息语义的不变量,不是实现缺陷。大缓冲、每帧数据、同步 layout 查询,这三件事不走这条路。把插件当 RPC 服务来设计:稳定 code、按 Engine 注册、主线程只做必须在主线程的事,Channel 就会安静地待在它该待的层。