Gradle高级篇



本篇介绍Gradle的高级玩法,主要包括多模块项目构建、测试任务构建、Groovy入门与自定义插件和任务,最后是高级自定义构建。

多模块项目构建

一个多模块项目有一个根目录,在其子文件夹中包含所有的模块。为了告知Gradle,项目的结构以及哪个文件夹包含哪些模块,你需要在项目的根目录提供一个settings.gradle文件。

1
include ':app', ':wear', ':mylibrary', ':lib'

上述代码确保app,wear、mylibrary以及lib模块包含在构建配置中(Gradle三大流程:配置阶段模块依赖阶段任务执行阶段)。如果想为app模块添加mylibrary模块依赖可以在app下的build.gradle使用如下代码:

1
2
3
dependences {
compile project(':mylibrary')
}

上述提到了Gradle构建流程,这里有必要详细叙述下。

第一阶段即配置阶段也叫初始化阶段,Gradle搜寻settings.gradle文件。如果该文件不存在,Gradle则假设你只有一个单独的模块构建。如果你有多个模块,那么你可以在settings文件中定义子目录来包含各个模块。如果这些子目录有其自己的build.gradle文件,则Gradle会处理,并把它们合并到构建进程模型。这也是为什么要使用相对根目录的路径来声明模块上的依赖,因为Gradle始终尝试从根目录找到依赖。

理解了Gradle配置工作,可以知道多模块的配置有以下三种方式:

  • 在根目录的build.gradle文件中配置所有的模块设置,优点配置简单,缺点层次凌乱需要各自的插件
  • 为每个模块配置build.gradle文件,优点解耦和,使得构建变化变得容易
  • 混合上述两种做法,在项目的根目录中设置一个构建文件,用来定义所有模块的通用性,然后为每一个模块创建build.gradle文件,AndroidStudio默认采用这种方式

单独构建某一模块可以采用进入到该模块目录级下运行./gradlew assemble,也可以在根目录下运行./gradlew :mylibrary:assembleDebug

测试任务的构建

测试主要包括单元测试、功能测试以及测试覆盖率。

单元测试

JUnit是一个非常受欢迎的单元测试依赖库,已经有十几年的历史了。它可以很容易的编写测试,同时确保他们的可读性。

注意:单元测试用例只对业务逻辑有效,对和Android SDK相关的代码无效。

当使用AndroidStudio创建一个新项目时,它会默认使用JUnit单元测试库,并在build.gradle加入JUnit依赖库以及创建相应的test文件夹。

1
2
3
4
dependencies {
...
testCompile 'junit:junit:4.12'
}



这里的依赖scope使用的是testCompile可以保证依赖库只在运行测试时构建,正式打包apk时不会将JUnit打包进去。

如果只想在某一个构建版本中使用JUnit,可以更改依赖scope。例如为自定义flavor版本paid依赖JUnit库:

1
2
3
dependencies {
testPaidCompile 'junit:junit:4.12'
}

编写完测试用例后就可以使用gradlew test进行所有的测试了。如果只想测试某一个测试版本,比如debug可以使用gradlew testDebug进行该版本的测试。如果一个测试用例失败了,则Gradle会在命令行界面上打印错误信息。如果所有的测试用例都运行成功则Gradle会打印BUILD SUCCESSFUL信息。

如果测试在一开始就失败了,那么它会中断接下来的测试,也就是说后面的测试用例无法被执行,这时可以使用-containue标记来避免构建中断:

1
gradlew test -continue

测试用例运行成功后还会在build/reports/tests目录下为每一个构建版本生成各自的测试报告,如下:



功能测试

功能测试用于测试应用中的几个组件是否可以一起正常工作。例如,你可以创建一个功能测试用例来确认某个按钮是否打开了一个新的Activity。Android中有几个功能测试框架,但最简单最基础的测试框架是Espresso框架。

和单元测试一样AndroidStudio在创建项目的时候就已将依赖了Espresso库并且在androidTest目录下创建了相应的功能测试类。

