16

Gradle 与 Android 构建入门

 3 years ago
source link: https://mp.weixin.qq.com/s/c2nPv671PNEPpduydmMAKw
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

biUvE3j.jpg!mobile

导读: 无论是否意识到 Gradle 的存在,每位 Android 程序员都会直接或间接的与 Gradle 打交道。 大家也许有偶尔会想,为什么需要 Gradle 这个自动化构建工具?默认创建的 Android 工程里的文件都有什么含义?什么是依赖管理?Android 的打包流程是什么?

通过阅读本文,可以大致了解 Gradle 是如何工作,可以有针对性搜索相关内容,更加快速的解决常见编译错误。

一 为什么需要自动化构建工具?

以下命令只是示例,方便说明问题,具体使用方式请查找相关命令手册用法。

我们知道,一个 APK 包其实是一个 zip 包,包含代码和资源。那么我们可以编写一个Shell 脚本,命名为 assemble.sh,任何人只要通过执行这个脚本就可以得到 apk 包:

1. # 将 .java 文件转换为 .class 文件,执行命令

2. javac xxx.java

3. # Android 还会将 .class 文件转换为 .dex 文件

4. dx xxx.class

5. # 打包成 apk

6. zip xxx.apk [需要打包的代码和资源]

在 Android 中代码对资源是通过 R.java 文件引用,于是需要继续添加命令,并要求这个命令在 javac 命令前执行。在实际开发中我们不可能所有功能都自己实现,有可能会依赖优秀的开源库,修改后的伪代码如下:

1. # 生成 R.java

2. aapt [资源文件]

3. # 将 .java 文件转换为 .class 文件,执行命令

4. javac xxx.java R.java -classpath xxx.jar

5. # Android 还会将 .class 文件转换为 .dex 文件

6. dx xxx.class R.class xxx.jar

7. # 打包成 apk

8. zip xxx.apk [需要打包的代码和资源]

一切似乎都尽在掌握之中,真的吗?让我们看看 Android APK 实际打包的流程是什么样的:

67jqemA.png!mobile

想想实现如此复杂流程的 Shell 脚本是不是有些头大?别急,实现后还会遇到下面这些问题:

  • 对于多个工程,每个工程都需要拷贝上述 Shell 脚本

  • 对于单个工程,每次添加一个功能都需要在原有流程中插入一段代码,随着需求增加,脚本难以维护

  • 如何管理引入的外部依赖?如何打 debug、release 包?如何打多渠道包?

此时我们需要一个简化上述过程的工具,通过一些约定,如将代码、资源等放在指定目录,再辅以构建脚本就可以快速得到最终的构建产物,这就是自动化构建工具,而 Gradle 就是其中一个。

对照刚刚那个简单的例子,每一个工程在 Gradle 中叫做一个 Project,每一个需要执行的任务,如生成 R 文件、编译 java 文件等,在 Gradle 中叫做一个 Task。

通过 TaskA.dependsOn(TaskB)可以实现先执行 TaskB 再执行 TaskA 的效果。同时 Gradle 也提供 doFirst、doLast 允许在每个 Task 前和后执行一些代码。

至此,我们知道为什么需要自动化构建工具:

  • 防止手动介入构建

  • 创建可重复的构建

  • 以及最重要的:提升编程效率,将精力集中在需求开发上

二 默认创建的 Android 工程都有什么

Q73eQzj.png!mobile

每当通过 Android Studio 新建一个工程时,AS 都会自动创建如上图所示的目录结构,图片中简单介绍了各个目录是干什么的,接下来为大家详细介绍每一个目录或者文件的含义:

.gradle 与 .idea

.gradle 与 .idea 存放 Gradle 和 AS 对于当前工程的缓存。

最常见的一个应用就是点击 sync 后,AS 会在每个工程下生成 .iml 文件,他们与 .gradle、.idea 配合为我们提供了代码提示等常见功能。所以如果你的代码飘红而你确认依赖没有问题,可以尝试下面步骤清除 AS 缓存:

1. 删.idea 删.gradle 文件

2. 命令行执行 ./gradlew clean

3. 选择 File -> invalidate caches/restart

4. Sync

gradle/wrapper 与 gradlew gradlew.bat

当我们初次配置 Android 环境时,需要安装 Java,安装 AS,但并不需要安装 Gradle,这其中就是 gradle/wrapper 的功劳。

