首页 / 资讯中心 / 文章详情

Unity与Android原生互通:AndroidJavaProxy回调桥接指南

Unity与Android原生互通:AndroidJavaProxy回调桥接指南 ★ FEATURED ARTICLE
做 Unity 和 Android 原生互通的时候大家第一反应就是AndroidJavaObject.CallUnity 往 Android 打很顺手。真正让人头疼的是反过来的场景Android 那边处理完登录、支付、扫码或者 WebView 的 JS 回调之后怎么把结果主动送到 Unity 的 C# 方法里。AndroidJavaProxy就是为解决这个问题准备的。它不是新 API也没什么黑魔法本质上是 Unity 提供给 C# 和 Java 之间的一个桥接对象。Java 侧可以持有一个这个桥接对象在任意时机调用到 Unity 的 C# 方法甚至支持返回值。这篇文章我会把接口设计、绑定流程、线程切换、混淆配置、生命周期几个关键环节全部过一遍把我实际接入原生 SDK 时踩过的坑和验证过的写法都放出来遇到 Unity-Android 双向通信需求的人可以直接照着抄。1. 先搞清楚要解决的到底是什么问题很多做 Unity 的小伙伴一说到“和原生互通”第一个想到的就是调AndroidJavaClass找类、AndroidJavaObject建对象、Call调方法这套流程解决的是“Unity 主动找 Android”。但真实项目里还有另一条路——Android 侧的事情做完了得主动喊 Unity。比如支付结果、扫码返回、定位回调、WebView 里的 JS 事件这些都是异步的Android 不知道 Unity 什么时候准备接收Unity 也不知道原生模块什么时候出结果。这种场景下需要的是反过来通信的工具AndroidJavaProxy就是干这个的。1.1 双向通信里的三层关系一次完整的 Android 调 Unity其实涉及三层关系。第一层是 Android 侧的业务代码它运行在 Java/Kotlin 的世界里持有业务对象比如支付回调接口、扫码结果监听器。第二层是接口定义规定两边能互相调用哪些方法一般会先在 Java 侧定义好一个接口。第三层是 Unity 侧的 C# 实现它不是普通类而是继承自AndroidJavaProxy的类里面写的是真正要执行的逻辑。举个例子。我有一个扫码场景Android 原生相机扫完二维码之后要把码的内容传给 Unity。最简单的做法是在 Java 侧定义一个接口里面有一个onScanResult(String content)方法Unity 侧写一个 C# 类继承AndroidJavaProxy实现这个onScanResult然后把 C# 的实例传给 Android 侧。Android 扫码模块扫完码拿到的不是 Java 对象而是这个桥接对象直接调用它的方法C# 这边的代码就跑起来了。这一层关系捋清楚后面所有配置都不会乱。很多人做不通不是代码写错而是对“C# 算不算 Java 接口实现类”这件事没转过弯来。在 Unity 的 JNI 世界里AndroidJavaProxy的实例确实可以冒充一个 Java 接口实现Android 侧用法和普通 Java 回调对象一模一样。1.2 AndroidJavaProxy 和 UnitySendMessage 怎么选Unity 还提供了一个更老的方案UnityPlayer.UnitySendMessage。它的用法是 Android 侧指定 GameObject 名称和方法名Unity 自动去那个物体上找方法调用。好处是代码少Android 侧一句话就能触发。坏处也很明显不能带返回值方法名是字符串匹配Unity 这边改了方法名Android 那边不一定记得住同步改参数只能传 string而且它本质上是发送消息不是接口调用类型上比较松散。用AndroidJavaProxy就没这些限制。它能按接口签名调方法能传各种 Java 类型也能拿到返回值。代价是准备的东西多一点Java 接口要定义好C# 类要继承两端签名要对齐。我自己的习惯是只要回调次数超过一次或者需要返回值一定用AndroidJavaProxy如果只是最简通知比如“图片保存完成”用UnitySendMessage图个方便也行但项目规范里我会要求团队统一走前者因为出问题好排查。2. 动手前先定接口顺序反了后面全是坑我第一次接这类通信的时候直接先写 C#写完发现 Java 侧没法用因为两边的方法签名没对齐JNI 反射找不到方法。后来我就学乖了第一步永远是定接口而且是在 Java 侧定。2.1 Java 接口长什么样才算合格接口的包名、类名、方法签名是硬约束C# 侧要按这个来Android 侧也要按这个来任何一处不一致都直接 NoSuchMethodError。package com.example.sdk; public interface UnityCallback { void onEvent(String eventName, String jsonPayload); String getToken(); }这个接口我故意设计得比较通用。onEvent负责所有“Android 通知 Unity”的调用第一个参数是事件名第二个参数是 JSON 字符串getToken演示带返回值的场景。这样做的好处是接口不需要频繁变动每个新回调需求加一个事件名就行不用重新生成桥接类。接口定义有几个硬性要求要注意。第一包名必须和 Android 工程一致不能随便写。第二方法权限必须是 public。第三如果要传复杂对象优先用 String像JSONObject这种类型在 C# 侧不好转换成AndroidJavaObject来回折腾容易出类型错误。第四避免重载Java 侧接口里不要出现同名不同参的方法Unity 的反射匹配在重载场景下会变得不可控。2.2 C# 侧继承 AndroidJavaProxy 的几个硬性要求C# 类必须继承AndroidJavaProxy构造的时候传 Java 接口的全类名。继承之后Java 接口里定义的方法直接照抄到 C# 类里方法名、参数名一致public 修饰。public class BridgeCallback : AndroidJavaProxy { public BridgeCallback() : base(com.example.sdk.UnityCallback) { } public void onEvent(string eventName, string jsonPayload) { Debug.Log($[Bridge] {eventName} - {jsonPayload}); } public string getToken() { return token_from_unity; } }这里有一个细节很多人不知道C# 方法名的大小写必须和 Java 接口完全一致。Java 习惯用驼峰比如onEventC# 这边不能图顺手改成OnEvent否则 Unity 在方法分派的时候找不到对应实现。这是最容易踩的坑我见过不止一个人改了大小写然后找了一整天报错。还有一点C# 侧的方法必须是 public。Unity 是通过反射来查找方法的方法的可见性不够是拿不到委托的。再强调一下AndroidJavaProxy实例并不是一个纯 C# 对象Unity 在底层生成了一个对应的 Java 实现类它实现了我们在构造时传入的 Java 接口。所以 Android 侧拿到它的时候以为自己在操作一个普通 Java 对象实际上每一个方法调用都会被 JNI 转发到 C# 侧。这个理解很重要后面排查问题全靠它。3. 从绑定到回调的完整实操接口定了两边类也写了接下来就是关键的一步把 C# 的桥接对象送到 Android 侧。这一步做不对后面全是静默失败。3.1 绑定回调对象时最容易漏掉的一件事绑定过程看起来简单new 一个桥接对象通过 AndroidJavaObject.Call 传给 Android 侧。private BridgeCallback _bridgeCallback; private void BindNativeCallback() { _bridgeCallback new BridgeCallback(); using (var bridge new AndroidJavaObject(com.example.sdk.NativeBridge)) { bridge.Call(setCallback, _bridgeCallback); } }绑定本身没什么坑。真正的坑是_bridgeCallback这个字段。很多初学者会把new BridgeCallback()直接写到bridge.Call参数里然后发现回调偶尔能触发偶尔不行甚至直接崩。原因在于 C# 侧的临时对象很快会被垃圾回收Unity 底层的 Java 对象也会跟着被回收Android 侧再调用就已经是一个悬空指针了。所以我一直强调桥接对象必须用一个成员字段或静态字段持有生命周期至少不能短于 Android 侧使用它的时间。绑定完成后bridge这个AndroidJavaObject可以释放它是用来“找到” Android 侧对象的一次性工具但_bridgeCallback不能释放后面每一次回调都依赖它。如果你是反复绑定比如每次打开扫码页绑一次要注意对象泄漏问题。旧的回调对象如果不再被 Android 侧使用直接换引用就行如果 Android 侧还持有旧引用那么新回调可能会被旧对象拦截。稳妥做法是绑定前先调一个clearCallback之类的方法再绑新的。3.2 Android 侧触发一次带返回值的调用先看 Java 侧NativeBridge需要持有我们传过去的接口对象并对外提供触发方法。package com.example.sdk; public class NativeBridge { private UnityCallback callback; public void setCallback(UnityCallback cb) { this.callback cb; } public void notifyEvent(String name, String payload) { if (callback ! null) { callback.onEvent(name, payload); } } public String fetchTokenFromUnity() { if (callback ! null) { return callback.getToken(); } return ; } }Android 侧调用的时候只需要正常调用接口方法和操作任何本地回调对象没有区别。Unity 侧的方法会在 JNI 线程上被执行。带返回值的方法要特别注意返回类型必须匹配 Java 接口定义。Java 接口返回StringC# 方法返回string这没问题Java 接口返回intC# 这边返回int也没问题。如果 Java 接口返回的是自定义 Java 对象C# 侧处理起来就很麻烦因为你要返回一个AndroidJavaObject而且这个对象的构造方式还得是 Unity 能识别的。我的经验是所有复杂返回值一律用String包装成 JSON两边都好处理。3.3 回调线程与主线程切换这是 AndroidJavaProxy 绕不开的线程问题。Android 侧刷新回调的地方不一定是主线程。支付回调可能来自网络线程扫码结果可能来自相机线程WebView 的 JS 事件更是在 WebView 自己的 JS 线程上。这些线程上调用桥接对象C# 方法虽然能执行但你不能在里面做任何 Unity 主线程依赖的操作比如改 Transform、加载场景、操作 UI。真正的解法是把逻辑塞进一个队列Unity 主线程更新的时候取出来执行。我项目里常年放一个简单的 UnityMainThreadDispatcher。public class UnityMainThreadDispatcher : MonoBehaviour { private static UnityMainThreadDispatcher _instance; private static readonly object Lock new object(); private readonly QueueSystem.Action _actions new QueueSystem.Action(); public static UnityMainThreadDispatcher Instance { get { if (_instance null) { var host new GameObject(UnityMainThreadDispatcher); _instance host.AddComponentUnityMainThreadDispatcher(); DontDestroyOnLoad(host); } return _instance; } } public static void Enqueue(System.Action action) { if (action null) return; lock (Lock) { Instance._actions.Enqueue(action); } } private void Update() { while (true) { System.Action action null; lock (Lock) { if (_actions.Count 0) break; action _actions.Dequeue(); } action?.Invoke(); } } }然后在桥接类里所有进入 Unity 世界的逻辑都走Enqueuepublic void onEvent(string eventName, string jsonPayload) { UnityMainThreadDispatcher.Enqueue(() { Debug.Log($[Bridge] {eventName} - {jsonPayload}); // 这里可以安全操作场景对象 }); }这里有个取舍。如果回调里只是打印日志不切线程问题不大Debug.Log线程是安全的。但如果你调用了UnityEngine.Object相关 API最好统一切到主线程。我见过有人因为扫描结果回调里直接改了一个图片显示的 Sprite结果一会儿有效一会儿失效后来发现是 Android 回调线程的问题。切线程的操作要趁早做别等出问题再补。getToken这种带返回值的方法很有特殊性它没法简单切主线程因为只有当前线程能拿到返回值。如果必须从主线程读取某个 Unity 状态再返回建议把这个值提前缓存到主线程能访问到的地方或者用synchronized加锁等待主线程填充结果。设计接口的时候能避免返回值就尽量避免非要有返回值也尽量返回不依赖场景状态的静态数据。3.4 完整流程里参数怎么传才算稳首先是最简单的基础类型。Java 的int、boolean、double、String在 C# 侧对应的类型比较简单直接用就好。然后是有一定争议的数组。Java 的int[]回调到 C# 侧实测会转换成int[]这点在多数 Unity 版本上都成立。但如果你传的是ListIntegerC# 侧等的又是AndroidJavaObject转换就不那么直观了。我给自己定的规矩是基础类型和数组尽量少用参数统一设计成String。复杂结构用 JSON 字符串传Unity 侧反序列化。这样 Android 端只需要做一次JSONObject.toString()Unity 端用JsonUtility或者手写解析都行两边类型系统完全隔离谁也不会被对方的类型逼疯。举个例子扫码结果需要传二维码内容、类型、时间戳三个字段。你当然可以定义onScanResult(String content, int type, long timestamp)但下个需求如果再加两个字段接口又得改。改成onScanResult(String json)事件内容变了接口不变桥接层的改动频率会明显下降。4. 高频报错与排查实录AndroidJavaProxy 的报错有个特点很多错误不会在绑定时报而是跑到运行时偶尔冒出来。所以排查起来非常考验对 JNI 机制的理解。下面这些是我接了不少 SDK 之后整理的典型问题和排查路径。4.1 接口方法找不到十次里有八次是混淆问题错误信息类似java.lang.NoSuchMethodError或者 JNI 调用失败。其实代码本身没有错方法也存在但 Android 打包的时候启用了混淆接口包名和类名被重命名了Unity 那边用原来的全类名去查找自然找不到。如果项目是 Unity 直接导出 APKUnity 会在 Gradle proguard 规则里自动处理大部分接口保留。但如果你的 Android 代码是二次嵌到另一个 AndroidStudio 工程里或者你在导出工程后又改了 proguard-rules.pro那么必须把接口和桥接类保留。-keep interface com.example.sdk.UnityCallback { *; } -keep class com.example.sdk.NativeBridge { *; }这条规则不仅要在AndroidManifest.xml所在的模块里加如果com.example.sdk.UnityCallback被打包成了 AAR 依赖建议在 AAR 自身的 consumer rules 里也带上。具体操作是在 AAR 工程的proguard-rules.pro里写好然后 Gradle 的consumerProguardFiles引用它。这样上层 App 混淆的时候自动生效不会因为你忘了同步规则而出问题。4.2 回调对象被回收或对象已销毁典型表现是绑定之后前几次回调正常然后突然有一次不触发了也没有任何报错。每天早上第一件事能复现重启之后又好一阵。这类问题多半出在 C# 侧没有持有桥接对象Unity 的 GC 把它回收了。提示AndroidJavaProxy的实例最好用字段持有别当临时变量用。Android 侧持有的只是 JNI 引用并不是 C# 对象的强引用。Unity 的 Java 侧对象会被 GC 回收C# 侧也已经释放的话回调就是空转。另一种“对象已销毁”表现在 Android 侧。比如你把NativeBridge作为单例存在 Java 里Unity 场景切了几次Activity 被重建Java 单例还拿着旧回调Unity 那边对应的 C# 场景早已销毁。解决办法是在 Unity 场景切换时重新绑定一次并调 Java 侧清理旧引用。4.3 返回值类型对不上或者回头拿不到结果Java 侧callback.getToken()拿到的返回值一直是空或者 C# 方法确实执行了但返回值不对。有一个隐藏规则Unity 给 Java 返回 String 时内部会做编码转换。如果你的字符串包含中文、emoji 等特殊字符建议在 Java 侧直接以String接住尽量不要在 Java 侧转为byte[]再转回 String因为两边的默认编码可能不一致容易变成乱码。另外C# 返回null给 Java 侧经常会被当成空对象Java 侧要注意空指针处理。返回空字符串比返回null更可靠这是我个人的编码习惯。Java 接口里可以把返回类型声明为Nullable String但在 JNI 边界上空字符串的兼容性永远比null好。4.4 生命周期与重复绑定的隐患再补一个常见问题Android 侧在子线程持有回调Unity 这边场景都切完了回调还在路上走到队列里执行时访问的组件已经不存在于是出现MissingReferenceException。我处理这类问题的思路是桥接类里维护一个bool IsAlive场景切换或应用退出时先置为 false。队列消费的时候判断一下如果已经失效就不执行。还有就是绑定、解绑要成对出现Unity 的OnDestroy里主动调 Java 侧的解绑方法把回调置空。如果 Android 侧每次回调都开一个线程那就更要注意回调风暴。合理做法是 Android 侧回调前先清理上一个还在跑的请求Unity 侧加上去重判断同一个事件在 1 秒内只处理一次。这些细节看着不起眼但真线上跑起来抖动、崩溃往往都藏在这里。4.5 常见问题速查表现象直接原因处理办法NoSuchMethodError方法名大小写不一致C# 方法名严格对齐 Java 接口NoSuchMethodError接口类被混淆proguard 保留接口consumerProguardFiles 配置回调偶尔不触发桥接对象被 GC 回收用字段持有不放在临时变量回调触发了但 Unity 侧报线程错误子线程调用了引擎 API通过主线程队列分发返回值为空或乱码编码转换问题返回空串代替 nullJava 侧用 String 接场景切换后崩溃旧回调访问已销毁对象解绑IsAlive 标志场景重绑WebView 的 JS 调不到方法缺少 JavascriptInterface 注解Java 侧包一层带注解的封装对象5. 我自己会加在项目里的几个设计习惯桥接能跑通只是第一步。真正让它在一个长期项目里可靠跑下去需要一些设计层面的固定习惯。5.1 通信消息统一走 JSON 字符串前面已经说过AndroidJavaProxy 能传的对象类型有限且版本差异较大。我在所有跨端通信里都约定事件名加 JSON 参数。Java 那边把业务对象塞进JSONObject转成字符串传过来C# 这边拿到字符串用JsonUtility或者Newtonsoft.Json反序列化。这带来的直接好处是接口稳定。支付、扫码、GPS、广告回调本质都是“事件名参数”接口永远只有onEvent一个方法。之后要加任何新回调不用新增 C# 类不用改 Java 接口只要两端约定好事件名和 JSON 结构就行。5.2 给每次回调带上请求标识跨端回调最难受的是对不上号。我发起了一个网络请求Android 侧回调回来的结果到底对应哪一次请求解决方式很简单Unity 侧发起时生成一个自增 ID 或者 GUID作为参数传给 AndroidAndroid 返回时原样带回。C# 侧用一个 Dictionary 记录每次请求的回调收到返回时按 ID 分发。这个习惯在大一点的项目里特别实用尤其支付和广告 SDK请求和回调之间隔了好几层没有请求 ID 根本没法对账。AndroidJavaProxy 本身不帮你做这些但方法签名里留好了参数位设计粒度刚刚好。5.3 双向通信的线程模型提前约定我在过代码评审时经常看到有人只在 C# 侧新建了一个队列类但 Java 侧回调线程到底在哪条线程上完全没约定。最后的局面是所有回调都经过队列但带返回值的方法在队列里执行完返回值却没法传递。我的做法是Java 侧在接口注释里写清楚每个回调的线程归属。比如onEvent可能从任意线程回调C# 侧必须切主线程getToken从调用线程直接返回不默认回到 Unity 主线程。两端都按这个约定写就不会出现“返回值拿不到”这种推出鬼火的线程竞争问题。总结成一句话能不设计带返回值的方法就别设计真要设计把线程模型写在代码注释第一行。5.4 收尾叮嘱AndroidJavaProxy 这东西看着小众但 Unity 接原生 SDK 是绕不开的一环。我最后一次调整这个桥接方案是在一个 AR Demo 里接原生相机的扫码返回。当初图省事用了 UnitySendMessage结果方法名改了两次都忘了同步 Android 侧线上白屏好几次。后来彻底换成 AndroidJavaProxy 加统一的onEvent(String, String)分发再也没为回调出过脾气。每次接入新的原生模块我实际都是同一套流程先写 Java 接口再写 C# 继承类绑定的时候用字段持有回调统一丢主线程队列返回值能少则少。这套模板不算惊艳但胜在稳尤其是在老项目里补原生功能时它能让你少跟底层 JNI 纠缠好几天。希望这份经验能给你省下一点排查时间让桥接层真正变成项目里最让人放心的一层。
阅读完成 · 觉得有帮助?
咨询建站