1
2
3
4
5
6
7
8
dependencies {
compile fileTree(dir: 'libs', include: ['*.jar'])
androidTestCompile('com.android.support.test.espresso:espresso-core:2.2.2', {
exclude group: 'com.android.support', module: 'support-annotations'
})
compile 'com.android.support:appcompat-v7:25.0.1'
testCompile 'junit:junit:4.12'
}



注意:对于Espresso库如何使用,笔者这里不再赘述。

在运行Espresso测试之前,你需要确保你有设备或者模拟器相连,如果没有设备或模拟器相连,那么构建时就会抛出异常。

使用./gradlew connectedCheck来运行Espresso测试。该任务将会执行connectedAndroidTestcreateDebugCoverageReport。其中connectedAndroidTest用来在所有连接设备的debug构建上运行全部测试用例。而createDebugCoverageReport则用来创建一份测试报告。测试报告位于build/reportes/androidTests下:



测试覆盖率

在开始为Android项目编写测试用例之后,最好知道你的代码被测试用例覆盖了多少。针对Java的测试覆盖工具有很多,其中最受欢迎的是Jacoco。Jacoco也是默认被包含的依赖包。

只需要在构建类型代码块中设置testCoverageEnabled = true即可激活测试覆盖率,并且在构建时生成覆盖率报告。

1
2
3
4
5
buildTypes {
debug {
testCoverageEnabled true
}
}

执行./gradlew connectedCheck时,会自动创建覆盖率报告。创建报告自身的任务是createDebugConverageReport。可以之间运行该任务,但是createConverageReport依赖connectedCheck所以不能分开执行它们,并且保证有设备或者模拟器相连。



由于未定义任何测试用例而是直接使用AndroidStudio初始项目时的用例所以覆盖率为0。

如果想为Jacoco指定具体的版本,可以在构建类型代码块中定义:

1
2
3
4
5
buildTypes {
jacoco {
toolVersion = "0.7.5.201505241946"
}
}

Groovy入门与自定义插件和任务

在Gradle的高级玩法中,自定义插件和自定义任务是最酷的一件事,但是如果完全搞懂自定义任务和插件必须要了解Groovy语言。

理解Groovy

Groovy是从Java衍生而来,运行在Java虚拟机上的敏捷语言。其目标,不管是作为脚本语言,还是编程语言,都很简单、直接使用。

在打印字符串到屏幕时,Java使用System.out.println("hello world!");而Groovy则直接使用println 'hello world!'

所以可以看到三点不同:

  • 没有System.out命名空间
  • 方法周围没有括号
  • 行末尾没有分号

注意:这个例子使用了单引号。对于字符串来说使用单引号和使用双引号是一样的,但是它们有不同的用途。双引号字符串可以插入表达式,差值表达式使用$V${...}。例如:
def name = ‘David’
def greeting = “Hello, $name!”
def name_size “Your name is ${name.size()} characters long.”
对于差值表达式还允许执行动态代码,例如:
def method = ‘toString’
new Date().”$method”()

对于类和成员变量的声明,Groovy和Java也很相似,例如:

1
2
3
4
5
6
7
class MyGroovyClass {
String greeting
String getGreeting() {
return 'Hello!'
}
}

注意:无论是类,还是成员变量,Groovy都没有明确的访问修饰符,它默认类和方法都为公有,而类成员为私有。

当使用MyGroovyClass对象时也会Java语言类似:

1
2
3
def instance = new MyGroovyClass()
instance.setGreeting 'Hello, World!'
instance.getGreeting()

注意:使用def创建新的变量,一旦有了类的实例后就可以操作它的成员。Groovy中的访问修饰符都是被自动添加的,当然可以使用getGreeting来覆盖getter方法,如果没有写getter或者setter方法,也可以调用它,因为他们是自动添加的。

1
2
println instance.getGreeting()
println instance.greeting

对于方法调用来说和使用成员变量一样,无需为方法定义一个返回类型,但是指定了返回类型也没什么。此处Groovy和Java的另外一个不同点就是在Groovy中,方法的最后一行通常默认返回,即使没有使用return关键字。例如,下面两个代码块分别是Java和Groovy定义的方法以及调用代码,他们的结果是一样:

1
2
3
4
5
public int square(int num) {
return num * num;
}
squre(2);
1
2
3
4
5
def square (def num) {
num * num
}
square 4

