在 Android Application 里 provided aar

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

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

需要解决的问题

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

  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. mavenLocalgradle.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 aarpom 文件不是在同一个目录的,目录树结构如下图:

获取 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,使其继承(extendsFromGradle 提供的已有的 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 所在的路径,一是因为这是永久缓存,除非你手动删除它,二同样是因为这样最简单。

这就是整个过程了。