本文是 unit_rc 系列第三篇。生成器部分见《urgen 如何把 .ur 编译成多语言绑定》。
跨语言框架把基础类型传过去并不难。真正危险的是对象:Java、Dart、Objective-C 和 C++ 各自都有自己的对象表示和内存管理;一个对象跨过语言边界后,会同时出现多个 wrapper、handle 和 proxy。只要其中一端提前回收,另一端就可能留下悬垂入口;如果每一端都“为了安全”强持有,又会形成永不释放的环。
unit_rc 把对象共同语义集中在 unit_rc/base/BaseRC.*。它不是要用 C++ 引用计数替代所有语言的 GC/ARC,而是建立一套桥接协议:同一个逻辑对象如何被识别,哪一端是真实实现,跨语言强引用和弱引用分别要求各端保存什么。

不同语言中的 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 查找时:
- 在锁内找到 weak entry;
- 在锁外尝试
lock(); - 若对象已经过期,再回到锁内清理 entry;
- 返回恢复出的
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 字段和平台桥缓存共同完成闭环:
toDart()先看当前 BaseRC 是否已有可用 Dart handle;- 没有才调用生成的 creator 建 wrapper;
- attach 时把 rcId 写入 wrapper;
fromDart()先尝试按 wrapper identity 恢复已有 C++ 对象;- 只有对象由 Dart 实现、且没有对应 C++ proxy 时才创建新 proxy。
如果每次都新建 wrapper,会破坏:
- 对象相等与哈希;
- listener 的注册/移除;
- 引用计数配对;
- 字段更新缓存;
- 性能和内存占用。
九、继承要求保留真实运行时类型
假设 PremiumPlayer : Player,C++ 实际对象是 PremiumPlayer,但某个方法参数声明为 Player。传到 Dart 时,如果只按静态参数类型创建 Player wrapper,派生能力就会丢失。
当前实现为 Dart 和 ArkTS 定义了 proxy chain:每个生成类描述自己的 creator、可选类型校验和父类链。转换时:
- 优先尝试真实派生类的 wrapper;
- 如果目标语言没有导入或注册该派生类 creator;
- 按继承关系 DFS 查找可用父类;
- 使用第一个能够创建的 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 失效,可以按下面顺序还原:
- 谁是真实实现端,谁是 proxy?
- 当前 BaseRC 的
rcId是多少,注册表能否找到? - 哪个平台 bridge 已 attach strong/weak?
- wrapper 上的 strong/weak pointer 指向谁?
_rc与_sharedThis是否仍在自持有?- 当前 runtime/Isolate 是否与 handle 一致?
- detach 是否在正确线程发生,是否因 identity 不匹配跳过?
- 对象是否经过继承退化,wrapper 类型是否符合预期?
不要只看某一端的引用数量。跨语言对象的完整所有权图,至少包含 C++ shared_ptr、平台 reference、wrapper identity 和运行时状态。
十二、小结
BaseRC 的核心作用可以归结为三点:
rcId -> 保持对象身份
proxy -> 隐藏真实实现语言
reference protocol -> 跨 GC/ARC/RC 维持生命周期
这里最反直觉、也最关键的设计是:跨语言弱引用不能只把所有指针换成 weak。为了让弱引用仍能提升,proxy 通道可能需要被强持有;为了避免强引用环,真实目标的反向链又必须保持弱。
下一篇会逐个平台对照 JNI、Objective-C、Dart 和 ArkTS 的桥接实现,看它们如何用完全不同的宿主 API兑现同一个 BaseRC 契约。