☰
Unity与Android跨语言回调:AndroidJavaProxy原理、线程安全与避坑指南
2026/10/14 4:20:01 网站建设 项目流程

1. 为什么回调这件事在 Unity 与 Android 之间这么别扭

做过 Unity 接 Android 原生能力的人都清楚,最舒服的路径是单向调用:C# 侧通过AndroidJavaObject或AndroidJavaClass调一个 Java 静态方法,传几个参数进去,拿个返回值回来,收工。这种模式简单直接,因为调用方向是单一的,数据流是同步的,谁调谁、谁返回谁,一清二楚。

真正让人头疼的是反向通信。比如原生侧有个耗时任务,做完之后要通知 Unity;或者原生侧监听了某个系统事件,事件触发时得把数据推回 C# 层。这时候调用方向反过来了,Java 层需要持有 C# 侧的一个“东西”,在合适的时机去调用它。问题是 Java 和 C# 是两套完全不同的运行时,Java 根本不认识 C# 的委托、事件、接口实例。你没法直接把一个 C# 的Action<string>塞给 Java 方法当参数。

AndroidJavaProxy就是 Unity 给出的官方解法。它的核心思路是:在 C# 侧创建一个代理对象,这个对象在 Java 看来是一个实现了指定 Java 接口的实例。Java 层拿到这个代理后,就可以像调用普通 Java 对象一样调用接口方法,而实际执行的是 C# 侧的代码。听起来很优雅,但实际用起来坑不少——线程问题、接口签名匹配问题、对象生命周期问题、泛型擦除问题,每一个都能让你调半天。

这篇文章就是把我自己在多个项目里用AndroidJavaProxy做回调的完整经验梳理出来。从设计思路、接口定义、代理实现、线程处理,到实际踩过的坑和排查方法,尽量讲透。适合已经会用 Unity 调 Android 基础 API、但一碰到回调就发怵的开发者,也适合想系统梳理这套机制的人。

2. 整体设计思路:代理模式在跨语言场景下的落地

2.1 为什么是代理,而不是别的方案

跨语言回调,理论上有很多种做法。最原始的是轮询:C# 侧每隔几帧去问一次 Java 层“事情做完了没”。这种做法实现简单,但实时性差、浪费性能,而且对于事件驱动的场景(比如传感器数据推送)根本不适用。

另一种是消息队列:Java 侧把结果写到一个共享的队列里,C# 侧去读。这解决了实时性问题,但引入了新的复杂度——队列的线程安全、数据的序列化、跨语言的数据结构映射,每一样都是额外的工作量。

AndroidJavaProxy走的是第三条路:让 Java 直接调用 C# 的方法。它的本质是 JNI 层面的动态代理。当你在 C# 侧创建一个AndroidJavaProxy子类实例时,Unity 会在 JNI 层生成一个 Java 对象,这个对象实现了你指定的 Java 接口。当 Java 代码调用这个接口的方法时,JNI 层会拦截这次调用,转发到 C# 侧的对应方法上。

这个方案的优势很明显:调用是直接的、同步的(在同一个线程内),不需要额外的队列或轮询机制,数据传递也是原生的(基本类型直接映射,对象通过AndroidJavaObject包装)。代价是你需要理解它的约束条件,否则很容易写出看起来能跑、实际上有隐患的代码。

2.2 核心约束:接口必须匹配

AndroidJavaProxy工作的前提是:C# 侧代理类实现的方法,必须和 Java 接口的方法签名完全一致。这里的“完全一致”包括方法名、参数类型、参数个数、返回值类型。任何一处不匹配,调用就会失败,而且失败的方式往往很隐蔽——可能只是静默不执行,也可能抛一个语焉不详的异常。

举个例子,Java 接口定义如下:

public interface IResultCallback { void onSuccess(String data); void onFailure(int code, String message); }

那么 C# 侧的代理类必须实现onSuccess(string)和onFailure(int, string)这两个方法。方法名大小写敏感,参数类型要对应(Java 的String对应 C# 的string,Java 的int对应 C# 的int)。如果你把onSuccess写成了OnSuccess,Java 侧调用时找不到匹配的方法,回调就不会触发。

