本文是 unit_rc 系列第三篇。生成器部分见《urgen 如何把 .ur 编译成多语言绑定》

跨语言框架把基础类型传过去并不难。真正危险的是对象:Java、Dart、Objective-C 和 C++ 各自都有自己的对象表示和内存管理;一个对象跨过语言边界后,会同时出现多个 wrapper、handle 和 proxy。只要其中一端提前回收,另一端就可能留下悬垂入口;如果每一端都“为了安全”强持有,又会形成永不释放的环。

unit_rc 把对象共同语义集中在 unit_rc/base/BaseRC.*。它不是要用 C++ 引用计数替代所有语言的 GC/ARC,而是建立一套桥接协议:同一个逻辑对象如何被识别,哪一端是真实实现,跨语言强引用和弱引用分别要求各端保存什么。

BaseRC 通过 rcId、强弱引用和 proxy 统一多语言 wrapper 的对象身份

不同语言中的 wrapper 可以各自存在,但最终都应解析到同一个 BaseRC 身份;强弱引用决定的是跨边界后的存活协议。

一、一个逻辑对象有多个物理表示

假设 Player 由 C++ 实现,先传给 Dart,再传给 Java:

C++ Player object
    ├─ BaseRC / shared_ptr
    ├─ Dart wrapper + persistent handle
    └─ Java wrapper + JNI reference

三个对象地址不同,甚至由不同内存管理器控制,但它们应该满足:

  • 业务上代表同一个 Player;
  • 任意一端再次把它传回 C++,都恢复为同一个 C++ 对象;
  • 相等和哈希尽量保持逻辑一致;
  • wrapper 存活时,真实对象不能提前释放;
  • 所有 wrapper 释放后,真实对象最终能够回收。

反过来,如果 Player 由 Dart 实现:

Dart Player implementation
    ├─ Dart object / handle
    └─ C++ PlayerDartProxy : Player
            └─ optional Java/ObjC/ArkTS wrapper

C++ 里的对象此时不是业务实体,而是通向 Dart 的 proxy。这个区别会改变强弱引用的处理方式。

二、rcId:进程内对象身份

每个 BaseRC 构造时都会取得递增的 rcId。运行时维护:

std::unordered_map<long, std::weak_ptr<BaseRC>> rcMap;

注册表只保存 weak_ptr,避免“只要注册过就永远存活”。通过 ID 查找时:

  1. 在锁内找到 weak entry;
  2. 在锁外尝试 lock()
  3. 若对象已经过期,再回到锁内清理 entry;
  4. 返回恢复出的 shared_ptr

这种结构让 Java 的 long、Dart/Objective-C 的指针字段或 ArkTS 的 bigint 可以携带一个轻量 identity,而不直接暴露 C++ 对象所有权。

rcId 不是永久 ID

它只在当前进程和当前对象生命周期内有效:

  • 不能持久化到磁盘;
  • 不能跨进程传递后恢复;
  • 不能在对象释放后继续调用;
  • 不能把它当成业务主键。

wrapper 中的 ID 必须与宿主引用一起管理。单独缓存一个 rcId,并不会自动延长对象寿命。

三、BaseRC 同时有两套“引用计数”

BaseRC 继承 std::enable_shared_from_this<BaseRC>,正常 C++ 所有权由 shared_ptr 管理。同时它还有 _rc_sharedThis

void BaseRC::addRC() {
  if (_rc == 0) {
    _sharedThis = shared_from_this();
  }
  _rc++;
}

void BaseRC::removeRC() {
  _rc--;
  if (_rc <= 0) {
    _sharedThis = nullptr;
  }
}

这里的 _rc 不是替代 shared_ptr 的通用计数,而是跨语言协议的“外部持有票据”。当某个宿主 wrapper 需要保证 C++ 对象存活时,它调用 addRC(),让对象通过 _sharedThis 自持有;对应 wrapper 失效时再 removeRC()

foreign wrapper alive
    -> addRC
    -> BaseRC::_sharedThis keeps C++ object alive

foreign wrapper finalized
    -> removeRC
    -> last ticket drops _sharedThis

这个设计的优点是宿主语言不需要直接保存一个 shared_ptr;只需在 wrapper 的 native 字段和 finalizer 中完成配对。

风险也很明确:

  • addRC/removeRC 不配对会泄漏或过早释放;
  • _rc 下溢属于协议错误;
  • finalizer 不执行或运行时提前退出,需要额外清理;
  • 自持有会形成显式环,必须依赖 remove 打破。

