热门标签 | HotTags
当前位置:  开发笔记 > 编程语言 > 正文

Android合并AAR踩坑之旅

背景在输出Android模块时,有时会因为个别原因(比如来自业务的不可抗力),要求将模块打包成一个文件提供给接入方。这就意
背景

在输出Android模块时,有时会因为个别原因(比如来自业务的不可抗力),要求将模块打包成一个文件提供给接入方。这就意味着在输出模块由多个子模块组成的情况下,我们需要把多个AAR(或JAR)合并成一个大AAR输出,这个合并过程涉及到了很多有用的知识和难点,这篇文章就详细解析下其中的内容。

首先来直观认识下AAR

AAR文件是一种Android归档包(类比Jar:Java Archive),这种归档包是由Gradle构建库的Android Library插件产出的。它是一个压缩包,里面的内容可以总结为5个目录和5个文件:

  • 卖个关子,在上面10个内容中,其中有一个是已经合并后的结果,它默认已经包含了所有子模块的内容,你能猜出来是那个么?

图里文字已经基本解释清楚了各个内容,不再赘述,现在我们根据现有的了解设想下合并AAR的大致思路:AAR里无非是几个目录和文件,所以最终AAR的5个目录下,必然包含了所有子模块的对应内容,而5个文件也肯定是各个子文件合并的结果。那么如何实现包含和合并呢?

第一反应是写个脚本对AAR们做解压、整合,再压缩的思路,但稍微推演下就知道不现实(理论上也不可行,AAR和普通压缩文件还是有区别的),再思考下,既然直接拿产物做合并不可行,能不能在构建主模块AAR时,就顺便将子模块内容纳入进来呢?

这无疑是个优雅的思路,理论上是否可行呢?答案是可以的,别忘了AAR是Android Gradle Plugin构建产出的,而Gradle强大的拓展支持刚好能实现我们的需求。所以合并AAR的方法,其实就是修改Gradle构建流程,在默认构建的基础上,插入我们自己的操作,最终产出一个包含子模块内容的大AAR。

在入题之前,有必要先理解下Gradle是如何支持构建拓展的,下图是Gradle官网截图:

Gradle中定义了ProjectTask两个概念,同时也将构建流程面向对象化(即构建是针对Project执行的一系列Task)。对Gradle的描述关键在于基于依赖编程,具体解释就是可以用Gradle来定义Task和Task间的依赖关系,最终得出一个Task的有向无环图来描述构建。

这意味着,我们需要将合并的操作封装为Task,并在合适的生命周期内,将自定义Task插入到任务图的依赖关系中去,进而得出一个新的Task图,最终构建出我们要的产物。
我们在主工程下执行./gradlew assembleRelease,看一下默认构建中都执行了哪些Task:

可以看到依次执行了收集依赖,合并资源、Javac编译、打包文件等Task。同时,通过观察/build/intermediates目录可以发现,不同任务会产出各自的构建中间结果,最终的AAR就是由这些中间结果打包而来的。所以理论上,我们需要在主模块执行打包Task前将子模块内容混入到中间结果中,而由于构建Task间存在依赖关系,混入操作需要分阶段、找时机执行。

合并前需要先确定到底需要合并哪些子模块。我们通过定义一个dependency configuration: emeded来标记需要被合并的模块,然后在gradle构建的afterEvaluate阶段收集被emeded依赖的模块信息:

