简介:这份工具集面向希望打通 Unity 与原生安卓交互的开发者,尤其适合刚接触 JNI 与 Android 插件封装、需要可运行参考案例的入门到进阶人群。内容围绕 Unity 调用原生安卓的常见需求展开,涵盖 UnityActivity、UnityAppContext、PackageManager、RunOnUIThread 等基础方法,以及 Toast、Log、Java 与 C# 字符串互转等实用封装。在此基础上还整理了获取 App 列表、判断服务与应用是否运行、打开/安装/卸载 App、发送广播,以及获取 WiFi 状态、安卓版本、原生类型 ID、内置 SD 卡路径等系统信息的方法,部分来自网络收集,部分为作者自行封装。资源包共 45 个文件,以 cs 脚本、asset 配置、meta 文件为主,另含 xml、unity 场景、txt 与 jar 包,整体约 48KB,结构轻量便于直接导入工程验证。目前已有 743 人学习,可作为快速理解 Unity 与安卓原生通信链路的参考起点。
1. 从一次打包翻车说起:Unity 调用原生安卓到底在解决什么
去年帮一个做智能硬件的团队排查问题,他们的 Unity 应用在编辑器里跑得好好的,一打到安卓真机上就黑屏。日志里只有一行ClassNotFoundException,追了半天发现是打包时把自定义的AndroidManifest.xml覆盖没了,原生模块的类根本没被加载进去。这类问题在 Unity 接安卓原生的场景里太常见了——不是代码写错,而是工程结构和调用链路没理清楚。
这份「Unity 调用原生安卓入门参考工具集」要解决的,就是这条链路上从零到跑通的完整问题。它适合两类人:一类是刚接触 Unity 安卓开发、需要调系统能力(蓝牙、串口、相机、文件权限)的新手;另一类是被 JNI 通信、AAR 打包、Gradle 版本冲突折腾过的熟手,想找一份能对照排查的参考。核心价值不在于教你写 Java,而在于把 Unity 侧和安卓侧的边界、通信方式、打包配置讲透,让你少走几遍我踩过的弯路。
2. 三种调用方式怎么选:JNI、AndroidJavaClass 与 AAR 插件
Unity 调用原生安卓,本质上只有三条路:直接通过AndroidJavaClass/AndroidJavaObject反射调用系统 API、把 Java 代码打成 AAR 或 JAR 插件再调用、以及通过 JNI 写 C/C++ 中间层。选错方式,后面全是坑。
2.1 反射调用:最快上手,但边界明显
反射调用适合调安卓系统自带的类,比如Toast、Vibrator、Settings。不需要额外配置,Unity 引擎自带AndroidJavaClass封装。常见做法是:
// 获取当前 Activity 的上下文,这是调用系统服务的前提 using (AndroidJavaClass unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer")) using (AndroidJavaObject activity = unityPlayer.GetStatic<AndroidJavaObject>("currentActivity")) { // 拿到震动服务 using (AndroidJavaObject vibrator = activity.Call<AndroidJavaObject>("getSystemService", "vibrator")) { // 参数 0 表示震动时长,单位毫秒 vibrator.Call("vibrate", 200L); } }这段代码的逻辑是:Unity 的UnityPlayer类持有当前 Activity 引用,所有需要 Context 的系统服务都得从这里拿。参数上注意vibrate传的是long类型,传int会找不到方法。反射调用的边界在于:它只能调已存在的类,没法调你自己写的 Java 代码,而且每次调用都有反射开销,高频调用场景(比如每帧读传感器)不建议走这条路。
2.2 AAR 插件:自定义逻辑的标准做法
当你需要写自己的 Java/Kotlin 逻辑——比如对接一个第三方 SDK、封装串口协议——就得打成 AAR。流程是:在 Android Studio 里建一个 Library 模块,写完后Build > Make Module生成 AAR,丢进 Unity 的Assets/Plugins/Android/目录。
这里有个关键点:AAR 里的AndroidManifest.xml会和 Unity 自动生成的清单合并。如果你的 AAR 里声明了权限或 Activity,合并规则是「并集」,但application节点的属性会冲突。我一般会在 AAR 的清单里只写必要权限,Activity 声明放到 Unity 侧统一管理,避免合并时出现tools:replace报错。
2.3 JNI 中间层:性能敏感场景才考虑
JNI 是在 C/C++ 层做桥接,Unity 侧通过DllImport调.so,.so再通过 JNI 调 Java。这条路适合高频数据交换,比如音频处理、图像算法。代价是调试成本高,一个签名对不上就是崩溃,而且不同 CPU 架构要分别编译。新手不建议一上来就走这条路,先用 AAR 把逻辑跑通,确实遇到性能瓶颈再下沉到 JNI。
| 调用方式 | 适用场景 | 配置成本 | 调试难度 |
|---|---|---|---|
| 反射调用 | 系统 API、简单功能 | 低 | 低 |
| AAR 插件 | 自定义 Java 逻辑、第三方 SDK | 中 | 中 |
| JNI 中间层 | 高频数据、性能敏感 | 高 | 高 |
3. 从零跑通一个 AAR 调用:工程配置与通信实现
光讲选型不够,这一章把 AAR 从创建到 Unity 调通的完整流程走一遍。我用的环境是 Unity 2021 LTS + Android Studio Arctic Fox,Gradle 版本 6.7.1,这套组合在多数项目里比较稳。
3.1 安卓侧:写一个带回调的 Java 类
先在 Android Studio 新建一个Empty Activity项目,然后File > New > New Module选Android Library,模块名比如叫nativebridge。在里面写一个类:
package com.example.nativebridge; import android.app.Activity; import android.widget.Toast; public class Bridge { private Activity activity; // 构造函数接收 Unity 传过来的 Activity public Bridge(Activity activity) { this.activity = activity; } // 无返回值方法,弹一个 Toast public void showToast(String message) { activity.runOnUiThread(() -> Toast.makeText(activity, message, Toast.LENGTH_SHORT).show() ); } // 带返回值方法,做加法 public int add(int a, int b) { return a + b; } }这里有两个细节:showToast必须切到 UI 线程,否则在非主线程调用会抛异常;构造函数持有 Activity 引用,是为了后续调系统服务方便。写完执行Build > Make Module 'nativebridge',在nativebridge/build/outputs/aar/下拿到nativebridge-release.aar。
3.2 Unity 侧:目录结构与调用脚本
把 AAR 复制到Assets/Plugins/Android/下。如果 AAR 依赖了其他库(比如androidx),还需要把依赖的 JAR 或 AAR 一并放进去,否则运行时会报NoClassDefFoundError。
然后写调用脚本:
using UnityEngine; public class NativeBridgeTest : MonoBehaviour { private AndroidJavaObject bridgeInstance; void Start() { // 获取 Unity 当前 Activity using (AndroidJavaClass unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer")) using (AndroidJavaObject activity = unityPlayer.GetStatic<AndroidJavaObject>("currentActivity")) { // 实例化 Java 侧的 Bridge 类,包名和类名必须完全一致 bridgeInstance = new AndroidJavaObject("com.example.nativebridge.Bridge", activity); } // 调用无返回值方法 bridgeInstance.Call("showToast", "来自 Unity 的问候"); // 调用带返回值方法 int result = bridgeInstance.Call<int>("add", 3, 5); Debug.Log($"原生加法结果:{result}"); } }逻辑说明:AndroidJavaObject的构造函数第一个参数是完整类名,后面跟的是 Java 构造函数的参数。Call<int>里的泛型指定返回类型,如果 Java 方法返回void就用Call不带泛型。参数类型要严格对应,Java 的int对应 C# 的int,long对应long,字符串对应string。
3.3 打包配置:Gradle 模板与清单合并
Unity 默认用的是内置 Gradle 模板,但接了 AAR 之后经常需要自定义。在Player Settings > Publishing Settings里勾选Custom Main Gradle Template和Custom Launcher Gradle Template,Unity 会在Assets/Plugins/Android/下生成mainTemplate.gradle和launcherTemplate.gradle。
常见做法是在mainTemplate.gradle的dependencies块里加上:
dependencies { implementation fileTree(dir: 'libs', include: ['*.jar']) // 如果 AAR 依赖了 androidx,需要显式声明 implementation 'androidx.appcompat:appcompat:1.3.1' **DEPS**; }**DEPS**是 Unity 的占位符,别删。如果 AAR 用了androidx但 Unity 项目没启用,需要在gradleTemplate.properties里加上android.useAndroidX=true和android.enableJetifier=true,否则打包会报资源找不到。
4. 避坑指南:JNI 崩溃、Gradle 冲突与清单覆盖
这一章是我自己踩过的坑,每条都按「现象 → 原因 → 解决」写,你对照排查能省不少时间。
4.1 现象:真机运行闪退,日志报ClassNotFoundException
原因:AAR 没被打进 APK,或者包名/类名写错。Unity 在打包时会扫描Plugins/Android/下的 AAR,但如果 AAR 放在子目录里且没有正确标记平台,可能被忽略。
解决:选中 AAR 文件,在 Inspector 里确认Platform Settings勾选了Android,CPU架构按需选。另外检查AndroidJavaObject里的类名是否和 Java 文件里的package一致,大小写敏感。
4.2 现象:打包报错Duplicate class androidx.xxx found
原因:多个 AAR 依赖了同一个库的不同版本,或者 Unity 内置的库和你的 AAR 冲突。
解决:在mainTemplate.gradle里用exclude排除重复依赖:
implementation('com.example.nativebridge:1.0') { exclude group: 'androidx.appcompat', module: 'appcompat' }如果冲突来自 Unity 内置库,可以在Player Settings > Publishing Settings里勾选Custom Gradle Properties Template,加上android.enableJetifier=true让 Gradle 自动迁移旧版支持库。
4.3 现象:自定义AndroidManifest.xml里的 Activity 没生效
原因:Unity 打包时会用自己的清单覆盖,或者合并时优先级搞反了。
解决:把自定义清单放在Assets/Plugins/Android/AndroidManifest.xml,并且在Player Settings > Publishing Settings里把Custom Main Manifest勾上。如果只是加权限,直接在 Unity 的Player Settings > Other Settings里勾选对应权限更省事,避免清单合并冲突。
4.4 现象:Java 侧回调 Unity 没反应
原因:UnityPlayer.UnitySendMessage要求 GameObject 名字、方法名完全匹配,且方法所在脚本必须挂在场景里的物体上。
解决:Java 侧这样写:
import com.unity3d.player.UnityPlayer; public void notifyUnity(String message) { // 参数依次是:GameObject 名字、方法名、消息内容 UnityPlayer.UnitySendMessage("NativeBridgeReceiver", "OnNativeMessage", message); }Unity 侧对应:
public class NativeBridgeReceiver : MonoBehaviour { // 方法名必须和 Java 侧传的一致 public void OnNativeMessage(string message) { Debug.Log($"收到原生消息:{message}"); } }注意这个脚本要挂在一个名为NativeBridgeReceiver的 GameObject 上,名字不能有空格或特殊字符。
4.5 现象:调用原生方法时主线程卡顿
原因:反射调用本身有开销,如果每帧调用或者传大量数据,会拖慢主线程。
解决:把高频调用改成批量传递,比如传感器数据攒够 10 帧再传一次;或者把逻辑下沉到 JNI 层用 C++ 处理。我一般会在 Java 侧开一个工作线程做耗时操作,完成后通过runOnUiThread回调 Unity。
5. 进阶技巧:用 AndroidJavaProxy 做双向通信与性能验证
前面讲的都是 Unity 主动调 Java,但实际项目里经常需要 Java 主动通知 Unity——比如蓝牙收到数据、下载进度更新。除了UnitySendMessage,更优雅的方式是AndroidJavaProxy,它能在 C# 侧实现 Java 接口,让 Java 直接回调 C# 对象。
5.1 用 AndroidJavaProxy 实现接口回调
先在 Java 侧定义一个接口:
package com.example.nativebridge; public interface ProgressListener { void onProgress(int percent); void onComplete(String result); }然后在 Java 类里持有这个接口并触发:
public class Downloader { private ProgressListener listener; public void setListener(ProgressListener listener) { this.listener = listener; } public void startDownload() { // 模拟下载过程 for (int i = 0; i <= 100; i += 20) { if (listener != null) { listener.onProgress(i); } } if (listener != null) { listener.onComplete("下载完成"); } } }Unity 侧用AndroidJavaProxy实现这个接口:
using UnityEngine; public class DownloadProxy : AndroidJavaProxy { // 构造函数传入 Java 接口的完整类名 public DownloadProxy() : base("com.example.nativebridge.ProgressListener") { } // 方法名和签名必须和 Java 接口一致 void onProgress(int percent) { Debug.Log($"下载进度:{percent}%"); } void onComplete(string result) { Debug.Log($"结果:{result}"); } } public class DownloadTest : MonoBehaviour { void Start() { using (AndroidJavaClass unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer")) using (AndroidJavaObject activity = unityPlayer.GetStatic<AndroidJavaObject>("currentActivity")) { AndroidJavaObject downloader = new AndroidJavaObject("com.example.nativebridge.Downloader"); downloader.Call("setListener", new DownloadProxy()); downloader.Call("startDownload"); } } }这里的关键是AndroidJavaProxy的构造函数参数必须是 Java 接口的完整类名,C# 侧的方法名和参数类型要和接口声明严格对应。如果 Java 接口方法有返回值,C# 侧也要返回对应类型,否则会抛NoSuchMethodError。
5.2 验证调用是否真的生效
光看日志不够,我一般会用三个手段交叉验证:第一,在 Java 侧加Log.d("NativeBridge", "method called"),用adb logcat -s NativeBridge过滤;第二,在 Unity 侧用Debug.Log打时间戳,确认调用顺序;第三,用 Android Studio 的 Profiler 看方法耗时,如果单次调用超过 5ms,就要考虑优化。
5.3 一个容易忽略的细节:线程与生命周期
AndroidJavaProxy的回调可能发生在任意线程,如果回调里要操作 Unity 的 GameObject,必须切回主线程。常见做法是用一个线程安全的队列缓存消息,在Update里消费:
using System.Collections.Concurrent; using UnityEngine; public class DownloadProxy : AndroidJavaProxy { // 线程安全队列,Java 线程写入,主线程读取 public ConcurrentQueue<string> messageQueue = new ConcurrentQueue<string>(); public DownloadProxy() : base("com.example.nativebridge.ProgressListener") { } void onProgress(int percent) { messageQueue.Enqueue($"进度:{percent}%"); } void onComplete(string result) { messageQueue.Enqueue($"完成:{result}"); } } public class DownloadTest : MonoBehaviour { private DownloadProxy proxy = new DownloadProxy(); void Update() { // 主线程消费队列 while (proxy.messageQueue.TryDequeue(out string msg)) { Debug.Log(msg); } } }另外,Activity 销毁时记得在 Java 侧把 listener 置空,否则可能内存泄漏。我习惯在 Unity 的OnDestroy里调一下downloader.Call("setListener", null),虽然多一行代码,但能避免很多玄学崩溃。
从那以后我每次接原生模块,都会先把 AAR 单独打一个空场景跑通,确认类加载、方法调用、回调链路都没问题,再往业务里集成。这个习惯帮我省下了至少三次通宵排查的时间。希望帮到你。
本文还有配套的精品资源,点击获取