
坦白讲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能解决的问题就不要去碰手写 JNIUnitySendMessage能搞定的简单通知就别为了“优雅”专门设计一套代理接口。往下走一层调试成本是翻倍的。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 库就必须打 aarjar 装不下。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 版本不一致表现为真机上一切正常但在某些特定机型上启动直接黑屏。需要对齐的三处是Unity Player Settings 里的Minimum API Level和Target API Level原生模块build.gradle里的minSdkVersion和targetSdkVersion打包时实际使用的 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.GetStaticAndroidJavaObject(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 nullbyte[]byte[]适合传二进制数据int[]/float[]对应数组适合传大批量数值AndroidJavaObject任意引用类型需要保证类型匹配nullnull重载方法会有歧义必须显式转型关于重载方法有个很坑的地方Java 侧如果有一个setValue(int)和一个setValue(String)C# 侧Call(setValue, null)会因为无法确定重载而抛异常。这时候必须写成Call(setValue, (string)null)用显式类型告诉 Unity 你要调哪个。返回值的类型也要严格对齐。Java 方法返回longC# 侧写Calllong如果误写成Callint小数值时看着正常一旦超过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.CallStaticAndroidJavaObject(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 Actionstring _onSuccess; private readonly Actionint, string _onFailure; public BridgeCallbackProxy(Actionstring onSuccess, Actionint, 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 接口方法返回booleanC# 侧就必须返回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是 IL2CPPTarget Architectures勾上你需要的 ABI。5.3 C# 侧接入与验证C# 侧照第 3 节的写法封一层然后在真机上验证这条链路。验证顺序我一般这么走先测AndroidJavaClass能不能找到类只new不调用确认 aar 被正确合并再测静态方法调用确认方法签名匹配再测实例创建确认构造函数参数类型对最后测回调确认代理接口全限定名正确这个顺序能让你在每一步失败时立刻定位到是哪一层的问题而不是一堆错误一起爆。5.4 Manifest 与权限声明如果原生模块需要声明权限或者ContentProvider在Library Module里放一个AndroidManifest.xml打包进 aar 后 Unity 会在构建时自动做 Manifest 合并。合并规则是权限取并集application下的节点按规则合并。需要留意的是tools:node属性。如果某个节点需要替换而不是合并必须显式声明uses-permission android:nameandroid.permission.CAMERA tools:nodereplace /不加这个可能出现重复声明或者被更高优先级的 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.proNoSuchMethodError参数类型签名不匹配对照 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 说“我没收到”查半天发现是接口名少了个字母或者线程名字不一样这种低级但高频的沟通成本靠文档就能避免。最后分享一个小习惯每接一个新的原生模块我会先写一个只有三个按钮的测试场景——一个调无参方法、一个调带参方法、一个触发回调。这个场景不到一百行代码但它能在半小时内把整条链路验证一遍比直接集成到业务里再排查要快得多。踩过的坑告诉我桥接层的问题从来不是“能不能调通”而是“在哪一步没调通”而这个测试场景就是把“哪一步”变成可观测的最省力办法。