☰
Unity与Android原生交互:桥接、回调线程、aar打包与混淆排错
2026/10/26 14:37:05 网站建设 项目流程

坦白讲,Unity 和 Android 的交互这块内容,我在过去几年里反复踩过坑、反复重构过,每次以为摸透了,换个项目又冒出新问题。它不是那种学一次就能一劳永逸的知识点,而是随着 Unity 版本、Gradle 版本、目标 Android 版本一起漂移的一整套工程实践。很多新手第一次接触,会以为只要AndroidJavaClass一 new,方法一 Call 就完事了,结果真机一跑,类找不到、方法找不到、线程崩、包打不出来,一路都是坎。

我写这篇东西,目的是把 Unity 与 Android 原生交互这条链路上真正会咬人的地方摊开讲清楚:从工程结构怎么搭、aar 怎么打、C# 和 Java 两边的对象怎么持、回调怎么走、线程怎么切,一直到打包混淆和线上崩溃怎么查。适合两类人看——一类是刚要把某个原生功能接进 Unity 项目的开发者,另一类是被某个诡异的ClassNotFoundException折腾了半天、想系统性理一遍思路的老手。内容里给到的目录结构、调用写法、Gradle 片段都可以直接抄去改,参数选择的地方我会写清楚为什么这么定,而不是只丢一个结论。

1. 先把“交互”这件事拆开看

1.1 三条链路:谁调谁、跑在哪

很多人一上来就写代码,写到一半发现方向搞反了。Unity 和 Android 之间的交互,本质上只有三条链路,认清楚这三条,后面的所有写法都是它们的变体。

第一条是C# 主动调 Java,也就是 Unity 侧发起,去调用 Android 原生里已经写好的一堆静态方法或实例方法。这是最常见的场景,比如调起系统相册、读取设备信息、拉起一个原生广告页、控制蓝牙模块。实现靠的是 Unity 内置的AndroidJavaClass和AndroidJavaObject,底层走 JNI,但 Unity 帮你包了一层,日常用不到手写 C 代码。

第二条是Java 主动回调 C#,也就是原生侧做完了某件事,要反过来通知 Unity 逻辑层。典型场景是原生 SDK 的回调接口,比如支付结果、扫码结果、权限申请结果。这条路有三四种走法,UnitySendMessage、AndroidJavaProxy、直接 JNI 函数指针,各有各的适用边界,选错了后期很难改。

第三条是双向的、带状态的长连接,比如原生侧维护一个持续工作的传感器服务,Unity 侧要不断拿数据,还要能反过来控制它的采样率。这条链路其实是一二条的叠加,但它对对象生命周期管理的要求高一个量级,因为一旦 Java 对象被 GC 掉,或者 C# 侧的引用没释放,就会出现那种“跑十分钟才崩一次”的玄学问题。

把这三条分清楚的意义在于:你动手之前得先明确,你这个需求属于哪条链路,回调是同步还是异步,回调发生在哪个线程。这三个问题没想清楚,代码写得再漂亮也是返工。

1.2 为什么有些功能非得交给原生

这个问题经常被跳过,但它是方案选型的地基。我见过有人把整个原生的功能硬用 Unity 重写一遍,最后发现系统能力根本拿不到。

最核心的原因是:Unity 的运行时运行在一个 Activity 里,它本身对 Android 系统能力的访问是受限的。像申请运行时权限、注册广播接收器、绑定系统服务、拉起系统级 Intent、读取某些受保护的硬件信息,这些都必须拿到Activity或Context才能做,而 Unity 侧没有直接暴露这些东西,必须通过一层 Java 代码桥接。

第二个原因是性能边界。某些计算如果放在 C# 层做,会有托管堆和 GC 的额外开销,放到 Java 层做反而更稳。更常见的是反过来——某些计算必须放在原生层,因为它依赖系统提供的硬件加速接口,比如某些图像处理、音视频编解码、传感器融合算法。

第三个原因是第三方 SDK 的约束。市面上大量的原生 SDK 只提供 Java/Kotlin 的接入方式,没有 Unity 版本,你只能自己写桥接层。这种情况下你不是“想不想交互”的问题,而是“必须交互”。