当执行 gradlew 脚本时,它可以保证每个项目都使用自己期望的 Gradle 版本,而其中的奥秘就在 gradlew 的这段代码中:

1.exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@"

gradlew 并没有直接启动 Gradle 而是启动 gradle-wrapper.jar,它会判断如果没有 Gradle 环境,从 gradle-wrapper.properties 中的 distributionUrl 中下载相应环境,并启动 Gradle。

因为 Gradle 允许命令行启动时附加参数来自定义 Gradle 的运行环境,所以百度app通过自定义 gradle-wrapper.jar,实现通过配置文件为不同内存大小的电脑、debug/release 包指定不同 gradle 运行内存,提升大家编译速度。

setting.gradle

BRBfaae.png!mobile

setting.gradle 中最关键的就是其提供的 include 方法,通过这个方法可以指定哪些工程需要参与编译,每一个参与编译的工程 Gradle 会为它创建一个 Project 对象。

根目录 build.gradle

yEr6VbA.png!mobile

首先是 buildscript 代码块:
gradle 默认是自顶向下执行,无论 buildscript 代码块在哪,它都会第一个执行。

接下来是 repositories 和 dependencies:
repositories 表示 dependencies 声明的依赖去哪些仓库找,google、jcenter、mavenCentral 都是第三方 Maven 仓库。同时,也可以通过 maven 方法添加自己的 Maven 仓库。需要注意的是,不应该假设组件一定会从特定仓库拉取,如果 Gradle 请求一个仓库超时,它会自动请求其他仓库。

dependencies:代表 Gradle 执行需要哪些依赖。比如需要 Android Gradle Plugin 插件为我们打包 apk 包,就需要添加

1. classpath 'com.android.tools.build:gradle:3.4.0'

最后是 allprojects 和 repositories:
在 allprojects 中的配置会对所有工程生效而里面的 repositories 则表示工程声明的 dependencies 去哪些仓库查找。

app build.gradle

rq26vmB.png!mobile

首先可以看到 apply plugin: 'com.android.application',当应用这个插件后,它会为我们创建一系列 Task,比如 assembleDebug、assembleRelease,执行这些 Task,就会得到最终的 APK。

android 代码块是插件为我们提供的 API允许我们修改 Task 的行为。dependencies 代码块的内容决定当前 Project 依赖哪些组件,而不同的依赖声明会有不同的结果,具体内容我们在下一节分析。

三 依赖管理

依赖配置

在 Android Gradle Plugin 3.0 时代,Google 使用 implementation 和 api 选项取代过去的 compile 选项。既然接口都变了,Google 索性将其他的配置项也进行了改名,方便大家理解其配置的含义。

需要注意的是,老版本的接口没有被立刻删除,但是在下一个主要版本中会被删除。下面是各个配置项的官方中文解释:

n6JRZzI.png!mobile

举个例子:

假设 A 依赖 B,B 依赖 C。

如果 B 对 C 使用 implementation 依赖,则 A 无法调用 C 的代码;

如果 B 对 C 使用 api 依赖,则 A 可以调用 C 的代码;

如果 B 对 C 使用 compileOnly 依赖,则 A 无法调用 C 的代码,且 C 的代码不会被打包到 APK 中;

如果 B 对 C 使用 runtimeOnly 依赖,则 A、B 无法调用 C 的代码,但 C 的代码会被打包到 APK 中;

实际上每一个组件都有自己的 compileClasspath 和 runtimeClasspath。
当一个组件参与编译时,Gradle 就会将其放在 compileClasspath 中;
当一个组件参与打包时,Gradle 就会将其放在 runtimeClasspath 中;

不同的依赖配置项,其实就是将声明的依赖放入不同组件的不同的 classpath 中,回到上面的例子。

对于 implementation ,其实就是将 C 放入 B 的 compileClasspath 和 runtimeClasspath,放入 A 的 runtimeClasspath 中,从而实现 A 如果调用 C 的代码,在 A 的编译阶段 javac 报错,但最终 C 会被打包到 APK 包中。

对于 api、compileOnly、runtimeOnly 原理相同。

源码与二进制

当想要依赖一个源码工程时只需要这样写:

1. implementation project(':demo:mylibrary')

而且我们可以明确知道 mylibrary 中的依赖都会被正确打包到 APK 中。

当我们依赖二进制需要这样写:

1. implementation 'androidx.appcompat:appcompat:1.0.2'

当执行依赖命令(只输出 release 包的 runtimeClasspath)

