很多 Dart/C++ Bridge 的第一个版本都很简单:Dart 通过 FFI 把几个函数指针注册给 C++,C++ 保存到全局变量,需要回调 Dart 时直接调用。只要应用里永远只有一个 Flutter Engine、一个 Isolate,并且 Isolate 始终绑定在同一条线程上,这个模型确实足够。
问题在于,这三个前提都不是稳定契约。页面容器可能创建多个 Engine;预加载和主页面可能短暂共存;后台任务会创建新的 Isolate;Flutter 的线程合并优化还可能让多个 Isolate 先后运行在同一条平台线程。此时,过去看起来只是“省一次查表”的全局变量或 thread_local,会变成函数错投、对象越界访问和销毁阶段消息丢失的根源。
本文把这些问题放到同一个运行时模型里讨论,因为它们本质上都在回答三个问题:
- 当前调用属于哪个 Dart 运行时?
- 一个 native 对象或 Dart 对象绑定在哪个运行时,何时失效?
- native 投递给 Dart 的异步消息,怎样确认最终被正确消费?
一、不要把函数指针误认为进程级能力
Dart VM 提供的 C API 和 Dart 代码通过 FFI 注册出来的函数指针,不是同一种东西。
- VM C API 由 native 库实现,通常是进程级的实际函数;
- Dart callback 只是当前 Isolate 中 Dart 函数的入口,带有运行时上下文;
- 一个 Isolate 注册的 callback,不能直接拿到另一个 Isolate 中使用;
- Isolate 销毁后,旧 callback 即使地址还在,也已经不再有效。
所以注册表的最小维度不是 method -> pointer,而是:
runtime/isolate -> module -> method -> function entry
一个简单的数据结构可以写成:
struct DartFunctionTable {
void* create_object = nullptr;
void* invoke_callback = nullptr;
void* release_handle = nullptr;
};
struct IsolateContext {
Dart_Isolate isolate = nullptr;
uint64_t generation = 0;
std::atomic<bool> accepting_calls{true};
std::unordered_map<ModuleId, std::shared_ptr<DartFunctionTable>> modules;
};
std::unordered_map<Dart_Isolate, std::shared_ptr<IsolateContext>> contexts;
函数按模块聚合到一个结构体,比每个方法单独维护一个 map 项更好:查到模块后可以通过固定偏移访问函数,既减少查表次数,也减少生成代码和静态变量数量。
二、为什么只用 thread_local 最终会出问题
早期实现常利用“Isolate 与线程一一对应”的观察,把当前 Isolate 标记和函数表存在 thread_local 中。访问快,也天然隔离不同线程。但这只是实现现状,不是应该写进 Bridge 的不可变假设。
线程合并后,可能出现多个 Isolate 先后在同一条平台线程执行。此时 thread_local 只能回答“当前线程上一次访问了什么”,不能回答“当前正在运行哪个 Isolate”。如果直接使用旧缓存,就可能调用另一个 Isolate 注册的函数。
更稳妥的策略是:
- 通过 VM API 获取当前 Isolate;
- 用 Isolate 作为真值,从全局注册表取得
IsolateContext; thread_local只做带 key 的最近一次缓存;- 每次命中缓存前,都比较当前 Isolate 和 generation。
thread_local Dart_Isolate cached_isolate = nullptr;
thread_local uint64_t cached_generation = 0;
thread_local IsolateContext* cached_context = nullptr;
IsolateContext* currentContext() {
auto isolate = Dart_CurrentIsolate_DL();
if (isolate == nullptr) return nullptr;
if (cached_context != nullptr &&
cached_isolate == isolate &&
cached_context->generation == cached_generation) {
return cached_context;
}
std::lock_guard lock(context_mutex);
auto it = contexts.find(isolate);
if (it == contexts.end()) return nullptr;
cached_isolate = isolate;
cached_generation = it->second->generation;
cached_context = it->second.get();
return cached_context;
}
这里的 generation 很重要。只比较 Isolate 地址,无法完全防止运行时释放后地址被复用;代数可以让旧缓存明确失效。
查表成本是否值得担心
在一组 10 万次调用的相对测试中,直接访问局部静态变量、全局 map、加入 Isolate 缓存、再按模块聚合函数表,结果如下:
| 方案 | 传递 bool | 传递 string |
|---|---|---|
| 局部静态变量 | 60 | 131 |
| 每次全局 map | 77 | 155 |
| 缓存 IsolateContext | 69 | 145 |
| 缓存并聚合模块函数 | 63 | 135 |
这里更值得关注的是相对差异,而不是脱离设备与构建模式解释绝对值。经过两层缓存后,安全模型带来的成本已经接近原始方案。不要为了省几次纳秒,把运行时身份从数据模型里删掉。
三、多 Isolate 与“单活引擎”是两种不同业务模型
支持多 Isolate 不代表所有业务都必须同时服务多个 UI 引擎。Bridge 应该区分两种模式。
3.1 多上下文并存
每个 Isolate 拥有独立的注册表、消息端口和对象集合。native 调用时明确指定目标上下文,适合后台任务、插件隔离或真正并行的多实例业务。
Native core
├─ Isolate A context ── objects A / port A
├─ Isolate B context ── objects B / port B
└─ Isolate C context ── objects C / port C
这种模式最灵活,但所有 native 单例都要重新审视:它保存的 listener 属于哪个 Isolate?一次回调要广播还是单播?多个 Isolate 同时销毁时是否会竞争同一资源?
3.2 单活上下文切换
有些业务虽然会创建多个 Engine,但 native 核心同一时间只允许一个 UI 与之交互。这时可以显式定义 active context:
- 新 Engine 注册后成为 active;
- 旧上下文进入 retiring,停止接收新调用;
- native 实现对象可以在新上下文重新创建 wrapper;
- Dart 实现对象不能跨 Isolate 复活,应随旧上下文释放;
- active 的读写需要加锁或通过单线程状态机串行化。
关键是把“单活”作为明确的业务约束,而不是偷偷用一个全局函数指针实现。前者能检测冲突和记录切换,后者只会在错调时崩溃。
四、对象能否跨引擎,取决于实现端
对象跨引擎迁移时,必须区分它由 native 实现还是由 Dart 实现。
native 实现对象
native 对象的真实状态不依赖某个 Isolate。旧 Engine 销毁后,可以解除旧 Dart wrapper 的句柄;新 Engine 激活时,再为同一个 native 对象创建新的 Dart wrapper。
Engine A active: native object ↔ Dart wrapper A
Engine A exit: native object
Engine B active: native object ↔ Dart wrapper B
注意,wrapper A 和 wrapper B 不是同一个语言对象,但它们指向同一个 native identity。注册表仍要防止同一 Engine 中重复包装。
Dart 实现对象
Dart 对象属于创建它的 Isolate。C++ 侧即使有一个代理,也只是通向该对象的句柄。Isolate 销毁后,代理应立刻标记失效,不能在新 Engine 中“重新创建”一个对象,因为 native 并不知道 Dart 对象的真实状态和构造参数。
Dart object A ↔ native proxy A
Engine A exit ⇒ both handles invalid
Engine B ⇒ cannot resurrect object A
把这两种对象混为一谈,常见后果是:旧 Isolate 的 Dart handle 被带到新 Isolate 使用,或者一个已经失效的 Dart listener 仍留在 native 单例中。
五、运行时需要一个可验证的状态机
仅用 bool active 很难覆盖并发注册、在途调用和销毁。可以给上下文定义更明确的状态:
Unregistered
│ register
▼
Active ──────> Retiring ──────> Destroyed
▲ switch-in │ drain │
└────────────────┘ └─ generation invalid
每个状态允许的行为:
| 状态 | 新调用 | 在途调用 | 新对象绑定 | 释放 |
|---|---|---|---|---|
Active | 允许 | 执行 | 允许 | 不执行总清理 |
Retiring | 拒绝 | 完成或取消 | 拒绝 | 等待 drain |
Destroyed | 拒绝 | 不应存在 | 拒绝 | 幂等返回 |
切换上下文时不要持有全局锁执行 Dart 回调。锁只用于修改状态和取得快照,实际回调在锁外发生,否则回调重入 Bridge 后很容易自锁。
六、native 到 Dart 的消息队列为何会永久堵塞
为了从任意 native 线程通知 Dart,常见实现是:
- native 把任务放入队列;
- 如果当前没有通知在途,就向 Dart port 发送一个整数或令牌;
- Dart 收到通知,通过 FFI 调用
consume(); - native 取出任务,最终把状态恢复为空闲。
一个过于简化的状态只有 idle/notified。如果某次通知链在任何环节断掉——发送 API 返回失败、Dart 未收到、Dart 收到但 FFI 没执行、Engine 正在销毁——队列会一直停在 notified,后续生产者认为已经通知过,于是再也不发送新通知。
更可靠的状态机应该记录序列号和确认:
Idle
│ enqueue(seq)
▼
NotifyPending ── post success ──> WaitingConsume
▲ │ consume(seq)
│ retry/backoff ▼
└────────────────────────── Consuming
│ empty
▼
Idle
建议为每次唤醒维护:
- 单调递增的
notification_seq; - 发送时间和重试次数;
- Dart 回传的 seq;
- 队列长度和最老任务年龄;
- 当前上下文 generation。
这样能区分“通知发送失败”“Dart 很晚才消费”“收到的是旧上下文通知”和“消费调用已进入但任务不匹配”。
重试要避免通知风暴
修复永久堵塞的简单办法,是只要队列仍有任务就重复通知。但每个生产者都立即发送,会在 Dart 忙碌时制造更多压力。更合适的是:
- 同一时刻只允许一个通知在途;
- 发送失败可立即有限重试;
- 等待消费超时后按退避策略重发;
- 新任务到来只更新队列状态,不无条件新增通知;
- 收到匹配的 ack 后清除重试计时器;
- 上下文进入 retiring 后停止重试,并对未执行任务做取消回调。
七、“超时”不等于“丢失”
移动端进入后台后,Dart 线程可能被大幅降权甚至暂停。native 在后台产生消息,几秒后应用回到前台,Dart 才继续消费,这在时间上像严重超时,却不一定丢失。
监控如果只看固定阈值,会把大量正常的生命周期暂停当成故障。更有用的分类是:
| 事件 | 含义 |
|---|---|
notify_failed | native 通知 API 明确返回失败 |
delayed | 超过阈值后最终收到匹配 ack |
consume_mismatch | Dart 回传的序列与等待序列不一致 |
retired_pending | 上下文退出时仍有未确认消息 |
lost_confirmed | 运行时彻底结束后仍未收到,且没有被取消 |
还应把前后台状态或调度暂停作为维度。若不方便直接访问生命周期,可以根据“超时后短时间内收到 ack”的模式将其归为 delayed,而不是 lost。
另一个容易误判的点是结束时机。Engine 通知退出,并不代表 Dart 线程已经停止执行;若此时立刻清点未确认消息,会把稍后仍能消费的任务判成丢失。统计结束应尽量靠近线程或 Isolate 真正销毁的时刻,或者在退出协议中显式执行 drain,再生成最终结论。
八、安全的销毁顺序
Bridge 的退出流程建议分成六步:
- 将上下文从
Active改为Retiring,拒绝新调用; - 取消定时重试和新的 native 投递;
- 等待在途同步调用结束,并给异步任务发送取消结果;
- 解绑 Dart 实现对象,标记所有代理失效;
- 解绑 native 对象对应的 Dart wrapper,但保留允许跨 Engine 的 native 实体;
- 从注册表删除上下文、递增 generation,最后释放 VM 相关句柄。
其中任何一步都应幂等。实际生命周期里,页面关闭、Engine 回调、native owner 析构和异常恢复可能从不同路径触发清理,不能假设退出只发生一次。
九、线程规则:能投递,不代表能同步返回
native 任意线程通常可以向 Dart port 投递消息,但不能在任意线程直接执行 Dart 函数并同步拿到返回值。带返回值的方法必须选择一种明确语义:
- 调用者已经在目标 Isolate 线程,允许同步执行;
- 切到目标线程后异步返回
Future/completion; - 把接口改成单向通知;
- 明确拒绝并返回线程错误。
不要用“投递到 Dart,再让 native 当前线程阻塞等待”的方式模拟同步调用,特别是在持锁状态下。Dart 回调很可能需要相同锁或需要当前线程继续处理事件,最终形成跨运行时死锁。
十、落地检查表
当 Bridge 需要支持多 Engine 或多 Isolate 时,可以逐项确认:
- 每个 Dart 注册函数是否绑定明确的 Isolate 和 generation?
thread_local是真值,还是只作为带 key 的缓存?- 业务到底允许多上下文并存,还是只允许一个 active?
- native 实现对象和 Dart 实现对象是否使用不同的迁移规则?
- 上下文退出时是否先拒绝新调用,再清理句柄?
- 消息队列有没有序列号、ack、超时重试和取消?
- 监控能否区分 delayed、failed 和 confirmed lost?
- 后台暂停与退出时序是否会制造脏数据?
- 所有同步返回接口是否验证目标线程?
多 Isolate 支持不是把一个全局变量换成 map 就结束了。真正可靠的 Bridge,需要把运行时身份、对象归属、消息确认和销毁协议放进同一套状态模型。这样即使 Flutter 的线程模型继续演进,桥接层也只需要调整“如何获得当前上下文”,而不必推翻所有调用和对象代码。