还有一个容易被忽略的点:用户体验的一致性。系统级的 UI(比如权限弹窗、系统分享面板、时间选择器)用原生的实现,和系统风格完全一致,用户不会觉得突兀;用 Unity 自己画的,容易出现字体、圆角、动画节奏对不上的情况。

1.3 几种典型方案的取舍对照

在选型阶段,我一般会拿这张表过一遍,避免一开始就选错方向。

方案形态适用场景主要优势主要代价
C# 直调 Java 静态方法一次性调用、无状态工具类写法最简单,无额外工程无法持有 Java 对象状态
C# 持有 Java 实例对象需要维护连接、会话、监听器状态可控,可反复调用生命周期必须手动管理
UnitySendMessage 回调简单的单向通知零配置,立刻能用只支持 GameObject + 单字符串参数,性能和灵活性都差
AndroidJavaProxy原生 SDK 的回调接口类型安全,参数丰富接口方法签名必须严格对齐
直接 JNI 函数指针高频回调、音视频帧数据开销最低实现复杂,易崩,调试难
原生 Activity/Fragment 叠加全屏原生页面、登录页完全原生体验与 Unity 渲染层的状态同步麻烦

我给的建议是:能用高的抽象层就别往下钻。AndroidJavaProxy能解决的问题,就不要去碰手写 JNI;UnitySendMessage能搞定的简单通知,就别为了“优雅”专门设计一套代理接口。往下走一层,调试成本是翻倍的。

2. 工程结构怎么搭才不会返工

2.1 目录划分:先把地方占好

工程结构这件事,前期多花半小时,后期能省好几天。我固定用下面这套布局,基本能覆盖九成情况:

Assets/ Plugins/ Android/ mybridge.aar AndroidManifest.xml proguard-user.txt libs/ (可选,独立的 jar) Scripts/ Native/ NativeBridge.cs (唯一的入口,外部只调这个) NativeCallbackProxy.cs (代理实现) NativeResult.cs (数据结构)

关键点在于Scripts/Native这一层。必须在 C# 侧再封一层门面,让业务代码永远只调NativeBridge.Init()、NativeBridge.RequestPermission(...)这种纯净的 C# 接口,绝对不能允许业务代码里出现new AndroidJavaClass(...)。

原因很实在:一旦业务代码里散落了十几处AndroidJavaClass,你后面想做平台兼容(比如 Editor 下模拟、iOS 下换实现、WebGL 下直接返回)、想做单元测试、想换底层实现,成本会高到你想扇自己。而如果所有原生调用都收敛在NativeBridge一个文件里,改起来就是一个文件的活。

另外一个实操细节:NativeBridge里所有对 Android 的调用,都要用#if UNITY_ANDROID && !UNITY_EDITOR包住,Editor 下走一套 mock 返回值。这不是洁癖,是刚需——你不可能每次调个方法都打一次包去真机上验。

2.2 aar 与 jar 的差异,以及 Unity 怎么吃它们

这块是新手最容易翻车的地方。简单说,jar 只装.class文件,aar 是个 zip 包,里面可以装classes.jar、AndroidManifest.xml、res/、assets/、jni/等等。只要你的原生模块带了资源文件、清单声明或者 so 库,就必须打 aar,jar 装不下。

Unity 对 aar 的处理有个细节必须记住:aar 内部的classes.jar必须就叫classes.jar。Unity 在构建时会去 aar 里找这个名字的 jar 并合并进最终的 classes.dex。有些同学为了让工程看着整齐,把里面的 jar 改名成mybridge-classes.jar,结果就是编译全过、运行时一调就ClassNotFoundException,因为那个 jar 压根没被合并进去。

还有一点:Unity 2019.2 之后,aar 直接丢进Assets/Plugins/Android/就能被识别,不需要额外配置。但如果你用的是自定义 Gradle 模板,或者工程里有多个 aar 之间存在依赖关系,就需要显式声明依赖。这种情况下我一般会在mainTemplate.gradle里加:

dependencies { implementation fileTree(dir: 'libs', include: ['*.jar']) implementation files('libs/mybridge.aar') // 如果 aar 内部依赖了第三方库 implementation 'androidx.annotation:annotation:1.7.1' }

implementation files(...)这种写法只解决单个 aar 的直接引用,它不会自动拉取 aar 内部声明的传递依赖。这是 aar 和 maven 依赖最大的差别,也是很多人“本地能跑、导出工程编译报错”的原因。解决办法是把 aar 内部的依赖在mainTemplate.gradle里手动补一遍。

2.3 版本对齐:三处 SDK 版本必须一致

我踩过最难受的一次坑,是项目里三个地方的 SDK 版本不一致,表现为真机上一切正常,但在某些特定机型上启动直接黑屏。

需要对齐的三处是:

  1. Unity Player Settings 里的Minimum API Level和Target API Level
  2. 原生模块build.gradle里的minSdkVersion和targetSdkVersion
  3. 打包时实际使用的 Android SDK / Build Tools 版本

minSdkVersion的原则是:取两者最大值。Unity 设的 22,原生模块设的 24,最终必须以 24 为准,否则在 22、23 的机器上会因为调用了不存在的高版本 API 而崩溃。targetSdkVersion的原则是:必须和 Unity 侧以及系统当前要求保持一致,因为 targetSdk 决定了系统的行为兼容模式,比如存储权限的行为、通知权限的行为在不同 target 下完全不同,混用会出现“代码没改但行为变了”的诡异现象。

我的习惯是:在原生模块的build.gradle里不写死数字,改成读rootProject.ext或者直接用 Unity 导出的工程里的配置:

android { compileSdkVersion 34 defaultConfig { minSdkVersion 24 targetSdkVersion 34 } }

然后每次升级 Unity 大版本时,专门花十分钟确认这三处还是不是一致的。这十分钟能省掉后面一整天的排查。

3. C# 调 Java:写法不难,难在持对象

3.1 AndroidJavaClass 与 AndroidJavaObject 的本质区别

AndroidJavaClass对应的是 Java 的java.lang.Class,用来访问静态成员;AndroidJavaObject对应的是一个具体实例,用来访问实例成员。这个区别看着基础,但它直接决定了你怎么设计桥接层。

如果你的原生工具类全是静态方法,那就每次调用都new AndroidJavaClass("com.xxx.Tools"),用完就丢,因为AndroidJavaClass本身开销不大,而且 Unity 内部对 Class 有一定缓存。但如果你的原生模块需要维护状态(比如一个蓝牙连接、一个播放器会话、一个持续监听的回调),就必须用AndroidJavaObject持住这个实例。

这里有个非常关键、但文档里不太强调的点:AndroidJavaObject是持有 JNI 全局引用的。也就是说,只要这个 C# 对象还活着,对应的 Java 对象就不会被 GC。反过来,如果你不显式Dispose(),这个全局引用就一直挂着,长期运行会造成内存泄漏。我见过一个项目,每次打开某个界面都new一个AndroidJavaObject但不释放,跑二三十次之后 OOM。

标准写法是这样:

private AndroidJavaObject _bridge; public void Init() { if (_bridge != null) return; var activity = GetActivity(); _bridge = new AndroidJavaObject("com.example.mybridge.Bridge", activity); } public void Release() { if (_bridge == null) return; _bridge.Call("destroy"); _bridge.Dispose(); _bridge = null; } private static AndroidJavaObject GetActivity() { using (var player = new AndroidJavaClass("com.unity3d.player.UnityPlayer")) { return player.GetStatic<AndroidJavaObject>("currentActivity"); } }

注意using的用法:AndroidJavaClass是短生命周期的,用完就Dispose,别长期持着。而currentActivity拿到的AndroidJavaObject不要 Dispose,因为它代表的是 Unity 的 Activity,你 Dispose 掉它只是释放了你这边拿到的全局引用,但如果你后续还要用,重新拿一次就行,千万别存着又不释放又不清理。

3.2 参数与返回值的类型映射

JNI 的类型系统比 C# 严格,映射错了不会编译报错,而是运行时直接崩或者返回垃圾值。这张表我建议贴在工位上:

C# 侧写法Java 侧类型备注
int/long/float/double对应的基本类型直接传,无装箱
boolboolean注意 Java 里没有 0/1 自动转换
stringString传 null 会变成 Java null
byte[]byte[]适合传二进制数据
int[]/float[]对应数组适合传大批量数值
AndroidJavaObject任意引用类型需要保证类型匹配
nullnull重载方法会有歧义,必须显式转型

关于重载方法,有个很坑的地方:Java 侧如果有一个setValue(int)和一个setValue(String),C# 侧Call("setValue", null)会因为无法确定重载而抛异常。这时候必须写成Call("setValue", (string)null),用显式类型告诉 Unity 你要调哪个。

返回值的类型也要严格对齐。Java 方法返回long,C# 侧写Call<long>;如果误写成Call<int>,小数值时看着正常,一旦超过int范围就静默截断,这种 bug 极难查。

还有一点关于数组:每次Call传数组都会在 Java 侧新建一个数组对象,如果这个调用是每帧执行的,会产生大量临时对象。高频场景(比如音视频帧、每一帧的传感器数据)应该改用直接 JNI 的AndroidJNI.SetByteArrayRegion或者干脆把数据放在 native 层共享内存。

3.3 线程约束:这条红线越一次崩一次

Unity 的 C# 侧代码,只有主线程能调用 Unity API。而从 Java 回调到 C# 的那段代码,可能跑在任意一个 Java 线程上,包括原生 SDK 自己开的 worker 线程。这时候你在回调里写Debug.Log、改Transform、调用GameObject.Find,都会崩,而且崩得很随机。

我遇到过最典型的场景:原生 SDK 在子线程回调扫码结果,C# 侧收到后直接更新 UI 文本,测试阶段因为回调太快一直在主线程附近,看起来没事;上线后用户网络慢,回调在子线程跑,十次里面有两次崩。

处理方式有两种。第一种是在 Java 侧就把线程切干净,回调回来后立刻activity.runOnUiThread(...)或者用Handler(Looper.getMainLooper()).post(...),再回调给 Unity。这种方式最省事,因为 Unity 侧不用管。

第二种是在 C# 侧做线程切换,适用于你没法改原生代码的情况:

private static AndroidJavaObject _mainHandler; private static void PostToMainThread(Action action) { if (_mainHandler == null) { using (var looper = new AndroidJavaClass("android.os.Looper")) using (var handlerClass = new AndroidJavaClass("android.os.Handler")) { var mainLooper = looper.CallStatic<AndroidJavaObject>("getMainLooper"); _mainHandler = new handlerClass(mainLooper); } } // 这里用 Unity 自带的 Runnable 辅助类比较省事 var runnable = new AndroidJavaRunnable(action); _mainHandler.Call("post", runnable); }

这段代码的意图是:把 C# 的Action包成一个AndroidJavaRunnable,投递到 Android 主线程的Handler上执行。因为 Android 的主线程和 Unity 的主线程是同一个,所以投递过去之后就能安全调用 Unity API 了。

注意:AndroidJavaRunnable包装的委托如果抛异常,异常不会传回 C#,而是直接在 Java 层吞掉或者导致进程崩溃,排查时几乎没有任何日志线索。所以回调体里必须自己 try-catch,把日志打出来。

3.4 一个容易忽略的初始化时机问题

Unity 启动时,UnityPlayer.currentActivity在Awake阶段可能还没准备好,尤其是在某些启动流程被改过的项目里。稳妥的做法是把原生桥接的初始化放在Start之后的第一次调用时懒加载,或者用协程等一帧。

另一个坑是:如果原生模块需要在 Application 级别初始化,比如注册 Activity 生命周期回调,那就不能靠 Unity 的脚本触发,必须在 aar 里提供一个ContentProvider或者继承Application来自动初始化。这也是很多商业 SDK 的做法——在AndroidManifest.xml里注册一个空的ContentProvider,借它的onCreate时机完成自动初始化,业务方什么都不用写。

4. Java 回调 Unity:三种走法怎么选

4.1 UnitySendMessage:够用,但边界很硬

UnitySendMessage(string gameObjectName, string methodName, string message)是最简单的回调方式,Java 侧一行代码就能调,不需要任何额外实现。但它有三个硬约束:

第一,接收方必须是场景中激活状态的GameObject,名字必须严格匹配。对象被销毁或者失活,消息直接丢弃,且不会有任何报错。

第二,被调用的方法只能接受零个或一个string参数,返回值必须是void。这意味着你没法传结构体、没法传二进制、没法传多个参数。

第三,消息是异步的,不是立即执行,Java 侧调完不保证 Unity 侧立刻就处理了。

所以UnitySendMessage的定位很明确:适合简单的、低频的、单值的通知,比如“原生页面关闭了”、“某个开关被打开了”。不适合传数据、不适合高频回调。另外,因为它是靠名字反射找方法的,方法名一旦被代码混淆(比如用 IL2CPP + 某些裁剪配置),就可能找不到,这是线上偶发的UnitySendMessage: object not found的常见原因。

4.2 AndroidJavaProxy:类型安全,但要严格对齐签名

AndroidJavaProxy是 C# 侧实现 Java 接口的标准做法。用法是继承它并传入 Java 接口的全限定名,C# 类里的方法名、参数、返回值都必须和 Java 接口一一对应。

Java 侧先定义一个接口:

public interface IBridgeCallback { void onSuccess(String data); void onFailure(int code, String msg); }

C# 侧实现:

public class BridgeCallbackProxy : AndroidJavaProxy { private readonly Action<string> _onSuccess; private readonly Action<int, string> _onFailure; public BridgeCallbackProxy(Action<string> onSuccess, Action<int, string> onFailure) : base("com.example.mybridge.IBridgeCallback") { _onSuccess = onSuccess; _onFailure = onFailure; } void onSuccess(string data) { PostToMainThread(() => _onSuccess?.Invoke(data)); } void onFailure(int code, string msg) { PostToMainThread(() => _onFailure?.Invoke(code, msg)); } }

这里有几个必须说清楚的细节:

第一,C# 方法不能加public。加了public会让 Unity 的代理匹配逻辑出问题,标准做法就是默认访问级别或者public之外的修饰符,实际经验是直接不写修饰符最稳。

第二,返回值类型必须严格匹配。Java 接口方法返回boolean,C# 侧就必须返回bool,不能返回void。少写返回值会直接崩。

第三,参数类型必须是 Java 能识别的类型,string、基本类型、AndroidJavaObject、AndroidJavaProxy本身。自定义的 C# 类传不过去。

第四,代理对象的生命周期要你自己管。如果你把一个AndroidJavaProxy传给 Java 侧,Java 侧持有了它,那这个代理对象在 C# 侧就不能被 GC,否则回调时指针是空的。稳妥做法是在 C# 侧用一个字段持住它,等不用了再置 null。

4.3 直接 JNI 回调:只在必须的时候用

当回调频率非常高(比如每帧几十上百次),或者需要传大块二进制数据时,上面两种方式的中间层开销就变成瓶颈了。这时候可以走AndroidJNI命名空间,手动管理函数指针。

大致思路是:Java 侧声明native方法,C# 侧通过AndroidJNI.RegisterNatives或者直接把 C# 的委托转成函数指针注册过去。

[UnmanagedFunctionPointer(CallingConvention.Cdecl)] delegate void OnFrameDataDelegate(IntPtr env, IntPtr obj, IntPtr data, int len);

这条路能省掉大量装箱和字符串转换,但它的问题也很明显:任何一个指针用错、任何一个局部引用没删,都是进程级崩溃,而且堆栈里全是 native 帧,基本看不懂。我的经验是,除非你已经确认前面两种方式确实是性能瓶颈(用 Profiler 量过),否则不要往下走。大多数项目根本到不了这个量级。

4.4 回调线程与生命周期的联动处理

回调这条链路上,真正难的不是“怎么回调过去”,而是“回调回来的时候,对象还在不在”。

最常见的崩溃场景是这样:原生侧发起了一个异步操作(比如网络请求、二维码识别),用户等不及,退出了这个界面,Unity 侧的GameObject被销毁了,但 Java 侧的异步任务还在跑,跑完之后回调过来,C# 侧的对象已经是 null,一访问就崩。

处理原则有三条:

第一,每个异步请求都要有一个可取消的句柄。在 Unity 的OnDestroy或者OnDisable里,调用原生侧的cancel(requestId),让原生侧停掉任务。

第二,回调的第一行永远做存活检查。代理对象里先判断目标GameObject是否为 null,再决定要不要继续。

第三,原生侧的回调注册要能反注册。很多 SDK 的监听器是 List 持有的,你注册了不反注册,它一直持有你的代理对象,你的GameObject销毁了它还在,这就是标准的泄漏路径。

5. 完整实操:从零到跑通一个桥接模块

5.1 原生侧的代码骨架

假设需求是:Unity 侧要调一个原生的能力,请求某个运行时权限,拿到结果后回调回 Unity。原生侧我一般这么组织:

package com.example.mybridge; import android.app.Activity; import android.content.pm.PackageManager; import android.os.Build; public class Bridge { private static final int REQ_CODE = 1001; private Activity activity; private IBridgeCallback callback; public Bridge(Activity activity) { this.activity = activity; } public void setCallback(IBridgeCallback cb) { this.callback = cb; } public void requestPermission(String permission) { if (Build.VERSION.SDK_INT < 23) { if (callback != null) callback.onSuccess("granted"); return; } if (activity.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED) { if (callback != null) callback.onSuccess("granted"); return; } activity.requestPermissions(new String[]{permission}, REQ_CODE); } public void onRequestPermissionsResult(int code, String[] perms, int[] results) { if (code != REQ_CODE || callback == null) return; boolean granted = results.length > 0 && results[0] == PackageManager.PERMISSION_GRANTED; if (granted) callback.onSuccess("granted"); else callback.onFailure(403, "denied"); } public void destroy() { this.callback = null; this.activity = null; } }

这里有个绕不开的问题:onRequestPermissionsResult是 Activity 的回调,你的Bridge类怎么收到它?三种解法:

一是让自定义的UnityPlayerActivity子类转发,需要重写 Activity,改动最大但最干净。

二是用Application.ActivityLifecycleCallbacks或者androidx.activity.result的 API,在库内部自己接收结果,不需要 Activity 转发。

三是在requestPermissions时直接调用 Activity 的方法,然后靠 Unity 侧在OnApplicationPause之后主动查一次权限状态。这种方式最糙,但在很多对实时性要求不高的场景里能跑。

我一般优先选第二种,因为它对 Unity 工程零侵入,直接丢 aar 就能用。

5.2 打包与接入

Android Studio 里新建一个Library Module,把上面的代码放进去,build.gradle里注意两个配置:

android { namespace 'com.example.mybridge' compileSdk 34 defaultConfig { minSdk 24 consumerProguardFiles 'consumer-rules.pro' } }

consumerProguardFiles这个配置非常关键。它会把混淆规则打进 aar 里,使用方(也就是 Unity 导出的工程)在混淆时会自动应用这些规则,不需要手动往proguard-user.txt里抄。规则内容大致是:

-keep class com.example.mybridge.** { *; } -keep interface com.example.mybridge.** { *; }

为什么要 keep 全部?因为这些类会被 JNI 通过字符串反射的方式查找,混淆之后名字变了就找不到了。这是 Unity 与 Android 交互里最经典的一类线上问题,特征是:Debug 包一切正常,Release 包一调用就崩。

打包命令是./gradlew :mybridge:assembleRelease,产物在mybridge/build/outputs/aar/下。把 aar 复制到Assets/Plugins/Android/,然后在 Player Settings 里确认Scripting Backend是 IL2CPP,Target Architectures勾上你需要的 ABI。

5.3 C# 侧接入与验证

C# 侧照第 3 节的写法封一层,然后在真机上验证这条链路。验证顺序我一般这么走:

  1. 先测AndroidJavaClass能不能找到类(只new不调用),确认 aar 被正确合并
  2. 再测静态方法调用,确认方法签名匹配
  3. 再测实例创建,确认构造函数参数类型对
  4. 最后测回调,确认代理接口全限定名正确

这个顺序能让你在每一步失败时立刻定位到是哪一层的问题,而不是一堆错误一起爆。

5.4 Manifest 与权限声明

如果原生模块需要声明权限或者ContentProvider,在Library Module里放一个AndroidManifest.xml,打包进 aar 后 Unity 会在构建时自动做 Manifest 合并。合并规则是:权限取并集,application下的节点按规则合并。

需要留意的是tools:node属性。如果某个节点需要替换而不是合并,必须显式声明:

<uses-permission android:name="android.permission.CAMERA" tools:node="replace" />

不加这个,可能出现重复声明或者被更高优先级的 Manifest 覆盖的情况。另外,android:exported在 Android 12 之后是强制要求的,凡是带intent-filter的组件都必须显式声明,漏了会直接构建失败。

5.5 运行时权限的实际处理流程

Android 6.0 之后,Manifest 里声明权限只是第一步,运行时还得请求。这条链路的完整流程是:

Unity 侧点按钮 → C# 调NativeBridge.RequestPermission("android.permission.CAMERA")→ Java 侧checkSelfPermission→ 未授权则requestPermissions→ 系统弹窗 → 用户选择 → 结果回到onRequestPermissionsResult→ 通过回调接口传回 C# → C# 切主线程更新 UI。

有一个细节很多人不知道:用户勾选了“不再询问”之后,再次请求权限会立刻返回拒绝,且不再弹窗。这时候应该引导用户去设置页,用Intent打开应用详情:

Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS); intent.setData(Uri.fromParts("package", activity.getPackageName(), null)); activity.startActivity(intent);

这段逻辑如果不处理,用户会陷入“点了没反应”的死循环,是非常典型的新手遗漏点。

6. 出问题时的排查思路

6.1 类找不到、方法找不到怎么定位

ClassNotFoundException和NoSuchMethodError是这条链路上最高频的两个异常。它们的排查路径其实很固定:

先确认 aar 有没有被真正合并进 APK。方法是把 APK 解出来,看classes.dex里有没有对应的类。用unzip解压 APK,然后dexdump或者直接用 Android Studio 的 APK Analyzer 打开,搜索类名。搜不到就是合并环节出了问题,常见原因:aar 里的 jar 没叫classes.jar、aar 放错目录、Gradle 依赖没声明。

搜得到但运行时还是找不到,那就基本是混淆问题。检查 Release 包的混淆配置里有没有 keep 住对应的类和接口。

方法找不到,则通常是签名不匹配。Java 侧的void foo(int a)在 C# 侧写成了Call("foo", 1L),长整型会被识别成long,导致找不到int重载。这种情况在 Unity 里不会报明确的错,只会抛一个含糊的异常。排查手段是把 Java 侧的签名抄下来,逐个参数对照。

6.2 崩溃与线程问题的排查手法

如果崩溃是随机的、偶发的,八成和线程有关。排查方向:在回调入口打一行日志,把Thread.currentThread().getName()打出来,看回调到底跑在哪个线程。

如果崩溃堆栈里有JNI DETECTED ERROR IN APPLICATION或者use of deleted local reference,那就是 JNI 引用管理出了问题——大概是某个AndroidJavaObject被Dispose之后还在被使用,或者某个局部引用没删导致引用表溢出。局部引用表默认容量是 512,在高频循环里new AndroidJavaObject不 Dispose,很容易打爆。

线上崩溃的日志里如果看到UnitySendMessage: object not found,那就是GameObject名字不对或者对象被销毁了。这类问题不会导致崩溃,但会导致功能静默失效,比崩溃更难查。

6.3 内存与生命周期问题的识别

有一个简单的判断方法:如果你在原生侧注册了任何监听器、回调、广播接收器,就必须有对应的反注册代码,而且反注册必须能在 Unity 的对象销毁时被调用。

我一般会在桥接层留一对Register()/Unregister(),然后在MonoBehaviour.OnDestroy里调Unregister()。同时,原生侧的destroy()方法里要把所有持有的引用置 null,包括 Activity、Callback、各种 Listener。

还有一个容易被忽略的点:静态持有 Activity 引用会泄漏整个页面。原生侧的类里不要写private static Activity sActivity,这个引用会一直挂着,页面销毁了内存也回收不了。

6.4 排查速查表

现象最可能原因优先验证方式
一调就ClassNotFoundExceptionaar 未合并 / 内部 jar 未命名 classes.jarAPK Analyzer 搜类名
Debug 正常,Release 崩混淆未 keep检查 consumer-rules.pro
NoSuchMethodError参数类型签名不匹配对照 Java 签名逐个参数核查
偶发崩溃、堆栈指向 UI 操作回调在子线程执行打印线程名
内存持续上涨AndroidJavaObject 未 DisposeProfiler 看堆 + 检查 Dispose 调用
回调收不到代理接口全限定名错 / 对象被 GC打印注册日志 + 持住代理引用
权限请求无响应用户勾选不再询问检查 shouldShowRequestPermissionRationale
首次启动黑屏minSdk 与设备不匹配核对三处 SDK 版本

7. 几个工程化层面的经验

7.1 调用开销与批量策略

跨语言调用的开销大致在微秒级别,单次调用看不出什么,但如果放进Update里每帧调几十次,累积起来就明显了。降低开销的办法有两个:

一是合并调用。与其每帧调五次取数据的方法,不如让原生侧提供一次取五个值的方法,或者让原生侧做批处理,攒够一批再回传。

二是减少对象创建。new AndroidJavaObject和字符串转换是主要开销来源,能复用就复用,能传基本类型就不传对象。

如果确实需要高频传输大块数据,考虑把数据放到 native 层,用AndroidJNI的数组操作接口直接读写,绕开 Java 对象层。

7.2 桥接层的测试策略

原生交互这块最容易陷入“只能真机手测”的困境。我的做法是在 C# 侧做三层隔离:

第一层是接口层,INativeService定义所有方法,Editor 下挂一个 Mock 实现,真机挂 Android 实现。这样业务逻辑在 Editor 里就能跑通大部分流程。

第二层是数据层,所有跨语言传递的数据都转成纯 C# 的 POCO 类,在边界处一次性转换。这样测试的时候只需要构造 POCO,不需要碰AndroidJavaObject。

第三层是日志层,桥接层的每一个入口和出口都打日志,格式统一成[Native][方法名][参数][耗时]。真机出问题时,一份日志就能定位到是哪一步断的。

7.3 团队协作中的边界约定

如果原生模块是别的同学写的,一定要在开工前约定好三件事:方法签名文档、回调线程约定、异常返回格式。

方法签名文档不能只说“有个方法叫 requestPermission”,要精确到包名、类名、方法名、参数类型、返回值类型、是否会抛异常。回调线程约定要明确写清楚“回调在哪个线程”,因为这会直接决定 Unity 侧要不要做线程切换。异常返回格式要统一,是回调onFailure(code, msg),还是让 Java 抛异常给 C#,两种情况 C# 侧的处理逻辑完全不同。

这三条约定清楚,后面联调的时间能省一半。我见过太多项目在这上面扯皮——原生说“我回调了”,Unity 说“我没收到”,查半天发现是接口名少了个字母或者线程名字不一样,这种低级但高频的沟通成本,靠文档就能避免。

最后分享一个小习惯:每接一个新的原生模块,我会先写一个只有三个按钮的测试场景——一个调无参方法、一个调带参方法、一个触发回调。这个场景不到一百行代码,但它能在半小时内把整条链路验证一遍,比直接集成到业务里再排查要快得多。踩过的坑告诉我,桥接层的问题从来不是“能不能调通”,而是“在哪一步没调通”,而这个测试场景就是把“哪一步”变成可观测的最省力办法。

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

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

立即咨询