本系列基于 unit_rc 2.10.0 源码快照整理。上一篇通用设计讨论见《设计一套跨语言绑定框架:从 IDL、代码生成到对象生命周期》,本系列继续进入实际工程实现。

跨语言框架最容易被理解成“自动生成 JNI 或 FFI 代码”。这个理解只覆盖了最外层。真正让框架成立的,是接口定义、生成器和运行时三部分共享同一套语义:方法由哪种语言实现,哪些语言可以调用;一个对象跨过边界后由谁持有;异步回调在哪个运行时执行;同一个对象再次返回时能否保持身份一致。

unit_rc 的实现选择是让 C++ 成为中转层。Java、Objective-C、Dart 和 ArkTS 各自只需要完成与 C++ 的双向适配,就能通过 C++ 间接与其他语言通信。

unit_rc 从接口定义和代码生成进入 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 文件,完成:

  • 解析接口、方法、字段、枚举和继承;
  • 解析 callimpl,判断每种语言需要哪些产物;
  • 把统一类型映射到 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);
}

这里至少包含四层信息:

  1. Player 的真实实现位于 C++;
  2. Java、Objective-C、Dart、ArkTS 都需要生成调用侧 API;
  3. PlayerListener 可以由 Dart 实现,再作为 C++ 可调用的 proxy;
  4. prepare 是异步语义,各语言可以映射成适合自己的完成方式。

callimpl 比“是否生成某语言”更准确。一个接口可能只允许 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/Kotlinunit_rc/jni/JNI global/weak global referenceJNIEnv 与线程 attach
Objective-Cunit_rc/objc/ARC 强弱引用和 ObjC 关联对象Objective-C++ 边界
Dartunit_rc/dart/persistent/weak persistent handleIsolate 与 Dart 线程
ArkTSunit_rc/arkts/N-API napi_refnapi_env 与运行时隔离

共同语义留在 BaseRC,宿主限制留在对应目录。比如“弱引用提升为强引用”是公共语义,但如何创建弱句柄是平台细节。

这样的分层并不意味着四端代码应该一字不差。恰恰相反,稳定的公共契约允许每个平台采用正确的宿主 API,而不必伪装成同一种内存模型。

八、字段为什么也要经过方法语义

.ur 支持 field,但跨语言字段不能真的共享同一块对象内存。生成器通常把字段降为 getter/setter,并在必要时触发 notifyFieldUpdated*

这样做有三个好处:

  1. 读写仍经过对象和线程检查;
  2. 可以对只读、静态或常量字段生成不同代码;
  3. 某一语言修改后,可以通知其他 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,也可能是生成代码选择了错误的强弱引用类型。先判断问题属于协议、生成还是运行时,是维护这类框架最重要的习惯。

十一、本系列的阅读路线

接下来五篇分别深入一层:

  1. urgen 生成链.ur 如何经过 parser、model、template 变成多语言代码;
  2. BaseRC 生命周期:对象身份、原对象、proxy 与跨语言强弱引用;
  3. 平台桥接:JNI、Objective-C、Dart、ArkTS 如何实现同一契约;
  4. 多 Isolate 与消息队列:运行时身份、异步投递、重试、超时和退出;
  5. 数据与可观测性:ByteBuffer、Protobuf、共享数据、性能采集和错误体系。

整套设计的核心不是“让所有语言看起来一样”,而是把它们共同的语义提到运行时,把无法统一的宿主限制放回平台桥。做到这一点以后,生成器才是在减少重复劳动;否则,它只是在批量复制同一种错误。