
简介一份面向Unity开发者的原生安卓调用入门工具集围绕UnityActivity、UnityAppContext、PackageManager、RunOnUIThread等核心方法覆盖Toast、Log、Java与C#字符串互转等基础交互并整合网上常用方式与自制封装提供获取App列表、判断服务/应用运行状态、打开/安装/卸载App、发送广播以及获取WiFi状态、安卓版本、原生类型ID、内置SD卡路径等系统信息。压缩包共45个文件以cs脚本、asset场景资源、meta配置文件为主另有unity场景、xml配置、txt说明和jar包整体仅48KB结构清晰便于按需取用。目前已有743人学习浏览。借助该工具集可减少原生插件开发的前期摸索直接复用现成方法快速集成到自己的Unity项目中同时可作为入门参考理解Unity与安卓原生交互的常见调用方式与封装思路。1. Unity调用原生安卓这件事值得先搞清边界再动手很多开发者第一次在 Unity 里遇到需要调用安卓原生能力时想的都是赶紧搜一段 AndroidJavaClass(android.widget.Toast) 的示例代码贴上去。某开发者在做一个跨平台工具时也这么干过结果项目能编译通过真机上一跑就崩最终发现是 Activity 引用为 null。Unity 调用原生安卓不是一句用 AndroidJavaClass 就行那么轻巧它涉及 JNI 桥接、Activity 生命周期、构建产物aar/jar如何进入最终 APK 等一系列前提。这份工具集适合已经在 Unity 里做过基础功能、却卡在原生互通这一步的从业者它能帮你快速搭出可运行的调用链路也把最容易踩的坑预先标出来。2. 桥接原理与工程准备JNI 链路和三个前置配置2.1 Unity 与安卓之间那层 JNIAndroidJavaClass 到底做了什么Unity 在安卓侧不是简单地反射调用。每个 AndroidJavaClass / AndroidJavaObject 实例背后都对应一个 JNI 全局引用。JNI 是 Java 与 C/C 之间的桥梁而 Unity 的 C# 层通过一组内部封装的 JNI 接口把类型信息、方法签名、参数值一层层传到 Java 虚拟机里。当一个 C# 脚本写下AndroidJavaClass jc new AndroidJavaClass(com.unity3d.player.UnityPlayer); AndroidJavaObject activity jc.GetStaticAndroidJavaObject(currentActivity);这里实际发生了两件事第一JNI 层根据类名com.unity3d.player.UnityPlayer去已加载的类里查找第二读取静态字段 currentActivity 并返回一个 JNI 引用。这个引用在 C# 侧被包装成 AndroidJavaObject底层指针指向 Java 堆上的 Activity 实例。理解这一点很重要如果你持有这个引用而 Activity 已经被系统回收那后续任何 Call 都可能抛异常。从调用模型上看Unity 侧所有对 Java 的调用都是单向、同步的除非你主动开线程。也就是说C# 调用 Java 方法后线程会卡在 JNI 调用点上直到 Java 方法返回。这给后续的性能优化留下了一个课题第5章会专门讲。2.2 工程侧三个前置配置SDK、Player Settings 和 Plugins/Android 目录在我经手的几个 Unity安卓互通项目里大部分跑不通的第一原因不是代码写错而是工程配置不完整。需要先整理好三件事。第一Android SDK 路径。Unity 的 Preferences 里 External Tools 面板需要正确指向 SDK 位置。如果你同时装了 Android StudioSDK 路径一般在 Android/Sdk 目录。如果路径不对Unity 打包时会直接报SDK not found这个一般难不倒人。第二Player Settings。打开 Edit Project Settings Player切换到 Android 标签页。重点检查三项Package Name包名必须与之后 Android 工程里要合并的 manifest 包名兼容避免冲突。Minimum API LevelUnity 会以此为准来决定哪些 API 可直接调用。设置太低Java 侧一些新接口会因 NoSuchMethodError 挂掉设置太高又会压缩可覆盖的机型范围。Scripting BackendIL2CPP 和 Mono 在跨语言调用上的行为其实不一样。IL2CPP 对 AOT 的限制更严格如果后续要在 IL2CPP 下做裁剪strip engine code某些反射调用会被裁掉需要加 link.xml。这点容易被忽略后面避坑章节会有一个专门案例。第三Plugins/Android 目录。Unity 在构建 APK/AAB 时会把这个目录下的内容合并进最终工程。最常见的路径是 Assets/Plugins/Android。要注意的是这个目录里如果同时存在同名不同版本的文件比如两个版本的 support 库最终行为完全取决于 Unity 内部的合并顺序这在后面也会细说。2.3 最小调用链路从 Unity 发一条 Toast 验证通路工程配置完成后先别急着写业务代码。我用一条最简单的 Toast 验证整条通路。创建一个 C# 脚本挂在场景主物体上using UnityEngine; public class NativeBridge : MonoBehaviour { void Start() { ShowToast(Hello from Unity); } public void ShowToast(string msg) { AndroidJavaClass unityPlayer new AndroidJavaClass(com.unity3d.player.UnityPlayer); AndroidJavaObject currentActivity unityPlayer.GetStaticAndroidJavaObject(currentActivity); AndroidJavaClass toastClass new AndroidJavaClass(android.widget.Toast); // 最后一个参数 0 表示 LENGTH_SHORT1 表示 LENGTH_LONG AndroidJavaObject toast toastClass.CallStaticAndroidJavaObject(makeText, currentActivity, msg, 0); toast.Call(show); } }这里解释一下关键点com.unity3d.player.UnityPlayer 是 Unity 在安卓侧的核心类currentActivity 这个静态字段在任何标准 Unity Activity 生命周期内都可访问。makeText 是 Toast 的静态方法接收三个参数Context、CharSequence、duration。C# 传字符串、int 时JNI 层会自动映射到对应的 Java 类型。toast.Call(show) 调用的是实例方法返回值是 void所以直接 Call 即可。代码是通了但有个细节必须说不要在任何回调、协程恢复、或者事件系统里反复 new AndroidJavaClass。这是因为每 new 一次JNI 全局引用表里就会多一条引用长期运行可能把引用表撑爆。这个坑很隐蔽实际项目里遇到过一次排查了一整天才定位到。3. 三种集成方式与参数透传从反射调用到导出 aar3.1 方式一运行时反射——适合轻量调用不碰 Android Studio如果你只是调用系统 APIToast、振动、剪贴板或者调用一个不常改动的第三方 Java 静态方法运行时反射方式最省事。它不需要你在 Android Studio 里建工程也不需要导出 aar直接把类名、方法名、参数写在 C# 里。比如读取设备 Android IDpublic string GetAndroidId() { AndroidJavaClass unityPlayer new AndroidJavaClass(com.unity3d.player.UnityPlayer); AndroidJavaObject activity unityPlayer.GetStaticAndroidJavaObject(currentActivity); AndroidJavaObject resolver activity.CallAndroidJavaObject(getContentResolver); AndroidJavaClass settingsSecure new AndroidJavaClass(android.provider.Settings$Secure); // 第二个参数是默认值当系统未开启时返回的兜底字符串 string androidId settingsSecure.CallStaticstring(getString, resolver, android_id); return androidId; }这种方式的好处是灵活C# 改完直接跑。坏处也明显没有任何编译期检查方法名拼错、参数类型传错都得运行时报异常而且异常信息被 JNI 层包了一层Logcat 里往往是 UnsatisfiedLinkError 或 NoSuchMethodError对新人来说排查成本高。所以我一般会在方法入口包一层 try-catch把异常信息带出来try { // ... 调用逻辑 } catch (System.Exception e) { Debug.LogError($[NativeBridge] JNI 调用失败: {e.Message}); }这样至少能看到是类找不到、方法找不到还是参数类型不匹配。3.2 方式二导出 aar——功能复杂时更稳的工程化路径当你的原生代码里开始有多个类、有 Activity 跳转、有回调接口的时候运行时反射方式会变得很脆每改一次 Java 代码都要重新打包、替换文件。这个阶段就要转到 Android Library 工程上去。常见做法是在 Android Studio 里新建一个 Library Module然后编写原生逻辑。我用一个最小示例模块名比如叫 nativebridgepackage com.example.nativebridge; import android.content.Context; import android.widget.Toast; public class Bridge { private Context context; public Bridge(Context ctx) { this.context ctx.getApplicationContext(); } public void show(String message) { Toast.makeText(context, message, Toast.LENGTH_SHORT).show(); } public int add(int a, int b) { return a b; } }模块的 build.gradle 需要做两处关键配置android { defaultConfig { // 这两项必须和 Unity 的 Player Settings 匹配或更低 minSdkVersion 22 targetSdkVersion 34 } }导出的 aar 文件位置一般在 build/outputs/aar/ 下。把这个 aar 复制到 Unity 工程的 Assets/Plugins/Android 目录后在 C# 侧这样调用public class NativeBridge : MonoBehaviour { private AndroidJavaObject bridgeObj; void Start() { AndroidJavaClass unityPlayer new AndroidJavaClass(com.unity3d.player.UnityPlayer); AndroidJavaObject activity unityPlayer.GetStaticAndroidJavaObject(currentActivity); // 构造 Bridge 实例传入当前 Activity 的 ApplicationContext bridgeObj new AndroidJavaObject(com.example.nativebridge.Bridge, activity); bridgeObj.Call(show, bridge ready); int sum bridgeObj.Callint(add, 3, 5); Debug.Log([NativeBridge] sum sum); } void OnDestroy() { // 显式释放 JNI 引用长期项目尤其重要 bridgeObj?.Dispose(); } }这段代码里有两个值得说的点。第一new AndroidJavaObject(com.example.nativebridge.Bridge, activity) 的第二个参数会被 JNI 当作构造函数的第一个参数。因为 Bridge 的构造器接收 Context这里传 activity 正好匹配。如果构造函数是重载的JNI 会根据传入参数类型去匹配签名匹配不到会抛 NoSuchMethodError。第二OnDestroy 里做 Dispose。不看 GC 的情况下C# 侧的 AndroidJavaObject 回收是不确定的显式 Dispose 能防止 JNI 引用表泄漏。3.3 数据类型映射表与带参调用示例跨语言调用最容易出错的是类型映射。下表是我从项目里总结出来的一份常用映射按C# - Java方向C# 侧类型Java 侧接收类型说明stringString自动转换为 UTF-16 序列注意不要传 nullintint32 位整型longlong64 位整型被 JNI 当作 jlongfloatfloat32 位浮点doubledouble64 位浮点boolbooleanJNI 层用 z 表示AndroidJavaObject任意对象传递的是 JNI 引用不是拷贝有一个隐蔽的坑C# 的 string 在传给 Java 时如果值是 nullJNI 层有时候会抛 NullPointerException 而不是友好提示。我在给某个功能传剪切板文案时翻过一次车文案是空的Java 侧收到的直接是 NPE。从那以后所有跨层字符串参数我都会先做空串兜底。再看一个带数组参数的例子。假设 Java 侧有一个方法接收 int[] 和 String[]public void processArray(int[] values, String[] names) { // 业务逻辑 }C# 侧调用路径是int[] values { 10, 20, 30 }; string[] names { alpha, beta }; bridgeObj.Call(processArray, values, names);注意这里不能传 List 或 List JNI 不会帮你做集合到数组的转换。你需要手动转一次Liststring nameList new Liststring { alpha, beta }; bridgeObj.Call(processArray, values, nameList.ToArray());3.4 从安卓侧拿返回值同步调用与异常捕获前面 add 的例子已经展示了 Call 同步拿返回值。这里的同步要再次强调Unity 的调用线程会阻塞直到 Java 方法执行完。如果 Java 方法里有耗时操作比如网络请求Unity 的 Update 循环会卡住表现就是游戏画面掉帧甚至看起来像卡死。情况再极端一点如果 Java 方法内部真正做的是阻塞在主线程上的东西而 Unity 侧恰好是在主线程调用那连 Activity 的 UI 事件都处理不了最终可能触发 ANR。处理方式是让 Java 侧把耗时操作放到子线程或者 C# 侧用异步包装。C# 侧异步包装的一个简单方式是用协程轮询结果。Java 侧先启动任务C# 侧每帧检查一个是否完成的标记IEnumerator WaitForNativeResult() { bool done bridgeObj.Callbool(isTaskDone); while (!done) { yield return null; done bridgeObj.Callbool(isTaskDone); } string result bridgeObj.Callstring(getResult); Debug.Log([NativeBridge] async result: result); }这段轮询代码适合低频任务但如果每帧检查频繁JNI 调用开销会累积。更高效的做法是让 Java 侧在任务完成时主动回调 Unity这个到第5章展开。4. 高频踩坑与排查五个典型问题的现象和根因这一章是我实际调试中遇到最多的问题。每条都按现象 - 原因 - 解决来写读的时候可以对照自己的项目逐条排除。4.1 现象调用 Java 方法直接闪退Logcat 提示 currentActivity 为 null原因UnityPlayer 的 currentActivity 静态字段会在 Activity 的早期生命周期阶段未被赋值。如果调用发生在 Application.onCreate比如你在自定义 Application 里初始化 SDK或者发生在 Activity.onDestroy 之后那拿到的引用就是 null。JNI 把 null 传给 Java 方法时很多原生方法会直接抛空指针。解决把初始化时机推迟到第一个场景加载完成后或者缓存当前 activity 之后手动判空if (currentActivity null) { Debug.LogError([NativeBridge] currentActivity 为空检查调用时机); return; }另外如果你的项目里启用了自定义的 Intent Filter 或者使用了别的安卓组件currentActivity 在不同进程里可能不一致这时候不要依赖它做操作改成传 Application Context 更稳。4.2 现象aar 文件放在 Assets/Plugins/Android 下打包出来类找不到原因这类问题我遇到三种情况。第一aar 里的 AndroidManifest.xml 与 Unity 主工程 manifest 冲突导致整个 aar 没有被打进最终包第二aar 的 minSdkVersion 高过 Unity 项目的 Minimum API LevelGradle 合并时直接跳过第三Unity 版本太老2019 之前对 aar 原生产物的支持有缺陷需要改成导出 Project 再通过 Android Studio 集成。解决先检查 aar 是否存在用压缩工具打开确认里面 classes.jar 是否包含你的类。再在 Player Settings 里调高 Minimum API Level 到至少和 aar 持平。如果还不行用 Unity 的 Export Project 导出 Gradle 工程然后在 Android Studio 里手动添加 aar 依赖看 Android Studio 的 Gradle 面板输出会更明确。4.3 现象从 Java 子线程调用 UnitySendMessageUnity 侧偶尔收到、偶尔崩溃原因UnitySendMessage 这个接口在部分版本下要求必须从 Unity 主线程GL 线程调用。Java 子线程直接调时如果正好赶上 Unity 渲染管线切换会触发一个未定义行为表现就是时好时坏。解决Java 侧不要在子线程直接调而是先用 Handler 把回调 post 到主线程再在主线程里调用 UnitySendMessageHandler mainHandler new Handler(Looper.getMainLooper()); mainHandler.post(new Runnable() { Override public void run() { UnityPlayer.UnitySendMessage(BridgeObject, OnNativeResult, done); } });这段代码把真正调用 UnitySendMessage 的时机锁定在安卓主线程规避了跨线程同步问题。4.4 现象C# 传 longJava 方法签名是 int方法能调到但参数值不对原因JNI 的参数匹配是按照方法签名严格来的C# 的 long 会映射成 Java 的 long而 Java 的 int 需要的是 C# 的 int两者签名的类型描述符不同本来应该抛 NoSuchMethodError。但某些 API特别是系统 API可能有重载JNI 匹配到了另一个重载参数被静默截断。表现就是代码能跑结果错得离谱。解决严格按第3章的映射表来。不确定 Java 方法签名时用 javap -s 查看真实签名然后回到 C# 侧比对。再补充一条这个方法在编译时不会报错因为 AndroidJavaObject.Call 的泛型参数只决定了返回值的转换不决定参数匹配。很多人在这一步翻车最后只能用日志排查。4.5 现象Release 构建在真机上跑报 NoClassDefFoundErrorDebug 构建正常原因这是混淆或裁剪把类丢了。两个方向R8/ProGuard 把 Java 侧类名混淆了Unity 侧再用反射查找原始类名自然找不到另一个是 IL2CPP 的 Managed DLL 裁剪把 Unity 侧调用 JNI 的 C# 类裁掉了Unity 侧代码直接不存在。解决如果是 Android 侧混淆在 proguard-rules.pro 里加保留规则-keep class com.example.nativebridge.** { *; }如果是 IL2CPP 裁剪在 Unity 里添加 link.xml保留包含 JNI 调用的程序集linker assembly fullnameAssembly-CSharp type fullnameNativeBridge preserveall / /assembly /linker这两种处理方式在很多项目里是同时要做的缺一不可。5. 进阶把回调链路理顺再做一次 JNI 调用瘦身5.1 用 UnitySendMessage 做双向通信的完整闭环前面提到 Java 侧要主动通知 Unity 时用 UnitySendMessage。双向通信在业务上往往是这样闭环的Unity 发起一个请求Java 侧在后台执行完成后回传结果。Java 侧关键代码public void startAsyncWork(final String param) { new Thread(new Runnable() { Override public void run() { // 模拟耗时 String result process(param); // 回调到 Unity 侧 runOnMainThread(new Runnable() { Override public void run() { UnityPlayer.UnitySendMessage(BridgeObject, OnNativeResult, result); } }); } }).start(); }Unity 侧对应接收方法public class BridgeObject : MonoBehaviour { void OnNativeResult(string json) { // 这里天然在主线程可以直接操作 Unity API ResultData data JsonUtility.FromJsonResultData(json); Debug.Log([NativeBridge] data.code data.message); } }注意 UnitySendMessage 的第一个参数是场景里的 GameObject 名称第二个是挂在它上面的组件里的方法名。GO 没找到或者方法名不存在时Unity 只在 Logcat 里打一条 warning不会崩溃这也会让问题更难发现。我的习惯是在 Start 里显式检查一次if (FindObjectOfTypeBridgeObject() null) { Debug.LogError([NativeBridge] BridgeObject 不存在回调会静默丢失); }5.2 高频调用场景下的引用缓存与批量透传最后一个贴士关于性能。高频调用时别每帧 new AndroidJavaClass。把类引用和实例引用缓存起来即可private static AndroidJavaClass sBridgeClass; private static AndroidJavaObject sBridgeObj; public static AndroidJavaObject GetBridge() { if (sBridgeObj null) { sBridgeClass new AndroidJavaClass(com.example.nativebridge.Bridge); // 初始化实例只做一次 } return sBridgeObj; }至于批量透传把多个参数拼成 JSON 字符串给 Java 侧解析可以把多次 JNI 调用合并成一次。我在某个模拟项目 X 里实践过用 dispatch(json) 统一入口后原来每帧 20 次 JNI 调用降到 1 次。JNI 单次调用成本在微秒级一帧几十次就会明显吃掉主线程时间。从那个第一次跑 Toast 就闪退的项目到现在我每次接安卓原生功能都强制先走一遍最小链路验证再谈业务扩展。阈值设得很简单Toast 能弹出来通路就通了通路不通后面所有功能都等于白搭。这个习惯帮我省下过很多个下午。希望帮到你。本文还有配套的精品资源点击获取