无论是返回值还是参数都没有明确定义类型而是使用关键字def。在没有return关键字的情况下,方法最后一行隐晦的返回一个值。

还有一种经常用在自定义任务中的定义方法的方式,这种方式使用closure,closure的概念并没有在Java中以相同的方式存在,但是它在Groovy和Gradle中发挥着显著作用。

1
2
3
4
5
def square = { num ->
num * num
}
square 8

Closure可以被看做是匿名代码块,可以接受参数和返回值,它们也可以视为变量,被当做参数传递给方法。就像上面的例子一样,你可以在大括号内添加一段代码来定义一个closure,如果更详细的话可以添加定义类型,就像下面一样:

1
2
3
4
5
Closure square = {
it * it
}
square 16

Closure关键字代表square是Closure类型,对于it这个代码块中使用的变量,其实它是默认的参数变量,当不给closure指定参数时num ->,closure会默认传入一个参数,而参数名为it

在Gradle中,我们时时都会和closure打交道,例如androiddependencies代码块都是closure。

在Gradle使用Groovy时也会常常用到两个重要的集合类型:lists和maps。类似于其他脚本语言中集合的概念列表使用List并且使用中括号来声明,Map使用中括号并以key-value的形式声明:

1
2
3
4
5
6
7
8
9
10
List list = [1, 2, 3, 4, 5]
//迭代并打印
list.each() { element ->
println element
}
//或者
list.each() {
println it
}
1
2
3
4
5
6
Map pizzaPrices = [margherita:10, pepperoni:12]
//使用get方法或者方括号访问map中的特定项目
pizzaPrices.get('pepperoni')
pizzaPrices['pepperoni']
//简写方式
pizzaPrices.pepperoni

有了以上对Groovy类、成员、方法以及集合的简单印象后,再回过头来看Gradle构建文件,很容易看懂每行的代码是什么意思。例如:

1
apply plugin: 'com.android.application'

上述代码完全是Groovy的简写,还原成最初的集合定义如下:

1
project.apply([plugin: 'com.android.application'])

注意:apply()是Project类的一个方法,Project类是每一个Gradle构建的基础构建代码块,为apply传入的参数是一个key为plugin,value为com.android.application的map对象。

任务入门

任务属于一个Project对象,并且每个任务都可以执行task接口。定义一个新任务的最简单方式是使用task关键字,将任务名称作为其参数的任务方法:

1
task hello

上述创建了一个新任务,但是不会做任何事情。为了创建一个有用的任务,你需要添加一些动作。

1
2
3
task hello {
println 'Hello, world!'
}

使用./gradlew hello 会输出:

1
2
Hello, world!
:hello

从输出上你肯定以为hello任务已经运行了,但实际上Hello, world!在执行该任务前就已经被打印出来了。因为Gradle的构建周期有三个阶段,初始化阶段配置阶段执行阶段。上述定义的hello任务即在花括号中定义的动作其实是设置任务配置,即使执行其他任务(非hello)也会打印Hello, world!

如果想在执行阶段给一个任务添加动作,则可以使用下面的表示法:

1
2
3
task hello << {
println 'Hello, world!'
}

在closure之前加上<<来告知Gradle,代码在执行阶段执行,而不是在配置阶段。

其实Groovy有很多简写,在Gradle中定义任务的常用方式有以下几种:

1
2
3
task(hello) << {
println 'Hello, world!'
}
1
2
3
task('hello') << {
println 'Hello, world!'
}
1
2
3
tasks.create(name: 'hello') << {
println 'Hello, world!'
}

前两个代码块只是以两种不同的方式通过Groovy来实现相同的事情。你可以使用括号,但你不需要这么做。你也不需要给参数加上单引号。在这两个代码块中,我们可以调用task()方法,其需要两个参数:一个是名为任务的字符串,另一个是closure。task()方法是Gradle Project类的一部分。

最后一个代码块没有使用task()方法,而是使用名为tasks的对象。tasks对象是TaskContainer的实例,存在于每个Project对象中。该类提供了一个create()方法,需要一个Map和一个closure作为参数,最终返回一个Task。

