本系列基于 unit_rc 2.10.0 源码快照整理。上一篇通用设计讨论见《设计一套跨语言绑定框架:从 IDL、代码生成到对象生命周期》,本系列继续进入实际工程实现。
跨语言框架最容易被理解成“自动生成 JNI 或 FFI 代码”。这个理解只覆盖了最外层。真正让框架成立的,是接口定义、生成器和运行时三部分共享同一套语义:方法由哪种语言实现,哪些语言可以调用;一个对象跨过边界后由谁持有;异步回调在哪个运行时执行;同一个对象再次返回时能否保持身份一致。
unit_rc 的实现选择是让 C++ 成为中转层。Java、Objective-C、Dart 和 ArkTS 各自只需要完成与 C++ 的双向适配,就能通过 C++ 间接与其他语言通信。

unit_rc 的中心辐射式架构:各语言只需适配 C++ 运行时,接口形状则由 .ur 和 urgen 统一生成。
一、为什么不是每两种语言写一套桥
假设系统里有 N 种语言。如果为每一对语言分别维护双向适配,桥接关系会快速增加:
Java ↔ C++
Java ↔ Dart
Java ↔ ObjC
Java ↔ ArkTS
C++ ↔ Dart
C++ ↔ ObjC
...
不考虑方向时,需要维护的组合数是:
N × (N - 1) / 2
五种语言已经有十组组合。更麻烦的是,每组适配还会独立回答空值、容器、对象、回调、异常和线程问题,长期一定出现语义分叉。
unit_rc 把拓扑改成星形:
Java / Kotlin
│ JNI
▼
Objective-C ◀────── C++ runtime ──────▶ Dart
ObjC++ FFI
│
│ N-API
▼
ArkTS
新增一种语言时,只需要新增“该语言 ↔ C++”这一条链。桥接数量从组合增长变成近似线性增长。更重要的是,对象身份、强弱引用、异步投递和错误模型可以集中到 C++ 运行时,而不是在每一对语言之间重新发明。
这个架构也有代价:C++ 层变成所有调用的共同故障域。BaseRC、类型转换或消息队列出现问题,会同时影响多个平台。因此运行时必须比普通业务胶水代码更强调契约、测试和可观测性。
二、仓库的两条主线
源码主要分为两部分:
unit_rc_lib/
├─ unit_rc_gen/ # urgen:.ur -> 多语言代码
└─ unit_rc/ # runtime:对象、引用、消息与平台桥接
它们不是两个互不相关的工具。生成器产出的代码,会直接依赖运行时提供的对象和平台能力。
urgen 负责静态语义
生成器读取 .ur 文件,完成:
- 解析接口、方法、字段、枚举和继承;
- 解析
call与impl,判断每种语言需要哪些产物; - 把统一类型映射到 C++、Kotlin/JNI、Objective-C、Dart/FFI 和 ArkTS/N-API;
- 生成接口声明、代理类、静态注册、回调包装和转换代码;
- 记录生成器版本并做增量生成。
runtime 负责动态语义
运行时处理生成时无法决定的事情:
- 当前对象到底由哪种语言实现;
- 同一个逻辑对象在各语言中的 wrapper;
- 强引用、弱引用和对象回收;
- 当前 Dart Isolate 或 ArkTS 环境;
- native 到语言运行时的消息投递;
- 超时、消息丢失和类型转换错误;
- Protobuf/buffer 等二进制数据的跨边界生命周期。
可以把两者的边界概括成一句话:urgen 生成调用的形状,runtime 保证调用在真实进程里还能成立。
三、一份 .ur 定义表达了什么
下面是一份经过简化的示例:
@global_config {
name = "MediaCore",
}
@config {
impl = [cpp],
call = [java, objc, dart, arkts],
}
interface Player {
static method create(string source): Player;
method play();
async method prepare(): bool;
field int64 position;
}
@config {
impl = [dart],
call = [cpp],
}
interface PlayerListener {
method onStateChanged(int32 state);
}
这里至少包含四层信息:
Player的真实实现位于 C++;- Java、Objective-C、Dart、ArkTS 都需要生成调用侧 API;
PlayerListener可以由 Dart 实现,再作为 C++ 可调用的 proxy;prepare是异步语义,各语言可以映射成适合自己的完成方式。
call 和 impl 比“是否生成某语言”更准确。一个接口可能只允许 Dart 调用 C++,却不允许 Dart 实现;也可能只把监听器实现能力开放给某一端。生成器根据方向决定是否产出抽象接口、native 声明、proxy 或注册入口。
四、一次 C++ 到 Dart 的调用如何完成
假设 C++ 持有 PlayerListener,需要调用 Dart 实现的 onStateChanged。
C++ business object
│ listener->onStateChanged(2)
▼
generated C++ Dart-proxy method
│ convert arguments
▼
unit_rc Dart runtime
│ locate current Isolate function table
▼
FFI-registered Dart entry
│ recover Dart wrapper/object
▼
Dart PlayerListener implementation
这里“函数调用”只占很小一部分。调用前还要确认:
- listener 是否是 Dart proxy;
- 该 proxy 所属 Isolate 是否仍然有效;
- Dart 注册的函数表是否属于当前 Isolate;
- 对象句柄是否仍能恢复为 Dart 对象;
- 当前线程是否允许同步进入 Dart;
- 参数转换过程中创建的临时资源何时释放。
如果不能同步进入 Dart,生成代码需要转为消息投递,或接口本身就必须采用异步语义。运行时的消息队列会记录端口、发送序号、待执行函数和通知状态。
五、一次 Dart 到 C++ 的调用如何完成
反方向相对直接,但仍涉及对象恢复:
Dart Player wrapper
│ player.play()
▼
generated Dart FFI function
│ object identity / arguments
▼
generated C-compatible entry
│ BaseRC lookup + type conversion
▼
C++ Player implementation
如果 Dart wrapper 代表一个 C++ 原对象,它会携带能够恢复 C++ 对象的 identity。C++ 侧通过 rcId 注册表或 wrapper 上的强弱指针关系取得 shared_ptr,再调用真实方法。
如果对象其实由 Dart 实现,那么 C++ 拿到的是继承同一抽象接口的 proxy。业务代码不需要判断实现语言:虚函数调用进入生成的 proxy,再从运行时回到 Dart。
这就是面向接口设计在跨语言框架中的价值。语言信息被限制在创建、转换与 proxy 内部,不向业务方法扩散。
六、BaseRC 是运行时的对象中心
unit_rc/base/BaseRC.h 是理解运行时的入口。它同时承担:
std::enable_shared_from_this提供的 C++ shared ownership;- 全局唯一的
rcId; - Java、Dart、Objective-C、ArkTS 桥接对象挂接点;
toJava()、toDart()、toObjc()、toArkTs()等虚拟转换入口;isProxy()和各平台 proxy 判断;- 字段更新通知;
- C++ 引用计数协议
addRC()/removeRC()。
一个逻辑对象可能同时拥有多种语言表示:
Java wrapper
│
Objective-C wrapper ── BaseRC identity ── Dart wrapper
│
ArkTS wrapper
BaseRC 不是把这些 wrapper 合并成一个对象,而是提供一个共同身份和互转中心。各平台的 *BaseRC 保存宿主语言引用,并根据对象是原对象还是 proxy 采取不同策略。
七、平台差异被压缩到桥接层
四个平台在源码中使用的宿主能力不同:
| 平台 | 桥接目录 | 强/弱持有基础设施 | 额外约束 |
|---|---|---|---|
| Java/Kotlin | unit_rc/jni/ | JNI global/weak global reference | JNIEnv 与线程 attach |
| Objective-C | unit_rc/objc/ | ARC 强弱引用和 ObjC 关联对象 | Objective-C++ 边界 |
| Dart | unit_rc/dart/ | persistent/weak persistent handle | Isolate 与 Dart 线程 |
| ArkTS | unit_rc/arkts/ | N-API napi_ref | napi_env 与运行时隔离 |
共同语义留在 BaseRC,宿主限制留在对应目录。比如“弱引用提升为强引用”是公共语义,但如何创建弱句柄是平台细节。
这样的分层并不意味着四端代码应该一字不差。恰恰相反,稳定的公共契约允许每个平台采用正确的宿主 API,而不必伪装成同一种内存模型。
八、字段为什么也要经过方法语义
.ur 支持 field,但跨语言字段不能真的共享同一块对象内存。生成器通常把字段降为 getter/setter,并在必要时触发 notifyFieldUpdated*。
这样做有三个好处:
- 读写仍经过对象和线程检查;
- 可以对只读、静态或常量字段生成不同代码;
- 某一语言修改后,可以通知其他 wrapper 刷新缓存或观察者。
如果直接把字段映射成裸地址,不同运行时的对象布局、GC 移动和线程模型都会让它不可维护。
九、错误不是平台日志,而是统一协议
UnitRCError 把错误拆成两个维度:
LanguageType:错误发生在哪条语言或桥接链;ErrorType:shared-from-this、attach/detach、from/to、消息队列、通知、超时、proxy 创建等类别。
UR_REPORT_ERROR 同时打印日志并调用统一 callback。业务可以把 callback 接入自己的监控系统,而生成代码和平台桥不需要知道上报 SDK。
这是一种很实用的依赖方向:基础库只定义结构化错误,应用层决定如何采集、采样和上传。
十、使用者与维护者看到的是两套界面
对使用者,流程应该足够简单:
write .ur
-> run urgen
-> implement generated interface
-> call it like a local API
对维护者,则必须沿完整链路定位:
DSL grammar
-> semantic model
-> language template
-> generated proxy
-> runtime BaseRC
-> host handle
-> message/error path
“生成结果不对”不一定是模板问题,也可能是模型没有表达清楚;“运行时对象失效”不一定只改 BaseRC,也可能是生成代码选择了错误的强弱引用类型。先判断问题属于协议、生成还是运行时,是维护这类框架最重要的习惯。
十一、本系列的阅读路线
接下来五篇分别深入一层:
- urgen 生成链:
.ur如何经过 parser、model、template 变成多语言代码; - BaseRC 生命周期:对象身份、原对象、proxy 与跨语言强弱引用;
- 平台桥接:JNI、Objective-C、Dart、ArkTS 如何实现同一契约;
- 多 Isolate 与消息队列:运行时身份、异步投递、重试、超时和退出;
- 数据与可观测性:ByteBuffer、Protobuf、共享数据、性能采集和错误体系。
整套设计的核心不是“让所有语言看起来一样”,而是把它们共同的语义提到运行时,把无法统一的宿主限制放回平台桥。做到这一点以后,生成器才是在减少重复劳动;否则,它只是在批量复制同一种错误。