Unity与Android交互避坑指南:JNI性能、线程安全与内存管理
1. 项目概述:为什么Unity与Android交互是个“技术雷区”?
如果你是一名Unity开发者,并且你的项目需要调用Android原生功能,比如获取设备信息、集成第三方SDK、处理推送通知,或者仅仅是做一个简单的“退出游戏”确认框,那么你大概率已经或即将踏入这个“技术雷区”。Unity与Android的交互,表面上看只是简单的函数调用,但实际操作起来,却是一个充满细节陷阱、版本兼容性问题和性能隐患的复杂过程。我见过太多项目,因为一个不起眼的交互问题,导致应用在特定机型上崩溃、ANR(应用无响应),或者仅仅是启动就慢了好几秒。
这个“雷区”的核心在于,Unity本质上是一个运行在Android Dalvik/ART虚拟机上的一个“应用”,而它又要通过JNI(Java Native Interface)去调用另一个“世界”(Android Java/Kotlin层)的代码。这中间涉及了Unity Player的Activity生命周期、Android的UI线程与Unity渲染线程的通信、数据类型的转换、内存管理,以及不同Android版本和厂商ROM的“个性化”行为。任何一个环节处理不当,轻则功能异常,重则直接导致应用崩溃。因此,理解这些陷阱并掌握优化策略,不是“加分项”,而是保证项目稳定上线的“必修课”。
2. 交互架构的基石:JNI与AndroidJavaClass/AndroidJavaObject
在深入陷阱之前,我们必须先夯实基础。Unity提供了两种主要方式与Android交互:底层的C# JNI调用和封装好的AndroidJavaClass/AndroidJavaObject。理解它们的区别是避坑的第一步。
2.1 两种方式的原理与选型考量
AndroidJavaClass/AndroidJavaObject是Unity封装的高级API,使用起来非常直观,就像在C#里调用Java对象一样。它的优点是简单易用,无需手动处理JNIEnv、jobject等复杂指针。
// 示例:调用Android的Toast显示消息 AndroidJavaClass unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer"); AndroidJavaObject currentActivity = unityPlayer.GetStatic<AndroidJavaObject>("currentActivity"); AndroidJavaClass toastClass = new AndroidJavaClass("android.widget.Toast"); AndroidJavaObject toast = toastClass.CallStatic<AndroidJavaObject>("makeText", currentActivity, "Hello from Unity!", toastClass.GetStatic<int>("LENGTH_SHORT")); toast.Call("show");然而,这种便利性背后隐藏着性能开销。每一次new AndroidJavaClass、Call或GetStatic操作,Unity引擎底层都会执行一次完整的JNI调用,涉及查找类、方法ID、参数编组(Marshaling)等步骤。在频繁调用的场景(如一帧内多次获取传感器数据),这会成为性能瓶颈。
纯C# JNI调用则更为底层和高效。你需要直接使用IntPtr、JNIEnv等接口,代码更复杂,但控制粒度更细,性能更好。通常用于对性能要求极高的核心交互,或者封装成稳定的插件供项目内反复使用。
// 简化示例:通过JNI获取当前Activity private static IntPtr _unityActivity = IntPtr.Zero; public static IntPtr GetUnityActivity() { if (_unityActivity == IntPtr.Zero) { using (AndroidJavaClass unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer")) using (AndroidJavaObject activity = unityPlayer.GetStatic<AndroidJavaObject>("currentActivity")) { _unityActivity = activity.GetRawObject(); } } return _unityActivity; } // 后续可以使用 _unityActivity 和 JNIEnv 方法进行高效调用选型策略:
- 对于一次性或低频调用(如初始化SDK、显示一个对话框),优先使用
AndroidJavaClass/AndroidJavaObject,开发效率高。 - 对于高频调用(如每帧获取陀螺仪数据、音频输入),必须使用纯JNI进行封装,甚至考虑将数据缓存到本地,通过共享内存等方式减少JNI调用次数。
- 对于需要封装给团队使用的插件,建议内部采用高效JNI实现,对外提供简洁的C# API。
2.2 版本适配与架构选择背后的“坑”
你以为选好了调用方式就万事大吉了?Android的碎片化会让你知道什么叫“防不胜防”。这里最大的陷阱是Target API Level(目标API级别)和Android Gradle插件版本的兼容性问题。
陷阱1:JNI方法签名变更从Android 8.0(API 26)开始,一些原生方法(Native Method)的签名或行为发生了改变。如果你的插件使用了JNI,并且在AndroidManifest.xml中声明了targetSdkVersion >= 26,但插件编译时依赖的却是旧版本的Android SDK,那么在调用这些方法时可能会遇到UnsatisfiedLinkError或静默失败。
规避策略:始终使用与你项目targetSdkVersion匹配或更新的Android SDK来编译你的原生插件(.so文件或AAR)。在Unity中,可以通过Player Settings -> Android -> Publishing Settings -> Minify下的ProGuard规则来保留必要的JNI方法。
陷阱2:Unity版本与Gradle构建系统的兼容性Unity 2019.3及以后版本,默认使用Gradle来构建APK,并逐渐淘汰了旧的内部构建系统。许多老旧的Android插件(特别是直接提供AndroidManifest.xml和res文件夹的)在Gradle构建下可能会因为资源合并冲突、依赖冲突而失败。
规避策略:
- 升级插件:寻找插件作者提供的Gradle兼容版本,或使用
.aar格式的插件包,它比原始的.jar+资源文件更规范。 - 手动处理Gradle文件:在Unity项目的
Assets/Plugins/Android目录下,可以放置mainTemplate.gradle文件来自定义依赖和构建规则。这是解决依赖冲突的终极手段。// mainTemplate.gradle 示例:排除冲突的依赖包 dependencies { implementation('com.some.plugin:plugin-aar:1.0.0') { exclude group: 'com.android.support', module: 'support-v4' } } - 注意Jetpack(AndroidX)迁移:如果插件使用了Android支持库(android.support.v7),而你的项目或其他插件使用了AndroidX,会发生严重的冲突。Unity 2020.1+ 提供了
Player Settings -> Android -> Publishing Settings -> Jetifier选项,可以自动迁移支持库到AndroidX,但这并非百分百可靠,手动检查和调整依赖仍是必要的。
3. 线程安全的“隐形杀手”:主线程与子线程通信
这是导致ANR和诡异崩溃的最常见原因之一。一个黄金法则:所有涉及Android UI的操作,必须在主线程(UI线程)上执行。
3.1 为什么不能在Unity线程直接调用UI?
Unity的脚本(如MonoBehaviour.Update)运行在Unity自有的渲染线程或脚本线程上,这不是Android的UI线程。如果你在这个线程里直接调用一个显示Toast、弹出Dialog或者更新TextView的方法,在低版本Android上可能侥幸运行,但在高版本或某些厂商ROM上,会立即抛出CalledFromWrongThreadException,导致应用崩溃。
3.2 安全的线程间通信方案
方案一:使用UnityPlayer.currentActivity.runOnUiThread这是最直接、最常用的方法。它将一个Runnable任务投递到Android UI线程队列中执行。
public void ShowToastOnUIThread(string message) { AndroidJavaClass unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer"); AndroidJavaObject activity = unityPlayer.GetStatic<AndroidJavaObject>("currentActivity"); // 注意:这里传入的Runnable是一个AndroidJavaProxy,它允许C#代码实现Java接口 activity.Call("runOnUiThread", new AndroidJavaRunnable(() => { AndroidJavaClass toastClass = new AndroidJavaClass("android.widget.Toast"); AndroidJavaObject toast = toastClass.CallStatic<AndroidJavaObject>("makeText", activity, message, toastClass.GetStatic<int>("LENGTH_SHORT")); toast.Call("show"); })); }关键细节:AndroidJavaRunnable是Unity提供的一个AndroidJavaProxy,用于实现Java的Runnable接口。确保回调内的代码简洁,避免嵌套复杂的逻辑。
方案二:通过Android Handler/Looper机制对于更复杂的、需要从子线程频繁向主线程发送消息的场景,可以在Android侧创建一个Handler,关联到主线程的Looper。Unity侧通过JNI调用这个Handler的post或sendMessage方法。
// Android插件代码 (Java) public class UnityBridge { private static Handler mainHandler; static { mainHandler = new Handler(Looper.getMainLooper()); } public static void postToMainThread(Runnable task) { mainHandler.post(task); } }// Unity C# 代码 AndroidJavaClass bridgeClass = new AndroidJavaClass("com.yourcompany.UnityBridge"); bridgeClass.CallStatic("postToMainThread", new AndroidJavaRunnable(() => { // 在主线程执行UI操作 }));方案三:谨慎使用AsyncTask(已废弃)虽然AsyncTask曾是标准方案,但它在Android 11(API 30)中已被正式废弃。在新项目中应避免使用,老项目迁移时也需注意。
实操心得:不要在
runOnUiThread的回调里执行耗时操作!这会让UI线程卡住,直接导致ANR。UI线程只应该用于快速更新界面。任何可能耗时的操作(如网络请求、文件读写、复杂计算),都应该在子线程中发起,待完成后,再通过上述方法回到主线程更新UI。
4. 内存泄漏与对象管理的“慢性病”
JNI交互中的对象管理不当,不会立刻导致崩溃,但会像“慢性病”一样逐渐消耗内存,最终引发OOM(Out Of Memory)和程序卡顿。
4.1 本地引用(Local Reference)的泄漏
每次通过JNI(包括AndroidJavaObject)创建的Java对象,在本地都会有一个对应的“本地引用”。JNI规范要求开发者需要管理这些引用,尤其是在本地方法中。虽然Unity的封装层在一定程度上帮我们管理了AndroidJavaObject的生命周期(通过C#的Dispose模式或垃圾回收),但在高频或复杂调用中,仍需注意。
陷阱:在循环中大量创建AndroidJavaObject而不及时释放。
// 错误示例:每一帧都创建新的Java对象 void Update() { AndroidJavaObject jo = new AndroidJavaObject("java.util.Date"); string time = jo.Call<string>("toString"); // jo 的本地引用在本帧结束后才会被GC回收,如果Update频率高,会快速积累 }优化策略:
- 缓存复用:对于频繁使用的Java类静态实例或Activity对象,在
Awake或Start中初始化并缓存。private AndroidJavaObject _cachedActivity; void Start() { AndroidJavaClass unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer"); _cachedActivity = unityPlayer.GetStatic<AndroidJavaObject>("currentActivity"); } void SomeMethod() { // 使用 _cachedActivity,而不是每次都new _cachedActivity.Call("runOnUiThread", ...); } void OnDestroy() { if (_cachedActivity != null) { _cachedActivity.Dispose(); // 显式释放 _cachedActivity = null; } } - 使用
using语句:对于确定生命周期的临时对象,使用using确保其Dispose被及时调用。using (AndroidJavaClass clazz = new AndroidJavaClass("some.class")) { // 使用clazz } // 离开作用域时自动Dispose - 对于纯JNI调用:务必记得使用
JNIEnv.DeleteLocalRef来释放手动创建的本地引用。
4.2 全局引用(Global Reference)与弱全局引用(Weak Global Reference)
如果你需要将一个Java对象(如一个回调监听器)长期保存在C#侧,以便后续调用,你不能仅仅保存它的本地引用(因为本地引用在方法返回后可能失效)。这时需要创建全局引用。
陷阱:创建了全局引用,但在C#对象销毁时忘记删除它,导致Java对象永远无法被垃圾回收。
private IntPtr _globalListenerRef = IntPtr.Zero; void SetupListener() { // 假设创建了一个Java监听器对象 jobject listener = ... _globalListenerRef = JNIEnv.NewGlobalRef(listener); JNIEnv.DeleteLocalRef(listener); // 删除本地引用 } // 如果忘记在OnDestroy中删除全局引用,就会内存泄漏正确做法:
void OnDestroy() { if (_globalListenerRef != IntPtr.Zero) { JNIEnv.DeleteGlobalRef(_globalListenerRef); _globalListenerRef = IntPtr.Zero; } }更优选择:考虑使用弱全局引用(JNIEnv.NewWeakGlobalRef)。它允许Java对象在只有弱引用指向时被GC回收。当你需要使用时,先通过JNIEnv.IsSameObject检查对象是否还存在。这适用于缓存那些生命周期可能比C#侧对象更短的Java对象(如Activity的Context)。
5. 数据传递的“性能黑洞”:从基础类型到复杂对象
在Unity(C#)和Android(Java)之间传递数据,每一次跨越边界都是一次性能开销。数据类型越复杂,开销越大。
5.1 基础类型与字符串
基础类型(int,float,bool等)的传递开销相对较小。但字符串是个需要特别注意的类型。C#的string和Java的String在内存中的编码格式都是UTF-16,但JNI传递时仍然涉及一次内存拷贝和编码确认。
优化策略:
- 避免高频传递长字符串:例如,不要每帧都传递一个完整的JSON字符串。可以考虑在Android侧解析JSON,只将必要的数值结果传递回Unity。
- 使用
StringBuilder(谨慎):对于需要在两端多次拼接的字符串,在Java侧使用StringBuilder,最后一次性传递结果。但这通常只在字符串操作本身是瓶颈时才有效,JNI调用的开销往往更大。
5.2 复杂对象与序列化
传递自定义类对象、列表或字典是开发中的常见需求。直接传递是不可能的,必须进行序列化。
常见陷阱与方案对比:
| 方案 | 实现方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| JSON序列化 | 使用JsonUtility(Unity)、Gson/Jackson(Android)将对象转为字符串传递。 | 实现简单,跨语言兼容性好,可读性强。 | 性能开销大(序列化/反序列化 + 字符串JNI传递),频繁调用时是性能杀手。 | 配置信息初始化、低频的复杂数据交换。 |
| ProtoBuf / FlatBuffers | 使用二进制序列化协议。 | 性能极高,数据体积小,序列化/反序列化速度快。 | 需要预先定义Schema(.proto文件),增加复杂度,二进制数据不可读。 | 高频数据交换(如游戏实时状态同步)、对性能要求苛刻的场景。 |
| 直接传递基础类型数组 | 对于纯数值数组(如float[]位置数据、int[]状态数组),可以通过JNI直接访问数组指针。 | 性能极致,几乎零拷贝。 | 仅适用于基础类型数组,需要手动管理内存和同步。 | 传感器数据流(陀螺仪、加速度计)、顶点数据传递。 |
| 共享内存/内存映射文件 | 在Native层(C/C++)开辟一块内存,Unity和Android原生代码均可直接访问。 | 性能极高,真正的零拷贝。 | 实现最复杂,需要编写JNI和Native代码,同步和生命周期管理困难。 | 极高性能要求的场景,如摄像头YUV帧实时处理、音频PCM数据流。 |
实操建议:对于大多数游戏交互,JSON用于低频配置,ProtoBuf用于高频网络或跨平台数据,基础类型数组用于传感器数据,是一个平衡了开发效率和运行性能的务实选择。
5.3 使用JNI直接操作数组(高级优化)
这是一个可以极大提升传感器等数据传递效率的技巧。以传递陀螺仪数据为例:
// Unity C# 侧 [DllImport("your_native_lib")] private static extern IntPtr GetGyroDataNative(); // 获取Native层数组指针 [DllImport("your_native_lib")] private static extern int GetGyroDataLengthNative(); // 获取数组长度 void Update() { IntPtr arrayPtr = GetGyroDataNative(); int length = GetGyroDataLengthNative(); if (arrayPtr != IntPtr.Zero && length > 0) { // 将原生内存数据直接拷贝到C#数组,避免JNI多层调用 float[] gyroData = new float[length]; Marshal.Copy(arrayPtr, gyroData, 0, length); // 使用 gyroData... } }对应的Native(C/C++)代码需要管理一个全局的float[]数组,并由Android的传感器服务直接填充。Unity通过P/Invoke拿到数组指针直接读取,实现了近乎零开销的数据传递。
6. 生命周期同步的“幽灵问题”:Activity与Unity Player
Unity游戏运行在一个特殊的Activity(通常是UnityPlayerActivity)中。这个Activity的生命周期(onCreate, onPause, onResume, onDestroy)必须与Unity引擎的生命周期(Awake, OnApplicationPause, OnApplicationFocus, OnDestroy)妥善同步,否则会出现诸如“切回游戏时黑屏”、“声音播放异常”、“传感器失效”等问题。
6.1 常见生命周期不同步陷阱
陷阱1:在Unity的OnApplicationPause(false)(即恢复)时,Android Activity的onResume尚未完成如果你在OnApplicationPause(false)中立即调用一个需要Activity上下文(如显示对话框)的Android方法,可能会因为上下文未就绪而失败。
解决方案:Unity提供了AndroidLifecycleEventListener接口(位于UnityEngine.Android命名空间)。你可以实现它来更精确地监听Android原生的生命周期回调,确保调用时机正确。
public class AndroidLifecycleHandler : AndroidLifecycleEventListener { public void OnActivityCreated(AndroidJavaObject activity, AndroidJavaObject savedInstanceState) {} public void OnActivityDestroyed(AndroidJavaObject activity) {} public void OnActivityPaused(AndroidJavaObject activity) { // Android Activity进入后台 } public void OnActivityResumed(AndroidJavaObject activity) { // Android Activity回到前台,此时可以安全调用UI相关方法 Debug.Log("Android Activity Resumed, safe to show UI now."); } // ... 其他方法 } // 在Awake中注册监听 void Awake() { if (Application.platform == RuntimePlatform.Android) { new AndroidLifecycleHandler(); } }陷阱2:Unity场景切换时,Android Native UI未正确隐藏假设你在Android侧弹出了一个原生对话框,当Unity加载新场景时,这个对话框可能不会自动消失,会悬浮在画面上。
解决方案:在Unity场景切换或游戏暂停时,主动通知Android侧关闭所有弹出的UI。
- 在Android插件中,维护一个当前打开对话框的引用。
- 在Unity的
OnApplicationPause(true)或特定场景卸载事件中,调用一个Android方法(如dismissAllDialogs())来关闭所有对话框。
陷阱3:Android配置变更(如屏幕旋转)导致Activity重建默认情况下,屏幕旋转会销毁并重建Activity。如果Unity侧没有正确处理,可能会导致游戏状态丢失或交互上下文失效。
解决方案:
- 固定屏幕方向:在
Player Settings -> Resolution and Presentation -> Default Orientation中设置为固定方向(如Landscape Left)。这是游戏最常用的方法,一劳永逸。 - 处理配置变更:在
AndroidManifest.xml中为Unity的Activity添加android:configChanges属性,声明由自己处理某些配置变更,避免Activity重建。
同时,需要在Unity中监听<activity android:name="com.unity3d.player.UnityPlayerActivity" android:configChanges="fontScale|keyboard|keyboardHidden|locale|mnc|mcc|navigation|orientation|screenLayout|screenSize|smallestScreenSize|uiMode|touchscreen">OnConfigurationChanged事件(通过AndroidLifecycleEventListener或JNI)来手动调整游戏布局(对于UI适配要求高的应用)。
6.2 初始化与反初始化的最佳实践
初始化:将Android插件的初始化(如获取Context、初始化SDK)放在Unity的Awake或Start方法中,并确保只初始化一次(使用单例模式或静态标志位)。
反初始化:在Unity的OnDestroy或OnApplicationQuit中,务必调用Android插件的清理方法,释放资源、注销监听器、关闭连接等。许多SDK(如推送、语音、广告)要求显式调用destroy或release方法,否则可能引起内存泄漏或后台服务异常。
void OnDestroy() { if (Application.platform == RuntimePlatform.Android) { // 调用Android侧清理方法 using (AndroidJavaClass pluginClass = new AndroidJavaClass("com.your.plugin.UnityBridge")) { pluginClass.CallStatic("release"); } // 释放缓存的全局引用等 } }7. 调试与问题排查的“实战手册”
当交互出现问题时,清晰的排查路径能节省大量时间。
7.1 日志输出:你的第一道防线
Unity侧:使用Debug.Log。确保在Player Settings -> Android -> Other Settings -> Debugging中勾选了Enable ADB和Logging。
Android侧:使用Log.d(String tag, String msg)。这是最重要的调试手段。你需要通过adb logcat命令来查看。
- 查看所有日志:
adb logcat -s Unity DEBUG(过滤Unity和DEBUG级别) - 查看特定Tag的日志:
adb logcat -s “YourPluginTag” - 查看崩溃堆栈:
adb logcat *:E(查看所有ERROR级别日志)
一个关键技巧是在JNI调用前后、回调函数入口处都打上日志,可以清晰看到执行流。
7.2 常见错误与崩溃分析
| 错误现象 | 可能原因 | 排查步骤 |
|---|---|---|
| 应用启动即崩溃 | 1. AndroidManifest.xml配置错误(权限、Activity声明)。 2. 插件依赖的.so库架构不匹配(armeabi-v7a, arm64-v8a)。 3. 插件初始化代码在Awake中抛出异常。 | 1. 检查adb logcat输出的崩溃堆栈,重点关注AndroidRuntime和Unity的报错。2. 检查APK包中 lib/目录下是否有对应架构的.so文件。3. 将初始化代码用try-catch包裹,输出异常信息。 |
| 调用某个方法时崩溃 | 1. 方法名或签名错误。 2. 调用线程不正确(非UI线程调用UI)。 3. 传递的参数类型或数量不对。 4. 对应的Java对象为null。 | 1. 使用javap -s命令检查Java类的准确方法签名。2. 确保UI操作在 runOnUiThread中。3. 检查C#调用代码与Java方法签名的严格匹配(注意 bool对应Java的Z,int对应I等)。4. 在调用前检查 AndroidJavaObject是否为null。 |
| ANR(应用无响应) | 1. 在主线程(UI线程)执行了耗时操作(如同步网络请求、大量文件IO)。 2. 死锁(如Unity与Android互相等待回调)。 | 1. 检查所有通过runOnUiThread或直接在主线程回调中执行的代码。2. 使用 adb logcat查看ANR发生时的堆栈信息(adb logcat -b events)。3. 梳理回调逻辑,避免循环等待。 |
| 功能间歇性失效 | 1. 生命周期不同步,在Activity未就绪时调用。 2. 对象被垃圾回收(如回调监听器使用了弱引用或未保持引用)。 3. 多线程竞争条件。 | 1. 添加生命周期日志,确认调用时机。 2. 检查关键对象的引用是否被意外释放。 3. 检查是否有多个线程同时访问和修改同一资源,考虑加锁。 |
| 性能低下,卡顿 | 1. 高频JNI调用(如每帧多次)。 2. 传递的数据量过大(如图片字节数组)。 3. 序列化/反序列化开销大。 | 1. 使用Profiler查看Update或FixedUpdate中JNI调用的耗时。2. 优化数据传递策略(见第5节)。 3. 考虑将高频调用移至Native插件,或使用数据缓冲池。 |
7.3 使用Android Profiler与Unity Profiler进行性能剖析
- Android Profiler (Android Studio):连接设备后,可以监控应用的CPU、内存、网络、电量使用情况。特别关注在Unity与Android交互时,Java堆内存是否有异常增长(可能内存泄漏),以及CPU使用率是否出现尖峰(可能由低效的JNI调用或序列化引起)。
- Unity Profiler:在
Development Build下运行游戏,通过Profiler查看。重点关注:- CPU Usage:查看
Mono和GfxDevice线程的耗时。如果Mono线程出现不明的耗时峰值,很可能来自低效的C#-Java交互。 - Memory:查看
GC Alloc(垃圾回收分配)。频繁的、大量的GC Alloc往往意味着在交互中产生了大量临时对象(如字符串、数组),需要优化。
- CPU Usage:查看
7.4 一个实用的调试工具类
我通常会编写一个简单的调试工具类,它封装了日志和错误处理,便于在开发阶段快速定位问题。
public static class AndroidInteropDebugger { private const string TAG = "Unity2Android"; public static bool IsDebugBuild = true; public static void Log(string message) { if (!IsDebugBuild) return; Debug.Log($"[{TAG}] {message}"); // 同时输出到Android Logcat (可选) try { using (AndroidJavaClass logClass = new AndroidJavaClass("android.util.Log")) { logClass.CallStatic<int>("d", TAG, message); } } catch {} } public static T SafeCall<T>(AndroidJavaObject jo, string methodName, params object[] args) { try { return jo.Call<T>(methodName, args); } catch (System.Exception e) { LogError($"Call {methodName} failed: {e.Message}"); return default(T); } } // ... 类似的安全封装方法 }8. 高级优化与未来考量
8.1 使用Unity新的Android交互API(Unity 2022.2+)
从Unity 2022.2开始,Unity引入了一套新的、更现代的Android交互API(位于UnityEngine.Android命名空间),旨在替代部分AndroidJavaClass的使用。例如,AndroidActivity类提供了更安全、更方便的方式来获取当前Activity。
// 新的API方式 var currentActivity = AndroidActivity.CurrentActivity; if (currentActivity != null) { // 使用 currentActivity 进行调用 }这套API内部做了更多优化和错误处理,建议在新项目中优先考虑使用。但需要注意其版本兼容性。
8.2 将复杂功能封装为Android Archive (AAR)
对于功能复杂的插件,强烈建议将其打包成.aar文件,而不是零散的.jar、资源文件和AndroidManifest.xml。AAR是一个标准的Android库格式,包含了编译好的代码、资源、清单文件和预编译的Native库,由Gradle自动处理依赖和合并,能极大减少配置冲突。
打包AAR的简单步骤:
- 在Android Studio中创建一个新的
Android Library模块。 - 实现你的Java/Kotlin代码,并处理好与Unity的接口(通常是一个继承自
UnityPlayerActivity的类或提供静态方法)。 - 在模块的
build.gradle中配置好依赖。 - 执行
Build -> Make Module,在build/outputs/aar/目录下找到生成的.aar文件。 - 将其放入Unity项目的
Assets/Plugins/Android目录中。
8.3 拥抱Kotlin与Coroutines
如果你的Android插件需要处理异步操作(如网络请求、数据库查询),强烈建议使用Kotlin协程(Coroutines)来代替传统的AsyncTask或Thread+Handler。协程的代码更简洁,能更好地处理生命周期,避免回调地狱。
在Unity中调用一个挂起函数(suspend function)的Kotlin协程,可以通过回调或者使用CompletableFuture进行桥接。这要求你的插件有更良好的架构设计,但带来的可维护性和性能提升是值得的。
8.4 关注Unity版本升级与Android API演进
Unity每年都会发布多个主要版本,Android系统也在不断更新。在升级Unity或提高项目的targetSdkVersion时,务必重新测试所有的Android交互功能。关注官方文档的更新日志,特别是关于Android构建支持和JNI相关的改动。例如,Unity对Android App Bundle (AAB)格式的支持、对Play Asset Delivery的集成等,都可能影响插件的工作方式。
我个人在多个项目的踩坑经验是,建立一个简单的“交互测试场景”,里面包含了所有关键的Android功能调用(UI、传感器、文件、网络等)。在每次引擎升级或插件更新后,跑一遍这个测试场景,是预防线上事故最有效的手段。
