在 Android Application 里 provided aar

首先得声明一下,其实 provided aar 这样的需求场景非常少。在 com.android.library 里可以直接 provided aar,因此这样的需求只会在 com.android.application 里存在。同时还得再声明一下,这里说的 provided 一个 aar,是只引用 aar 里的类,而 aar 里的资源还是没办法引用的。

而完成插件化之后的微店,就恰恰对这样的需求非常强烈。一个 application module 里 compile 了一个aar,在其他 application moduel 里也想用这个 aar 里的类,而作者在发布 aar 的时候,并没有发布对应的 jar,这时候我们的做法往往是手动地解压出来 aar 里的 classes.jar,然后再在 dependencies 里添加这个 classes.jar 的 provided 依赖。看上去很简单嘛,可问题是遇到一个就要这样做一次,这就好麻烦了。那么有没有一种纯自动的方式去做这个事情呢?这篇文章讲的就是这个事情。

需要解决的问题

首先,我们有必要把这个问题给拆解一下,看上去貌似只是一个解压一个 aar(其实就是一个 zip 压缩包),然后拿到其中一个叫 classes.jar 的 entry。可真要将其完全融入整个项目构建,做成完全自动化的一个过程,其中可能遇到的难点可能远超你我的想象。

  1. 直接 provided 一个 aar,在编译过程中是一定会抛出 provided dependencies can only be jars 这样的异常的,那我们该如何添加这样一个依赖?
  2. Gradle 是有离线编译功能的,如果本地缓存里已经有了这个依赖,那么开启离线编译模式,一样可以进行编译,我们 provided aar 怎么做到离线编译呢?
  3. 上一个步骤里,我们说了要能够从本地缓存取依赖,那么都有哪些本地缓存呢?
  4. 如果不是离线编译,本地缓存没有这个依赖,需要从远程仓库下载这个依赖,我们又该怎么下载这个远程依赖呢?
  5. 与步骤3类似的问题,上一步骤里我们说要从远程仓库下载这个依赖,那么我们怎么知道从哪些远程仓库下载依赖呢?
  6. 我们需要将整个过程完美地嵌入到构建之中,那么应该怎样嵌入到构建过程中的哪些步骤之间呢?
  7. 如何实现自动化从 aar 里取到 jar,同时又将 jar 添加到依赖里去呢?

这样分析下来,你会发现如果不是对 Android 构建有比较深入的了解,每一个问题都不是那么容易就能搞的定的。而实际上,真正把所有的问题都解决掉,只需要不到 150 行代码。下面,我们就一步步去解决这些问题。

自定义 Android Dependency Scope

我们经常看到 provided compile apt annotationProcessor 这样的字符串,这其实就是 Dependency Scope,也是 Gradle 项目的 configuration,我们通过调用如下的 api,就可以获取这个 configuration 包含哪些依赖:

1
project.configurations.getByName('provided').dependencies // provided/compile/apt...

我们既然要实现 providedAar,现有的 Dependency Scope 都不满足我们的需求,那就需要像 apt 那样去自定义一个 Scope,其实也就是 configuration 了:

1
project.configurations.create('providedAar')

当然,这都是一些 api 调用,没什么难度,我们只需要记住 configuration Dependency Scope 这些概念就可以了。

加载缓存

Android 编译用到的缓存从以下几个地方去取:

  1. mavenLocal 和 gradle.user.home(不要问我怎么知道的,看源码吧);
  2. 在 Android 项目里,还会有一个 SDK 目录下的仓库路径;
  3. 旧版本的 Gradle Plugin 会从 $buildDir/intermediates/exploded-aar (这是个临时路径,每次 clean 都会被清空)去读取,新版本会从 $HOME/.android/build-cache (这不是临时路径,执行 clean 不会被清空)读取,当然,这个是有开关,可以选择是否开启的;

mavenLocal 里的依赖的父目录路径大致是这样的:~/.m2/repository/com/google/code/gson/gson/2.7,这路径下面就是具体的 jar aar pom 等文件。根据这些现象,我们再去看源码,就很容易会发现 mavenLocal 里的依赖父目录路径遵循以下规律(mavenLocal 路径,与依赖的 groupId artifactId version 拼接而成):

1
project.repositories.mavenLocal().url.toString() - 'file:' + group.replace('.', '/') + '/' + name.replace('.', '/') + '/' + version // name  对应的就是依赖的 artifactId

同理,gradle.user.home 下的依赖父目录路径大致是这样:~/.gradle/caches/modules-2/files-2.1/com.google.gson/gson/2.6.2/,这里要注意,gradle 缓存的 jar aar 和 pom 文件不是在同一个目录的,目录树结构如下图:

获取 Gradle Cache 路径的代码如下:

1
2
3
4
5
6
7
8
9
10
11
static String getGradleCachePath() {
def defaultGradleUserHome = new File("${SystemProperties.instance.userHome}/.gradle")
def gradleUserHome = System.getProperty('gradle.user.home')
if (gradleUserHome == null) {
gradleUserHome = System.getenv("GRADLE_USER_HOME")
if (gradleUserHome == null) {
gradleUserHome = defaultGradleUserHome.absolutePath
}
}
"${gradleUserHome}/caches/modules-2/files-2.1/"
}

然后再加上依赖的 groupId artifactId version 就可以确定此依赖的父目录路径,跟 mavenLocal 不同的是,它的的 groupId 本身就是个目录,而无需使用 “/“ 替换 “.”:

1
getGradleCachePath() + group + '/' + name.replace('.', '/') + '/' + version // name  对应的就是依赖的 artifactId

不过实际上不管是哪种缓存,我们只要拿到它的父目录,就可以遍历父目录,然后拿到这个具体的依赖,然后加载这个依赖缓存。