任务中动作执行顺序

Task接口是所有任务的基础,其定义了一系列的属性和方法。所有这些都是由一个叫做DefaultTask的类实现的。这是标准的任务实现方式,创建的每一个任务都是基于DefaultTask的。

每个任务都包含一个Action对象的集合。这些动作按顺序执行,但是可以使用doFirst()doLast()方法来为一个任务添加动作来改变执行顺序,而doFirst()doLast()方法都是以一个closure作文参数,然后被包装到一个Action对象中。我们之前使用的左位移(<<)运算符就是doFirst()的简写,它将在任务中作为第一个动作被执行,同理doLast()定义的动作将被放到最后执行。

1
2
3
4
5
6
7
8
9
10
11
task hello {
println 'Configuration'
doLast {
println 'Goodbye'
}
doFirst {
println 'Hello'
}
}

执行结果如下:



注意:doFirst()总是添加一个动作到task的最前面,而doLast() 总是添加一个动作到最后面,所以在涉及到顺序执行的任务时一定要小心使用这两个方法。

在涉及到给tasks排序时,可以使用mustRunAfter()方法。该方法将影响Gradle如何构建依赖关系图,例如下面代码:

1
2
3
4
5
6
7
8
9
task task1 << {
println 'task1'
}
task task2 << {
println 'task2'
}
task2.mustRunAfter task1

在执行task1task2时,不管指定什么样的顺序,task1总是在task2之前执行。



在上述两个任务中不存在任何依赖关系,mustRunAfter()只是定义一个任务执行先后顺序,那么使用dependsOn可以定义一个任务依赖关系,被依赖的任务必须先要被执行才能执行原任务,例如:

1
2
3
4
5
6
7
8
9
task task1 << {
println 'task1'
}
task task2 << {
println 'task2'
}
task2.dependsOn task1

分别构建task1和task2,构建信息如下:





可以看到task2的构建必须依赖task1才能完成。

使用任务来简化release过程

在构建release版本的时候,通常要配置keystore对apk进行签名,对于创建keystore,以及保留自己的一对私钥都是非常隐私东西,尤其将应用发布为开源项目时,更需要隐藏自己的私钥。如下代码展示的是完全暴露了私钥对的构建脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
signingConfigs {
release {
keyAlias 'release.keystore'
keyPassword 'password'
storeFile file('release.keystore')
storePassword 'password'
}
}
buildTypes {
release {
signingConfig signingConfigs.release
...
}
}

因为作为开源项目,Gradle脚本以及配置都是要被追踪到远程仓库的,所以我们要想一个办法也就是使用自定义任务来从本地文件中读取密钥。如下任务getReleasePassword

1
2
3
4
5
6
7
8
task getReleasePassword << {
def password = ''
if (rootProject.file('private.proterties').exists()) {
Properties properties = new Properties();
properties.load(rootProject.file('private.proterties').newDataInputStream())
password = properties.getProperty('release.password')
}
}

该任务会在项目的根目录搜索一个叫private.proterties的文件,如果存在该文件就使用流来读取其中的内容,并找到key为release.password的value值。

为了确保没有private.proterties文件或者没有release.password属性key也能使Gradle构建继续运行,可以添加如下询问并由用户输入密钥:

1
2
3
4
if (!password?.trim()) {
password = new String(System.console().readPassword
("\nWhat's the password? "))
}

注意:在Groovy中,字符串使用!password?.trim()检查是否为null,如果password为空,则其会阻止trim()方法的调用,因为在一个if判断中,无论是null还是空字符串都代表false。

一旦有了keystore的密钥,我们就可以在release构建中配置签名属性了:

1
2
android.signingConfigs.release.storePassword = password
android.signingConfigs.release.keyPassword = password

到此为止,getReleasePassword任务已将构建完毕,现在要做的就是在执行release构建时,该任务先被执行,要做到这点需要在build.gradle文件中添加如下代码:

1
2
3
4
5
tasks.whenTaskAdded { theTask ->
if (theTask.name.equals("packageRelease")) {
theTask.dependsOn "getReleasePassword"
}
}

使用./gradlew assembleRelease构建得到如下输出:



Hook Android插件

