ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Unity Android插件/SDK开发全流程:从AAR导出到C#封装实战

Unity Android插件/SDK开发全流程:从AAR导出到C#封装实战 做Unity开发最绕不开的一道坎就是“这功能Unity自己搞不定得去调安卓原生”。不管是接渠道SDK、用蓝牙、读串口、还是搞华为/小米推送Unity层那套C# API在Android上总有边界。最常见的方案就是写插件把Java/Kotlin层的功能包装成Unity能调的接口。这篇文章我就把Unity Android平台下插件/SDK开发的完整流程梳理一遍从环境准备、Android原生侧开发、AAR导出、Unity侧封装到实战中的坑一次性讲清楚适合刚接手Unity原生接入任务的开发也适合想系统搞懂这条链路的人。先交代一下背景。我最早接触这类需求是做渠道SDK对接Unity官方文档看得云里雾里网上资料又碎片化严重整整折腾了一周才把第一版跑通。后来做公司内部SDK、串口通信、XR设备能力接入踩的坑多了才慢慢把这套流程的内核抽出来无非就是“上下文传递、方法调用、数据回调”三件事。你把这三件事捋顺了任何插件/SDK开发都能套用同一个套路。1. Unity Android插件到底在解决什么问题1.1 哪些功能必须走原生层很多人一开始不明白Unity不是有AndroidJavaObject和AndroidJavaClass吗很多功能是不是直接调Java代码就行理论上是的但实际操作里你会撞上一堆墙。比如系统级能力读设备序列号、调前置摄像头、获取电池状态、注册广播接收器这些能用C#调但逻辑稍微复杂一点就绕不开Java再比如第三方SDK微信登录、支付宝支付、极光推送、友盟统计这类SDK基本都是面向Android原生环境设计的初始化需要Context、回调走的是Java接口Unity侧拿不到还有硬件相关的场景USB串口、蓝牙BLE、NFC读写这些底层API涉及文件描述符、线程和事件分发C#直接调原生API经常因为上下文不对而崩溃。1.2 桥接这件事说到底就三个核心点我后来总结过凡是Unity和Android原生通信本质上就是在处理三个问题上下文从哪来、方法怎么调、数据怎么回。上下文是最早翻车的地方。Android很多API必须要Activity或者Context对象而Unity在Android上跑的时候有一个UnityPlayerActivity它是整个Unity应用的入口。你要想拿Context标准姿势是通过UnityPlayer.currentActivity来拿。很多新手喜欢自己在Java侧new一个Context或者用ApplicationContext结果轻则功能异常重则直接闪退。原因很简单某些API必须绑定UI线程和Activity生命周期ApplicationContext拿不到窗口相关的信息。方法调用很简单但细节值得注意。Unity侧的AndroidJavaObject.Call()能调Java方法Java侧的UnityPlayer.UnitySendMessage()能把数据传回Unity。而数据回调的协议通常就是约定一个GameObject的名字和一个方法名Unity侧挂一个MonoBehaviour脚本Java侧拼好字符串调UnitySendMessage发送。下面我会把这三件事拆开讲透。2. 动手前先把环境理顺Unity、Android Studio、JDK与SDK2.1 版本匹配是第一个大坑我在多个项目里被版本问题坑过先说结论Unity和Android Studio、JDK、Gradle、SDK Build Tools之间不是随便配的它们的版本有严格的对应关系。拿最常用的Unity 2021 LTS来说它推荐的Gradle版本是6.1.1对应的Android Gradle Plugin版本是4.0.1JDK版本是1.8。但很多人电脑上装的是Android Studio最新版自带的JDK已经到11了这就会导致Unity打包时报Gradle版本不兼容。我的经验是Unity工程里尽量用Unity自己下载的JDK和SDK不要在Player Settings里手动指定一个系统装的路径否则版本错位很难查。如果你是新装的Android Studio默认的SDK Manager可以勾选但很多人在国内打开SDK Manager发现列表加载不出来或者某个组件复选框是灰色的没法勾选。前者是网络问题后者通常是Android Studio版本太老不认识新版SDK的版本号。处理办法很简单把Android Studio升级到当前发布的稳定版本然后再进SDK Manager重新勾选。还不行的可以直接去SDK官网手动下载platform和build-tools压缩包解压到你自己的SDK目录里虽然麻烦点但很管用。2.2 NDK不是必须装但装了不亏如果你的插件里包含C/C的代码比如串口通信里的so库、FFmpeg封装那必须配置NDK和CMake。Unity 2021以上打包默认会用自己的内置工具链但如果你的AAR里依赖了外部so记得在Unity的Player Settings里把“Use Embedded寄明信片寄送SDK”这类选项关掉然后勾选ARMv7和ARM64。如果漏了ARM64在真机运行时会直接提示“Unable to find libxxx.so”非常常见。还有一个小细节Android SDK的platform版本一定不能低于Unity要求的targetSdkVersion。比如Unity 2021默认的targetSdk是32那你SDK Manager里至少要装到Android 12否则打包会提示找不到android-32目录。3. 原生侧开发做一个“活”的Android Library3.1 用Library Module而不是新建App工程很多人习惯在Android Studio里新建一个普通的App工程写完代码再导出一个jar。但我的建议是Unity插件项目一定要建Library Module。原因有三个第一Library Module编译出来直接就是AAR格式Unity可以直接识别第二AAR可以带上AndroidManifest.xml、资源文件、so库而jar只能带class文件第三你在开发阶段可以直接用一个App壳工程去调用Library在Android Studio里就能调试不需要每次都打包进Unity开发效率高很多。我在一个真实项目里的做法是这样的工程名叫UnityBridgeSDK里面包含一个library模块叫bridge还有一个app模块叫demo。demo模块用来模拟Unity环境在MainActivity里写几个按钮去调用bridge的接口这样调试SDK逻辑时根本不用碰Unity。3.2 捕获Unity的上下文这是桥梁的“脐带”Library模块里第一个要写的类就是初始化类。核心逻辑是接收Unity传来的Activity把它保存为静态变量之后所有需要Context的地方都从这里取。package com.example.unitybridge; import android.app.Activity; public class UnityBridge { private static Activity sActivity; public static void init(Activity activity) { sActivity activity; } public static Activity getActivity() { return sActivity; } }有同学会问为什么要用Activity而不是ApplicationContext因为在Android里很多操作需要Activity实例比如弹出对话框、切换Fragment、获取当前窗口的DecorView。如果你用ApplicationContext去弹Toast没问题但弹Dialog就会崩。所以插件初始化时接收到的应该是UnityPlayer.currentActivity而不是getApplicationContext()。3.3 导出AAR的配置细节在Library Module里写完代码同步一下Gradle然后在Android Studio的右侧栏找到Gradle面板找到你的module点Tasks再点build双击assembleRelease或assembleDebug。构建完成后在module/build/outputs/aar/目录下就能看到生成的aar文件。这里有一个很关键的细节如果你的Library模块引用了其他第三方库比如okhttp、gson默认情况下assembleRelease生成的AAR并不会帮你把依赖打包进去。Unity侧直接放这个AAR还是会报ClassNotFoundException。解决办法有两种一是用fat-aar插件把依赖的库和资源全部合到同一个AAR里二是干脆把依赖的jar放到Unity工程的Plugins/Android目录下混编。我个人的习惯是能合并就合并避免Unity打包时libs目录下jar太多导致版本冲突。4. 接入Unity工程从AAR到C#封装4.1 放AAR、改Player Settings把上一步生成的bridge-release.aar复制到Unity工程的Assets/Plugins/Android/目录下。如果你是AndroidX项目且AAR里有AndroidX依赖还要在Plugins/Android下放一个mainTemplate.gradle打开并检查一下是否启用了AndroidX。接下来进Player Settings在Other Settings里调整几个关键项包名一定要和你的AndroidManifest或者UnityPlayerActivity所在包名匹配Scripting Backend建议选IL2CPPTarget Architectures勾选ARM64如果还兼容旧设备就勾上ARMv7Minimum API Level根据你的SDK要求来一般21以上就够了。如果你用的Unity版本比较新还需要在Publishing Settings里勾选Custom Main Manifest、Custom Main Gradle Template等选项这样Unity才会识别你自定义的Android构建文件。4.2 C#侧调用AndroidJavaObject使用规范C#调用Java的API核心就是AndroidJavaClass和AndroidJavaObject。AndroidJavaClass用来访问Java的静态类AndroidJavaObject用来操作实例对象。下面这段代码是我在项目里一直用的初始化封装using UnityEngine; public class AndroidBridge { private static AndroidJavaObject _bridgeInstance; public static void Init() { using (var unityPlayer new AndroidJavaClass(com.unity3d.player.UnityPlayer)) { var activity unityPlayer.GetStaticAndroidJavaObject(currentActivity); using (var bridgeClass new AndroidJavaClass(com.example.unitybridge.UnityBridge)) { bridgeClass.CallStatic(init, activity); _bridgeInstance bridgeClass.CallStaticAndroidJavaObject(getInstance); } } } public static string GetDeviceInfo() { return _bridgeInstance.Callstring(getDeviceInfo); } }这里有几个容易踩的坑。第一currentActivity是UnityPlayer这个Java类的静态字段你拿到的不是字符串也不是int必须用AndroidJavaObject来接第二CallStatic后面跟的方法名要和Java侧完全一致大小写都不能差第三每次Call完如果返回的是AndroidJavaObject记得用using包住或者手动调用Dispose否则一直持有引用重度调用会导致内存泄漏。我在一个项目里就是没管这个跑了一晚上内存涨了300MB查了好久才发现是这里的问题。4.3 双向往返UnitySendMessage必须遵守的两个规则从Java侧回调Unity侧标准做法是UnityPlayer.UnitySendMessage。这个方法签名的三个参数是GameObject名称、方法名、消息内容。Unity方法必须挂在游戏对象上并且是public、返回值是void。UnityPlayer.UnitySendMessage(BridgeManager, OnNativeCallback, hello from java);对应的Unity侧C#脚本是这样public class BridgeManager : MonoBehaviour { void OnNativeCallback(string message) { Debug.Log(收到Java的回调: message); } }这里面有两个坑是我实测踩过的。一是UnitySendMessage只能传一个字符串参数你传int、float都会失效因此复杂数据统一用JSON字符串传。二是UnitySendMessage必须在主线程调用如果你在Java侧开了子线程去执行耗时任务回来后直接调UnitySendMessageUnity可能不响应甚至在部分设备上会闪退。正确做法是在子线程里用runOnUiThread包装一下或者用Android的Handler切回主线程再调用UnitySendMessage。5. 从“一个插件”到“一套SDK”工程化设计5.1 SDK的单例与初始化协议如果你的目标是做一个供多个Unity项目复用的内部SDK不建议让业务方在C#侧手动去调Init。我在实际开发中会在Java侧做一个统一入口类比如SdkManager内部维护一个单例并且在init的时候把Unity的Activity传进去然后把Unity的GameObject名和方法名约定为一个全局静态常量这样SDK内部的所有模块登录、支付、推送、分享都可以通过同一个入口对外发消息。这种设计的好处是业务方接入成本低他们在C#侧只需要调用一句话SdkBridge.Init(BridgeManager, OnNativeCallback);之后所有事件都通过OnNativeCallback收到。坏处是如果你没有做好消息的路由和分发Unity侧那个回调方法会变成一个大杂烩什么都有。我在项目里是把收到的字符串约定为JSON格式JSON里带type字段然后Unity侧用switch分发到对应的逻辑。一旦出问题也能通过日志快速定位是哪个模块的哪个消息。5.2 生命周期、线程和内存释放生命周期管理这块容易被人忽视。Unity的OnApplicationPause和OnApplicationResume要能同步到Java侧否则你在Android的原生生命周期里做的一些事情会错位。比如SDK里如果有前台服务或者定位监听Unity切到后台时Java侧应该暂停切回前台时再恢复。我常用的做法是在C#侧的BridgeManager里重写OnApplicationPause和OnApplicationFocus调用Java侧的对应方法void OnApplicationPause(bool paused) { AndroidBridge.CallNativeMethod(paused ? onPause : onResume); }线程问题前面提到了这里再强调一遍UnitySendMessage必须在主线程AndroidJavaObject.Call也必须注意线程。有些Unity开发者习惯在C#侧开Task去处理耗时逻辑然后回调里直接调AndroidBridge这往往会因为线程没切回主线程而出问题。我的建议是Unity侧的所有Java调用都放在Unity主线程里耗时操作放到Java侧做Java侧做完再通过UnitySendMessage把结果抛回Unity。内存释放方面Java侧注册的BroadcastReceiver、Service连接等一定要在Unity销毁的时候反注册否则会泄漏。C#侧要注意AndroidJavaObject的Dispose。我在后面会专门写一节讲常见问题那里也会提这个。5.3 版本管理、混淆与资源合并给SDK做版本号是一个很容易被忽略的点。我见过不少团队Unity工程里放AAR改了代码就重新导出一份但从不更新版本号最后线上出了问题根本不知道用是哪个版本。我的习惯是在Java侧的SdkManager里加一个VERSION常量同时Unity侧SdkBridge里也维护一个字符串版本号初始化时校验一次不一致就报警告日志。这样还能提前发现“项目资源没更新”的问题。混淆配置也是一个重点。如果你在Android Library里开了minifyEnabled导出AAR时一定要同步一个consumerProguardFiles否则Unity工程打包时SDK里的类名被混淆了C#侧就找不到类了。最稳妥的方案是Android Library模块不开混淆混淆留到Unity最终打包时统一处理然后Proguard里keep住你的SDK包名。6. 三个真实场景的避坑拆解6.1 串口通信硬件类SDK必踩的路有段时间我做的Unity项目要接一个工业级传感器数据通过安卓设备的USB串口出来。Unity自己肯定没有串口API所以必须走Android原生。这时候你需要的是串口so库比如用经典的google usb serial库或者工业设备厂商提供的串口SDK。通用流程是Java侧拿到串口设备节点比如/dev/ttyS1设置波特率、数据位、停止位然后开一个线程循环读取串口数据。读到的二进制数据转成十六进制字符串通过UnitySendMessage发给Unity侧。这个场景里最坑的是权限和IO流管理。串口设备节点一般都要求root或特定系统权限普通App根本没有/dev/ttyS1的读写权限。我在项目里折腾了很久最后方案是让设备系统替我们放开权限或者在AndroidManifest里声明设备专属权限。此外串口流不关闭的话会一直占着文件描述符设备一多就崩。Java侧必须在Activity销毁时关闭输入输出流并且在C#侧每次收到串口数据后做CRC校验因为串口传输偶发丢字节是常态。6.2 扩大按钮点击范围和World UI遮挡很多人会在Unity里遇到一个问题一个按钮的点击区域特别小用户怎么都点不准。常规解决思路是给Image加一个透明的扩大层或者重写Graphic的raycast目标区域但如果你是AR/VR项目Pico或Quest这类设备上这种方式有时不生效因为它们的事件系统走的是XR Interaction Toolkit。我在Pico4项目上就遇到过一次UIRaycaster对透明区域默认不响应导致按钮死活点不中。这时候绕弯的办法是把按钮的Image底色设成透明但保留一个可点击的alpha值并设置Image.alphaHitTestMinimumThreshold为0.1这样能保证点击判定严格基于像素的alpha测试。这个属于Unity纯逻辑问题但往往会和Android原生那边的触控事件混在一起误判我把这个问题列进来就是提醒大家不要所有交互问题都往原生插件方向想有时候引擎本身就有解。World UI无遮挡这个问题不少人也遇到过。UI明明在最前面却被场景里的3D物体挡住。常规做法是把Canvas的renderMode设为ScreenSpaceOverlay但WorldSpace下的UI就没办法了。我的经验是通过Shader的ZTest解决把UI材质球的ZTest改成Always或者在URP里单独调一下UI的RenderPass的深度测试。这个痛点在于有时候改了某个版本的Unity渲染管线UI材质会失效所以要有在Shader层面排查的思路。6.3 文件Provider与URI权限的坑做社交分享、保存文件这类功能时你会遇到“content://com.tencent.wework.fileprovider/external_path/...”这种报错。在很多安卓7.0以上的设备上从相册选图片然后用FileProvider回调给你的App修改文件的时候URI不是file://开头而是content://开头。如果SDK里还在傻傻地用File去new一个FileInputStream大概率会抛FileNotFoundException。常见场景是Unity项目接了分享SDK要把一张图片传到微信或企业微信。微信SDK的回调给了你一个content Uri你要读取这张图片。正确做法是Java侧通过ContentResolver打开输入流而不是直接用new File(Uri.getPath())。这个坑其实和Unity关系不大但和插件SDK开发关系很大因为Unity层传过来的图片路径通常还是一个本地路径容易让人忽视真实Uri差异。后来我在封装的SDK里专门做了一个工具类同时支持file://和content://两种协议按协议类型分流处理问题才彻底解决。7. 高频报错与排查思路7.1 命令行崩溃的几个高频现象先列我遇到最多的几个报错给一张速查表方便大家直接对照现象大概率原因处理方式ClassNotFoundException: com.example...AAR没有正确打入包或者打包时被混淆检查Plugins/Android下的AAR是否在最终APK内检查proguard规则MethodNotFoundExceptionJava侧没有对应的方法名或者签名不一致反编译APK确认方法名或直接在Android Studio里看Java代码Unable to find libxxx.so缺少对应ABI的so库检查Player Settings的Target Architectures确保ARMv7/ARM64勾选Gradle project sync failed版本不兼容对照Unity版本、Gradle、AGP版本统一调整UnitySendMessage没有回调线程错误或者GameObject名字错误确保在主线程调用确保C#方法所在的GameObject在场景中一直存在Toasting on non-UI thread子线程弹Toast用Handler切主线程这里有一条通用排查思路Unity打包的报错先看Build Report再看Gradle Console最后启动App看logcat。logcat里一般会直接打印Java层的崩溃堆栈比Unity的Console要详细得多。AndroidStudio的Logcat可以直接连真机看启动App后用adb logcat -s Unity来过滤我每次排查插件问题基本都是这么查的。7.2 内存泄漏和对象生命周期的实操心得最后讲一个容易被忽视但很重要的点AndroidJavaObject的引用。Unity调用Java层返回的AndroidJavaObject如果没有正确释放在IL2CPP下会持有Java虚拟机里的引用导致GC一直回收不掉。表现是项目跑着跑着内存高得离谱切后台再回来直接闪退。我后来的规范是所有Call返回的AndroidJavaObject要么在using语句块内使用要么手动调用Dispose绝对不裸放。这个规范在我们代码评审里是写进checklist的。还有一点应用场景切换的时候比如从Unity界面切到Android原生Activity比如微信授权页C#侧的MonoBehaviour代码不会走OnDisable这时候你在Java侧维护的一些状态需要通过UnityPlayer.UnitySendMessage来同步。我在一个登录SDK项目里遇到过用户跳去微信授权回来后Unity的UI状态没刷新因为回调消息比Unity的OnApplicationFocus还早到达导致页面显示“登录中”但实际已经登录了。后来我在Java侧加了延时发送并且在Unity侧做了状态机去兜底才算彻底解决。写在最后的几条经验这套流程我前前后后给公司搭了三遍最大的感受是插件开发本身不复杂复杂的是环境之间的版本匹配和数据交互的边界问题。新项目踩坑多老项目踩坑少不是因为老项目代码写得好而是把坑都填平了。个人建议项目一启动就固定Unity版本和Android构建工具版本别没事升级升级带来的收益通常远小于修兼容性问题的成本。还有一个技巧大家可以试试在Java侧写一个debug接口把插件里所有模块的版本号、上下文是否初始化、权限是否授予、最近一次回调时间全部拼成一个字符串返回给Unity。出问题的时候在Unity侧打一句日志就能定位八九成比盲猜效率高得多。如果看完这篇你正准备做一个Unity的Android插件建议先别直接导AAR花半天时间把Android Studio的Demo跑通在原生环境把每一个接口都调一遍再封装成Unity接口。原生层没问题Unity侧最多只是调用姿势不对好查如果原生层就有问题混在Unity工程里查起来真的是地狱模式。
返回列表