1
2
3
4
5
6
7
8
9
10
11
12
13
static File getAar(String aarParentPath) {
def aar = null
def file = new File(aarParentPath)
if (!file.exists()) {
return aar
}
file.eachFileRecurse {
if (it.isFile() && it.absolutePath.endsWith('.aar')) {
aar = it
}
}
return aar
}

下载依赖

在 Gradle 里下载依赖有多种方式,而且代码实现都极其简单:

  1. 直接获取要下载的依赖的 url,然后下载,这样整个逻辑就都要自己处理了;

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    def repoUrls = new HashSet()
    repoUrls.add(project.repositories.jcenter().url.toString())
    repoUrls.add(project.repositories.mavenCentral().url.toString())
    // repoUrls.add(project.repositories.google().url.toString()) gradle plugin 3.0 版本之后要加此仓库
    project.repositories.each {
    /**
    * 不处理 FlatDirectoryArtifactRepository 和 IvyArtifactRepository
    */

    if (!(it instanceof DefaultMavenLocalArtifactRepository) && (it instanceof MavenArtifactRepository)) {
    repoUrls.add(it.url)
    }
    }

    static String getDependencyByDownload(Project project, String dependency, HashSet<String> repoUrls, String mavenLocalDepPath) {
    def aarPath = ''
    try {
    def providedAarParent = new File(mavenLocalDepPath)
    if (!providedAarParent.exists()) {
    providedAarParent.mkdirs()
    }
    def split = dependency.split(':')
    def depPath = split[0].replace('.', '/') + '/' + split[1].replace('.', '/') + '/' + split[2]
    def depName = split[1] + '-' + split[2] + '.aar'
    def depFile = new File(providedAarParent, depName)
    if (depFile.exists()) {
    return depFile.absolutePath
    }
    for (String url : repoUrls) {
    def repoDepUrl = url.toString() + depPath + '/' + depName
    if (!repoDepUrl.startsWith('file:')) {
    project.logger.info 'repoDepUrl : ' + repoDepUrl
    FileUtils.copyURLToFile(new URL(repoDepUrl), depFile)
    aarPath = "${providedAarParent.absolutePath}/${depName}"
    break
    }
    }
    } catch (Exception e) {
    project.logger.error e.toString()
    aarPath = null
    }
    return aarPath
    }
  2. 使用 detachedConfiguration ,这可能是最常用最正规的方式了(atlas 里是这么做的);

    1
    2
    3
    4
    5
    static String getDependencyByDownload(Project project, String dependency) {
    def configuration = project.configurations.detachedConfiguration(project.dependencies.create(dependency))
    configuration.setTransitive(false)
    configuration.singleFile.absolutePath
    }
  3. 自定义 Dependency Scope,使其继承(extendsFrom) Gradle 提供的已有的 Dependency Scope,这是最投机取巧也最省时省力的方式(apt 就是这么做的);

    1
    project.configurations.create('providedAar').extendsFrom(project.configurations.getByName('provided'))

那么,我们这里下载依赖应该选取哪种方式呢?答案是只能是第一种方式。那么问题来了,这是为什么呢?这个问题的答案也是下面一节要讲述的的内容。

生命周期之解决依赖关系

Gradle 整个构建过程,生命周期分为三个阶段:Initiliazatin Configuration Execution,而 resolveDependencies 之前,调用 detachedConfiguration 是没有用的,而我们要去 hook 的是resolveDependencies 这个过程,显然 detachedConfiguration 这种方式就不合适了。

而第三种方式,老实讲,我不知道为什么不行,按照我的有限理解,应该是可以的,但是我试了之后,确实不可以,这个问题留待以后再看吧。

所以我们直接使用第一种方式是不是就可以正常下载了呢?其实也不是。还是考虑到生命周期的问题,我们必须要去 hook resolveDependencies 这个过程,不是我们经常看到的 afterEvaluate 或者 beforeEvaluate,因此,我们只能通过给 gradle 添加监听去实现,而且在 hook 之后,要手动 remove 这个监听。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
project.gradle.addListener(new DependencyResolutionListener() {
@Override
void beforeResolve(ResolvableDependencies dependencies) {
project.configurations.getByName('providedAar').dependencies.each {
def group = it.group
def name = it.name
def version = it.version
def dependency = "${it.group}:${name}:${version}"
project.logger.info 'dependency : ' + dependency
}
project.gradle.removeListener(this)
}

@Override
void afterResolve(ResolvableDependencies dependencies) {}
})

从 Aar 里获取 Jar

这一步就比较简单了,就是从 zip 包里读取一个 entry,然后输入输出流转成文件存储即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
static void getJarFromAar(Project project, File aar) {
def aarName = aar.name
def zipFile = new ZipFile(aar)
try {
def entries = zipFile.entries()
while (entries.hasMoreElements()) {
def element = entries.nextElement()
project.logger.info element.name
if (element.name == 'classes.jar') {
InputStream inputStream = zipFile.getInputStream(element)
def file = new File(aar.parent, aarName - '.aar' + '.jar')
project.logger.info file.absolutePath
OutputStream outputStream = new FileOutputStream(file)
IOUtils.copy(inputStream, outputStream)
outputStream.close()
inputStream.close()
}
}
} catch (Exception e) {
project.logger.error e.toString()
} finally {
zipFile.close()
}
}

最后,还有两个我个人的实践经验:

  1. 从 remote repository 里下载 aar 的存放路径,我是存放在了 mavenLocal() 里,这是最简单的方式,因为它的匹配规则最简单;
  2. 我在解出来 jar 之后,直接把 jar 放在了 aar 所在的路径,一是因为这是永久缓存,除非你手动删除它,二同样是因为这样最简单。

这就是整个过程了。