1. ./gradlew :app:dependencies --configuration releaseRuntimeClasspath > dependencies.txt

输出依赖关系图时会看到并不是仅仅依赖一个 appcompat 组件(只显示部分依赖),还包含该组件自己的依赖,以及依赖的依赖,直到组件自身没有依赖,这样的特性叫做依赖传递。

1. releaseRuntimeClasspath - Resolved configuration for runtime for variant: release

2. \--- androidx.appcompat:appcompat:1.0.2

3. +--- androidx.annotation:annotation:1.0.0

4. +--- androidx.core:core:1.0.1

5. | +--- androidx.annotation:annotation:1.0.0

6. | +--- androidx.collection:collection:1.0.0

7. | | \--- androidx.annotation:annotation:1.0.0

8. | +--- androidx.lifecycle:lifecycle-runtime: 2.0 . 0

9. | | +--- androidx.lifecycle:lifecycle-common:2.0.0

10. | | | \--- androidx.annotation:annotation:1.0.0

11. | | +--- androidx.arch.core:core-common:2.0.0

12. | | | \--- androidx.annotation:annotation:1.0.0

13. | | \--- androidx.annotation:annotation:1.0.0

14. | \--- androidx.versionedparcelable:versionedparcelable:1.0.0

15. | +--- androidx.annotation:annotation:1.0.0

16. | \--- androidx.collection:collection:1.0.0 (*)

17. +--- androidx.collection:collection:1.0.0 (*)

18. +--- androidx.cursoradapter:cursoradapter:1.0.0

那么 Gradle 是怎么确定这些依赖呢?

当使用Maven 规范上传组件时,不单单会上传组件的二进制,还会上传一个 pom.xml 文件,依赖信息就在这个文件当中。

因为查看公共的 Maven 服务器有可能需要翻墙,下面给大家展示百度app自己搭建的服务器的后台,方便理解被上传的二进制在服务器是以怎样的结构存放的。

uuAzYnm.png!mobile

这个是百度app自己搭建的 Maven 服务器后台,点击一项查看详情:

26FrmuA.png!mobile

有上传的二进制 aar,也有 pom 文件,还有我们在上传时自定义的文件 readme。看完远端的 POM 文件,我们在看看当二进制被下载后在本地是如何存放的:

uaQBjar.png!mobile

下面是一个简单的 POM 文件:

umYBfmb.png!mobile

可以看到有两个 dependency,需要注意的是 scope,也会分为 runtime 和 compile,runtime 不会参与编译,但会参与打包,compile 会参与编译和打包。

两个实际例子:
一:假设 A 依赖 B,B 依赖 C

QRvUZby.png!mobile

B 对 C 使用 implementation 依赖
B 中有类 Foo 继承于 C中的 Bar
在 A 中使用类 Foo 时会报错找不到类 Bar,解决办法只能让 A 再依赖 C,所以应该尽量避免使用继承

二:假设 A 依赖 B,B 依赖 C

BC 是二进制, B 的 POM 中对 C 的依赖是 runtime
在 Gradle 4.4 中,A 依然可以调用 C 的代码,这个问题在 Gradle 5.0 后被修复

依赖冲突

什么是依赖冲突:

MFr6ZzM.png!mobile

假设 ABC 是源码,D 是二进制,A 声明依赖 B,A 声明依赖 C,B 声明依赖 D 1.0 版本,C 声明依赖 D 1.1版本,这时,D 有依赖冲突,需要确定是使用 1.0 还是 1.1 版本。

如何解决依赖冲突:
1. 进行编译时,B 编译时依赖 D 的1.0版本,C 编译时依赖 D 的1.1版本,但最终打包到 apk 中 D 的版本是 1.1,因为版本号冲突默认选择最高版本
2. Gradle 为我们提供了一系列解决依赖冲突的规则如:不允许依赖传递,exclude 移除一个依赖,替换一个组件为另一个组件,这些方法就不一一介绍了,按需百度即可
3. 百度app在此基础上增加规则:如果最终应用的版本号高于在 version.properties 定义的版本号则报错

注意:

1. 假设 D 发布 1.2 版本,但 B、C 都没有基于 D 1.2 版本发布新版本,则最终打包还是 D 的 1.1 版本,所以所有组件最终被打包到 APK 包中的版本都为 version.properties 中定义的版本;