afterEvaluate {def dependencies = new ArrayList(configurations.embedded.resolvedConfiguration.firstLevelModuleDependencies)dependencies.reverseEach {...it.moduleArtifacts.each {artifact ->if (artifact.type == 'aar') {if (!embeddedAarFiles.contains(artifact)) {//要合并的AAR文件embeddedAarFiles.add(artifact)}if (!embeddedAarDirs.contains(modulePath)) {...//每个AAR的解压目录embeddedAarDirs.add(modulePath)}} else if (artifact.type == 'jar') {...//要合并的JAR文件embeddedJars.add(artifactPath)} ...}}
}

可以看出,我们收集了三个集合:

  • 要合并的AAR文件

  • 每个AAR的解压目录

  • 要合并的JAR文件

AAR和JAR分开收集是因为合并这两种文件的操作不同,JAR只需纳入将其Class文件,而AAR需要合并更多内容。
下面针对AAR中的5和目录和5个文件,逐个介绍合并Task,从最简单的开始:

/assets目录

合并assets内容的Task很简单,只需将子模块解压后的assets目录添加到主工程的assets.srcDirs中即可

task embedAssets << {embeddedAarDirs.each { aarPath ->android.sourceSets.main.assets.srcDirs &#43;&#61; file("$aarPath/assets")}
}

/res目录

通过conventionMapping&#xff0c;修改构建流程task packageRecourses的输入集合&#xff0c;将收集到的子模块/res目录追加到Task输入参数中

task embedLibraryResources << {def oldInputResourceSet &#61; task_packageResources.inputResourceSetstask_packageResources.conventionMapping.map("inputResourceSets") {getMergedInputResourceSets(oldInputResourceSet)}
}
private List getMergedInputResourceSets(List inputResourceSet) {...List newInputResourceSet &#61; new ArrayList(inputResourceSet)embeddedAarDirs.each { aarPath ->...rs.addSource(file("$aarPath/res"))newInputResourceSet &#43;&#61; rs}return newInputResourceSet
}

/jni目录

遍历子模块解压目录&#xff0c;将其中的so文件copy到主工程build目录下

task embedJniLibs << {embeddedAarDirs.each { aarPath ->copy {from fileTree(dir: "$aarPath/jni")into file("$bundle_release_dir/jni")}}
}

proguard.txt

注意&#xff1a;这个文件与AAR构建自身时的混淆操作无关&#xff0c;只作用于接入到宿主后的混淆操作

task embedProguard << {//bundle_release_dir &#61; "build/intermediates/bundles/release"def proguardFile &#61; file("$bundle_release_dir/proguard.txt")//遍历aar解压目录&#xff0c;将子模块的proguard.txt文件内容追加到主工程build目录内的proguard.txt中embeddedAarDirs.each { aarPath ->...def proguardLibFile &#61; file("$aarPath/proguard.txt")if (proguardLibFile.exists())//调用file.append()方法可以在txt文件里追加内容proguardFile.append("\n" &#43; proguardLibFile.text)...}
}

AndroidManifest.xml

合并AndroidManifest使用官方库manifest-merger即可&#xff0c;这里遇到了第一个难点&#xff1a;想要把子模块AndroidManifest合并进来必须使用MergeTypeAPPLICATION的ManifestMerger对象&#xff0c;但在这种MergeType下会替换定义在AndroidManifest里的PlaceHolder&#xff0c;比如${applicationId}

然而矛盾的是&#xff0c;此时的构建还没有接入到宿主App&#xff0c;也就拿不到最终的ApplicationId&#xff0c;怎么办呢&#xff1f;通过阅读源码发现&#xff0c;官方仁慈的给我们开放了一个功能位&#xff0c;通过在初始化ManifestMerger对象时传入一个NO_PLACEHOLDER_REPLACEMENT功能位&#xff0c;即可禁止在APPLICATION模式下替换PlaceHolder

buildscript {repositories {jcenter()}dependencies {classpath &#39;com.android.tools.build:manifest-merger:25.3.2&#39;}
}
...
task embedManifests <<{...embeddedAarDirs.each { aarPath ->File dependencyManifest &#61; file("$aarPath/AndroidManifest.xml")if (!libraryManifests.contains(aarPath) && dependencyManifest.exists()) {//先收集需要合并的子模块AndroidManifestlibraryManifests.add(dependencyManifest)}}...Invoker manifestMergerInvoker &#61; ManifestMerger2.newMerger(origManifest, mLogger, MergeType.APPLICATION)//通过Invoker.Feature.NO_PLACEHOLDER_REPLACEMENT这个Flag禁止执行PlaceHolder替换.withFeatures(Invoker.Feature.NO_PLACEHOLDER_REPLACEMENT)manifestMergerInvoker.addLibraryManifests(libraryManifests.toArray(new File[libraryManifests.size()]))manifestMergerInvoker.setMergeReportFile(reportFile);//执行合并MergingReport mergingReport &#61; manifestMergerInvoker.merge();...
}

classes.jar

这似乎是最重要的文件&#xff0c;但是合并方法却不难&#xff1a;对于AAR类型的子模块&#xff0c;我们需要提取两样东西&#xff1a;一是解压子模块内部classes.jar得到的Class文件&#xff0c;二是看看子模块内部有没有自身携带的JAR文件。对于Class文件&#xff0c;将它们copy到主工程build/intermediates/classes/release目录下&#xff0c;随后的构建任务会从这个目录打包出主模块的classes.jar&#xff1b;对于子模块内部的JAR文件&#xff0c;将它们和JAR类型的子模块一起&#xff0c;放入主工程的build/intermediates/bundles/release/libs目录下&#xff0c;作为依赖包携带即可

task embedClassesAndJars(dependsOn: embedRJar) << {embeddedAarDirs.each { aarPath ->...embeddedAarFiles.each {artifact ->...//找到每个aar里的clasess.jardef aarFile &#61; aarFileTree.files.find { it.name.contains("classes.jar") }// 解压clasess.jar&#xff0c;将classes放入build/intermediates/classes/releasecopy {from zipTree(aarFile)into classes_dir}}...//找到每个aar里携带的额外jar包FileTree jars &#61; fileTree(dir: jar_dir, include: &#39;*.jar&#39;, exclude: &#39;classes.jar&#39;)...//放入build/intermediates/bundles/release/libscopy {from jarsinto file("$bundle_release_dir/libs")}}//主工程直接依赖的jar包&#xff0c;也放入build/intermediates/bundles/release/libscopy {from embeddedJarsinto file("$bundle_release_dir/libs")}
}

小节

至此&#xff0c;我们已经合并了三个文件和四个目录&#xff1a;

剩下的内容里&#xff0c;/aidl目录和annotation.zip不常用到&#xff0c;我们暂不理会&#xff0c;乍一看好像已经合并完成了&#xff0c;只剩下个看似无害的R.txt&#xff0c;它是干嘛用的&#xff1f;我们当真完成了么&#xff1f;

答案是没有&#xff0c;假如按照目前的改造构建出一个AAR并接入到App中运行&#xff0c;结果是App会在每一处使用到AAR子模块资源文件的时候崩溃&#xff0c;堆栈日志很简单&#xff1a;NoClassDefFoundError - 没有子模块包名下的R文件。啊~忘了R文件这个东西了&#xff01;可R文件不是构建时自动生成的么&#xff1f;没错&#xff0c;是自动生成的&#xff0c;但由于我们合并了模块&#xff0c;子模块将丢失它们的R文件&#xff0c;仔细梳理下App构建流程就会发现原因&#xff1a;

如图上部所示&#xff0c;正常情况下&#xff0c;不管是主模块还是子模块&#xff0c;都是作为一个个独立的dependence引入到宿主工程的&#xff0c;App构建时会给每一个dependence生成它们包名下的R文件&#xff1b;而当我们自主的将一群AAR合并为一个AAR后&#xff08;图下半部&#xff09;&#xff0c;对于宿主来说最后只接入了一个dependence&#xff08;主模块&#xff09;&#xff0c;子模块本该对应的dependences不存在了&#xff0c;所以App构建时只给主模块生成了R文件&#xff01;知道原因了&#xff0c;怎么解决呢&#xff1f;

我们知道&#xff0c;构建主模块时&#xff0c;会在/build/generated/source/r目录下生成所有包名的R文件&#xff0c;当然也包括子模块R文件&#xff0c;那么我们把这个目录下的子模块R文件打成Jar包&#xff0c;放入主工程build/intermediates/bundles/release/libs目录下&#xff0c;这样R.jar会作为依赖库被AAR携带&#xff0c;不就可以了吗&#xff1f;我们兴冲冲地改了脚本&#xff0c;再次构建运行&#xff0c;结果还是崩溃&#xff01;看下堆栈信息&#xff0c;提示资源找不到&#xff1a;Resources$NotFoundException&#xff0c;这又是为什么&#xff1f;R文件可是自动生成的呀&#xff1f;为什么生成的文件内容&#xff08;也就是资源ID&#xff09;找不到对应资源&#xff1f;

在解释原因之前&#xff0c;先抛几个问题&#xff1a;

  • AAR里默认有R文件么&#xff1f;

  • 为什么Android Library工程生成的R.java里的域不是final修饰的&#xff1f;

  • 生成R文件流程的输出无疑是R.java&#xff0c;那么输入是什么&#xff1f;

在回答这些问题之前&#xff0c;先复习下Java基础知识&#xff0c;下面是一段简单的Java代码&#xff1a;

public class Test {private static final int SOME_ID &#61; 0x07111111;private int getID(){return SOME_ID;}
}

假如我们将SOME_IDfinal修饰符去掉&#xff0c;那么编译后的字节码跟不去掉final相比&#xff0c;会有什么不同呢&#xff1f;我们反编译看下结果&#xff1a;

//带final修饰符
public class Test {private static final int SOME_ID &#61; 118558993;public Test() {}private int getID() {return 118558993;}
}
//不带final修饰符
public class Test {private static int SOME_ID &#61; 118558993;public Test() {}private int getID() {return SOME_ID;}
}

对比发现&#xff0c;带final时&#xff0c;getID()方法直接返回了数值&#xff1b;而不带final时&#xff0c;getID()方法返回的是SOME_ID&#xff0c;即依然保留着对变量的符号引用。这个看似不起眼的差别却暗藏玄机&#xff0c;看下图&#xff1a;

如图&#xff0c;每个AAR被接入后&#xff0c;都会跟随App工程再经历一次构建&#xff0c;即宿主工程的构建。而我们知道&#xff0c;R文件是跟随每次构建重新生成的&#xff08;不管是AAR的构建还是App的构建&#xff09;&#xff0c;而R.java中每个域的值是由当前工程的资源集合做排列得出的&#xff0c;这意味着&#xff0c;假如工程的资源集合发生了变化&#xff0c;那么R.java中域的值都可能发生变化。

对于AAR文件来说&#xff0c;自身工程的资源集合必然和宿主工程的资源集合不一样&#xff0c;或者可以这样理解&#xff0c;R.java的值是一次性的&#xff0c;它只保证在当前构建结果下有效&#xff0c;这次生成的R文件&#xff0c;不保证在下次构建后可用&#xff0c;这也是每次构建都重新生成R文件的原因。这样我们就找到了上面的崩溃原因和抛出的前两个问题的答案&#xff1a;AAR接入到App后跟随App又经历了一次构建&#xff0c;资源发生了重排列&#xff0c;所以手动打到AAR中的R文件ID值全部失效&#xff0c;无法再索引到资源&#xff0c;所以运行时崩溃。

AAR中默认没有R文件&#xff0c;因为带上也完全不能用。同样因为重排列&#xff0c;AAR无法预知自身资源接入到App后的ID值&#xff0c;所以Library工程生成的R.java中的域都不能用final修饰。

*&#xff1a;这里的逻辑很刁钻&#xff0c;但却很重要&#xff0c;是理解后续操作的前提&#xff0c;务必要反复品味...

那要怎么办呢&#xff1f;其实关键在主模块身上&#xff1a;合并子模块后&#xff0c;主模块成了所有子模块的代言人&#xff0c;App只需接入主模块就等于接入了所有模块。能力越大责任越大&#xff0c;我们赋予了主模块如此特殊的地位&#xff0c;那么它也应该起到足够特殊的作用&#xff0c;比如桥接子模块到宿主的资源索引&#xff1a;

//com.sub.library
public final class R {public static final class string {public static int some_string &#61; com.main.library.R.string.some_string;}
}

如代码所示&#xff0c;我们在把子模块R文件放入classes.jar之前&#xff0c;手动将其中的域指向主模块R文件相同的域&#xff08;正是因为这里没有final修饰符&#xff0c;才能保留对主模块域的符号引用&#xff0c;编译后才不会变成数值而无法被更改&#xff09;&#xff0c;因为主模块R文件会跟随每一次App构建而重新生成&#xff0c;所以它的ID值总是新鲜可靠的&#xff0c;这样子模块就可以通过这个桥接拿到正确的资源索引了。这时你可能会拍案而起&#xff0c;不对啊&#xff0c;主模块没有子模块里的资源文件&#xff0c;为什么R文件中会有跟子模块相同的域呢&#xff1f;&#xff01;

这其实就是上面提到的第三个问题&#xff1a;生成R文件流程的输出无疑是R.java&#xff0c;那么输入是什么&#xff1f;这个问题也牵扯到本文刚开始卖的关子&#xff1a;那个默认包含子模块内容的文件是谁&#xff1f;没错&#xff0c;就是R.txt&#xff08;这个看似老实的东西其实坏的很 XD&#xff09;。

我们知道&#xff0c;APK或AAR构建时会用Aapt编译资源文件&#xff0c;这个R.txt正是Aapt的产物&#xff0c;通过在aapt package命令后面跟上--output-text-symbols参数得到。R.txt中会记录下-S参数传入的资源目录下所有的资源文件信息&#xff0c;一个资源占一行&#xff0c;格式为int type name id&#xff0c;比如: int string some_string 0x7f050000&#xff08;和R文件格式一致&#xff0c;所以猜到了吧&#xff0c;R.txt就是R.java的种子&#xff09;。

在AAR的构建中&#xff0c;R.txt自然记录了Library库内的资源文件信息&#xff0c;不过一个很不自然的事情是&#xff0c;构建时aapt package命令的-S参数传入的不是本工程/src/res目录&#xff0c;而是build/intermediates/res/merged/release目录&#xff0c;也就是合并子模块资源后的/res目录&#xff01;所以&#xff0c;默认生成的R.txt中就已经包含了所有子模块的资源文件&#xff0c;而这个R.txt恰恰就是构建生成R.java的种子文件&#xff0c;脚本会根据R.txt中每一行的内容生成R.java中对应的域。这也就解释了为什么主模块的R.java会包含所有子模块的资源索引。终于&#xff0c;我们知道了如何处理R文件&#xff1a;

task redirectRJava << {...embeddedAarDirs.each { aarPath ->...def sb &#61; "package $subPackageName;" << &#39;\n&#39; << &#39;\n&#39;//将ID赋值为主模块包名下对应的值sb << " public static $type $name &#61; ${mainPackageName}.R.${subclass}.${name};" << &#39;\n&#39;...//generated_rsrc_dir &#61; "build/generated/source/r/release"mkdir("$generated_rsrc_dir/$packagePath")file("$generated_rsrc_dir/$packagePath/R.java").write(sb.toString())}
}//将R文件打成Jar包放入libs目录
task embedRClass(type: org.gradle.jvm.tasks.Jar, dependsOn: collectRClass) {...baseName "EmbedR"destinationDir file("$bundle_release_dir/libs")from base_r2x_dir
}

解决完R文件的难题后&#xff0c;我们就基本完成了AAR的合并工作&#xff0c;但是在实际业务中&#xff0c;会遇到另一个棘手的问题&#xff1a;Support库兼容问题。上面已经分析过&#xff0c;一个库的R文件中会包含它依赖的所有子模块的资源文件&#xff08;还记得原因么&#xff09;&#xff0c;假如我们的模块依赖了Support库&#xff08;实际业务中很常见&#xff0c;比如用到了v4Fragment或者RecyclerView&#xff09;&#xff0c;那么R文件或R.txt中就会包含Support库内所有资源文件的信息&#xff0c;而当模块被接入后&#xff0c;在App的构建流程中&#xff0c;会根据最终的Support库版本&#xff08;App依赖的Support库版本不可知&#xff09;对各个R.txt里的内容做过滤&#xff0c;只保留App工程中确实存在的资源文件&#xff0c;生成最终的R文件&#xff08;源代码见com.android.builder.symbols.RGeneration&#xff09;。

举个例子&#xff0c;假如模块使用的A版本的Support库中有资源文件res1&#xff0c;而接入方App使用了更高版本的Support库中没有res1这个资源&#xff0c;那么App构建生成的R文件中将没有res1这个资源索引&#xff0c;可是我们打入AAR中的R文件还保留着对res1的符号引用&#xff0c;结果就是在运行时类初始化失败&#xff08;R.class的错误&#xff09;&#xff1a;

public final class R {public static final class string {//这里的com.main.library.R.string.res1被过滤掉不再存在&#xff0c;符号引用失效public static int res1 &#61; com.main.library.R.string.res1;}
}

怎么办呢&#xff1f;第一反应是保证support库版本一致&#xff08;必须精确到小版本号&#xff09;&#xff0c;即针对每个接入方构建出跟其Support库一致的产物&#xff0c;这就带来了很多隐藏成本&#xff08;还需要手动做R.txt的过滤&#xff09;&#xff0c;更何况假如接入方某天更新了Support库版本&#xff0c;直接就Runtime Crash了&#xff0c;这属于对接事故&#xff0c;显然不能接受。那么&#xff0c;应该怎么做呢&#xff1f;其实很简单&#xff1a;禁止Support库资源写入R.txt即可&#xff08;其实官方也明确提示过不要自己使用Support库里的资源&#xff09;&#xff0c;还记得R.txt是根据什么生成的么&#xff1f;

没错&#xff0c;是build/intermediates/res/merged/release目录下的资源合集&#xff0c;而这些文件又是根据遍历依赖合并进来的&#xff0c;假如我们能在合并时过滤掉Support库的依赖项&#xff0c;就在源头上过滤掉了Support库资源。所以我们只需要对合并Resources的Task稍做手脚&#xff1a;

task filterMergeResource << {def oldInputResourceSet &#61; task_mergeResources.inputResourceSetsList<ResourceSet> newInputResourceSet &#61; new ArrayList(oldInputResourceSet)newInputResourceSet.removeAll {//过滤com.android.support包名下的依赖&#xff0c;避免support库资源打入R.txt(null !&#61; it.libraryName) && (it.libraryName.contains("com.android.support"))}task_mergeResources.conventionMapping.map("inputResourceSets") {newInputResourceSet}
}

总结

最终我们插入了以上几个自定义任务到不同的构建节点中&#xff0c;完成了合并AAR的构建改造&#xff0c;但仍然有拓展空间&#xff0c;比如支持buildType和productFlavor&#xff0c;有兴趣的同学可以自己尝试下&#xff08;其实就是找到对应的build目录&#xff09;。

更多资料分享欢迎Android工程师朋友们加入安卓开发技术进阶互助&#xff1a;856328774免费提供安卓开发架构的资料&#xff08;包括Fultter、高级UI、性能优化、架构师课程、 NDK、Kotlin、混合式开发&#xff08;ReactNative&#43;Weex&#xff09;和一线互联网公司关于Android面试的题目汇总。

推荐阅读
author-avatar
重新生活好吗
这个家伙很懒,什么也没留下!
PHP1.CN | 中国最专业的PHP中文社区 | DevBox开发工具箱 | json解析格式化 |PHP资讯 | PHP教程 | 数据库技术 | 服务器技术 | 前端开发技术 | PHP框架 | 开发工具 | 在线工具
Copyright © 1998 - 2020 PHP1.CN. All Rights Reserved | 京公网安备 11010802041100号 | 京ICP备19059560号-4 | PHP1.CN 第一PHP社区 版权所有