这个约束看起来简单,但实际项目中 Java 接口往往来自第三方库或者系统 API,方法签名可能涉及复杂类型、泛型、重载,匹配起来就没那么直观了。后面我会专门讲几种典型场景的处理方式。

2.3 线程模型:最容易翻车的地方

AndroidJavaProxy的回调执行在哪个线程,取决于 Java 侧是在哪个线程调用它的。如果 Java 侧在主线程调用,回调就在 Unity 的主线程执行,你可以直接操作 GameObject、UI 等。如果 Java 侧在子线程调用(比如网络请求的回调、耗时任务的结果通知),回调就在子线程执行,这时候你碰任何 Unity 的 API 都会出问题。

这是AndroidJavaProxy使用中最常见的坑。很多开发者写完代理,测试的时候发现回调能触发,就以为万事大吉了。结果在某些设备上、某些场景下,回调偶尔在子线程触发,操作了 Unity API,导致崩溃或者行为异常。而且这种问题往往难以复现,排查起来非常痛苦。

正确的做法是:在代理方法内部,第一件事就是判断当前线程。如果不在主线程,就把数据打包,通过一个线程安全的队列或者AndroidJavaObject的runOnUiThread机制,切回主线程再处理。这个后面会给出具体的实现模板。

3. 核心细节解析:从接口定义到代理实现的完整链路

3.1 Java 侧接口的定义规范

要让AndroidJavaProxy顺利工作,Java 侧的接口定义有一些讲究。首先,接口必须是public的,否则 C# 侧无法访问。其次,接口方法尽量使用基本类型和String,避免使用复杂对象。如果必须传递复杂数据,优先考虑用 JSON 字符串,而不是直接传自定义对象。

为什么?因为 Java 的自定义对象传到 C# 侧会变成一个AndroidJavaObject,你需要再通过一系列Call方法去取字段,代码冗长且容易出错。而 JSON 字符串在 C# 侧用JsonUtility或者第三方库反序列化,干净利落。这个取舍在实际项目中非常实用。

另外,接口方法的返回值类型也要注意。如果 Java 接口方法声明了返回值,C# 侧的代理方法必须返回对应类型的值。如果 Java 侧声明的是void,C# 侧也必须是void。这一点在写代码时容易忽略,尤其是从其他语言转过来的开发者,习惯性地给方法加个返回值。

还有一个细节:Java 接口中的方法如果抛出了异常,这个异常会传播到 C# 侧吗?答案是会的,但表现形式可能不是你预期的。Java 侧的异常会被 JNI 层捕获,然后以某种形式抛到 C# 侧。具体行为取决于 Unity 版本和 JNI 实现,建议在代理方法内部做好 try-catch,避免异常跨语言传播导致难以排查的问题。

3.2 C# 侧代理类的实现要点

C# 侧的代理类需要继承AndroidJavaProxy,并在构造函数中传入 Java 接口的全限定名。全限定名就是包名加接口名,比如com.example.app.IResultCallback。这个字符串必须和 Java 侧完全一致,包括大小写。

代理类的方法命名要和 Java 接口方法一致。这里有个小技巧:如果你不确定 Java 接口方法的准确签名,可以先用javap命令反编译 class 文件,或者查看源码。不要凭记忆写,很容易出错。

方法参数的类型映射也需要留意。Java 的long对应 C# 的long,Java 的float对应 C# 的float,Java 的double对应 C# 的double,Java 的boolean对应 C# 的bool。Java 的String对应 C# 的string。Java 的数组对应 C# 的数组,但元素类型要匹配。Java 的Object对应 C# 的AndroidJavaObject。

如果 Java 接口方法有重载(同名方法不同参数),C# 侧需要实现所有重载版本。这一点在实现系统接口时经常遇到,比如Runnable接口只有一个run方法,但某些自定义接口可能有多个重载。

3.3 代理对象的生命周期管理

AndroidJavaProxy实例在 C# 侧是一个普通对象,受 GC 管理。但在 Java 侧,它对应一个 JNI 引用。如果 C# 侧的代理对象被 GC 回收了,而 Java 侧还持有它的引用,后续调用就会出问题。