2. 假设 D 的 MavenId 由 D 改成了 E,C 基于 E 发布二进制,B 还是老样子,在实际打包中会报类重复的错误,原因就是 B 的 POM 文件中依赖的还是 D,所以需要让 B 基于 D 改名后的 E重新发一个二进制。

四 打包流程

有了前面这些铺垫,让我们实际看看在执行打包 Task 时,实际还执行了哪些 Task。环境配置如下:

Gradle 5.1.1
Android Gradle Plugin 3.1.2
org.gradle.parallel=true 开启并行编译
release 包 minifyEnabled true

执行命令可以得到如下图所示输出:

1. # --dry-run 表示不实际执行每个 Task

2. gradlew assembleRelease --dry-run

RbIfEvV.png!mobile

Task 很多,接下来为大家介绍几个重点的 Task,其余没介绍的感兴趣的同学可以找找对应的实现类,看看它的实现。

preBuild

描述:做一些编译前的检查
一个例子:有的人可能遇到下面的错误

1. "Android dependency "+ display+ "is set to compileOnly/provided which is not supported"

这个的原因就是由前面说过的 compileClasspath 和 runtimeClasspath 引起的。

当一个组件因为不同的依赖配置项导致它的 compileClasspath 比如为 1.1.1版本,但他的 runtimeClasspath 是 1.1.2版本,preBuild 就会检测出这个问题并报错我们处理。

compileReleaseAidl

类:AidlCompile
描述:内部使用 AidlProcessor 调用 call 方法使用 build-tool 下的 aidl 执行编译。

各类 generate和 merge

这些 Task 允许我们在整个编译工程中动态的生成一些代码,生成好的资源需要和已有的资源进行合并,并且需要注意有可能覆盖已有资源,就不再详细介绍了。

过程

RzmMRnf.png!mobile

第一步: 我们有 app 工程下的 Java 源文件,还有 AIDL、generate、R.java 等生成的 Java 源文件,还有本身依赖,源码子工程的 jar 包、远端 aar 解压的 jar 包等一系列二进制文件,源码文件是 javac 需要编译的内容,二进制文件 .jar/.class 则是当 javac 编译遇到一个类名等符号时,如果发现在现有的源文件找不到,该去哪找的集合,对应的 javac 参数就是 -classpath。

而这个参数,其实就是 compileClasspath 的一个应用,如果你源文件引用了一个类,它的 jar 包不在 compileClasspath 中,那么在编译时 javac 就会报错找不到符号了。

第二步: 当源文件被编译成类文件后,Google 提供了 Transform 机制允许我们对二进制文件在打包前进行修改,比如前面图片中的 :app:transformClassesWithXXXForRelease SKIPPED 就是我们自定义的 Transform。

通过:app:transformClassesAndResourcesWithProguardForRelease SKIPPED 也可以看到 Proguard 也是通过 Transform 机制实现的,这里图片中一个 .class 文件,一个是 .jar/.class 文件,第一个显然是 javac 编译后的产物,第二个则是 runtimeClasspath,就是那些需要被打包的二进制。相信大家这样就理解了 compileClasspath 和 runtimeClasspath 是如何影响打包过程。

第三步: 当 Transform 处理好所有的 class 文件后,接下来就是将 .class 文件转换为 .dex 文件。

值得注意的是,javac 只能发现源码的问题,不会发现那些未参与编译的二进制的问题。而在 dex 转换过程中则可以发现比如类重复问题或者一个类,名字不变,但是由 Class 变成了 Interface 这类严重的代码问题。

第四步: 就是将前面的和资源进行打包。对应的类是 PackageApplication,得到这个 Task 后可以对打包的内容进行自定义。

五 结语

虽然我们本次仅对常见的一些配置提供个大纲,没有详细介绍 Gradle 和 Android Gradle Plugin 相关内容,比如 Gradle 的生命周期、插件开发、Transform 机制等等,但是同学们看完后能对整个编译工具链有整体的了解,在需要的时候明白该从哪个方向去解决问题,这才是本文的价值所在。

参考资料

1. https://docs.gradle.org/current/userguide/userguide.html

2. https://developer.android.com/studio/build/gradle-plugin-3-0-0-migration?hl=zh-cn

3.https://github.com/gradle/gradle

4.https://android.googlesource.com/platform/tools/base/+/refs/tags/gradle_3.1.2

----------  END  ----------

3UVVVzN.png!mobile

一键三连,好运连连,bug不见   :point_down:


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK