ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Unity调用原生安卓入门:JNI桥接、AAR打包与权限回调工具集

Unity调用原生安卓入门:JNI桥接、AAR打包与权限回调工具集

简介:这份工具集面向需要在 Unity 中调用原生安卓能力的开发者,尤其适合刚接触 Unity 与 Android 交互、希望快速跑通基础链路的入门者。内容围绕 UnityActivity、UnityAppContext、PackageManager、RunOnUIThread 等基础方法展开,并覆盖 Toast、Log 以及 Java 与 C# 字符串互转等常用操作,同时整理了获取 App 列表、判断服务与应用是否运行、打开/安装/卸载 App、发送广播,以及获取 WiFi 状态、安卓版本、原生类型 ID、内置 SD 卡路径等实用封装。资源共 45 个文件,以 cs 脚本、asset 配置、meta 与 xml 文件为主,另含 jar 包和 unity 场景,压缩包约 48KB,结构轻量便于直接导入参考。目前已有 743 人学习,适合作为 Unity 调用原生安卓的入门跳板与日常开发速查工具。

1. 从一次打包翻车说起:这套 Unity 调用原生安卓工具集到底解决什么

去年帮一个做跨平台应用的小团队看包,Unity 侧功能都跑通了,一打安卓真机就出问题:权限弹窗不弹、Toast 不显示、拿不到系统电量,日志里全是ClassNotFoundException。他们一开始想用纯 C# 硬扛,结果发现安卓那套东西绕不过去,最后还是得回到 Unity 调用原生安卓这条路上。这套「Unity 调用原生安卓 入门参考 工具集」就是干这个的——它把 Unity 和 Android 之间那层 JNI 桥怎么搭、AAR 怎么打、权限怎么回调、常见 API 怎么封装,整理成一套能直接照着复现的参考工程和工具脚本。适合两类人:刚接触 Unity 安卓混合开发、被 JNI 报错劝退的新手,以及想把手头零散调用代码收敛成规范工具集的熟手。下面我按「原理立住 → 动手复现 → 避坑排查 → 进阶封装」的顺序拆一遍,中间该贴的代码和参数一个不省。

2. Unity 与 Android 的通信底座:JNI、AAR 与 AndroidJavaClass 怎么串起来

2.1 为什么不能只用 C#,非得走 JNI

Unity 的 C# 运行在 Mono/IL2CPP 上,安卓系统 API 是 Java/Kotlin 写的,两者不在一个运行时里。想让 Unity 拿到安卓原生的能力——读取Build信息、调用Vibrator、申请运行时权限、拉起系统分享——中间必须有一座桥,这座桥就是 JNI(Java Native Interface)。Unity 把这层桥封装成了AndroidJavaClass、AndroidJavaObject、AndroidJavaProxy三个类,你写 C# 的时候感觉像在调 Java,底层其实是 JNI 在传引用、转类型。

常见做法是:把要用的安卓逻辑写成一个 Android Library 模块,编译成 AAR,丢进 Unity 的Assets/Plugins/Android目录,然后在 C# 里用AndroidJavaClass找到对应的 Java 类去调。这套工具集的价值就在于,它把「Java 侧怎么写、AAR 怎么出、C# 侧怎么调」这条链路固化成了模板,不用每次从零配 Gradle。

2.2 环境与目录:先把工程骨架搭对

动手前先确认版本边界,这是最容易翻车的地方。Unity 2019 之后安卓插件目录结构变过,2021 LTS 和 2022 LTS 对 Gradle 模板的处理也不一样。我一般按下面这套来:

项目建议值说明
Unity 版本2021.3 LTS 及以上低于此版本 Gradle 模板差异大,AAR 冲突多
Android Gradle Plugin7.0+与 Unity 内置 Gradle 对齐,避免重复类
minSdkVersion24覆盖绝大多数真机,权限 API 稳定
targetSdkVersion33注意 Android 13 的通知权限变化
AAR 存放路径Assets/Plugins/Android放错目录 Unity 不会打进包

目录骨架大致是这样,工具集里也是这个结构:

Assets/ Plugins/ Android/ unitycallandroid.aar # 编译好的安卓库 AndroidManifest.xml # 权限与组件声明 Scripts/ AndroidBridge.cs # C# 调用入口 PermissionHelper.cs # 权限回调封装

提示:AndroidManifest.xml如果和 AAR 里的清单冲突,Unity 打包时会合并,合并规则以主工程为准,权限声明建议统一写在主工程的清单里,别散落在各处。

2.3 Java 侧:写一个能被 Unity 找到的类

安卓库这边,核心是暴露一个静态方法给 C# 调。工具集里给的是一个UnityBridge类,负责返回设备信息和触发 Toast:

package com.example.unitybridge; import android.app.Activity; import android.content.Context; import android.widget.Toast; import android.os.Build; public class UnityBridge { // 静态方法,C# 侧通过 AndroidJavaClass.CallStatic 调用 public static String getDeviceModel() { return Build.MANUFACTURER + " " + Build.MODEL; } // 需要 Activity 上下文的方法,用 runOnUiThread 保证在主线程弹 Toast public static void showToast(final Activity activity, final String msg) { activity.runOnUiThread(new Runnable() { @Override public void run() { Toast.makeText(activity, msg, Toast.LENGTH_SHORT).show(); } }); } }

逻辑说明:getDeviceModel是纯静态、无上下文依赖的方法,C# 侧调用最简单;showToast涉及 UI,安卓要求必须在主线程操作,所以用runOnUiThread包一层,否则会抛CalledFromWrongThreadException。参数上,Activity要从 Unity 侧传进来,不能在里面 new,因为安卓的 Activity 生命周期由系统管。

编译这个模块用 Gradle:

./gradlew :unitybridge:assembleRelease # 产物在 unitybridge/build/outputs/aar/unitybridge-release.aar

把 AAR 复制到Assets/Plugins/Android,Unity 会自动识别。注意 AAR 里如果带了classes.jar之外的依赖,要么一起打进去,要么在 Unity 侧用mainTemplate.gradle补依赖,否则运行时报NoClassDefFoundError。

2.4 C# 侧:AndroidJavaClass 的正确打开方式

C# 这边调用要区分「静态方法」和「实例方法」,工具集里AndroidBridge.cs把两种都封了:

using UnityEngine; public class AndroidBridge { private const string BRIDGE_CLASS = "com.example.unitybridge.UnityBridge"; // 调用静态方法:用 AndroidJavaClass public static string GetDeviceModel() { using (var bridge = new AndroidJavaClass(BRIDGE_CLASS)) { return bridge.CallStatic<string>("getDeviceModel"); } } // 调用需要 Activity 的方法:先拿当前 Activity,再传进去 public static void ShowToast(string msg) { using (var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer")) using (var activity = unityPlayer.GetStatic<AndroidJavaObject>("currentActivity")) using (var bridge = new AndroidJavaClass(BRIDGE_CLASS)) { bridge.CallStatic("showToast", activity, msg); } } }

逻辑说明:AndroidJavaClass对应 Java 的类,AndroidJavaObject对应实例。currentActivity是 Unity 暴露的静态字段,拿到它才能做需要上下文的操作。using包裹是为了及时释放 JNI 引用,长时间不释放会累积导致内存问题,这点新手很容易忽略。参数上,CallStatic<T>的泛型是返回类型,传参顺序必须和 Java 方法签名严格一致,类型对不上会直接崩。

3. 权限、回调与线程:把运行时权限和异步结果接回 C#

3.1 运行时权限为什么不能只写清单

Android 6.0 之后,危险权限(相机、定位、存储、录音)光在AndroidManifest.xml里声明没用,必须在运行时动态申请,用户点了「允许」或「拒绝」之后还要拿到回调。Unity 自己不带这套回调机制,得靠原生侧实现onRequestPermissionsResult,再通过 JNI 把结果传回 C#。这是 Unity 调用原生安卓里最容易断链的一环。

工具集的做法是:Java 侧写一个PermissionHelper,C# 侧用AndroidJavaProxy接收回调。先看 Java 侧:

package com.example.unitybridge; import android.app.Activity; import android.content.pm.PackageManager; import androidx.core.app.ActivityCompat; import androidx.core.content.ContextCompat; public class PermissionHelper { public static final int REQ_CODE = 1001; public static boolean hasPermission(Activity activity, String permission) { return ContextCompat.checkSelfPermission(activity, permission) == PackageManager.PERMISSION_GRANTED; } public static void requestPermission(Activity activity, String permission) { ActivityCompat.requestPermissions(activity, new String[]{permission}, REQ_CODE); } }

逻辑说明:hasPermission先查当前状态,避免重复申请;requestPermission用ActivityCompat发起申请,REQ_CODE是自定义请求码,回调时靠它区分是哪个请求。注意这里依赖了androidx.core,AAR 打包时要把这个依赖带上,否则运行时报找不到ActivityCompat。

3.2 用 AndroidJavaProxy 接住权限回调

C# 侧要接住onRequestPermissionsResult,得继承AndroidJavaProxy,把 Java 接口映射过来:

using UnityEngine; using System; public class PermissionCallback : AndroidJavaProxy { private Action<string, bool> _onResult; // 构造时传入要实现的 Java 接口全名 public PermissionCallback(Action<string, bool> onResult) : base("androidx.core.app.ActivityCompat$OnRequestPermissionsResultCallback") { _onResult = onResult; } // 方法名和签名必须和 Java 接口一致 void onRequestPermissionsResult(int requestCode, string[] permissions, int[] grantResults) { for (int i = 0; i < permissions.Length; i++) { bool granted = grantResults[i] == 0; // 0 = PERMISSION_GRANTED _onResult?.Invoke(permissions[i], granted); } } }

逻辑说明:AndroidJavaProxy的构造参数是 Java 接口的全限定名,$用于表示内部接口。方法名、参数类型、返回类型必须和 Java 侧完全对应,大小写都不能错,否则回调静默失败——这是最阴的坑,不报错但就是不触发。grantResults[i] == 0里的 0 对应PackageManager.PERMISSION_GRANTED,别硬编码成别的值。

3.3 线程与生命周期:回调回来时 Activity 可能已经没了

安卓的权限回调是异步的,用户可能过几秒才点,这期间如果 Unity 场景切换、Activity 重建,回调回来时上下文就失效了。血泪经验是:回调里不要直接操作 UI 或假设 Activity 还在,先判空再处理。工具集里在 C# 侧加了一层保护:

public static void RequestCamera(Action<bool> callback) { using (var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer")) using (var activity = unityPlayer.GetStatic<AndroidJavaObject>("currentActivity")) using (var helper = new AndroidJavaClass("com.example.unitybridge.PermissionHelper")) { if (activity == null) { callback?.Invoke(false); return; } bool has = helper.CallStatic<bool>("hasPermission", activity, "android.permission.CAMERA"); if (has) { callback?.Invoke(true); return; } helper.CallStatic("requestPermission", activity, "android.permission.CAMERA"); // 实际回调通过 PermissionCallback 注册,此处省略注册细节 } }

逻辑说明:先判activity是否为空,再查权限,已有权限直接回调,没有才发起申请。参数上,权限字符串必须用安卓标准常量,写错一个字母就永远申请不到。这套流程跑通后,相机、定位、录音都能套同一个模板。

4. 避坑与排查:Unity 调用原生安卓最常见的五类翻车

4.1 现象:运行时报 ClassNotFoundException

原因通常是 AAR 没打进包,或者包名和 C# 里写的对不上。Unity 只识别Assets/Plugins/Android下的 AAR,放别处不生效;另外 Java 包名大小写敏感,com.example.UnityBridge和com.example.unitybridge是两个类。解决:确认 AAR 路径正确,用AndroidJavaClass前先打印一次类名核对,打包后用 Android Studio 的 APK 分析器看classes.dex里有没有这个类。

4.2 现象:Toast 不显示,日志无报错

多半是没在主线程调用。安卓的 UI 操作强制在主线程,Unity 的 C# 回调线程不一定是主线程。解决:Java 侧统一用runOnUiThread包一层,或者 C# 侧用AndroidJavaObject拿到 Activity 后runOnUiThread。别指望它自己切线程,它不会。

4.3 现象:权限弹窗弹了,但 C# 收不到回调

AndroidJavaProxy的方法签名和 Java 接口对不上是最常见原因。比如 Java 接口参数是int[],C# 写成int[]没问题,但写成AndroidJavaObject就静默失败。解决:对照 Java 接口源码逐字核对方法名和参数类型,requestCode也要对上,回调里加日志确认是否进入。

4.4 现象:打包报 duplicate class 冲突

AAR 里带的依赖和 Unity 内置的androidx版本不一致,或者多个 AAR 引了同一个库。解决:在mainTemplate.gradle里用exclude group排掉重复依赖,或者统一用implementation而非api减少传递依赖。工具集里建议只保留一份androidx.core。

4.5 现象:真机上偶发崩溃,模拟器正常

常见于 JNI 引用泄漏或 Activity 生命周期错配。AndroidJavaObject用完不Dispose,长时间运行引用表爆掉。解决:所有AndroidJavaClass、AndroidJavaObject都用using包裹,回调里判空,别在OnDestroy之后还去调原生方法。

5. 进阶:把零散调用收敛成可复用工具集

5.1 用泛型封装统一的调用入口

调多了会发现每个功能都写一遍AndroidJavaClass很啰嗦。我一般会封一个泛型入口,把类名和方法名参数化:

public static T CallStatic<T>(string className, string method, params object[] args) { using (var clazz = new AndroidJavaClass(className)) { return clazz.CallStatic<T>(method, args); } } // 用法 string model = CallStatic<string>("com.example.unitybridge.UnityBridge", "getDeviceModel");

逻辑说明:params object[]让参数数量可变,T指定返回类型。注意泛型不能是void,无返回值的方法单独封一个CallStaticVoid。这样新增功能时只改类名和方法名,不用重复写 JNI 样板。

5.2 验证工具集是否真的生效

别只在编辑器里跑,编辑器根本不走 JNI。验证必须打真机包,按这个清单过一遍:

验证项方法预期
类能否找到调getDeviceModel返回真实机型字符串
权限流程首次安装申请相机弹窗出现,允许后回调 true
拒绝分支拒绝后再次申请回调 false,不崩溃
线程安全连续弹 10 次 Toast全部显示,无错乱
内存循环调用 1000 次无持续增长,无崩溃

5.3 一个我踩过的坑和后来的习惯

早期我图省事,把AndroidJavaObject当普通 C# 对象缓存起来复用,结果跑久了必崩,查了半天才发现是 JNI 引用没释放。从那以后我每次写调用都强制走using,回调里先判空再干活,打包前必用真机跑一遍权限拒绝分支。这套工具集我建议你拿到后先别急着加功能,把示例里的设备信息、Toast、相机权限三个跑通,再照模板扩自己的 API。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进