因此 attach/detach 不是普通 setter,而是运行时最高风险的路径之一。

四、原对象与 proxy 的区别

BaseRC::isProxy() 会检查对象是否是 Java、Objective-C、Dart 或 ArkTS proxy。所谓 proxy,是“真实业务对象位于另一种语言,C++ 只保存调用入口”。

原对象

例如 C++ 实现的 PlayerCpp

  • C++ 对象拥有真实状态;
  • 其他语言 wrapper 可以销毁后重建;
  • strongBaseRC() 通常返回 shared_from_this()
  • 弱引用主要遵循 C++ 对象本身的生命周期。

proxy 对象

例如 Dart 实现的 PlayerDartProxy

  • 真实状态位于 Dart object;
  • C++ proxy 的可用性取决于 Dart handle;
  • strongBaseRC() 需要尝试从 Dart 强引用链恢复;
  • 宿主 Isolate 退出后,proxy 不能在另一个 Isolate 凭空复活。

业务 C++ 只看到同一个抽象接口:

std::shared_ptr<Player> player;
player->play();

虚函数派发决定是进入 C++ 实现,还是生成的 Dart/Java/ObjC/ArkTS proxy。这正是接口生成与运行时对象模型结合的地方。

五、为什么强引用会在 wrapper 上写入“弱指针”

平台桥中有一个容易看反的设计:

  • native 强持有宿主对象时,常在宿主 wrapper 上写入 C++ 的弱向 identity;
  • native 弱持有宿主对象时,反而需要宿主 wrapper 对 C++ 建立强向票据。

以 Java 为例,源码大致表现为:

attachStrongJava
  -> NewGlobalRef(javaObject)
  -> wrapper.weakPointer = rcId

attachWeakJava
  -> NewWeakGlobalRef(javaObject)
  -> wrapper.strongPointer = rcId
  -> BaseRC.addRC()

这不是命名错误,而是为了让整个跨语言链保持预期语义。

native 强持有 Java

C++ 已通过 GlobalRef 保证 Java object 存活,不需要 Java wrapper 再反向强持有 C++,否则双方互相强持有形成环。wrapper 只保存能在调用时恢复对象的弱向 identity。

native 弱持有 Java

C++ 只持有 WeakGlobalRef,Java object 可能被 GC;但在 Java object 仍存活期间,它对应的 C++ proxy 通道必须存在,才能完成 weak.lock()。因此 wrapper 通过 strongPointer 给 C++ 增加一张外部持有票据,等 Java finalizer/清理时再 remove。

可以概括为:

一条方向强,反方向就应弱;
一条方向弱,通道本身可能需要反方向强来维持。

如果两边都强,泄漏;如果两边都弱,中间 proxy 可能先消失,弱引用即使目标仍活着也无法提升。

六、WeakPtr 为什么对 proxy 保存 strong_ptr

unit_rc/base/WeakPtr.h 的实现根据 isProxy() 分两种情况:

if (ptr->isProxy()) {
  strongPtr = ptr->weakBaseRC();
} else {
  weakPtr = ptr;
}

乍看之下,“弱引用内部保存 strong_ptr”似乎自相矛盾。关键是这个 strong_ptr 保存的是 proxy 通道,而不是无条件强持有远端真实对象。

对 C++ 原对象,普通 std::weak_ptr 足够:对象没有其他 strong owner 时就应该销毁。

对跨语言 proxy,如果 C++ 连 proxy 壳都只弱持有,它可能在远端对象仍然存活时先被 C++ 销毁。此后 lock() 没有任何对象可以执行 strongBaseRC(),也就无法向远端尝试提升。于是 WeakPtr 保留 proxy 壳,再把真正的弱/强语义交给 proxy 的 weakBaseRC()/strongBaseRC()

WeakPtr holds proxy channel strongly
      │ lock()

proxy.strongBaseRC()
      │ ask foreign runtime

foreign weak handle -> strong handle or null

这里保存通道和保存目标是两回事。只要 proxy 的 weakBaseRC() 没有错误地强持有真实对象,弱语义仍然成立。

七、attach 与 detach 必须对称

每个平台桥都提供相似操作:

attachStrong
attachWeak
detachStrong
detachWeak

对称不只指调用次数,还包括:

  • 创建何种宿主 reference,就用对应 API 删除;
  • wrapper 上写了哪个 identity 字段,就在 detach 时清空;
  • attach 时 addRC(),detach 时必须 removeRC()
  • detach 要验证 wrapper 中仍是预期 rcId,避免清掉后来重新绑定的对象;
  • 运行时线程不正确时,清理要调度到合法线程;
  • 对重复 detach 保持幂等或明确报错。

