上个月帮另一个业务组做联调,对方只丢过来一句话:“把支付模块打个包发我。”源码不能给,纯JAR带不了res资源和Manifest,Android SDK的jar包形式根本撑不起一个完整模块,最后用的就是AAR。这件事操作起来并不难,Android Studio里面点几下就能出产物,但真正把AAR用好、让同事一行依赖直接接进工程,中间其实藏了不少门道。依赖没打进去、混淆规则没跟上、AGP版本不一致导致构建失败,这些坑我全踩过。这篇文章就把Android Studio打包AAR这件事从头到尾拆开讲清楚:AAR和JAR到底差在哪、怎么从新建Module一路打出产物、AAR文件内部什么结构、打包过程中的高频坑,以及拿到AAR之后别人该怎么集成。
1. 为什么是AAR而不是JAR:先搞清楚格式差异
1.1 JAR与AAR的本质区别
很多新手对AAR的第一反应是:“不就是一个zip压缩包吗,和JAR有什么区别?”从打包产物来看,AAR确实是一个zip格式的文件,扩展名从.jar变成了.aar,但两者承载的内容完全不同。
JAR(Java Archive)本质上就是一堆.class文件的集合,它不关心Android资源、不携带Manifest、不包含so库的ABI目录结构。如果你的模块是纯Java逻辑、不涉及任何Android API,那JAR是够用的,比如一些算法库、网络协议解析库、纯工具类库。可一旦模块里用到了layout布局、drawable资源、AndroidManifest声明的Activity或权限,JAR就无能为力了。
AAR(Android Archive)是专门为Android Library设计的打包格式,它同样基于zip,但内部结构为Android生态做了完整适配。一个标准AAR里至少包含编译后的classes.jar、res资源目录、AndroidManifest.xml、R.txt索引、proguard混淆规则,还可以携带so库、assets资源。这意味着你交付的不再是“孤零零的代码”,而是一个“可以被Android工程直接当模块使用的完整单元”。
我把两者核心差别整理成了表格,对比会更直观:
| 对比维度 | JAR | AAR |
|---|---|---|
| 文件本质 | class字节码集合 | zip归档,内部含class+资源+Manifest |
| Android资源 | 不支持 | 支持res/assets |
| AndroidManifest | 无 | 有,可参与清单合并 |
| so库 | 不支持 | 支持jni/目录 |
| 混淆规则 | 无 | 可内置proguard.txt |
| R资源ID | 无 | 附带R.txt统一管理 |
| 使用场景 | 纯Java逻辑 | Android模块化、SDK交付 |
1.2 哪些场景应该用AAR
我实际使用AAR最多的场景有三类。
第一类是模块化架构下的二方库交付。团队A维护一个基础组件,团队B的业务工程要接,两边不想共享源码仓库。直接把module目录发给对方,对方还得调整自己的Gradle配置、处理依赖冲突,费时费力。打成AAR发过去,丢到libs目录或者走内部Maven,对方一行依赖搞定。第二类是SDK封装。给第三方App提供登录、支付、推送能力,肯定不能把源码交出去,AAR配合混淆规则是最稳妥的交付形态。第三类是本地预编译加速。同一个App里几个模块之间耦合很深,但变更频率低,我一开始踩过的坑就是直接把AAR的classes.jar抽出来用,结果运行时疯狂报资源找不到,后来才彻底理解了AAR的边界在哪里。
1.3 AAR的边界:哪些东西不会被打进去
AAR并不是万能的。它最让人误解的一点是:AAR不会把你依赖的第三方库也打进去。比如你的library用了OkHttp,你在build.gradle里写了implementation 'com.squareup.okhttp3:okhttp:4.9.0',打出来的AAR里不会包含OkHttp的class,也不会有OkHttp的代码。AAR只会记录一个依赖声明,真正消费这个AAR的工程需要自己声明OkHttp依赖。
这一点从设计上是合理的,避免同一个库被重复打进多个AAR导致APK体积膨胀。但从打包者的角度必须心里有数:交付AAR的时候,必须把依赖清单同步给使用方,否则对方运行起来直接NoClassDefFoundError。
另外,AAR的代码在打包阶段不会被预先混淆。library模块里的minifyEnabled默认是false,即使你开了release类型的混淆,AGP在构建AAR时也不会把混淆后的class放进去。混淆是“使用方App打包”时统一执行的。你的library如果提供的是SDK给外部用,就要靠consumerProguardFiles把规则传递给下游。
2. 从零到一:用Android Studio打出第一个AAR
2.1 创建Library Module:两种方式
Android Studio里创建Library模块有两条路。
一条是图形化操作:File -> New -> New Module,在弹出的窗口里选择Android Library,填好Module名(一般叫xxlibrary或者xx_sdk之类),Finish之后会生成一个包含build.gradle、AndroidManifest.xml、空Activity等文件的标准模块。这个模块在Project视图下会显示一个安卓书本样式的图标,和App模块区分开。
另一条是改造已有模块:如果你手头有一个App module,想把它变成库,直接打开它的build.gradle,把第一行插件声明从com.android.application改成com.android.library。
// 改造前 plugins { id 'com.android.application' } // 改造后 plugins { id 'com.android.library' }改完之后需要同步一下,然后注意一个关键差异:Library模块没有applicationId,因为你不再是一个独立的App,不需要包名去申请安装。同时Library模块的AndroidManifest.xml里也不能再声明launcher入口Activity,否则构建时会报错或者被主工程直接忽略。
2.2 关键Gradle配置:namespace、compileSdk和输出名
创建完模块后,build.gradle里的配置是决定打包成败的核心。我一般会先检查三个点。
第一是namespace。AGP 7.0之后namespace必须显式声明,它代表这个库的包名,决定了R类和BuildConfig的生成路径。AGP 8.0之后如果漏了namespace,构建直接报错,而且它不能从manifest里自动读取。比如我写库时namespace声明为com.example.paysdk,那后续代码里引用R类就要用com.example.paysdk.R。
第二是compileSdk和minSdk。这里有一个很容易被忽略的细节:AAR里虽然不会记录你用的compileSdk版本号,但AGP会在AAR的META-INF里写入一份metadata标记。消费方App的compileSdk如果不低于你这个值,会有兼容性问题;如果低于,构建直接报“requires libraries and applications that depend on it to compile against version XX or later of the Android APIs”。所以我给外部团队交付库时,会主动问一句对方的compileSdk是多少,避免对方接进去就炸。
第三是输出文件名。默认打出来的AAR叫module名-release.aar,比如mylibrary-release.aar。如果你想自定义版本号,可以在android闭包里直接配archivesName,这个写法在AGP 7.0到8.x都能用:
android { namespace 'com.example.mylibrary' compileSdk 34 defaultConfig { minSdk 21 targetSdk 34 consumerProguardFiles 'consumer-rules.pro' } archivesName = "mylib-1.0.0" }这样打出来的AAR就是mylib-1.0.0-release.aar,版本信息一目了然。老版本的写法libraryVariants.all调整outputFileName同样有效,但新项目我建议直接用archivesName,代码更精简。
2.3 执行打包任务:界面点选和命令行两条路
配置写完,sync成功,接下来就是打包。
最快的方式是打开Android Studio右侧的Gradle面板,找到你的library模块,展开Tasks -> build,双击assembleRelease或者assembleDebug。assembleDebug适合本地自测,assembleRelease是正式交付产物。构建完成后,AAR文件输出在模块目录/build/outputs/aar/下面。
命令行方式更适合写脚本、走CI。在项目根目录执行:
./gradlew :mylibrary:assembleRelease如果你在开发机上还没有配环境变量,也可以用Android Studio自带的Terminal窗口跑。执行完之后用ls看一眼产物目录:
ls -la mylibrary/build/outputs/aar/会看到两个文件:mylib-1.0.0-release.aar和mylib-1.0.0-release-sources.jar。后者是源码包,默认配置下打AAR时AGP会顺手帮你把Java/Kotlin源码打成一个sources.jar,方便IDE在集成时跳转到源码。如果不想生成它,可以在android闭包里把这个任务排除掉,不过日常交付我反而建议留着,对使用方体验好很多。
打debug版本和release版本有一个细微差别:如果你在defaultConfig里没有额外配置BuildType,release版AAR其实也没有做代码优化,它和debug版最主要的差异是buildConfig里的DEBUG字段值不同以及是否可调试。很多人在排查问题时会反复确认自己导出的是release包还是debug包,这个习惯其实挺重要,调试时要让使用方带上debug的AAR,日志和断点信息才完整。
3. 用解压工具看AAR内部:必须知道的结构
3.1 解开AAR看看里面到底装了什么
AAR本质是zip,拿到手之后第一件事,用解压工具或者命令行解开它:
unzip mylib-1.0.0-release.aar -d aar_contents解开之后你大概会看到下面这些内容(根据模块不同略有差异):
AndroidManifest.xml classes.jar res/ R.txt proguard.txt jni/ assets/ lint.jar META-INF/逐个说一下它们的作用。AndroidManifest.xml是library自己的清单文件,它已经被编译成了二进制的AXML格式,直接当文本打开会乱码,但在AAR集成时,主工程会读取它并做清单合并。classes.jar就是你的代码,javac或kotlinc编译后的class字节码被打包在这个jar里,命名叫classes.jar,和APK内部的classes.dex不同,它还没有经过D8转化为dex格式。res目录存放你的资源文件,layout、drawable、values这些按原始目录结构保留。R.txt是一个文本索引,里面列出了所有资源的类型、名称和ID值,主工程编译时会基于它生成自己的R类引用。
proguard.txt是我前面说的consumerProguardFiles指定的混淆规则文件,AAR里会原样打包。jni目录只在包含so库时出现,里面按ABI子目录(arm64-v8a、armeabi-v7a等)存放动态库。assets和lint.jar是可选内容,assets在最终打包时会合并进主工程的assets,lint.jar是AGP生成的对library做静态检查用的Lint规则。
3.2 classes.jar不是给Android直接用的
很多人第一次看到AAR里的classes.jar会误以为可以直接解压出来塞进App的libs目录用。这个操作我试过,项目启动直接抛异常,原因在于classes.jar里的字节码还停留在“模块编译”阶段,真正进入APK之前,主工程构建链会把AAR解包,把classes.jar里的class交给D8/R8进行dex化,同时把R.txt、res、AndroidManifest和主工程的内容合并。所以AAR不是简单解压即用的仓库,它是一个“中间件”,必须放进Gradle构建流程里被消费。
这个机制也解释了为什么在Android Studio里直接“双击打开AAR文件”只能看到静态结构,不能直接运行。它能被主工程识别的唯一途径,就是通过Gradle把它注册为依赖。
3.3 资源冲突与清单合并:AAR隐藏的两个机制
当主工程依赖了多个AAR和模块时,所有library的资源和清单会在构建期被合并到一起。合并的过程就会带来两个经典问题。
第一个是资源名冲突。我的library里如果定义了一个string叫app_name,主工程或者另一个library里也定义了一个同名的string,资源合并时轻则报错,重则安静地覆盖,运行时页面数据显示成别人的文案。约定俗成的做法是给library的所有资源加前缀,比如paysdk_btn_login、paysdk_color_primary,这样能极大降低冲突概率。这个习惯最好在模块创建第一天就建立,等资源写多了再改前缀是一件非常痛苦的事。
第二个是Manifest合并。主工程会把自己的AndroidManifest.xml和所有依赖library的清单合并。如果library里声明了一个 ,它会被自动合并到最终App的清单里;如果library声明了权限,同样会被合并。但多个模块对同一个属性有不同声明时,就需要在library的清单节点上用tools:replace标记,告诉合并器“这个属性以我为准”。实际操作中我遇到最多的场景是theme和label,尤其是SDK库要声明一个穿透主题给Activity用的时候,不加tools:replace经常会撞车。
3.4 依赖不会自动打进AAR:前面说的关键认知再强调一遍
AAR内部结构里没有“dependencies”目录,就是因为它压根不打包第三方依赖。library在build.gradle里写的那堆implementation依赖,只会被记录到Maven的POM文件(如果发布到Maven)或者根本不记录(本地文件传阅)。使用方集成的时候如果缺了某个依赖,报错信息不会来自AAR本身,而是运行时崩溃。
所以我在交付AAR时一定会附带一个说明,写上完整的三方依赖清单:
gson:2.10.1 okhttp:4.10.0 rxjava:2.2.21或者更正规一点,直接把依赖写入POM,使用方通过仓库方式依赖时就能自动拉取。走本地AAR文件喂给对方的方式,这个步骤绝对不能省,对方在Android Studio里如果能看到NoClassDefFoundError或者ClassNotFoundException,第一个排查方向就是去匹配依赖清单。
4. 打包过程中最常见的坑和对策
4.1 打出来的AAR没有把依赖带进去,对方一集成就崩
这个坑我在第3.4节里已经埋了伏笔。实际表现是:我这边一切正常,AAR也成功导出了,对方把AAR塞进工程,编译过了,一运行就抛NoClassDefFoundError,定位到是我library里的工具类引用了第三方库。我当时的第一个反应是“这AAR是不是没打全”,然后把AAR解压翻了个底朝天,确实没找到第三方库的class。后来查文档才确认这是AAR的设计规则,不是构建异常。
对策:交付时把依赖清单写在README里,或者用maven-publish发布到内部仓库,让Gradle自行解析依赖。最常见的内部仓库方案是在build.gradle里配置publishing,pom.withXml节点手动补全依赖,使用方通过maven { url 'xxx' }引用时依赖会自动传递。
4.2 混淆规则写错了地方,发布出去后使用方开启混淆就出事
library模块一旦只配置了buildTypes.release.proguardFiles,打出AAR后使用方即使开启了minifyEnabled,也只会去找library的proguard.txt,回忆一下第3.1节的AAR结构,根目录下那个proguard.txt是通过consumerProguardFiles配置进来的。如果这个文件是空的,那library里所有类在App混淆阶段可能被重命名,导致主工程反射调用崩溃。
对策:
defaultConfig { consumerProguardFiles 'consumer-rules.pro' }consumer-rules.pro里写清楚哪些类需要keep。尤其是对外暴露的API入口、被反射调用的类、Java/Kotlin混编时的注解类,一网打尽。
4.3 AGP、Gradle、JDK版本之间的三角关系
AAR构建失败最鬼畜的一类问题是:代码没写错,配置看着也正常,但assembleRelease任务一跑就报一堆GradleAPI错误。这通常是AGP版本和Gradle版本、JDK版本不匹配。我吃过一次大亏,项目切了AGP 8.2后忘了升级JDK,构建直接抛UnsupportedClassVersionError。
当前比较稳妥的版本匹配关系如下:
| AGP版本 | 最低Gradle版本 | 推荐JDK版本 |
|---|---|---|
| 7.4.x | 7.5 | JDK 11 |
| 8.0.x | 8.0 | JDK 17 |
| 8.2.x | 8.2 | JDK 17 |
| 8.7.x | 8.9 | JDK 17 |
Android Studio里配置JDK路径在File -> Project Structure -> SDK Location,或者直接在Gradle JVM下拉框里切。判断自己项目AGP版本,看根目录build.gradle里的com.android.tools.build:gradle版本号就行。
4.4 lint检查失败导致release构建中断
library模块里如果写了某种lint告警,默认情况下lint是“提示但不中断”,但一旦代码里出现Error级别的问题,assembleRelease会在lintVitalRelease任务上挂掉。最常见的是NewApi错误,比如minSdk是21但代码用了23才有的API。对库来说,因为不知道下游App的minSdk是多少,这个问题会被lint死死咬住。
对策:在android闭包里显式关闭lint阻断:
lint { abortOnError false checkReleaseBuilds false }老项目里如果你看到的写法是lintOptions { abortOnError false },在AGP 8里lintOptions已经被标记deprecated,建议迁移到lint块。虽然lintOptions目前还能用,但升级AGP时迟早要改。
4.5 改了代码但AAR没变:构建缓存的迷惑行为
还有一个很阴间的体验:我明明改了library里的方法,执行assembleRelease后把AAR发出去,对方说没看到改动。检查发现是Gradle增量构建缓存把旧产物缓存住了,特别是你改了AAR输出名但没改模块内部代码,或者两个任务并发执行时互相污染outputs目录。
对策:打包前先clean一下。
./gradlew :mylibrary:clean :mylibrary:assembleRelease或者加--rerun-tasks强制忽略缓存重跑全部任务。我固定流程是clean+assembleRelease连打,虽然慢个一两秒,但能杜绝不少玄学问题。另外,如果AAR是要发出去的正式包,我一般会在文件管理器里把build/outputs/aar整个目录删掉再打,从根源上避免旧文件残留。
4.6 Library模块里不能用switch引用R.id,除非想编译报错
这个坑在新手写library时特别常见。Library模块的R类字段不是final常量,因为在构建阶段资源ID还没有最终确定,要等主工程合并完才能分配。不是final的话,Java的switch-case无法编译,报错信息是“Attribute value must be constant”,case后面只能跟常量表达式。
对策:在library代码里都用if-else if替代switch。Kotlin的when在这里不受影响,因为Kotlin编译时不会把它强转为switch字节码,所以用Kotlin写library的会少踩这个坑。但如果你的library是Java写的,并且刚好有一堆onClick判断,这个错误几乎一定会遇到。
5. 拿到的AAR怎么用:三种常见引用姿势
5.1 最省事:project模块依赖
如果你们是同一套代码仓库、同一个项目里拆的模块,直接用project依赖最省心。在主App的build.gradle里写:
implementation project(':mylibrary')这种方式的优点是不需要管AAR文件位置、不需要担心版本同步,改完library代码立刻生效,开发期迭代效率极高。缺点是双方必须共享同一份源码,做不到真正的“二进制交付”。很多团队内部组件库维护时,开发期用project依赖联调,发版时切到仓库依赖,就是折中方案。
5.2 把AAR文件放进libs目录:flatDir老写法和files新写法
发给外部团队或者在本地试包时,最常见的就是把AAR丢进app/libs目录。这里有两种用法,我建议优先用新的files方式。
第一种写法(AGP 4.2之后实测较稳):
implementation files('libs/mylib-1.0.0-release.aar')只要AAR在app/libs下,这样写就能直接编进去。没有任何额外配置,干净利落。
第二种是老教程里常见的flatDir方式,需要在settings.gradle的dependencyResolutionManagement配置仓库:
dependencyResolutionManagement { repositories { flatDir { dirs 'libs' } } }然后dependencies里写:
implementation(name: 'mylib-1.0.0-release', ext: 'aar')这套写法在老版本AGP里可用,但新AGP对(name, ext)组合的支持一直不太稳定,尤其带上传递依赖解析时很容易出问题。所以新项目我统一用files方式,宁可多打几行也懒得被这个坑折腾。
5.3 正经一点:发布到本地Maven仓库
如果你的AAR不只给一个人用,而是要给多个团队、多个工程集成,那libs目录就不合适了。发布到本地Maven仓库是成本最低的中间方案。在library的build.gradle里配上maven-publish插件:
plugins { id 'com.android.library' id 'maven-publish' } afterEvaluate { publishing { publications { release(MavenPublication) { from components.release groupId = 'com.example' artifactId = 'mylib' version = '1.0.0' } } repositories { maven { url = uri('../local-repo') } } } }然后执行:
./gradlew :mylibrary:publishReleasePublicationToMavenRepositoryAAR会发布到项目根目录的local-repo文件夹下。使用方在settings.gradle里加上仓库地址:
dependencyResolutionManagement { repositories { google() mavenCentral() maven { url uri('../local-repo') } } }接着在主工程dependencies里正常引用:
implementation 'com.example:mylib:1.0.0'这个方法最大的好处是依赖能自动传递。之前说的“AAR不携带依赖”的坑,在Maven发布模式下被POM文件解决了——使用方引一行,Gradle会自动去解析POM里声明的OkHttp、Gson这些传递依赖。这也是团队内部最推荐的交付姿势。
5.4 更进一步:推到远程仓库
本地仓库只适合单机或小范围试玩。真正要做成二方库、三方SDK,应该推到Nexus、Artifactory、GitHub Packages这类远程仓库。发布逻辑和本地Maven几乎一样,只要把publishing.repositories里的url换成远程地址,再配上账号密码或token就行。远程仓库的好处是版本管理统一、天然支持传递依赖、可以不把源码暴露给使用方。
这里提醒一句:如果走远程仓库,版本号一定要认真定。很多团队的SDK发到内网仓库后,版本号用-SNAPSHOT满天飞,最后使用方缓存了旧版本查半天。我习惯的做法是release版本号用三段式如1.2.0,每次发布递增,绝不覆盖已经发布的版本。
6. 进阶方向:源码AAR与多模块交付
6.1 源码AAR:用sources.jar解决调试联调的痛苦
前面提到打包AAR时默认会生成一个sources.jar,比如mylib-1.0.0-release-sources.jar。这里面装的是library的Java/Kotlin源码,主要给IDE在反编译时跳转用的。本地引用AAR时,Android Studio有时会自动关联同目录下的sources.jar,你在代码里Ctrl+点击库里的方法能看到源码实现,不用每次跑去看.class反编译。如果是Maven仓库依赖,IDE会自动去拉sources-artifact,前提是发布时发布了sources的artifact。
对SDK提供方来说,给不给sources.jar是一个策略问题。内部二方库建议开放,定位问题效率高;对外SDK看商业考虑,有些商业SDK只发混淆后的class,源码包不发布。
6.2 多个library合并成一个AAR的两种思路
当你维护的library越来越多,经常遇到“这个模块依赖那个模块、最后要一起交付”的情况。两种思路供参考。
第一种是把源码合并成一个library module。把需要合并的代码统一切到一个模块里,资源统一加前缀,依赖统一声明。这个方案最干净,构建链路简单,不会出现AAR互相依赖后资源或清单冲突。缺点是模块边界被打破,从架构上不再优雅,适合几个库之间本身逻辑耦合度就很高的情况。
第二种是使用fat-aar一类的插件,把依赖的AAR/JAR内容直接拆包塞进主AAR。几年前我试过这种方式,当时确实能解决“一个AAR交付完整功能”的痛点,但兼容性问题非常突出,AGP版本一升级插件可能就废掉。而且它天然破坏了第1.3节说的“AAR不携带依赖”原则,容易导致多个AAR都内嵌同一份第三方库,APK体积和类冲突双爆炸。我现在的态度是:不推荐新项目用fat-aar,宁可多发布几个AAR,让使用方依赖解析去处理,也别搞魔法打包。
6.3 模块化架构下AAR的定位
从一个更宏观的视角看,AAR是Android模块化体系里“二进制复用”的载体。组件化开发可以以源码级module协作,但跨团队、跨仓库、跨版本交付时,AAR就是最标准的交接格式。它让每一个模块都有了明确的版本号、独立的编译周期和清晰的外部依赖描述,主工程不再需要关心模块内部是怎么写的,只要依赖一行坐标。
具体到落地,我比较推荐的做法是:平时开发用project依赖保证迭代效率,每次发版用CI脚本构建好AAR并发布到内部仓库,主工程锁定版本号。这样既有开发期的热更新速度,又有交付期的稳定可追溯。
我自己在打包AAR这条路上踩过的坑不少,从最开始不知道依赖不会传递,到后来混淆规则写错地方导致合作方线上崩溃,每一次都是花时间一个个排查出来的。现在团队内部已经把AAR打包发布流程规范成了固定动作:配置consumerProguardRules、发布到Maven仓库、附上依赖清单、锁版本号。把这几个动作做扎实,AAR这块基本就不会再出幺蛾子。