在开发Android时,我们希望大部分tasks都能涉及到Android插件,通过hook到构建进程来增加任务的行为是可行的。例如,我们可以hook到操作variant的插件:

1
2
3
android.applicationVariants.all { variant ->
//do something
}

通过使用applicationVariants对象来得到构建variant的集合。一旦有了构建variant的引用,就可以操作它的属性和方法,比如名称、说明等。

使用hook用的最多的就是自动重命名apk,通过hook得到variant然后改变其输出属性outputFile,代码如下:

1
2
3
4
5
6
7
8
9
10
android.applicationVariants.all { variant ->
variant.outputs.each { output ->
def file = output.outputFile
if (file != null && file.name.endsWith('.apk')) {
def date = new SimpleDateFormat("yyyy-MM-dd").format(new Date())
def apkName = "xxx_${variant.buildType.name}-${variant.versionCode}_${variant.versionName}-${date}.apk"
output.outputFile = new File(file.parent, apkName)
}
}
}

高级自定义构建

基础篇中已经介绍了一些基础的自定义构建包括构建版本、BuildConfig的使用、默认任务等。对于高级自定义构建主要是针对APK优化、构建速度、以及Lint进行自定义构建。

APK瘦身

随着项目的迭代APK的大小也是有增无减,而且大部分的APK都有缩减size的潜力。这时我们要明白APK增大的原因有哪些?首先不可避免的是新需求导致更多的功能注入其次是很多时候为了快速迭代使用了一些比较重的依赖库最后就是增加了很多资源密度并且有的未被使用到。所以我们要针对后两点为apk瘦身。

混淆器ProGuard

ProGuard是一个Java工具,它不仅可以缩减APK的大小,还可以在编译期优化、混淆和预校验你的代码。它通过应用中所有的代码路径来找到未被使用的代码,并将其删除,不仅这样他还会重命名类和字段来进行混淆,使得逆向工程师更难理解代码。

好了如何开启ProGuard呢?在构建类型中有一个minifyEnabled的布尔类型属性将它设为true即表示开启ProGuard。并且在构建release版本时proguardRelease任务会被执行。

1
2
3
4
5
6
buildTypes {
release {
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}

值得注意的是,当开启ProGuard后在构建APK时很有可能删除一些你不希望被删除的代码,导致应用运行崩溃,这也是很多工程师不愿意使用它的原因。为了解决这个问题,你需要定义ProGuard规则,排除那些被删除或混淆的类。我们可以使用proguardFiles属性来定义ProGuard规则的文件。例如,为了保留一个类,你可以像下面这样添加一条简单的规则:

1
-keep public class <MyClass>

getDefaultProguardFile('proguard-android.txt')方法从Android SDK的tools/proguard文件夹下的proguard-android.txt文件中获取默认的ProGuard设置,除此之外,在单个模块下还有proguard-rules.pro文件来记录ProGuard规则,所以你可以在该文件下定义那些类不需要混淆或者不需要删除。

有关ProGuard更多内容访问官方文档http://developer.android.com/tools/help/proguard.html

资源缩减

当给APK打包时,Gradle和Gradle的Android插件可以在构建期间删除所有未使用的资源。如果你有旧的资源忘记删除,那么这个功能非常有用。另外一个当加入很多资源依赖库时而只使用其中一小部分那么可以定义配置来打包使用到的资源。

首先可以使用shrinkResources属性,将其设为true时,在构建过程中会自动判断哪些资源没有被使用并将其排除在APK之外。

使用该功能有一个前提就是必须开启ProGuard。因为缩减资源的工作方式是直到代码引用这些资源被删除之前,Android构建工具不能指出哪些资源没有被用到。

1
2
3
4
5
6
7
buildTypes {
release {
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}

如果想要看到资源到底在构建后缩减了多少,那么可以使用sharinkReleaseResources任务来打印出包缩小的信息。

当然也可以通过构建命令添加--info标志,来获得APK缩减资源的概览:

1
gradlew clean assembleRelease --info

这种方式可以移除过多的资源,特别是那些被动态使用的资源可能被意外的删除,为了防止这种情况的发生,可以在res/raw/下创建keep.xml文件,并且定义这些例外:

1
2
3
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools"
tools:keep="@layout/keep_me,@layout/also_used_*"/>

keep.xml文件本身也会在最终结果中剥离出来。

除了使用shrinkResources外还可以在defaultConfig中使用resConfig属性来配置要保留的资源。例如某一个依赖库中含有世界各国的语言资源包,而我们只需要英文和中文的支持并不想将其他的语言也打包到APK中:

1
2
3
4
5
android {
defaultConfig {
resConfigs "en", "zh-rCN"
}
}

加速构建

提高构建速度也算是高级自定义构建的核心内容了,通过前两篇的Gradle介绍,我们知道Gradle构建是分为三个阶段的:配置阶段、模块依赖阶段、任务执行阶段。所以构建提速也要从这三个阶段入手。

Gradle参数或者AndroidStudio设置

Gradle和AndroidStudio都在一下三个方面为构建提速提供了支持:

  • 并行构建
  • 首次构建启动Gradle daemon,下次构建时省去进程启动过程
  • 调整Java虚拟机参数来加速编译过程
  • 多模块可配属性Configure on demand




上图中使用AndroidStudio的设置可以看到Configure on demand挑勾了,它是默认开启的。在grade中可以在gradle.propertes文件中设置如下:

1
org.gradle.configureondemand=true

这句话表示它会忽略正在执行的task不需要的模块来限制在配置阶段的时间消化。

除了configureondemand外同样在gradle.propertes文件下设置是否开启并行构建、是否开启daemon、配置Java虚拟机参数:

1
2
3
org.gradle.parallel=true
org.gradle.daemon=true
org.gradle.jvmargs=-Xms256m -Xmx1024m

注意:Xms参数用来设置初始内存大小,Xmx用来设置最大内存。

如果想将这些属性设置为系统级别,从而适用到本台机器上的所有Gradle项目上。那么你可以在Gradle的home文件夹下配置gradle.properties文件。grade的home目录通常位于系统的home目录下的.gradle隐藏文件下。对于Windows来说位于%UserProfile%.gradle下。

构建过程profile报告

如果想找出构建中让速度变慢的真正原因,那么可以拆分整个构建过程。并为其生成一份构建报告。使用–profile标志就可以实现这一点。通常生成的报告为html格式并位于根目录下的build/reports/profile下:

profile report

Jack&Jill

Jack和Jill是实验工具从构建工具21.1.1以及Gradle插件1.0.0以后引入。

Jack(Java Android Compiler kit)是一个新的Android构建工具链,其可以直接编译Java源码为Android Dalvik的可执行格式。它有自己的.jack依赖库格式,也采用了打包和缩减。Jill(Jack Intermediate Library Linker)是一个可以将.aar和.jar文件转换成.jack依赖库的工具。这些工具还在试验阶段。可用来简化Android构建过程并且缩短构建时间,但是不建议在项目生产中使用该工具进行构建。使用Jack和Jill可以通过useJack属性开启:

1
2
3
4
5
6
android {
buildToolsRevision '22.0.1' # 必须21.1.1以上并且插件版本大于等于1.0.0
defaultConfig {
useJack true
}
}

也可以单独应用在不同的构建脚本中:

1
2
3
4
5
6
7
8
9
10
11
android {
productFlavors {
regular {
useJack = false
}
experimental {
useJack = true
}
}
}

只要设置了useJack为true,就再也不能通过ProGuard进行缩小和混淆了,但是仍然可以使用ProGuard的语法规则来指定某些规则和异常。

Lint

当通过Gradle执行一个release任务时,代码就会执行一个Lint检查。Lint是一种静态代码分析工具,它会在你的布局和Java代码中标记潜在的bug。在某些情况下甚至会阻塞整个构建过程,当然可以通过abortOnError属性来配置是否阻塞整个构建过程:

1
2
3
4
5
android {
lintOptions {
abortOnerror false
}
}

上述配置对于一个非Gradle项目迁移到Gradle的项目来说非常重要,因为迁移到Gradle项目上一般会存在一些潜在的bug例如.9图片不正确亦或是资源文件不全都会被Lint检查出来导致构架中断。