前言
首先简单讲一下这个需求的背景,大部分场景下,是没有这个需求的,这个需求出现在插件化中,当一个android插件引用aar中的类的时候,并且这个插件是使用com.android.application 这个gradle插件进行打包的,但是这个类已经在宿主中或者其他插件中,其实就没有必要将这个重复类打包到插件中,因此只需要进行引用,不需要打包到插件中。引用是其中一个目的,保证混淆的正确性则是另一个目的。寻找这个需求的解决方案的过程中,发现这个问题其实并没有想象中的那么好解决,会遇到许许多多的细节问题。
Atlas的解决方案
atlas的插件并没有使用com.android.application进行编译,而是使用com.android.library进行编译,然后发布maven中的也只是aar(awb),真正编译插件的时机是在宿主中,使用bundleCompile引用插件aar,在编译宿主的过程中会执行插件编译,然后将编译好的插件放入动态库armeabi目录,而在com.android.library中使用provided aar是完全可以的,于是atlas也就没有了在com.android.application中使用provided aar的需求,完全不存在这个问题。但是假设我们的插件在集成到宿主中前就已经编译好了,因此就需要使用com.android.application进行编译,对于一些宿主中或其他插件中存在的重复类,就必然需要使用到provided的功能,对于jar来说,没有问题,正常使用即可,但是对于aar来说,android gradle plugin肯定会爆出一个问题,该问题如下
| 1 | Provided dependencies can only be jars. | 
因此必须寻找一个在com.android.application中使用provided aar的方法。
方法一:自定义Configuration
使用自定义的Configuration,然后将provided这个Configuration继承我们自定义的Configuration,这个方法理所当然的会最先被想到。
首先创建一个自定义的Configuration对象providedAar,然后将providedAar中的所有依赖解析出来,将解析出来的文件进行判断处理,如果是aar,则提取classes.jar,如果是jar,则直接使用,然后添加到provided的scope上。这样就完成了,代码如下:
| 1 | @Override | 
这里需要注意,如果要添加provided依赖的jar文件,只需要调用project.dependencies.add(“provided”, FileCollection fileCollection)方法进行添加即可。
其实上面的代码忽视了一个十分重要的问题,即完全无视了gradle的生命周期。依赖对应的文件类型FileCollection,只有依赖被解析之后才能拿到,即在afterResolve回调之后才能拿到,而afterResolve之后如果再对dependencies对象继续修改,必然会抛出一个异常
| 1 | Cannot change dependencies of configuration ':app:providedAar' after it has been resolved | 
所以这段代码思路是行不通的,会存在一个循环依赖的问题,添加依赖前无法获取依赖文件,获取到依赖文件后,无法添加到provided scope依赖中去。这就是这种方法行不通的根本原因。
方法二:实现maven下载协议
参考这篇文章 如何在Android Application里provided-aar,解决的思路是在beforeResolve中添加依赖,但是在beforeResolve是拿不到依赖对应的文件FileCollection对象的,于是这个方法最大的问题变成了如何获取依赖文件。这个文件获取如果要做的完美,其实是很复杂的,涉及到整个依赖树的有向图构建,版本冲突解决,以及传递依赖的递归问题。
首先来了解一下maven下载的最简单的逻辑。
对于release版本的依赖,其实很简单,拼接依赖的path路径即可,路径格式为
| 1 | /$groupId[0]/../${groupId[n]/$artifactId/$version/$artifactId-$version.$extension | 
假设依赖为io.github.lizhangqu:test:1.0.0,则pom文件对应的依赖path路径以及pom文件对应的md5和sha1文件路径为
| 1 | /io/github/lizhangqu/test/1.0.0/test-1.0.0.pom | 
根据这个路径获取到pom文件中的内容,读取pom.xml中的packaging的值,假设pom.xml文件内容为
| 1 | 
 | 
从上述内容很容易得到这里packaging的值为aar,则对应的文件以及该文件对应的md5和sha1文件路径为
| 1 | /io/github/lizhangqu/test/1.0.0/test-1.0.0.aar | 
如果该依赖存在classifier,则路径更复杂,比如javadoc和sources,其路径格式会变成
| 1 | /$groupId[0]/../$groupId[n]/$artifactId/$version/$artifactId-$version-$classifier.$extension | 
上述依赖的classifier路径及classifier文件对应的md5和sha1文件的路径则是
| 1 | /io/github/lizhangqu/test/1.0.0/test-1.0.0-javadoc.jar | 
如果classifier不是jar文件,则gradle中必须强制手动指定extension,否则会提示找不到对应的classifier文件。假设classifier为so文件,则需要强制指定为
| 1 | io.github.lizhangqu:test:1.0.0:javadoc@so | 
而对于SNAPSHOT版本,就比较复杂了,假设依赖为io.github.lizhangqu:test:1.0.0-SNAPSHOT,则我们需要获取该版本的maven-metadata.xml文件,对应的path路径及其md5和sha1值的文件路径为
| 1 | /io/github/lizhangqu/test/1.0.0-SNAPSHOT/maven-metadata.xml | 
假设这个文件内容如下
| 1 | <metadata> | 
从该文件中看出当前1.0.0-SNAPSHOT的信息是这个artifact发布了200次,最新发布的时间是20171222.013814,拼接这两个值,得到20171222.013814-200,于是就获得了该依赖版本的最新快照的path路径以及md5和sha1文件路径
| 1 | /io/github/lizhangqu/test/1.0.0-SNAPSHOT/test-1.0.0-20171222.013814-200.pom | 
假设这个pom.xml文件中的内容如下
| 1 | 
 | 
从pom.xml中可以看到packaging为aar,则对应的文件的路径及该文件的md5和sha1文件路径为
| 1 | /io/github/lizhangqu/test/1.0.0-SNAPSHOT/test-1.0.0-20171222.013814-200.aar | 
之后的处理就和release版本是一样的。
依赖部分的path知道了,那么还需要知道maven服务所在地址,可通过gradle直接拿到
| 1 | project.getGradle().addListener(new DependencyResolutionListener() { | 
拼接最终的地址,如下
| 1 | #[]内的内容为可选 | 
上述只是简单的讲了下下载的逻辑。文件是如何缓存的,如何更新本地的SNAPSHOT版本,如何将本地信息与远程信息进行合并,以及传递依赖是如何处理的都没有讲到,实际情况要比这个远远复杂的多。
就拿缓存路径来说,对于mavenLocal,其本地文件缓存的路径是最简单的,为
| 1 | #[]内的内容为可选 | 
而gradle的缓存目录十分复杂,由gradle自己处理,且各个版本路径可能会有差异,大致位于
| 1 | #[]内的内容为可选 | 
其中sha1Value的值为\$artifactId-\$version[-\$classifier].\$extension文件对应的sha1值
gradle的缓存策略可以见文档 gradle caching
gradle各个版本元数据的缓存目录如下
| 1 | public LocallyAvailableResourceFinder<ModuleComponentArtifactMetadata> create() { | 
知道了这些后,我们可以自己去实现一套maven下载协议,但是还是十分复杂的,所以个人认为这没有必要,如果自己去实现,首先,不能复用gradle现有的缓存,其次自己下载的缓存,无法正常的与gradle缓存进行合并,虽然可以放到mavenLocal目录下,但是一些策略性的问题,不能考虑得十分周全,如SNAPSHOT版本的更新。因此,这里就不自己实现一套下载协议,而是复用gradle内部的下载代码及现有的缓存。不过经过测试,很遗憾,不支持传递依赖。具体是怎么实现的请见方法三里的一部分处理。
方法三:合理使用反射,Hack一下代码
方法三,是看了几天android gradle plugin以及gradle代码总结出来的,看代码的过程是十分痛苦的,只能一点点猜测相关的类在哪里,不过最后实现的脑洞比较大,比较黑科技。
既然在com.android.application中使用provided aar会报出Provided dependencies can only be jars. 异常,那么有没有办法把这个异常消除掉呢。我们发现在com.android.library中是支持provided aar的,这说明android gradle plugin其自身是支持provided aar的,只是在哪个环节做了下校验,在com.android.application中抛出了异常而已。简单的浏览了下android gradle plugin的代码,发现是完全可以行的通的,不过比较遗憾的是只能消除2.2.0之后的版本的异常,2.2.0之前的版本能消除,但是无法正常使用provided功能,即使是provided的,也会被看成是compile依赖,所以2.2.0之前的版本需要单独处理一下。除此之外,3.0.0与之前的版本消除方式不大一样,需要做区分处理。而对于2.2.0以下的版本怎么做呢?答案是使用方法二中的复用gradle现有代码及缓存。
于是我们需要进行一下android gradle plugin的版本区分,这里做了如下分界
- android gradle plugin [1.5.0,2.2.0), 不支持传递依赖 ,且gradle版本必须为2.10以上,而android gradle plugin 1.5.0以下版本太过久远,就不支持了
- android gradle plugin [2.2.0,2,5.0), 支持传递依赖 ,使用反射消除异常
- android gradle plugin [2.5.0,3.1.0+], 支持传递依赖 ,使用反射替换task执行逻辑,将抛异常修改为输出日志信息
其中,2.4.0+和2.5.0+都未发布过正式版本,而2.4.0预览版代码比较趋向于2.3.0的代码,2.5.0预览版代码比较趋向于3.0.0的代码,于是产生了上述的分界。
先从最简单的版本开始,我们先处理[2.2.0,2,5.0)的版本,这里以2.3.3的代码为例,只要在[2.2.0,2,5.0)这个区间内的版本,都可以适用。
通过搜索 Provided dependencies can only be jars,发现这个异常在com.android.build.gradle.internal.dependency.DependencyChecker中被记录,类型type为7,错误严重级别为2,在配置评估阶段只会打出WARNING的日志,而真正抛出异常的地方是在PrepareDependenciesTask执行的时候抛出的。其关键代码如下
| 1 | private final List<DependencyChecker> checkers = Lists.newArrayList(); | 
也就是说我们只要在这个task action被执行前,把checkers变量中对应类型的异常remove掉,就不会出现报错了,实现一下,具体代码如下
| 1 | //遍历所有变体 | 
简单测试一下,跑了下,没问题,后面测试了下兼容性,在[2.2.0,2,5.0)内的版本都适用该代码,不过有个细节性的问题需要处理,即2.4.0+预览版的gradle,其代码虽然偏向2.3.0+的代码,但是内部一些逻辑还是比较偏向于3.0.0的代码的,经过试验发现,不能在prepareDependenciesTask.configure去执行该闭包,那个时候checkers中的syncIssues还是空的,因此需要延迟执行,但必须在任务执行前执行,因此需要将其移到doFirst中去执行,但是对于非2.4.0+预览版的版本,doFirst执行就太晚了,必须在configure阶段进行移除,否则就会报异常,所以需要做下版本区分,需要获取gradle的版本号,那么这个版本号怎么获取呢。很简单,代码如下
| 1 | String getAndroidGradlePluginVersionCompat() { | 
然后做下版本区分
| 1 | String androidGradlePluginVersion = getAndroidGradlePluginVersionCompat() | 
这样[2.2.0,2,5.0)这个区间内的版本就处理完了,接下来处理第二复杂的版本,即[2.5.0,3.1.0+],这里以3.0.1的代码为例,修改代码适用于[2.5.0,3.1.0+]区间内的所有版本
对于这个区间范围内的版本,如果在com.android.application中使用provided aar,则会报出这个错误信息
| 1 | Android dependency '$dependency' is set to compileOnly/provided which is not supported | 
通过搜索报错的关键字符串信息,可以定位到这个异常是在AppPreBuildTask中报出来的,其关键代码如下
| 1 | private ArtifactCollection compileManifests; | 
简单说下上述代码的逻辑
- 获取Android aar依赖的编译期依赖compileManifests和运行期依赖runtimeManifests
- 创建一个map对象runtimeIds,遍历运行期依赖,将依赖的坐标作为key,依赖的版本号作为值
- 遍历编译期依赖,用依赖的坐标从runtimeIds map中取值,如果取到的值,即依赖的版本号为空,则表示编译期的aar依赖在运行期依赖中不存在,于是抛出Android dependency ‘$dependency’ is set to compileOnly/provided which is not supported异常
- 如果版本号不为空,则比较编译期和运行期的版本号,如果不一致,则抛版本号不一致的异常,这个需要业务上控制版本号一致,不需要特殊处理
从上面的分析可以看出,抛出异常的原因是aar的编译期依赖在运行期依赖中不存在。所以消除这个异常的方法就是那么我们让其存在即可,但是如果让其存在,最终编译期provided的aar也会被编译进apk中去,这不是我们想要的。换一种方式,替换执行逻辑。因此我们需要替换这个task真正执行的内容,让其抛出异常的部分修改为打日志。具体的修改代码如下,可以直接看注释,关键部分就是将抛异常的逻辑修改为输出日志,其他逻辑和原代码一样。还有一点需要注意的是2.5.0+预览版的代码和3.0.0+版本的代码有一点区别,需要进行兼容处理。
| 1 | //寻找pre${buildType}Build任务 | 
[2.5.0,3.1.0+]区间内的版本也搞定了,通过兼容性测试,发现这段代码完美的适配了[2.5.0,3.1.0+]这个区间内的所有版本。
当然,为了进行各个版本的区分处理,需要进行一些逻辑上的自定义,所以,我们最好不要直接使用provided这个configuration,而是创建自定义的providedAar,这么做的好处就是为了后续扩展,提供更加灵活的途径,而不需要业务修改现有的代码。这部分的代码如下:
| 1 | //如果没有引用com.android.application插件,直接return | 
业务上使用
| 1 | dependencies { | 
不要使用
| 1 | dependencies { | 
值得注意的是2.2.0+以上的版本,处理后providedAar是支持传递依赖的,但是2.2.0以下由于其特殊性,不能支持传递依赖。
接下里就是最蛋疼的2.2.0以下的版本的处理,2.2.0以下的版本,虽然也可以使用[2.2.0,2.5.0)这个区间的方法将异常进行消除,但是即使消除了,因为其代码的特殊性,导致即使是provided依赖的aar,最终也会被打包进apk中,这里以2.1.3的代码为例,如下:
| 1 | //遍历过滤后剩余的编译期的依赖 | 
对比2.3.3版本的代码
| 1 | //获得maven坐标 | 
从两个版本的代码中可以看出有一处不同,如果不存在provided aar问题的话,2.2.0以下的版本在com.android.library中会将lib设为可选,但是在com.android.application就会直接抛出异常,具体的区别代码如下
| 1 | if (isLibrary || lib.isOptional()) { | 
一旦将lib设置为可选,后续就不会打包进apk,那么我们能不能将其设置为可选从而跳过该异常呢,答案是不能,第一个原因是时机过早,无法替换,第二个原因是待替换部分的代码过多,并且该逻辑在DependencyManager中,这个类比较关键,能不改还是不要改的比较好。于是只能回退到最原始的解决方案,复用gradle下载依赖的逻辑和缓存策略,手动获取aar,提取jar文件,添加到provided这个scope上。所以这个问题拆分出来就变成了如下问题。
- 如何判断当前是否在离线模式
- 如何在非离线模式下使用gradle现有代码获取最新的依赖文件
- 如何在离线模式下使用现有的gradle缓存
- 在线获取失败的情况下使用本地缓存进行重试,如场景:公司maven,外网无法访问,但是本地有缓存
- SNAPSHOT版本的获取
- Release版本的获取
对于第一个问题,如何判断当前gradle是否在离线模式下,很简单,这个值位于project.gradle.startParameter中,调用其isOffline()函数即可获取是否在离线模式下
| 1 | /** | 
对于第2,3,4个问题,其解决问题的大致伪代码如下
| 1 | project.getGradle().addListener(new DependencyResolutionListener() { | 
于是问题就变成了resolveArtifactFromRepositories函数的实现,该函数中需要做的就是遍历每一个maven仓库,判断依赖是否存在,找到一个就返回,平时我们在gradle中使用each去遍历,而我们只要找到一个需要的值就返回,因此这里需要用到any去遍历,找到之后返回true,其大致代码如下
| 1 | /** | 
最终代码变成了resolveArtifactFromRepository函数的实现,该函数的功能主要是从单个maven仓库中进行解析。其代码如下
| 1 | /** | 
由resolveArtifactFromRepository函数,拆分问题,于是问题变成了createModuleComponentArtifactMetaData函数、
createArtifactResolver函数,getLocallyAvailableResourceFinder函数,fetchFromLocalCache函数,fetchFromRemote函数的实现逻辑
首先来看createModuleComponentArtifactMetaData函数,具体的逻辑见代码中的注释
| 1 | /** | 
然后是createArtifactResolver函数,这个函数为了获取ExternalResourceArtifactResolver,实现是调用MavenResolver对象的父类函数createArtifactResolver进行创建
| 1 | /** | 
接下来是getLocallyAvailableResourceFinder函数,这个函数是为了获取本地缓存文件的查找器LocallyAvailableResourceFinder对象
| 1 | /** | 
剩下的两个函数,是最重要的两个函数,即fetchFromLocalCache函数和fetchFromRemote函数
先来看怎么从本地缓存中获取对应的依赖文件,具体逻辑已经在代码中进行了注释说明
| 1 | /** | 
然后是从远程获取对应的依赖,更新本地文件,具体的实现逻辑由gradle内部取保证,我们只要调用其函数即可。
| 1 | /** | 
将以上代码进行组合,就得到了CompatPlugin.groovy中的providedAarCompat函数,搜索该文件中的providedAarCompat函数,即最终组合完成的代码。
可以看到,整个实现逻辑还是十分复杂的,尤其是本地缓存依赖和在线依赖的解析部分,以及SNAPSHOT版本时间戳的获取及对应类的包装。需要理解整个过程还是需要自己过一遍整个代码。
不过遗憾的是,这部分的实现是不支持传递依赖的,如果要支持传递依赖,则会涉及到configuration的传递,逻辑会变得更加复杂,因此这里就不处理了,实际上问题也不大,只需要手动声明传递依赖即可。
总结
最重要的还是解决问题的思路,同一个问题,可能有不同的解决方法,需要整体考虑选择何种方式去解决。解决问题的过程就是不断学习的过程,通过实现这个需求,对gradle的依赖管理策略、缓存也更加熟悉了。
