
最近在给项目组接一个第三方的Android SDK对方不给aar也不给jar直接甩过来一堆Java源码和一份“自行集成”的说明。Unity这边的情况很尴尬一方面不想为了维护一个小库就引入整个Android Studio工程另一方面业务层还要兼顾iOS所以最干净的做法就是把这份Java代码打成aar扔进Assets/Plugins/Android里让Unity在构建APK时自动打包进去。网上搜下来讲“Android Studio里怎么创建Library Module”的文章车载斗量但真正记录怎么不打开Android Studio、纯靠命令行和Gradle手动把Java源码打成aar的内容少得可怜。我翻了不少资料也踩了几次编译失败和运行时ClassNotFound的坑最后终于把这条链路彻底打通了。这篇文章就按我在实际项目里跑通的流程拆开揉碎地讲一遍从为什么非要“手动”打包到环境准备、Gradle工程怎么写、aar怎么接入Unity、C#和Java怎么互相调用再到一堆绕过不掉的坑全部记录下来给后面被同样需求折磨的人省点时间。1. 为什么要把Java手动打成aar1.1 aar就是一个“装了class的zip包”很多Unity开发者分不清jar和aar的差别简单说jar里面只有纯Java的class没法带Android资源文件aar本质上是zip压缩包里面除了classes.jarJava源码编译出的class压缩包还有一个AndroidManifest.xml、R.txt、res资源目录、jni目录偶尔还有assets。Android系统级的清单合并、资源编译逻辑全部依赖这些结构。所以如果第三方SDK里不仅有Java代码还带了布局文件、图片资源、so库那就只能用aar不能用jar。还有一点很关键aar的AndroidManifest.xml在Unity构建APK时会被合并进最终包的AndroidManifest里。也就是说你在aar里声明的权限、Activity、Application最终都生效。很多Unity开发者以为aar只是个“高级jar”是完全的误解。1.2 哪些场景适合用手动打包不是所有Unity项目都需要手动折腾但在下面几类场景里这条路径的价值特别明显第三方服务商只提供了Java源码不提供预编译好的aar或jar这是最典型的情况。项目需要把多个内部公共模块统一成一套库想在Android和iOS逻辑层做隔离Android侧只留一个aar入口。公司内网或CI流水线不允许每台打包机器都装Android Studio只提供命令行环境。Unity构建机是Docker或远程Linux环境图形界面缺失Android Studio根本没法跑。想严格控制aar的Gradle版本、依赖项防止Unity主工程模板被自动改乱。在这些情况下手动用GradleAndroid SDK打包不只是“可以用”而是最干净、最可控的方式。1.3 选Gradle而不是aapt2的核心理由有人会问Android SDK里不是有aapt2和javac吗能不能直接绕开Gradle用命令行把class和资源压成aar理论上可以但实际工作量会让你崩溃。资源编译、R.java生成、AAR标准化结构、多Module依赖、混淆规则这些环节全靠手工处理太容易出错。最关键的是Unity本身构建APK时就会读取Gradle模板你手动打包出来的aar应该和Unity用的Gradle体系保持兼容。用Gradle打aar相当于让产物从出生开始就符合Unity构建框架的预期避免后续各种未知的兼容性问题。另外Gradle的依赖传递机制能帮你把第三方库处理好手敲命令行可没有这个待遇。2. 打包前的环境准备一套能稳定复现的工具链2.1 JDK、Android SDK、Gradle三件套怎么配手动打包最核心的工具链就是JDK、Android SDK、Gradle。先说JDK版本Unity在不同版本对应的JDK要求差别很大Unity 2020及以前通常配Java 8Unity 2021以后某些版本推荐Java 11。这个版本必须和你本机Unity设置里的JDK版本一致否则你本地编译没问题Unity一构建就疯狂报版本错。Android SDK方面只需要两样东西platforms/android-XX下的android.jar以及build-tools里的aapt2、d8、zipalign这些基础工具。不用装Android Studio直接去Android官网下载Command Line Tools然后用sdkmanager把需要的platform和build-tools拉下来就可以。如果公司有内网镜像速度会更快。Gradle版本就要谨慎一点了不同AGPAndroid Gradle Plugin版本打包出来的产物行为有差异我建议直接参考Unity自带的Gradle版本稍后展开。注意环境变量里的JAVA_HOME和ANDROID_HOME必须提前配好。如果有多套JDK务必确保当前shell里生效的JDK与Unity设置里的一致这是避免一堆诡异问题的最省心做法。2.2 直接拿Unity自带Gradle版本不会乱Unity安装目录里其实自带了一份Gradle位置大致在Windows: Editor\Data\PlaybackEngines\AndroidPlayer\Tools\Gradle macOS: Unity.app/Contents/PlaybackEngines/AndroidPlayer/Tools/Gradle用这份Gradle的好处很直接Unity构建APK用的就是它你手动打包时用同款版本出来的aar在兼容性上几乎不会出问题。下载第三方Gradle当然也可以但版本一旦和Unity内置的AGP对不上就会出现一些“本地能编译Unity构建时却炸了”的现象排查起来费神。更稳妥的做法是打开Unity项目找到Assets/Plugins/Android/mainTemplate.gradle如果没有就确认是否启用了自定义Gradle模板直接看里面的com.android.tools.build:gradle:xxx版本号然后用匹配的AGP写你自己的Gradle配置文件。2.3 项目目录结构与初始化形态手动打包之前先把目录结构规划好。我在项目中喜欢建一个和Unity工程分离的独立文件夹比如android-libs/mylib/避免资源混在一起android-libs/ ├── mylib/ │ ├── src/main/ │ │ ├── java/com/example/mylib/MyLib.java │ │ └── AndroidManifest.xml │ ├── build.gradle │ ├── proguard-rules.pro │ └── gradle.properties ├── build.gradle ├── settings.gradle └── gradle.properties这个结构和Android Studio里的Library Module形态是一致的但不需要app层、不需要kotlin模块、不需要各种IDE自动生成的文件只留下Gradle打包必须的东西。这样整个工程可以提交到Git里任何一台机器clone后都能直接跑命令打包。2.4 坑版本不匹配的连锁反应版本不匹配是新手最常踩的坑举一个真实例子Unity主工程模板用的是AGP 4.0.1Gradle 6.1.1如果你手动打包时用了AGP 7.x打出来的aar结构可能混入新格式Unity构建时旧Gradle解析不了新版资源格式直接报AAPT2 error出错位置还不在你的aar在Unity的Gradle Build日志深处。所以我的建议是不要追新。手动打包的核心目的是稳定交付不是尝新无脑对齐Unity内置Gradle和AGP版本就对了。下表是我整理的常见Unity版本和Gradle/AGP对应关系供参考Unity版本默认Gradle默认AGP推荐JDK2019.45.1.13.4.0JDK 82020.36.1.14.0.1JDK 82021.36.1.14.0.1JDK 112022.36.7.14.2.0JDK 11当然你的Unity项目可能通过Gradle模板手动升级过AGP版本一切以实际项目的mainTemplate.gradle为准。3. 手把手打包出aar从Java源码到可验证产物3.1 Java源码写一个带回调的工具类为了让示例足够贴近真实场景我这里写一个稍微复杂一点的工具类包含静态方法、实例方法以及一个最简回调接口。这个类后面会一步步被编译进aar并在Unity侧调用。package com.example.mylib; import android.app.Activity; import android.content.Context; import android.os.Handler; import android.os.Looper; import android.widget.Toast; public class MyLib { private static Handler sMainHandler new Handler(Looper.getMainLooper()); public interface ICallback { void onResult(String message); } public static void showToast(Context context, String message) { if (context instanceof Activity) { Activity activity (Activity) context; if (!activity.isFinishing() !activity.isDestroyed()) { Toast.makeText(activity, message, Toast.LENGTH_LONG).show(); } } else { Toast.makeText(context, message, Toast.LENGTH_LONG).show(); } } public static int add(int a, int b) { return a b; } public void postDelayedResult(final String input, final ICallback callback) { final long delayMs 1000; sMainHandler.postDelayed(new Runnable() { Override public void run() { if (callback ! null) { callback.onResult(result: input); } } }, delayMs); } }这段代码里有两点值得注意。第一Toast逻辑里判断了Activity是否正在finish或destroyed避免在Unity执行切场景操作时弹出崩溃性异常。第二回调接口用Handler切到主线程执行确保Java侧如果有耗时任务回调会回到Android UI线程避免多线程同步问题。3.2 AndroidManifest.xml最小声明与合并规则aar里的AndroidManifest.xml不能随便乱写它会被Unity合并进最终的APK清单。最稳妥的策略是只做基础声明不写任何多余的东西。以当前例子来说MyLib的工具类没有Activity、没有Service、不需要自定义权限所以Manifest只需要一个空壳。?xml version1.0 encodingutf-8? manifest xmlns:androidhttp://schemas.android.com/apk/res/android packagecom.example.mylib /manifest如果你在aar里声明了Activity那么所有涉及这个Activity的android:name、exported、intent-filter这些属性都要在aar的Manifest里写对否则Unity合并时或运行时可能报找不到Activity。这里有一个容易踩的坑如果你在aar的Manifest里声明了application android:name.MyApp而Unity主工程或另一个aar也声明了Application两个会冲突。优先把Application相关声明放在Unity的mainTemplate.gradle或Unity的AndroidManifest.xml里aar里不要碰。3.3 build.gradle把库模块配置到可用状态关键文件来了。整个手动打包流程的核心就是build.gradle的配置。先看项目根目录下的build.gradle它的作用是声明构建插件和仓库buildscript { repositories { google() mavenCentral() } dependencies { classpath com.android.tools.build:gradle:4.0.1 } } allprojects { repositories { google() mavenCentral() } }除非有必要否则不要在allprojects.repositories里加jcenter()早期很多教程里都有它但现在JCenter已经进入只读状态新依赖拉不到能不用就不用。接下来是模块级mylib/build.gradleapply plugin: com.android.library android { compileSdkVersion 30 buildToolsVersion 30.0.3 defaultConfig { minSdkVersion 22 targetSdkVersion 30 versionCode 1 versionName 1.0 } compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } } dependencies { // 这里放aar依赖的第三方库例如: // implementation androidx.appcompat:appcompat:1.3.1 }很多人会纠结compileSdkVersion到底写多少我的建议是看Unity项目里的mainTemplate.gradleUnity默认的compileSdkVersion是多少就写多少。版本号高了或低了都会在Unity构建时产生资源版本兼容问题。如果Java代码里用到了AndroidX那就在dependencies里加对应的依赖同时在项目的gradle.properties里开启android.useAndroidXtrue android.enableJetifiertrue org.gradle.jvmargs-Xmx2048mandroid.useAndroidXtrue是必须的否则在依赖AndroidX库时会报错。3.4 执行打包命令并验证aar内容配置完成后在android-libs目录下执行打包命令# 如果使用系统全局Gradle gradle :mylib:assembleRelease # 如果使用Unity自带Gradle /path/to/Gradle/gradle :mylib:assembleRelease首次执行时会下载Gradle依赖和插件耗时取决于网络环境。如果网络不好后面第5.2节有解决方案。打包完成后产物位于mylib/build/outputs/aar/mylib-release.aar用解压工具打开这个aar你会看到里面出现了classes.jar、AndroidManifest.xml、R.txt这些标准文件。想验证classes.jar里的类是否存在可以用jar命令jar tf mylib/build/outputs/aar/mylib-release.aar # 输出里会有 classes.jar # 解压后看class unzip mylib/build/outputs/aar/mylib-release.aar -d aar_unzip jar tf aar_unzip/classes.jar | grep MyLib看到com/example/mylib/MyLib.class就算打包成功了。3.5 是否引入Unity classes.jar的取舍手动打包时Java代码里如果要用到com.unity3d.player.UnityPlayer这个类就需要在编译时引入Unity的classes.jar。常见引入方法是在mylib/build.gradle的dependencies里加一句compileOnly files($UNITY_CLASSES_JAR)这里的UNITY_CLASSES_JAR变量可以通过环境变量传入或者直接写绝对路径它通常位于Unity安装目录下的Editor/Data/PlaybackEngines/AndroidPlayer/Variations/mono/Release/Classes/classes.jar注意这里用的是compileOnly不是implementation或api。原因很直接Unity本身的classes.jar会出现在Unity构建的最终APK里如果你的aar又把这个类打进去就重复了运行时大概率出现“duplicate class”的Crash。用compileOnly只参与编译不进入aar产物让最终Unity构建时各归各位这个坑很多新手会踩。4. 在Unity里接入aar并打通双向调用4.1 aar放置位置和Unity的打包规则将aar放入Unity工程目录Assets/Plugins/Android/Unity在构建Android APK时会自动扫描这个目录下的aar和jar文件把它们作为依赖加入Gradle工程。如果aar附带有自己的AndroidManifest.xmlUnity也会把它合并进最终APK的Manifest。这个自动合并过程大多数时候没问题但遇到Application冲突等情况就要手动处理了。如果你的aar依赖了第三方库比如androidx.appcompatUnity构建时可能会因为主工程的Gradle模板里没有这些依赖而报错。解决办法是打开Unity的mainTemplate.gradle把aar需要的依赖加进去。4.2 C#调JavaAndroidJavaClass与AndroidJavaObjectUnity侧调用Java要借助AndroidJavaClass和AndroidJavaObject这两个类。AndroidJavaClass用于访问Java静态方法或静态字段AndroidJavaObject用于实例化Java对象和调用实例方法。以下代码演示如何调用我们刚才打包的MyLibusing UnityEngine; public class MyLibBridge : MonoBehaviour { private AndroidJavaObject _myLib; private void Start() { AndroidJavaClass playerClass new AndroidJavaClass(com.unity3d.player.UnityPlayer); AndroidJavaObject activity playerClass.GetStaticAndroidJavaObject(currentActivity); // 调用静态方法showToast AndroidJavaClass myLibClass new AndroidJavaClass(com.example.mylib.MyLib); myLibClass.CallStatic(showToast, activity, Hello from aar); // 调用静态方法add int sum myLibClass.CallStaticint(add, 3, 5); Debug.Log(Sum sum); // 实例化并调用实例方法postDelayedResult _myLib new AndroidJavaObject(com.example.mylib.MyLib); MyCallback callback new MyCallback(); _myLib.Call(postDelayedResult, UnityValue, callback); } private class MyCallback : AndroidJavaProxy { public MyCallback() : base(com.example.mylib.MyLib$ICallback) { } public void onResult(string message) { Debug.Log(Callback from Java: message); } } }这里有个细节内部接口ICallback在Java源码里是MyLib的内部类JNI名称会是com.example.mylib.MyLib$ICallback所以传给AndroidJavaProxy的base字符串里要写$符号。如果Java接口是独立顶层接口就直接写完整类名不要带$。这一点容易踩坑JNI规则对内部类的命名和Java层不一样漏掉$会直接抛NoSuchMethodError。4.3 Java回调C#用AndroidJavaProxy和UnitySendMessage在4.2的例子中我用了AndroidJavaProxy让Java回调直接进入C#。AndroidJavaProxy的好处是类型安全回调参数可以自动转成C#对象适合参数较多、数据类型复杂的场景。另一种常见的回调方式是Java侧调用UnitySendMessage这个API要求场景里有一个名字对应的GameObject并挂载一个方法名对应的脚本UnityPlayer.UnitySendMessage(BridgeObject, OnJavaCallback, message from java);UnitySendMessage的问题在于Android UI线程和Unity主线程并不总是同一个线程直接调用UnitySendMessage跨线程操作Unity API可能会导致崩溃。另外UnitySendMessage传递的字符串有长度限制大概在几KB级别不适用于大数据。所以如果回调逻辑复杂优先用AndroidJavaProxy它至少在Unity侧可以把回调派发到你期望的线程再执行。4.4 AndroidManifest合并的细节aar的Manifest和Unity工程本身的Manifest会在构建时自动合并。如果两个Manifest里都声明了同一个Activity或者都有application的属性配置合并规则不是你随便写写就行的。我的经验是在打包aar之前把该类库使用到的Activity、权限等声明全部集中在aar的Manifest里如果确实和Unity主工程有冲突先确认Unity侧能不能改。大多数插件SDK的情况权限声明放aar里没有问题但Application节点尽量避开。合并后的Manifest可以在Unity的Temp/StagingArea/AndroidManifest.xml路径下查看Unity构建失败时查这里特别有用。5. 常见问题与排查实录5.1 打包报错找不到SDK Platform执行打包时如果出现Failed to find target with hash string android-30说明Android SDK目录下没有下载对应版本的Platform。用sdkmanager安装sdkmanager platforms;android-30 build-tools;30.0.3如果网络受限可以去对应厂商的镜像源手动下载平台包并放到platforms目录下。5.2 依赖下不动换国内镜像源Gradle下载依赖卡的场景太常见了。如果google()和mavenCentral()访问缓慢或超时可以在根目录的build.gradle里临时加上国内镜像仓库buildscript { repositories { maven { url https://maven.aliyun.com/repository/google } maven { url https://maven.aliyun.com/repository/public } } }这个方案只推荐在依赖拉不动时用正式上CI前最好把依赖缓存好或者直接配置公司内部的Maven私服。5.3 Unity运行时报ClassNotFoundaar明明编译进APK了运行却报ClassNotFoundException最常见的三个原因混淆把类名改掉了。aar的package和你C#里写的类名不一致。编译时用错了implementation导致依赖没有传递到Unity主工程。针对混淆release包默认会开混淆如果打包时不想混淆可以在mylib/build.gradle里加android { buildTypes { release { minifyEnabled false } } }如果确实需要混淆就得准备proguard-rules.pro把Unity会调用的类keep住-keep class com.example.mylib.** { *; }把这条规则加到defaultConfig的proguardFiles配置里。注意Unity侧用反射方式调用Java类时如果Java类名在proguard阶段被改掉Unity跑起来就直接找不到了所以keep规则能写多宽写多宽。5.4 回调线程与主线程之间的问题我在真实的Pico 4和普通安卓手机上做测试时遇到过一次“Unity页面卡死但Logcat有日志”的情况。后来定位到是Java侧回调的线程不固定Unity侧直接操作了GameObject和Transform导致Unity主线程冲突。解决方式很简单Java侧统一用Handler切到主线程或C#侧收集消息后放入队列在Update里统一处理。代码层面的典型写法private Queuestring _messageQueue new Queuestring(); private void Update() { lock (_messageQueue) { while (_messageQueue.Count 0) { string msg _messageQueue.Dequeue(); // 在这里安全操作Unity API } } }5.5 依赖冲突support和androidx的修罗场aar里如果带了支持库依赖很容易和Unity或另一个aar的依赖冲突。常见报错是Duplicate class android.support.v4.app...这类问题的根源是AndroidX和旧版Support库混用。Unity 2020及之后的版本默认使用AndroidX所以如果手动打包的aar引用了旧的support库尽量手动改成AndroidX。开启android.enableJetifiertrue可以自动转换依赖中的Support库到AndroidX但它不能解决所有问题最好的办法还是源头上依赖就用AndroidX。另外如果同一个第三方库在多个aar里都打了包Unity构建时会报重复类这时需要判断哪个aar是权威来源把另一个里面对应的classes.jar用exclude规则排除掉或者干脆修改依赖方式不要在aar里带第三方库统一放到Unity的mainTemplate.gradle里。5.6 打包时原Garble打印一堆警告手动打包时经常看到类似warning: [options] bootstrap class path not set in conjunction with -source 8这样的警告一般不影响产物可以忽略。但如果警告里带error关键字就要拿日志去查具体问题。这类警告的本质是因为JDK版本和Java源码版本不匹配比如JDK 11编译sourceCompatibility 1.8的代码时就会提示。6. 几个我后来才想明白的打包心得打包aar这件事表面上只是一条gradle assembleRelease命令但涉及到版本一致性、依赖抽取、Manifest合并、JNI命名这类细节后复杂度就上来了。我个人在实际操作中最重视的永远是“和Unity构建环境保持对齐”这一条。现在的Unity项目普遍使用Gradle作为Android构建工具链aar作为中间产物只有在和Unity的Gradle版本、AGP版本、JDK版本、compileSdk保持同步的情况下才能在Unity里无缝工作。很多人只关注“能不能打出aar”忽略了“这个aar在Unity里能不能顺利构建”这两者之间隔着无数版本问题。还有一点一定要记住aar的构建环境尽量独立。不要把你Unity项目的Assets目录当成打包工程最好单独建一个android-libs项目里面只放Java源码、Gradle配置和aar产物这样任何人在任何机器上Clone下来都能一条命令复现打包。而且也不会因为Unity升级、插件目录变化而误删或混淆源码。我在后续项目里还会把手动打包的Gradle命令接入CI让提交代码后自动构建一份新的aar产物推送发布到内网仓库。这个过程里手动打包流程反而比Android Studio方案更有优势因为它轻量、可脚本化、不依赖图形界面。如果你也在维护Unity插件或游戏SDK建议尽早把手动打包这件事跑通它带来的长期收益会远超第一眼看上去的麻烦。