源码中的 _preferRcId 就承担了绑定校验。detach 前比较当前 wrapper 的 pointer 是否仍指向自己,避免旧桥接对象把新绑定误清除。

八、对象往返时为什么要复用 wrapper

假设 C++ Player 已经传给 Dart,随后 Dart 又把同一对象作为参数传回 C++。理想结果是直接取得原来的 shared_ptr<Player>,而不是创建另一个 proxy。

C++ object --toDart--> Dart wrapper
     ▲                      │
     └------fromDart────────┘

identity 字段和平台桥缓存共同完成闭环:

  1. toDart() 先看当前 BaseRC 是否已有可用 Dart handle;
  2. 没有才调用生成的 creator 建 wrapper;
  3. attach 时把 rcId 写入 wrapper;
  4. fromDart() 先尝试按 wrapper identity 恢复已有 C++ 对象;
  5. 只有对象由 Dart 实现、且没有对应 C++ proxy 时才创建新 proxy。

如果每次都新建 wrapper,会破坏:

  • 对象相等与哈希;
  • listener 的注册/移除;
  • 引用计数配对;
  • 字段更新缓存;
  • 性能和内存占用。

九、继承要求保留真实运行时类型

假设 PremiumPlayer : Player,C++ 实际对象是 PremiumPlayer,但某个方法参数声明为 Player。传到 Dart 时,如果只按静态参数类型创建 Player wrapper,派生能力就会丢失。

当前实现为 Dart 和 ArkTS 定义了 proxy chain:每个生成类描述自己的 creator、可选类型校验和父类链。转换时:

  1. 优先尝试真实派生类的 wrapper;
  2. 如果目标语言没有导入或注册该派生类 creator;
  3. 按继承关系 DFS 查找可用父类;
  4. 使用第一个能够创建的 wrapper 作为兼容退化。
PremiumPlayer creator? no
    -> Player creator? yes
    -> create Player wrapper

这比直接失败更适合模块化场景:目标语言可能只依赖基础接口。但退化也意味着目标端只能看到父类契约,不能假设派生方法仍可调用。

十、跨 runtime 对象不能自动迁移

Dart 和 ArkTS 的 handle 绑定运行时环境。一个 Dart proxy 保存 _isolateInfo,每次转换或提升引用前都要检查当前 Isolate。

  • C++ 原对象:旧 Isolate wrapper 失效后,可以在新 Isolate 创建新 wrapper;
  • Dart 实现对象:真实对象属于旧 Isolate,旧 Isolate 销毁后 proxy 必须失效;
  • 缓存 handle:只有 runtime 信息匹配时才能复用;
  • detach:必要时要投递回原运行时线程执行。

因此,“同一个 C++ 核心服务支持多 Engine”与“一个 Dart 对象可以跨 Engine”完全不是一回事。

十一、生命周期问题的排查顺序

遇到泄漏、空对象或 proxy 失效,可以按下面顺序还原:

  1. 谁是真实实现端,谁是 proxy?
  2. 当前 BaseRC 的 rcId 是多少,注册表能否找到?
  3. 哪个平台 bridge 已 attach strong/weak?
  4. wrapper 上的 strong/weak pointer 指向谁?
  5. _rc_sharedThis 是否仍在自持有?
  6. 当前 runtime/Isolate 是否与 handle 一致?
  7. detach 是否在正确线程发生,是否因 identity 不匹配跳过?
  8. 对象是否经过继承退化,wrapper 类型是否符合预期?

不要只看某一端的引用数量。跨语言对象的完整所有权图,至少包含 C++ shared_ptr、平台 reference、wrapper identity 和运行时状态。

十二、小结

BaseRC 的核心作用可以归结为三点:

rcId      -> 保持对象身份
proxy     -> 隐藏真实实现语言
reference protocol -> 跨 GC/ARC/RC 维持生命周期

这里最反直觉、也最关键的设计是:跨语言弱引用不能只把所有指针换成 weak。为了让弱引用仍能提升,proxy 通道可能需要被强持有;为了避免强引用环,真实目标的反向链又必须保持弱。

下一篇会逐个平台对照 JNI、Objective-C、Dart 和 ArkTS 的桥接实现,看它们如何用完全不同的宿主 API兑现同一个 BaseRC 契约。