很多 Dart/C++ Bridge 的第一个版本都很简单:Dart 通过 FFI 把几个函数指针注册给 C++,C++ 保存到全局变量,需要回调 Dart 时直接调用。只要应用里永远只有一个 Flutter Engine、一个 Isolate,并且 Isolate 始终绑定在同一条线程上,这个模型确实足够。

问题在于,这三个前提都不是稳定契约。页面容器可能创建多个 Engine;预加载和主页面可能短暂共存;后台任务会创建新的 Isolate;Flutter 的线程合并优化还可能让多个 Isolate 先后运行在同一条平台线程。此时,过去看起来只是“省一次查表”的全局变量或 thread_local,会变成函数错投、对象越界访问和销毁阶段消息丢失的根源。

本文把这些问题放到同一个运行时模型里讨论,因为它们本质上都在回答三个问题:

  1. 当前调用属于哪个 Dart 运行时?
  2. 一个 native 对象或 Dart 对象绑定在哪个运行时,何时失效?
  3. 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 注册的函数。

更稳妥的策略是:

  1. 通过 VM API 获取当前 Isolate;
  2. 用 Isolate 作为真值,从全局注册表取得 IsolateContext
  3. thread_local 只做带 key 的最近一次缓存;
  4. 每次命中缓存前,都比较当前 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
局部静态变量60131
每次全局 map77155
缓存 IsolateContext69145
缓存并聚合模块函数63135

这里更值得关注的是相对差异,而不是脱离设备与构建模式解释绝对值。经过两层缓存后,安全模型带来的成本已经接近原始方案。不要为了省几次纳秒,把运行时身份从数据模型里删掉。

三、多 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,常见实现是:

  1. native 把任务放入队列;
  2. 如果当前没有通知在途,就向 Dart port 发送一个整数或令牌;
  3. Dart 收到通知,通过 FFI 调用 consume()
  4. 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 忙碌时制造更多压力。更合适的是:

  1. 同一时刻只允许一个通知在途;
  2. 发送失败可立即有限重试;
  3. 等待消费超时后按退避策略重发;
  4. 新任务到来只更新队列状态,不无条件新增通知;
  5. 收到匹配的 ack 后清除重试计时器;
  6. 上下文进入 retiring 后停止重试,并对未执行任务做取消回调。

七、“超时”不等于“丢失”

移动端进入后台后,Dart 线程可能被大幅降权甚至暂停。native 在后台产生消息,几秒后应用回到前台,Dart 才继续消费,这在时间上像严重超时,却不一定丢失。

监控如果只看固定阈值,会把大量正常的生命周期暂停当成故障。更有用的分类是:

事件含义
notify_failednative 通知 API 明确返回失败
delayed超过阈值后最终收到匹配 ack
consume_mismatchDart 回传的序列与等待序列不一致
retired_pending上下文退出时仍有未确认消息
lost_confirmed运行时彻底结束后仍未收到,且没有被取消

还应把前后台状态或调度暂停作为维度。若不方便直接访问生命周期,可以根据“超时后短时间内收到 ack”的模式将其归为 delayed,而不是 lost。

另一个容易误判的点是结束时机。Engine 通知退出,并不代表 Dart 线程已经停止执行;若此时立刻清点未确认消息,会把稍后仍能消费的任务判成丢失。统计结束应尽量靠近线程或 Isolate 真正销毁的时刻,或者在退出协议中显式执行 drain,再生成最终结论。

八、安全的销毁顺序

Bridge 的退出流程建议分成六步:

  1. 将上下文从 Active 改为 Retiring,拒绝新调用;
  2. 取消定时重试和新的 native 投递;
  3. 等待在途同步调用结束,并给异步任务发送取消结果;
  4. 解绑 Dart 实现对象,标记所有代理失效;
  5. 解绑 native 对象对应的 Dart wrapper,但保留允许跨 Engine 的 native 实体;
  6. 从注册表删除上下文、递增 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 的线程模型继续演进,桥接层也只需要调整“如何获得当前上下文”,而不必推翻所有调用和对象代码。