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