Skip to content

Native-Unity 信号链路:P/Invoke 直连与推拉改造 ​

一张图要和 Android 服务持续交换上百个信号。这套链路的改造核心是三件事:用 P/Invoke 直连替代 JNI 中转(单次调用 ~100μs → ~1μs)、把 C++ 主动推送改成 Unity 按需拉取、用双白名单把上行出口管住。

调用延迟~100μs → ~1μs
下行信号~165 个
上行白名单~60 个

背景 ​

一张图不是单纯的 Unity 项目:感知数据、车道级渲染数据、车控指令、系统属性都要在 Android Java 层、C++ Native 层(module_xdata)和 Unity C# 层之间流动。改造前链路的典型形态是 Unity 通过 JNI CallStatic 调 Java、C++ 通过回调主动推数据给 Unity。

问题 ​

问题表现根因
调用贵JNI 单次调用约 100μs,内存开销与 GC 压力高Unity↔Java 走 JNI 中转
推送压主线程高频信号回调直接打进 Unity 主线程C++ 主动 push,Unity 被动消费
出口失控C++ 什么信号都能往 Java 转缺少统一过滤点
并发隐患触摸/心跳回调跨 Java 线程与 C# 线程注册方和触发方不在同一线程

性能目标定得很明确:延迟 < 1ms、内存零分配/复用、GC 压力最小化、CPU 零拷贝解析 + 批量处理。

方案 ​

两套链路并存 ​

mermaid
flowchart TD
    subgraph Android["Android Java 层"]
        DC["DataCenter<br>dispatchSignal()"]
        JT["JniTransport.java"]
    end
    subgraph CPP["C++ Native 层"]
        JC["jni_common.cpp<br>标准 JNI 入口"]
        SD["SignalDispatcher<br>中央信号总线·观察者分发"]
        X3D["DataServicePlugin<br>P组 Protobuf / M组 FlatBuffers"]
        JU["jni_unity.cpp<br>P/Invoke 导出函数"]
        JNIT["JniTransport C++<br>~60 信号白名单"]
    end
    subgraph Unity["Unity C# 层"]
        QP["XDataNativeProxy<br>QueryProtocolData 拉取"]
        NSS["NativeSendService<br>SendMsgToNative 发送"]
    end
    DC -->|JNI| JC --> SD --> X3D
    X3D -.数据模型存 C++ 内存.-> QP
    QP -->|P/Invoke 主动拉取| X3D
    NSS -->|P/Invoke| JU --> SD --> JNIT -->|白名单过滤| JT
  • 下行主链路(x3d 路径):Android → jni_common.cpp(标准 JNI)→ SignalDispatcher 观察者分发 → DataServicePlugin 内部分组建模(P组 15 个信号走 Protobuf,M组 150+ 个走 FlatBuffers/struct)→ 解析后的数据模型存在 C++ 内存 → Unity 用 QueryProtocolData 主动拉取。
  • 上行主链路(native 路径):Unity NativeSendService.SendMsgToNative() → jni_unity.cpp(P/Invoke 导出)→ UnityReceiveService → SignalDispatcher → JniTransport C++ 白名单过滤 → Java。

推送改拉取 ​

改造前 UnityTransport 负责把 12 个大信号(SD_OVER_ALL、LD_TILE、OCC、NAVI_PATH 等)主动推给 Unity。这些推送全部废弃,代码级证据是 unity_transport.cpp 的推送段整体注释、setX3DPluginCallback 标注仅回放使用。改成 Unity 在主线程节奏里按需 Query:

  • 回调复杂度消失,不再需要管理跨线程推送生命周期。
  • 高频信号不再冲击 Unity 主线程——拉取频率由消费方自己控制。
  • 唯一存活的推送信号是高德精模独立通路(非策略输出),属于特例而非范式。

关键实现 ​

P/Invoke 为什么快两个数量级。 JNI CallStatic 每次调用要走 Java 方法查找、参数装箱和线程状态切换;P/Invoke 是对 C 导出函数的直接调用,无 JNI 中转,实测约 1μs,内存开销和 GC 压力都极低。C# 侧由 XDataNativeProxy 集中声明 DllImport 和回调注册,业务只面对 ISendService 接口。

数据格式一份 proto 两端用。 信号格式统一由 signal.proto 定义(ProtoObject=19 走 Protobuf,FlatObject=20 走 FlatBuffer),Unity 和 Native 必须用同一份 proto 生成代码,保证枚举值一致——这是联调期最容易踩的坑。

线程模型。 下行:Native 回调线程 → ProtocolManager.PushMessage() 进线程安全队列 → Unity 主线程 OnUpdate() 消费。上行:Unity 主线程 → P/Invoke 同步调用 → Native 线程 OnReceive()。回调不进主线程、消费不离开主线程,两条链路都不留跨线程悬空的中间态。

高可用设计。 Native 层崩溃不影响 Unity 主循环(故障隔离);Native 链路异常时可回退 JNI 链路(AutoSwitch 降级);服务支持重启和状态恢复。

四个值得抄的决策:

决策原因
g_receive_service 缓存避免每次 SendMsgToNative 都做一次服务查找
触摸/心跳用 atomic 回调Java 线程注册、C# 线程调用,靠 atomic 保证并发安全
JniTransport 双白名单Android 侧 whitelist.json 控入口,C++ 侧 mSignalCodes 控出口
调用深度最多 3 层扁平化结构,拒绝嵌套回调和复杂继承

指标 ​

指标改造前改造后
单次跨端调用JNI ~100μs,GC 压力高P/Invoke ~1μs,GC 压力极低
下行信号规模推送 12 个大包信号拉取 ~165 个(P组 15 + M组 150+)
上行出口无统一过滤~60 个 SignalCode 白名单
推送通道UnityTransport 全量存活仅 1 个独立通路信号存活

复盘 ​

  1. 跨端性能问题先看调用方式再看业务逻辑:换 P/Invoke 拿到的收益比优化任何单条消息处理都大。
  2. 高频数据宁可拉不要推。推送把节奏控制权交给了生产方,拉取把控制权还给消费方,主线程压力才能可预期。
  3. 白名单要放在出口而不只是入口——入口防的是误接入,出口防的是误外发,两者不是一回事。
  4. 这套链路后来和 Android 信号 KSP 注解改造 形成互补:KSP 管编译期注册生成,这套管运行时传输与分发。

罗子初 · AI 座舱专家 · 8年端侧研发

Lv.1