这个问题的典型场景是:你把代理对象传给 Java 层后,Java 层把它存了起来,准备在未来的某个时刻回调。但 C# 侧没有其他地方引用这个代理对象了,GC 一跑,对象就没了。Java 侧再调用时,要么静默失败,要么抛异常。

解决办法是:在 C# 侧保持对代理对象的强引用,直到确定 Java 侧不再需要它为止。通常的做法是在调用方(比如一个 MonoBehaviour)里用一个字段持有代理对象,在OnDestroy或者明确的清理时机把它置空。如果 Java 侧提供了取消注册的方法,也要记得调用。

这个坑我在一个音频处理项目里踩过。当时是把代理对象传给原生侧的音频引擎,引擎在播放完成后回调。测试的时候一切正常,因为测试时播放很快完成,GC 还没来得及跑。但在实际使用中,用户可能播放几分钟才结束,GC 早就把代理对象回收了,回调就丢了。后来加了强引用才解决。

4. 实操过程:一个完整的回调实现案例

4.1 场景设定与接口设计

假设我们要实现一个功能:Unity 调用 Android 原生的一个异步任务(比如文件压缩),任务完成后通过回调通知 Unity,并传递压缩后的文件路径和文件大小。

Java 侧的接口定义如下:

package com.example.unityplugin; public interface ICompressCallback { void onCompressSuccess(String outputPath, long fileSize); void onCompressFailed(int errorCode, String errorMessage); void onCompressProgress(int progress); }

这个接口有三个方法:成功回调、失败回调、进度回调。成功回调传递输出路径和文件大小,失败回调传递错误码和错误信息,进度回调传递百分比。

Java 侧的执行类大致如下:

package com.example.unityplugin; public class FileCompressor { public void compressAsync(String inputPath, String outputPath, ICompressCallback callback) { new Thread(() -> { try { // 模拟压缩过程 for (int i = 0; i <= 100; i += 10) { Thread.sleep(200); callback.onCompressProgress(i); } // 压缩完成 long size = 1024 * 1024; // 模拟文件大小 callback.onCompressSuccess(outputPath, size); } catch (Exception e) { callback.onCompressFailed(-1, e.getMessage()); } }).start(); } }

注意这里是在子线程中执行压缩,并在子线程中调用回调。这意味着 C# 侧的代理方法会在子线程执行,必须做线程切换处理。

4.2 C# 侧代理类的完整实现

C# 侧的代理类实现如下:

using System; using System.Collections.Concurrent; using UnityEngine; public class CompressCallbackProxy : AndroidJavaProxy { private readonly Action<string, long> _onSuccess; private readonly Action<int, string> _onFailed; private readonly Action<int> _onProgress; private readonly ConcurrentQueue<Action> _mainThreadQueue; public CompressCallbackProxy( Action<string, long> onSuccess, Action<int, string> onFailed, Action<int> onProgress, ConcurrentQueue<Action> mainThreadQueue) : base("com.example.unityplugin.ICompressCallback") { _onSuccess = onSuccess; _onFailed = onFailed; _onProgress = onProgress; _mainThreadQueue = mainThreadQueue; } void onCompressSuccess(string outputPath, long fileSize) { _mainThreadQueue.Enqueue(() => _onSuccess?.Invoke(outputPath, fileSize)); } void onCompressFailed(int errorCode, string errorMessage) { _mainThreadQueue.Enqueue(() => _onFailed?.Invoke(errorCode, errorMessage)); } void onCompressProgress(int progress) { _mainThreadQueue.Enqueue(() => _onProgress?.Invoke(progress)); } }

这里有几个关键设计。第一,代理类不直接执行回调逻辑,而是把回调包装成一个Action,放入一个线程安全的队列。第二,队列由外部传入,这样调用方可以在Update中统一处理队列里的任务,确保所有回调都在主线程执行。第三,代理类持有回调委托的引用,这些委托由调用方提供,代理类本身不关心具体逻辑。

这种设计的优势是职责清晰:代理类只负责接收 Java 侧的回调并转发到主线程队列,具体的业务逻辑由调用方决定。同时,队列机制保证了线程安全,避免了在子线程直接操作 Unity API 的问题。

4.3 调用方的完整代码

调用方的 MonoBehaviour 实现如下:

using System; using System.Collections.Concurrent; using UnityEngine; public class CompressController : MonoBehaviour { private ConcurrentQueue<Action> _mainThreadQueue = new ConcurrentQueue<Action>(); private CompressCallbackProxy _callbackProxy; private AndroidJavaObject _compressor; void Start() { // 创建代理对象 _callbackProxy = new CompressCallbackProxy( OnCompressSuccess, OnCompressFailed, OnCompressProgress, _mainThreadQueue ); // 获取 Java 侧的 FileCompressor 实例 using (var javaClass = new AndroidJavaClass("com.example.unityplugin.FileCompressor")) { _compressor = javaClass.Call<AndroidJavaObject>("new"); } } public void StartCompress(string inputPath, string outputPath) { if (_compressor == null) { Debug.LogError("Compressor not initialized"); return; } _compressor.Call("compressAsync", inputPath, outputPath, _callbackProxy); } void Update() { // 在主线程处理队列中的回调 while (_mainThreadQueue.TryDequeue(out var action)) { try { action?.Invoke(); } catch (Exception e) { Debug.LogError($"Error executing callback: {e}"); } } } private void OnCompressSuccess(string outputPath, long fileSize) { Debug.Log($"Compress success: {outputPath}, size: {fileSize}"); // 在这里更新 UI 或执行其他 Unity 操作 } private void OnCompressFailed(int errorCode, string errorMessage) { Debug.LogError($"Compress failed: {errorCode}, {errorMessage}"); } private void OnCompressProgress(int progress) { Debug.Log($"Compress progress: {progress}%"); // 在这里更新进度条 } void OnDestroy() { // 清理代理对象,避免 Java 侧持有无效引用 _callbackProxy = null; _compressor?.Dispose(); _compressor = null; } }

这段代码展示了完整的调用链路。Start中创建代理对象和 Java 实例,StartCompress触发异步任务,Update中处理主线程队列,OnDestroy中清理资源。

4.4 参数传递的细节处理

在实际项目中,Java 接口方法的参数往往比示例复杂。这里整理几种常见类型的处理方式。

基本类型(int、long、float、double、boolean)直接对应 C# 的同名类型,不需要额外处理。String对应string,也是直接映射。

如果 Java 接口方法接收一个byte[],C# 侧对应byte[]。这个映射是直接的,但要注意数组的拷贝语义。Java 侧的数组传到 C# 侧会创建一个新的 C# 数组,修改 C# 数组不会影响 Java 侧的原数组。

如果 Java 接口方法接收一个自定义对象,C# 侧对应AndroidJavaObject。你需要通过Call方法去取对象的字段或调用方法。比如:

void onDataReceived(AndroidJavaObject data) { string name = data.Get<string>("name"); int value = data.Get<int>("value"); // 处理数据 }

如果 Java 接口方法接收一个List,C# 侧对应AndroidJavaObject,你需要把它当作一个 Java 对象来处理,通过Call方法调用size、get等方法。这种场景下,用 JSON 字符串替代List往往更简单。

如果 Java 接口方法接收一个接口类型的参数(回调套回调),C# 侧需要再创建一个AndroidJavaProxy实例传进去。这种嵌套回调的场景比较复杂,建议尽量避免,或者通过中间层简化。

5. 常见问题与排查技巧实录

5.1 回调不触发:从签名匹配开始排查

回调不触发是最常见的问题。排查的第一步是确认方法签名是否匹配。方法名大小写、参数类型、参数个数、返回值类型,任何一处不匹配都会导致回调失败。

一个实用的排查方法是:在代理类的每个方法入口加一行日志,看看方法是否被调用。如果日志没有输出,说明 Java 侧根本没有调用到这个方法,问题出在签名匹配或者代理对象传递上。如果日志有输出但业务逻辑没执行,问题出在后续的处理逻辑上。

签名匹配的另一个隐蔽问题是 Java 接口的继承关系。如果 Java 接口继承自另一个接口,C# 侧的代理类需要实现所有继承链上的方法。这一点在实现系统接口时经常遇到,比如TextWatcher接口继承自NoCopySpan,虽然NoCopySpan没有方法,但某些接口可能有。

还有一种情况是 Java 侧使用了泛型接口。泛型在编译时会被擦除,所以 C# 侧实现的方法签名要对应擦除后的类型。比如Callback<T>接口的方法onResult(T result),擦除后变成onResult(Object result),C# 侧应该实现onResult(AndroidJavaObject result)。

5.2 线程崩溃:子线程操作 Unity API 的识别与处理

子线程操作 Unity API 导致的崩溃,往往表现为随机崩溃、堆栈信息不明确、难以复现。识别这类问题的关键是:在代理方法入口打印当前线程 ID,和主线程 ID 对比。

void onCompressSuccess(string outputPath, long fileSize) { Debug.Log($"Callback thread: {System.Threading.Thread.CurrentThread.ManagedThreadId}, " + $"Main thread: {Time.frameCount}"); // ... }

如果发现回调线程和主线程不一致,就需要做线程切换。前面介绍的队列方案是一种通用的处理方式。另一种方式是使用AndroidJavaObject的runOnUiThread,但这种方式需要持有 Activity 的引用,而且代码更复杂,不推荐。

队列方案的关键是:队列必须是线程安全的(用ConcurrentQueue),队列的处理必须在主线程的Update中执行,队列中的任务要包裹在 try-catch 中避免异常中断整个队列处理。

5.3 对象被回收:代理对象生命周期问题的排查

代理对象被 GC 回收导致回调丢失,这个问题比较隐蔽。表现是:回调在测试时正常,但在长时间运行或者特定场景下偶尔丢失。

排查方法是:在代理类的构造函数和析构函数中加日志,观察代理对象的创建和回收时机。如果发现代理对象在 Java 侧还需要它的时候就被回收了,说明 C# 侧缺少强引用。

解决办法前面提过:在调用方持有代理对象的强引用,直到确定不再需要为止。如果 Java 侧提供了取消注册的方法,在OnDestroy中调用它,然后释放代理对象。

还有一种情况是 Java 侧持有代理对象的引用,但 C# 侧的代理对象被回收后,Java 侧的引用变成了悬空引用。这种情况下,Java 侧调用回调时可能会抛异常或者静默失败。解决办法同样是保持强引用,或者在 Java 侧做好空值检查。

5.4 常见问题速查表

问题现象可能原因排查方法解决方案
回调完全不触发方法签名不匹配在代理方法入口加日志核对 Java 接口方法签名,确保完全一致
回调偶尔丢失代理对象被 GC 回收在构造函数和析构函数加日志在调用方持有强引用
随机崩溃子线程操作 Unity API打印回调线程 ID使用主线程队列切换线程
回调参数为空类型映射错误打印参数值检查类型映射,复杂对象用 JSON
回调执行但业务逻辑异常异常跨语言传播在代理方法内 try-catch在代理方法内捕获所有异常
回调顺序错乱多线程并发调用打印时间戳和线程 ID使用队列保证顺序,或加锁

5.5 几个实用的避坑技巧

第一个技巧:在代理类的所有方法外层包一层 try-catch,把异常记录下来而不是让它传播到 Java 侧。跨语言传播的异常往往丢失堆栈信息,排查起来非常困难。在 C# 侧捕获并记录,能保留完整的调用栈。

第二个技巧:如果 Java 接口方法很多,但实际只关心其中几个,可以在代理类中实现所有方法,但不关心的方法留空。不要试图只实现部分方法,那样会导致签名不匹配。

第三个技巧:在开发阶段,可以在代理类的构造函数中打印 Java 接口的全限定名,确认和 Java 侧一致。这个简单的日志能避免很多低级错误。

第四个技巧:如果回调频率很高(比如进度回调每秒几十次),不要在每次回调中都触发 Unity 侧的重操作。可以在代理类中做节流,比如每 100 毫秒才转发一次进度。这样能显著降低主线程的压力。

第五个技巧:在OnDestroy中清理代理对象时,如果 Java 侧还在执行异步任务,最好先通知 Java 侧取消任务,再释放代理对象。否则 Java 侧可能在代理对象释放后调用回调,导致异常。

6. 进阶场景:复杂回调结构的处理

6.1 多接口代理与接口继承

有些 Java 库要求传入一个实现了多个接口的对象。AndroidJavaProxy本身只支持一个接口,但可以通过继承的方式实现多个接口的代理。具体做法是:创建一个 C# 类继承AndroidJavaProxy,在构造函数中传入一个接口的全限定名,然后在类中实现所有相关接口的方法。

但这种方式有个限制:Java 侧的类型检查是基于接口的,如果 Java 侧要求对象同时实现接口 A 和接口 B,而AndroidJavaProxy只声明了接口 A,类型检查会失败。这种情况下,需要在 Java 侧写一个包装类,实现多个接口,然后把AndroidJavaProxy作为其中一个接口的实现传进去。

接口继承的情况前面提过,C# 侧需要实现继承链上的所有方法。如果继承链很深,方法很多,可以考虑用代码生成工具自动生成代理类的骨架。

6.2 回调中的异步操作与状态管理

如果回调中需要执行异步操作(比如回调触发后再发起另一个网络请求),要注意状态管理。回调可能在子线程触发,异步操作也可能在子线程完成,多个异步操作之间的状态同步需要仔细设计。

一个实用的模式是:在调用方维护一个状态机,代理类只负责把回调事件转发到主线程队列,状态机在主线程中根据事件更新状态并决定下一步操作。这样所有状态变更都在主线程,避免了并发问题。

另一个要注意的是回调的幂等性。某些 Java 库可能在异常情况下重复调用回调,或者在取消后仍然调用回调。代理类应该做好防护,比如用一个标志位记录是否已经回调过,避免重复处理。

6.3 性能优化:高频回调的处理策略

高频回调(比如传感器数据、音频数据)对性能影响很大。每次回调都跨语言传递数据,JNI 调用的开销不可忽视。优化策略有几个方向。

第一是减少回调频率。如果 Java 侧支持配置回调间隔,尽量调大。如果不支持,可以在代理类中做节流,累积一定时间或一定数量的数据后再转发。

第二是减少数据量。回调传递的数据尽量精简,避免传递大对象或大数组。如果必须传递大量数据,考虑用共享内存或者文件作为中介,回调只传递一个标识符。

第三是避免在回调中做重操作。回调应该只做数据接收和转发,具体的处理逻辑放到主线程队列中异步执行。

第四是考虑用AndroidJavaObject的CallStatic替代实例方法调用,静态方法调用的开销略小。但这个优化效果有限,不建议为了这点性能牺牲代码可读性。

7. 我个人的一些实操体会

AndroidJavaProxy这套机制,刚接触的时候觉得挺神奇,用多了之后发现它就是一个工具,有它的适用场景和边界。它最适合的场景是:Java 侧提供一个接口,Unity 侧实现这个接口,Java 侧在合适的时机回调。这个“合适的时机”最好是明确的、低频的、数据量小的。

如果回调频率很高、数据量很大、或者时序要求很严格,AndroidJavaProxy可能不是最优解。这种情况下,可以考虑用共享内存、文件、或者消息队列作为中介,回调只用来通知“数据准备好了”,具体数据通过其他通道传递。

线程问题是这套机制里最需要重视的。我的习惯是:不管 Java 侧是不是在主线程回调,C# 侧的代理方法一律把回调转发到主线程队列。这样虽然多了一次队列操作的开销,但换来的是线程安全的保证和代码的简洁。这个取舍在大多数场景下是值得的。

代理对象的生命周期管理也是容易忽略的点。我的做法是:在调用方用一个字段持有代理对象的引用,在OnDestroy中清理。如果 Java 侧提供了取消注册的方法,一定要调用。不要依赖 GC 来清理代理对象,因为 GC 的时机是不确定的。

最后,日志是排查这类问题的最有力工具。在代理类的构造函数、析构函数、每个方法的入口都加上日志,能快速定位大部分问题。这些日志在开发阶段可以保留,发布时通过条件编译去掉。

这套东西说到底就是跨语言调用的一个桥梁,理解了它的原理和约束,用起来就不难。真正难的是对 Java 侧接口的理解和对线程模型的把握,这两点需要在实践中慢慢积累。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询