我现在还记得第一次想在 Android Studio 里打出 Jar 包时的场景:菜单、右键、Build 入口翻了个遍,愣是找不到一个叫“Make Jar”的按钮。后来才明白,Android Studio 默认只给 Android Module 产出 AAR 包,想拿 Jar 得自己动手配 Gradle Task。这篇文章就把这条路完整走一遍,内容包括在 Android Studio 里新建 Java Library、配置 Gradle 打包 Jar 的完整写法、第三方依赖怎么处理、如何用 AAR 中间产物取巧,以及打包后测试、混淆、反编译验证的整套经验。适合想把工具类或 SDK 逻辑抽出来分享给其他项目使用的开发者,也适合刚接触 Gradle 自定义任务的 Android 新手。
1. 先搞清需求:Android 里“Jar 包”到底能干嘛
1.1 Jar 和 AAR,一字之差差在哪
很多人第一次打 Jar 失败,就是因为没想明白 Android 项目的产物到底是什么。Jar 本质上是 Java 字节码和资源文件的压缩包,里面装的是 .class 文件,最多再带一些配置文件、META-INF 目录。它不关心 Android 的资源系统,也没有办法表达 AndroidManifest.xml 里的组件声明。而 AAR 是 Android 专属的库打包格式,重点在于它能携带四样东西:编译后的代码、res 资源目录、AndroidManifest.xml、还有 JNI 的 so 文件。
我见过不少人在工具类模块里写了 R.string 引用,然后试图打成 Jar 扔给别的项目,结果对方一运行就崩或者找不到资源,这就是典型的选择错误。AAR 之所以成为 Android 默认的依赖格式,就是因为 Android 构建系统把资源和代码的绑定关系处理得足够复杂,Jar 这种纯 Java 时代的产物根本装不下。
1.2 什么情况下选 Jar,什么情况下选 AAR
实际项目里我建议按这个标准判断:如果你的模块里不涉及 res、assets、Manifest 配置、so 库,也完全不引用 R 类,代码纯粹是业务逻辑、算法、网络封装、Json 解析这类纯 Java/Kotlin 内容,那 Jar 完全够用,而且比 AAR 更轻、更容易被非 Android 项目复用。反过来说,只要你用到了 res 目录下的任何东西,包括布局、图片、字符串、颜色,或者需要在 Manifest 里注册 Activity、Service、Provider,又或者包含 libs/*.so,那就老老实实出 AAR,别跟自己过不去。
一个很容易踩坑的场景是:代码里只用了 android.util.Log 和 android.text.TextUtils 这类 Android SDK 类,就以为自己写的是“纯 Java”。严格意义上,一旦引用了 android.* 下的 API,这个 Jar 在普通 Java 虚拟机里是跑不起来的,只能在 Android App 里跑。所以打 Jar 的时候要么接受“只能给 Android 用”这个限制,要么尽量把 Android 依赖从核心逻辑里剥离开。
1.3 我为什么一开始走了弯路
我第一次接手这类需求时,直接在 App Module 里写代码,然后跑到 build 目录里到处找 classes.jar。找是找到了,但那个 Jar 是中间产物,包含了 Activity、资源引用、各种依赖,拿去做依赖库用非常奇怪。后来才悟到一件事:打 Jar 应该在独立的 Java Library 模块里做,而不是在你正在开发的 App 模块里做。App 模块天生面向“组装”,它的构建目标是生成 APK;IntelliJ 的 IDEA 项目能一键导 Jar,本质也是因为它默认创建的是纯 Java Module。这句话我希望读者在第一遍读这篇文章时就记住,后面所有步骤都建立在这个认识上。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 正路:新建 Java Library 模块,用 Gradle 打 Jar
2.1 为什么不建议在 App 模块里直接打 Jar
在 App 模块下打 Jar 不是完全不行,但会遇到三件麻烦事。第一,App 模块的源码里通常会引用资源、依赖第三方 SDK,编译后的 class 数量巨大,你没法轻松区分哪些是你自己的代码,哪些是依赖的代码,打出来的 Jar 体积和内容都不可控。第二,App 模块使用 com.android.application 插件,这个插件的任务图非常复杂,直接对它自定义 Jar 任务很容易和现有的 bundle、assemble 任务产生耦合,某些 AGP 版本里甚至会重复执行或找不到输出。第三,App 模块的 BuildConfig、资源 R 类、Manifest 生成的类都会被混进来,接收方如果用这个 Jar,会拿到一堆不该有的类。
Java Library 模块用的插件是 java-library,或者老一点写法里的 java 插件,它不参与 APK 构建,打出来的就是一个纯粹的 Jar 任务,干净、好控制、可复现。这也是我推荐的正路。
2.2 创建 Java Library 的实操步骤
在 Android Studio 里操作其实很简单,File > New > New Module > Java Library 或 Java or Kotlin Library(不同版本菜单名称略有差异),给它起个名字,比如 mylib。创建完成后,模块的 build.gradle 大概是这个样子的:
groovy复制apply plugin: 'java-library'
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
}
这里有个关键认知:Java Library 模块默认不依赖 Android SDK,也不会有 R 类、AndroidManifest 合并、资源处理这些环节。如果你在这个模块里写了 import android.util.Log,编译时会直接报“找不到 Android SDK”的错误。解决方法是在依赖里加上 Android 提供的 stub jar:
groovy复制dependencies {
// 仅编译期可用,运行时由系统提供,不会被打进 Jar
compileOnly 'com.google.android:android:4.1.1.4'
}
这个配置我后面会展开解释,先记住它很关键。
2.3 配置打包 Task 的核心代码与参数说明
在 Java Library 的 build.gradle 里,添加一个自定义 Jar 任务。我常用的写法是这样:
groovy复制version = '1.0.0'
tasks.register('buildJar', Jar) {
archiveFileName = 'mylib-' + version + '.jar'
destinationDirectory = file("$projectDir/outputs")
// 把自己模块编译后的 class 打进去
from sourceSets.main.output
}
在终端或 Android Studio 的 Gradle 工具窗口里执行 ./gradlew :mylib:buildJar,等任务跑完,mylib/outputs/mylib-1.0.0.jar 就出现了。用解压工具打开看,里面就是 .class 文件。
这段配置里最核心的是 from sourceSets.main.output,它把所有编译产物原样放进了 Jar。如果你什么都不写,Gradle 默认的 Jar 任务只打 src/main/resources 里的内容,不包含 class 文件,这也是新手最常踩的坑之一。
destinationDirectory 我建议设成模块独有的输出目录,不要用默认的 build/libs。因为 Gradle 的 clean 任务会把 build 目录整个删掉,有时候你刚打完想立刻去拿 Jar,结果不小心执行了 clean,就只能重新跑一遍。放到 outputs 目录下,它还可能是 Git 忽略的目录,不会污染版本库,但不会被轻易删除。
2.4 执行打包并检查产物
执行完任务后,我习惯先用 jar tf 命令看一眼内容:
bash复制jar tf mylib-1.0.0.jar
这个命令的作用是列出 Jar 内的所有条目,类似在压缩包里浏览文件树。你会看到类似 com/example/mylib/MyUtils.class 这样的结构。如果发现少类、多了不该有的类,就能第一时间发现。我开发时会在终端挂着这个命令,每次打完包就跑一遍,几秒钟的事,比打开压缩软件快得多。
3. 依赖处理:把第三方库打进去、排除还是保留
3.1 三种选择的判断标准
如果你的模块里引用了第三方库,例如 Gson、OkHttp、Kotlin stdlib,打 Jar 时就面临三种选择。
第一种是“打进 Jar 里”,输出一个包含所有依赖类的 fat jar,接收方只需要这一个 Jar 就能编译运行。第二种是“排除依赖”,只在 Jar 里放你自己的代码,接收方自己在项目里处理依赖。第三种是“用 provided/compileOnly 标注”,意思是编译时需要这个库、运行时由宿主项目提供,这种适用于 Android 系统自带类或宿主明确会提供的库。
选择标准其实也简单:如果你把 Jar 提供给完全不可控的外部项目,建议 fat jar;如果是自己团队内部使用,依赖由统一的基础工程管理,那就只打自己的代码。fat jar 的好处是省心,坏处是容易产生类重复、版本冲突,接收方项目里如果已经有一份老版本 Gson,可能就会出现类 NoSuchMethodError 这种诡异问题。
3.2 Fat Jar(内置依赖)配置详解
Fat Jar 的 Gradle 配置我会这样写:
groovy复制tasks.register('fatJar', Jar) {
archiveFileName = 'mylib-all-' + version + '.jar'
destinationDirectory = file("$projectDir/outputs")
duplicateStrategy = DuplicatesStrategy.EXCLUDE
from sourceSets.main.output
from configurations.runtimeClasspath.collect { it.isDirectory() ? it : zipTree(it) }
}
关键在第二行 from:configurations.runtimeClasspath 是运行时依赖的集合,每个依赖可能是一个 jar 文件,也可能是一个目录,zipTree(it) 会把 jar 解开、平铺进最终的 fat jar,这样才达到“把依赖类合并进去”的效果。duplicateStrategy 必须设置为 EXCLUDE,不然两个依赖里出现了同名文件(比如 META-INF/LICENSE),Gradle 会直接抛异常。
还要注意一个细节:如果模块还引用了 compileOnly 'com.google.android:android:4.1.1.4',runtimeClasspath 不会包含它,因为 compileOnly 依赖不进入运行时 classpath,所以 Android SDK 的类不会被意外打进去,这个设计刚好符合我们的需求。
3.3 打包过程中的重复类与依赖冲突问题
实际打 fat jar 时,我遇到过至少三次重复类的问题。最常见的来源是 META-INF 下的 NOTICE、LICENSE、kotlin_module 文件,重复策略设置 EXCLUDE 之后,Gradle 会保留第一个出现的文件,后续重复的直接忽略,基本不会产生问题。
稍微麻烦一点的是类文件本身重复。比如你依赖了 com.google.code.gson:gson:2.8.9,而传递依赖又引入了一个旧版本的 gson,两个版本的不同类名拼在一起,builder 阶段不会报错,但运行期可能因为类版本不对而出现 ClassNotFoundException。排查方式是在 fat jar 生成后用 jar tf 配合 findstr(Windows)或 grep(macOS/Linux)搜索你关心的类路径,看是否只有一个版本存在。
如果你的接收方是 Android App,我还要提醒一点:Android 对方法数有 65535 限制。fat jar 把大量依赖塞进去,可能直接让依赖方的方法数暴涨。真有这种问题,我会考虑用 R8 在打包前把没用到的类剔除掉,或者干脆输出普通 Jar,让宿主自己决定怎么处理。
4. 土办法:利用 AAR 中间产物 classes.jar 改包名
4.1 classes.jar 在工程里的真实位置
不是所有人都愿意新建一个 Java Library 模块,有时候你只是在一个已有的 Android Library 模块里加了些工具类,想顺手导出一个 Jar。这时候有一个土办法:执行模块的构建任务后,去 build 目录里翻 classes.jar。
不同 AGP(Android Gradle Plugin)版本位置不一样,常见的有这些:
| AGP 版本 | 常见路径 |
|---|---|
| 3.x ~ 4.x | build/intermediates/bundles/release/classes.jar |
| 4.1 ~ 7.x | build/intermediates/aar_main_jar/release/classes.jar |
| 8.x | build/intermediates/runtime_library_classes_jar/release/classes.jar |
做法是:执行 ./gradlew :mylibrary:assembleRelease,然后去上面这些路径里把 classes.jar 拷贝出来,改名成 mylib.jar。
4.2 为什么这个土办法不稳定但我仍然建议你了解
我必须坦白地说,这个办法稳定性很差。Gradle 的任务名和输出路径在不同 AGP 版本之间变动过很多次,官方也没有承诺这些中间产物路径是稳定接口。某个版本升级后,你写的文档里那个路径可能就没了。
但我仍然建议你了解它,有两个原因。第一,有些老项目没有条件立刻改成 Java Library 模块,这个办法能最快解燃眉之急,尤其在维护历史代码时。第二,理解它的原理能帮助你理解 Android 构建流程:AAR 中确实存在一个纯 classes 的 Jar,它只是作为 AAR 内部的组成部分存在,并不是面向外部用户的产物。
如果你发现某个版本下 classes.jar 找不到,可以先执行 ./gradlew :mylibrary:tasks --all 列出所有任务,看到类似 bundleReleaseAar、packageReleaseAar、transformClassesAndResourcesWithSyncLibJarsForRelease 这样的任务名,再顺藤摸瓜去 build 目录里搜索 *.jar。搜索命令在 macOS/Linux 下是 find . -name "*.jar",Windows 下是 PowerShell 的 Get-ChildItem -Recurse -Filter *.jar。
4.3 AAR 方案其实才是 Android 团队的默认答案
顺着这个土办法延展一下,为什么不干脆用 AAR?如果你这个模块是给 Android 项目用的,AAR 才是 Android 团队设计好的默认答案。Gradle 里一行 implementation project(':mylibrary') 或 implementation files('mylibrary.aar') 就够了,资源、Manifest、proguard 规则全自动合并,不需要你处理依赖和路径问题。
我见过有团队为了“统一依赖格式”,把所有库都打成 Jar 给 Android App 用,结果资源文件只能靠代码动态创建,so 库也不知道该放哪,最后绕了一大圈又回到 AAR 的怀抱。这里我想说一句经验之谈:Jar 很好,但不要为了用 Jar 而用 Jar。判断标准始终是模块内容,而不是“领导说统一格式”。
5. 打包后的三道考验:导入测试、混淆、反编译验证
5.1 新项目导入 Jar 的正确姿势
打包任务跑通只是第一步,真正的问题是“这个 Jar 能不能被别人用”。我每次都会新建一个空项目,把 Jar 拖进去测一遍。具体操作是:把 mylib-1.0.0.jar 放到 app/libs/ 目录下,然后在 app/build.gradle 里加:
groovy复制dependencies {
implementation files('libs/mylib-1.0.0.jar')
}
如果 libs 目录下还有别的 Jar,也可以改成 implementation fileTree(dir: 'libs', include: ['*.jar'])。加完之后在测试代码里调用你暴露的公共类,编译并运行到模拟器或真机上。这一步能验证两件事:Jar 里有没有你声明的类,以及这些类在 Android Runtime 里能不能正常工作。
有一个细节值得注意:implementation files 和 implementation fileTree 的差异在于后者会把目录下所有 Jar 都塞进 classpath,如果你同时放了多个版本的 Jar,很容易出现类冲突。我自己在测试时倾向只用 files,这样是精准控制,不会突然冒出来一个旧的 Jar 影响编译。
5.2 混淆的配置和坑
打 Jar 后给外部使用,通常有两种思路。一种是直接把混淆后的代码输出成 Jar,这样对方拿到的 class 已经被混淆过,体积更小、更难以破解。另一种是输出不混淆的 Jar,把混淆规则放到接收方项目里做。
如果你选择第一种,在 Java Library 模块里可以用 ProGuard 或 R8 配合自定义任务来做。示例配置如下:
groovy复制tasks.register('obfuscatedJar', proguard.gradle.ProGuardTask) {
injars sourceSets.main.output
outjars "$projectDir/outputs/mylib-obfuscated.jar"
libraryjars System.getenv('JAVA_HOME') + '/jmods/java.base.jmod'
libraryjars System.getenv('ANDROID_HOME') + '/platforms/android-30/android.jar'
keep 'class com.example.mylib.** { *; }'
dontshrink
}
但这块坑比较多,最典型的是 libraryjars 的配置。如果你的代码里用了 android.* 的类,混淆时必须把 android.jar 作为 library jar 提供给 ProGuard,否则它会认为找不到这些符号而报错。许多人在本地编译时没有配置 ANDROID_HOME 环境变量,导致这个任务一直失败,所以我把路径写成了动态获取。
其实大多数情况下我更推荐输出一个不混淆的 Jar,然后在最终 App 工程里开启混淆。原因很简单:混淆规则和 App 的 keep 规则经常需要联动,你在库 Jar 阶段做混淆,规则又不可能覆盖所有使用方的具体场景,反而容易把接收方需要的类混淆掉,届时追查问题会非常痛苦。
5.3 反编译与内容核验
打完 Jar 之后,我养成了“每次必反编译”的习惯。这不是为了破解别人的东西,而是为了核验自己的 Jar 内容是否符合预期。反编译工具我能想到的就有好几款,比如 JD-GUI、Luyten,或者命令行工具 jadx。用 jadx 反编译 Jar 的命令是:
bash复制jadx mylib-1.0.0.jar -d output
打开反编译出来的源码,我会重点检查三件事:类结构是否完整、方法是否是空壳、敏感信息有没有残留。方法变成空壳通常是因为混淆时 -dontshrink 没配好导致代码被优化掉了;敏感信息残留则常见于把 DEBUG 日志、接口地址不小心打进了 Jar 里。这些在反编译源码面前都无所遁形。
5.4 “无效的源发行版”和“文件只读”这类编译错误怎么解
打包过程中遇到的两个高频率报错,热搜里也很多人问。第一个是 错误: 无效的源发行版: 21 或类似版本号报错,这说明项目要求的 Java 字节码版本比当前 Gradle/Javac 能处理的版本更高。解决方法是在 build.gradle 里显式指定 Java 版本:
groovy复制java {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
如果项目用的是 JDK 17 或更高,把 VERSION_1_8 换成就近的版本。关键是要保证 Gradle 运行时的 JDK 版本、sourceCompatibility/targetCompatibility、以及 IDE 设置里的 SDK 三处一致,否则就会出现这种“编译器和源码版本对不上”的错。
第二个是“将 jar 包解压了进行编辑时 idea 依旧报该文件只读”。这个不是 Gradle 或 Android 的问题,而是 build 目录或输出目录被 Gradle 任务锁定了,IDE 会把它视为只读。尤其当你解压的是 build 目录里的中间产物时,因为 Gradle 下次执行任务会重新覆盖这些文件,IDE 干脆把它们全部标记为只读。解决办法是把 Jar 复制到项目之外的工作目录,比如 ~/workspace/tmp,然后在那里解压修改,改完再放回 libs 目录。
6. 踩坑清单与我的最终建议
6.1 那些打 Jar 时容易忽略的细节
我把自己这几年打 Jar 的经验整理成一份清单,每一条都是真实踩过的:
- 依赖不要乱打:如果接收方项目本身就会引入 OkHttp,你的 fat jar 里又有 OkHttp,编译不报错,但运行期可能因为版本不一致出现
NoSuchMethodError。最好的方式是问清楚接收方的依赖版本,或者干脆只输出纯代码 Jar,把依赖交给对方。 - 注意
META-INF目录:很多第三方库里有签名文件.SF、.RSA,这些文件放进 fat jar 后,有时会导致 APK 打包时出现签名相关错误。我通常在 fat jar 配置里把META-INF/*.SF、META-INF/*.RSA、META-INF/*.DSA排除掉。 - Kotlin 模块注意
kotlin-stdlib:如果用 Kotlin 写库,Jar 里默认不会包含 stdlib。接收方如果是 Kotlin 项目还没事;如果是纯 Java 项目,运行时就会报KotlinNullPointerException或者NoClassDefFoundError: kotlin/jvm/internal/Intrinsics。遇到这种情况,要么引导接收方引入 kotlin-stdlib,要么在 fat jar 里把 stdlib 打进去。 - BOM 和传递依赖:
implementation声明的依赖不会暴露给调用方,这是 Gradle 的模块边界设计。但在打 Jar 场景下,这个边界完全由你手工控制,稍不留神就会漏掉某个传递依赖,导致接收方在运行时报ClassNotFoundException。我习惯最后用jdeps或jar tf检查一遍。 - Jar 内目录结构:Java 的包名决定了 Jar 内目录结构,不要把
com/example/MyClass.class和com/example/mylib/MyClass.class混在一起,否则接收方在 IDE 里看 import 时会非常困惑。
6.2 如果只是给内部模块用,考虑直接依赖 Module
这里想补充一个更“偷懒”的思路:如果你的接收方也是同一个 Android Studio 工程内的模块,那完全没必要打 Jar,直接 implementation project(':mylib') 就行。Gradle 会帮你处理依赖、增量编译、资源合并,开发效率高得多。打 Jar 这种操作真正适合的是跨工程交付,比如把 SDK 交给第三方、给测试团队提供可运行的库、或者发布到公司内部 Maven 仓库。
顺带一提,如果你打算实现“一个 SDK 同时供 Android 和纯 Java 后端使用”,我的建议是设计成两层:核心逻辑层用纯 Java 写,不依赖 Android SDK;Android 包装层再打一个 AAR 或直接模块依赖。这样核心层出的 Jar 是真正跨平台的,包装层可以放心使用 Android API。这点在做 SDK 架构时尤其重要。
6.3 给新手的参考流程
最后给一个从零开始的完整流程,按顺序执行即可:
- 创建 Java Library 模块,确认
build.gradle使用java-library插件。 - 根据你的代码是否需要 Android SDK 类,决定是否加
compileOnly 'com.google.android:android:4.1.1.4'。 - 在模块的
build.gradle里添加buildJar和fatJar两个任务,参数按前文配置。 - 执行
./gradlew :mylib:buildJar,用jar tf验证 Jar 内容。 - 新建一个空 Android 项目,把 Jar 放在
libs目录,implementation files(...)导入,调用核心方法测试。 - 如果 Jar 需要给外部团队,建议写一份 README,注明 Java 版本要求、依赖列表、混淆规则。
- 发布前用 jadx 反编译核对内容,确认没有敏感信息残留。
这套流程我实际用了快五年,每次在新版本 Android Studio 里都能走通。重点不是背命令,而是理解每一步背后的原理:Java Library 模块为什么干净、from sourceSets.main.output 为什么必不可少、依赖为什么需要手工决策。这些想清楚了,不管 AGP 和 Gradle 以后怎么升级,你都能快速适应。
我个人最终的体会是:打 Jar 本身的技术难度并不高,难的是想清楚这个 Jar 的使用场景和边界。工具是给需求服务的,先搞清需求,再选方案,比盲目执行任务重要得多。希望这篇文章能帮你一次走通 Android Studio 打 Jar 包的完整链路,少走我当年走过的那些弯路。
