1、前言
通过前面一篇关于Groovy基础知识的讲解,我们了解了Groovy的基础知识,现在我们回过头来看看Android里面的Gradle build文件,发现我们现在可以比较容易理解为何里面的关于配置的语法是那样方式写的了。例如Gradle里面应用一个Android pluggin插件的代码如下:
1 |
apply plugin: 'com.android.application' |
这段代码包含了很多groovy的缩写。如果换成不用缩写的方式,上面这段代码会是下面这样:
1 |
project.apply([plugin: 'com.android.application']) |
不使用缩写后可以发现其实apply()是Project类的一个方法,apply方法有一个参数,这个参数是一个Map类型,它的key为plugin, 值为’com.android.application’。
另一个例子是dependencies块。我们通常定义我们项目的依赖是这样写的:
1 2 3 |
dependencies { compile 'com.google.code.gson:gson:2.3' } |
我们现在可以知道这段代码其实是一个闭包,它作为一个参数传给了Project对象的dependencies()方法。这个闭包会最终会传给DependencyHandler类,这个类有一个add()方法,这个add()方法需要传入3个参数:
- 一个定义了configuration的字符串
- 一个定义了依赖标记的对象
- 一个包含了这个依赖属性的闭包
把它完整的写出来,会是这样:
1 2 3 4 5 |
Project.dependencies({ add('compile', 'com.google.code.gson:gson:2.3',{ //Cofiguration statements }) }) |
现在我们再去看build.gradle文件会更容易理解,因为现在你知道了它幕后真实的样子了。
如果你想要知道更多关于Gradle在背后用groovy做了什么,你可以从官方的文档关于Project的介绍开始。你可以在这里找到它:http://gradle.org/docs/current/javadoc/org/gradle/api/Project.html
2、了解Gradle里面的task
自定义一个任务可以提升开发人员的工作效率。Task可以操作已有的构建流程,添加新的构建步骤,或者改变一个构建的输出。你可以运行一个简单的task,例如对生成的apk文件修改名称,hook 进 Gradle的Android Plugin。Task也可以执行更复杂的代码,例如:你可以在app打包前生成适配多个屏幕像素密度的图片。一旦你知道了如何创建一个你自己的task,你会发现你自己已经能够可以你构建流程中的任何方面了。当你学习完如何hook到Android的插件的时候你会发现这是真的。
3、定义task
一个Project对象拥有很多的Task,每一个Task对象都实现了Task接口。最简单的方式来定义一个新的task是执行task方法,并传入一个task名称作为它的参数:
1 |
task hello |
这样创建完一个task,当你执行它时它是不会做任何事情的。为了创建一个有用的task,你需要为它添加一些action。一些新手在创建任务时常会有这样错误的写法:
1 2 3 |
task hello { println 'Hello, World!' } |
当你执行这个task时,你会看到这样的输出:
1 2 3 |
$ gradlew hello Hello, World! :hello |
从输出看你可以误认为这个任务有效果,但是实际上,“Hello, World!”是在task执行前打印出来的。为了了解这中间发生了什么,我们需要了解Gradle构建的生命周期,Gradle的构建包含3个阶段:初始化阶段、配置阶段、执行阶段。你的代码是上面这样的写法,你实际上是在Task的配置阶段做一些事情。此时如果你执行了任意一个其它的任务,这个“Hello, World!”仍然会打印出来。
我们用一张图回忆以下gradle 构建的生命周期:
如果你想要在一个任务的执行阶段添加action,你可以这样写:
1 2 3 |
task hello << { println 'Hello, World!' } |
不同点在于闭包的前面有<< 符号,这是告诉Gradle那些代码是需要在执行阶段有效的,而不是配置阶段。为了展示这个不同,这样改写以一下:
1 2 3 4 5 6 7 |
task hello << { println 'Execution' } hello { println 'Configuration' } |
上面代码我们在执行阶段和配置阶段分别打印了对应的字符串,虽然配置阶段的代码在执行阶段的代码的后面定义,但是它仍然会先被执行,上面这段代码的执行输出如下:
1 2 3 4 |
$ gradlew hello Configuration :hello Execution |
注意:我们最开始的代码例子是一个常见的错误写法。我们在创建自己的task时需要时刻记住这一点。
由于Groovy有很多的缩写,所以Gradle里面定义一个任务有多种写法:
1 2 3 4 5 6 7 8 9 10 11 |
task(hello) << { println 'Hello, World!' } task('hello') << { println 'Hello, World!' } tasks.create(name: 'hello') << { println 'Hello, World!' } |
前面两种写法是在groovy里面用不同的方式实现了同样的事情。你可以用圆括号,也可以不用,你也不需要在参数上添加单引号,这两种写法调用task()方法,传了两个参数:task名称的字符串和一个闭包。task()方法是Gradle的Project类里面的。
最后一种写法没有用task()方法,而是用了tasks对象,这个是TaskContainer的实例,在每一个Project对象里面都存在。这个TaskContainer类提供了一个create()方法,它需要传入一个Map和一个闭包作为参数,并且会返回一个Task。
如果用缩写的方式会很方便,很多网上的例子和实践都是用缩写。但是用完整的形式写出来的话对于学习会很有帮助。其实如果Gradle都是用完整的方式来写的话,就不会让初学者看起来那么的神秘,也会让大家更容易了解它的执行到底发生了什么。
4、task分析
Task接口是所有task的基础,它定义了一系列的属性和方法。这些都被一个为DefaultTask实现了,当你创建一个task的时候,它是基于DefaultTask的。
从技术上讲,DefaultTask不是真正的实现了Task接口所有方法的类。Gradle有一个对内的类叫AbstractTask,它实现了Task接口的所有方法。由于AbstractTask是私有的,我们不能继承它。因此,我们可以用DefaultTask,这个DefaultTask是从AbstractTask派生而来,可以被继承。
每个Task都包含了一个Action对象的集合。当一个task执行时,所有的action按照顺序依次执行。如果想要为一个task添加action,你可以使用doFirst()和doLast()方法。这些方法都是用闭包做为参数,并为你把它传入Action对象。
如果你想要在task的执行阶段添加代码你通常会用到doFirst或者doLast()方法。这种向左的<<操作符是doFirst()方法的缩写方式。
这里有一个doFirst()和doLast()的代码示例:
1 2 3 4 5 6 7 8 9 10 11 |
task hello { println 'Configuration' doLast { println 'Goodbye' } doFirst { println 'Hello' } } |
当你执行这个hello task的时候,输出如下:
1 2 3 4 5 |
$ gradlew hello Configuration :hello Hello Goodbye |
尽管打印‘Goodbye’的代码定义在打印‘Hello’的代码的前面,task执行的时候还是以正确的顺序执行。你也可以多次使用doFirst()和doLast(),例如:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
task mindTheOrder { doFirst { println 'Not really first.' } doFirst { println 'First!' } doLast { println 'Not really last.' } doLast { println 'Last!' } } |
执行这个task的输出如下:
1 2 3 4 5 6 |
$ gradlew mindTheOrder :mindTheOrder First! Not really first. Not really last. Last! |
注意doFirst()方法总是添加一个action到一个task最开始的地方,同样的doLast()方法会总是把action添加到一个task的最末尾。这意味着你使用这些方法的时候需要非常小心,尤其是顺序非常重要。
既然谈到了task任务的顺序问题,你也可以用mustRunAfter()方法,这个方法允许你修改Gradle创建的依赖树。当你使用mustRunAfter()方法时,目的是为了控制当两个task执行时,一个task必须始终在另一个task之前执行:
1 2 3 4 5 6 7 8 9 |
task task1 << { println 'task1' } task task2 << { println 'task2' } task2.mustRunAfter task1 |
同时运行两个task(task1和task2),最终的结果为无论你如何指定task的顺序,task1始终在task2之前执行。
1 2 3 4 5 |
$ gradlew task2 task1 :task1 task1 :task2 task2 |
mustRunAfter()方法并没有在task之间创建依赖关系;如果你单独执行task2而不执行task1也是可以的。如果你需要一个任务依赖另一个任务,你可以使用dependsOn()方法来替代。我们用一个例子来描述mustRunAfter()方法和dependsOn()方法的不同:
1 2 3 4 5 6 7 8 9 |
task task1 << { println 'task1' } task task2 << { println 'task2' } task2.dependsOn task1 |
此时你只执行task2,而不执行task1的结果:
1 2 3 4 5 |
$ gradlew task2 :task1 task1 :task2 task2 |
我们可以看出当你使用mustRunAfter()方法时,你需要运行2个task(task1和task2),运行的结果为task1始终在task2之前执行,但是它们都是独立执行的。然而当你使用dependsOn()方法时,task2的执行始终会触发task1的执行,虽然你没有指定task1需要执行,这个是最重要的区别。
5、用一个task来简化正式版本发布流程
当你准备发布一个Android app到Google play商店之前,你需要为它签名。为了实现这个功能,你需要创建你自己的密钥,这个密钥包含了一系列的私钥。当你有了一个你自己的keystore文件和该应用的私钥时,你可以像这样在Gradle里面配置签名:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
android { signingConfigs { release { storeFile file("release.keystore") storePassword "password" keyAlias "ReleaseKey" keyPassword "password" } } buildTypes { release { signingConfig signingConfigs.release } } } |
上面这段代码有一个明显的缺点,就是你的keystore的密码是明文存放在仓库里面的。如果这是在一个开源项目里面的话,这样写是绝对不行的;任何人都可以访问你的keystore和你的密码,然后他们就可以这些来发布app了。为了避免这个事情发生,你需要创建一个task在每次编译app时要求用户手动输入。这样会有一些麻烦,并且这会让你的服务器上面的自动构建无法工作。一个好的解决方案是创建一个配置文件来保存keystore的密码,并且这个配置文件不提交到远程仓库。
我们可以在工程的根目录创建一个名为private.properties的文件,并在该文件里面加上这一行:
1 |
release.password = thepassword |
我们假设keystore的密码和key本身的密码是一样的。如果你需要两个不同的密码,很简单,加第二个属性即可。一旦这些已经完成,你可以定一个名为getReleasePassword的task:
1 2 3 4 5 6 7 8 9 |
task getReleasePassword << { def password = '' if(rootProject.file('private.properties').exists()) { Properties properties = new Properties(); properties.load(rootProject.file('private.properties').newDataInputStream()) password = properties.getProperty('release.password') } } |
这个task会在工程的根目录找一个文件名为private.properties的文件。如果这个文件存在,这个task会加载这个文件内容的所有属性。properties.load()方法会查找key-value键值对,例如我们在properties文件里面定义的release.password。
为了确保运行脚本的人如果没有private.properties文件,或者文件存在,但是password属性没有时,我们需要做一个兼容处理。如果password仍然为空,我们需要在命令行询问password:
1 2 3 4 5 |
if(!password?.trim()) { password = new String(System.console().readPassword( "\nwhat's the secret password? " )) } |
使用Groovy来检查一个字符串不为null或空串是非常简单明了的。password?.trim()中的问号字符会做null检查,并保证在password为null的情况下不会调用trim()方法。我们不需要明确的检查null或者空串,因为null和空串在if语句里面都等同于false。
这里的new String代码是非常有必要的,因为System.readPasword()方法会返回一个字符数组,需要被转换为字符串。
一旦我们拿到了keystore的密码,我们可以为release构建配置签名:
1 2 |
android.signingConfigs.release.storePassword = password android.signingConfigs.release.keyPassword = password |
到此我们已经完成了我们的task编码,我们需要它在release构建时被执行。所以我们需要在build.gradle文件里面加上这些行:
1 2 3 4 5 |
tasks.whenTaskAdded { theTask -> if(theTask.name.equals("packageRelease")) { theTask.dependsOn "getReleasePassword" } } |
这段代码是在task添加到依赖树这个时机插入了一个闭包的方式hook Gradle的流程。在packageRelease task执行之前密码是不需要的,所以我们需要将packageRelease任务dependsOn 我们的getReleasePassword任务。我们为什么不能直接用packageRelease.dependsOn()方法是因为Gradle的Android plugin创建packaging 任务是动态的,它是基于构建变体的。这意味着packageRelease task是不存在的,直到Android plugin知道了所有的构建变体。
在添加了task和hook了构建过程后,下面是执行gradlew assembleRelease的执行结果:
6、hook Android plugin
在Android开发过程中,我们接触到的大部分task是和Android plugin相关的。前面一节我们知道了如何将一个自定义task添加依赖到正常的构建过程中。这一节我们继续了解Android里面更多的hook方式。
有一种hook Android plugin的方式是操作构建的变体。要实现这个非常的简单,你仅需要下面的这样的代码遍历一个app里面所有的构建变体:
1 2 3 |
android.applicationVariants.all { variant -> // Do something } |
为了获取构建的变体列表,你可以使用applicationVariants对象。当你获得了其中一个构建变体的引用时,你就可以访问和操作它的属性了,例如名称,描述等等。同样的方式你也可以用在Android library库里面,此时使用的是libraryVariants而不是applicationVariants.
注意我们在遍历构建变体时用的是all()方法而不是each方法。这是因为each()方法是在评估阶段触发的,它是在Android plugin创建构建变体之前。然而all()方法是在每个新的变体添加到集合时触发的
这种hook方式可以用来在保存apk文件前修改apk的名字,在文件名添加版本号。这对维护多个apk存档非常有帮助,而不需要手动修改apk的名字。下一节我们会研究如何实现这个。
6.1、自动修改apk名称
修改构建过程的一个常用的案例的是在打包后修改apk的名称,在名称中间加入一个版本号。你可以在遍历app的构建变体中来做这件事情,并修改它的输出的outputFile属性,如下面代码所示:
1 2 3 4 5 |
android.applicationVariants.all { variant -> variant.outputs.all { output -> outputFileName = "${variant.name}-${variant.versionName}.apk" } } |
输出结果如下:
注意gradle3.0版本起在构建过程中修改变体的输出是无效的
7、动态创建新的task
我们以一个安装后启动app的例子来描述如何定义这个task。首先我们需要hook applicationVariants属性:
1 2 3 4 5 6 7 8 |
android.applicationVariants.all { variant -> if(variant.install) { task.create(name: "run${variant.name.capitalize()}", dependsOn: variant.install) { description "Installs the ${variant.description} and runs the main launcher activity" } } } |
我们会遍历每一个variant变体,并检查是有存在有效的install task。这个检查是必要的,因为我们新创建的run task需要依赖它。一旦我们已经验证了install task存在,我们就可以创建一个新的task, 并基于构建变体的名字给它命名。同时我们让这个新的task 依赖 variant.install task。这个会让install task在我们的task执行前被触发。我们在task的create()方法的闭包里面添加了一个description,这个会在你执行gradlew tasks命令行时会显示出来。
我们除了添加description,也可以添加task action,例如我们想启动一个app。我们一般用adb命令来启动一个app:
1 |
$ adb shell am start -n com.package.name/com.package.name.Activity |
Gradle有一个exec()方法可以让我们执行命令行,例子如下:
1 2 3 4 5 6 |
doFirst { exec { executable = 'adb' args = ['shell', 'am', 'start', '-n', "${variant.applicationId}/.MainActivity"] } } |
为了拿到完整的包名, 我们用了构建变体的applicationId,这个值会包含suffix后缀(如果我们指定了)。这样就会有一个问题,activity的classpath是不会带这个suffix后缀的,例如有这样一个配置:
1 2 3 4 5 6 7 8 9 10 11 |
android { defaultConfig { applicationId 'com.grdleforandroid' } buildTypes { debug { applicationSuffix '.debug' } } } |
此时的包名是com.gradleforandroid.debug, 但是activity的路径仍然是com.gradleforandroid.Activity。为了确保能拿到正确activity的路径,需要从applicationId中去除suffix:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
doFirst { def classpath = variant.applicationId if(variant.buildType.applicationIdSuffix) { classpath -= "${variant.buildType.applicationIdSuffix}" } def launchClass = "${variant.applicationId}/${classpath}.MainActivity" exec { executable = 'adb' args = ['shell', 'am', 'start', '-n', launchClass] } } |
首先我们定义了一个变量名为classpath,赋值为appcliationId, 然后我们找suffix, 这个值可以从buildType.applicationIdSuffix属性获得。
同时在Groovy里面你可以用‘-’减符号从另一个字符串里面减去一个字符串。这样就可以确保在安装完成后启动应用时不会因为使用了suffix而失败了。
8、创建你自己的插件
如果你有很多的task想在多个project之间复用,你可以将这些task抽象到一个自定义插件里面。这样就可以在别的project里面也可以使用了。
插件可以用groovy写,也可以用其它的在JVM上运行的语言便编写,例如Java和Scala。实际上Gradle里面的Android插件大部分都是用Java和Groovy混合来写的。
9、创建一个简单的插件
为了从构建配置文件里面提取已经存在的构建逻辑,你可以在build.gradle文件里面直接创建一个插件。这个是创建一个自定义插件最简单的方式。
为了创建一个插件,创建一个实现了Plugin接口的class。我们用前一节动态创建run task的代码来写一个插件,代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
class RunPlugin implements Plugin<Project> { void apply(Project project) { project.android.applicationVariants.all { variant -> if(variant.install) { project.tasks.create(name: "run${variant.name.capitalize()}", dependsOn: variant.install) { //Task definition } } } } } |
Plugin接口定义了apply()方法。Gradle在使用这个插件的时候会调用这个方法。
完整代码示例如下:
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 |
class RunPlugin implements Plugin<Project> { void apply(Project project) { project.android.applicationVariants.all { variant -> if(variant.install) { project.tasks.create(name: "run${variant.name.capitalize()}", dependsOn: variant.install) { doFirst { def classpath = variant.applicationId if(variant.buildType.applicationIdSuffix) { classpath -= "${variant.buildType.applicationIdSuffix}" } println("conio >> run ${classpath}") def launchClass = "${variant.applicationId}/${classpath}.MainActivity" project.exec { executable = 'adb' args = ['shell', 'am', 'start', '-n', launchClass] } } } } } } } apply plugin: RunPlugin |
gradle同步后我们就可以看到我们的runRelease,runDebug task了:
此时运行runRelease,结果如下:
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 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 |
下午3:57:26: Executing task 'runRelease'... Executing tasks: [runRelease] :buildSrc:compileJava NO-SOURCE :buildSrc:compileGroovy UP-TO-DATE :buildSrc:processResources UP-TO-DATE :buildSrc:classes UP-TO-DATE :buildSrc:jar UP-TO-DATE :buildSrc:assemble UP-TO-DATE :buildSrc:compileTestJava NO-SOURCE :buildSrc:compileTestGroovy NO-SOURCE :buildSrc:processTestResources NO-SOURCE :buildSrc:testClasses UP-TO-DATE :buildSrc:test NO-SOURCE :buildSrc:check UP-TO-DATE :buildSrc:build UP-TO-DATE conio >> debug, task ':app:installDebug' conio >2> debug, task ':app:installDebug' conio >> release, task ':app:installRelease' conio >2> release, task ':app:installRelease' :app:checkReleaseClasspath UP-TO-DATE :app:preBuild UP-TO-DATE :app:preReleaseBuild UP-TO-DATE :app:compileReleaseAidl NO-SOURCE :app:compileReleaseRenderscript UP-TO-DATE :app:checkReleaseManifest UP-TO-DATE :app:generateReleaseBuildConfig UP-TO-DATE :app:prepareLintJar UP-TO-DATE :app:mainApkListPersistenceRelease UP-TO-DATE :app:generateReleaseResValues UP-TO-DATE :app:generateReleaseResources UP-TO-DATE :app:mergeReleaseResources UP-TO-DATE :app:createReleaseCompatibleScreenManifests UP-TO-DATE :app:processReleaseManifest UP-TO-DATE :app:splitsDiscoveryTaskRelease UP-TO-DATE :app:processReleaseResources UP-TO-DATE :app:generateReleaseSources UP-TO-DATE :app:javaPreCompileRelease UP-TO-DATE :app:compileReleaseJavaWithJavac UP-TO-DATE :app:getReleasePassword :app:mergeReleaseShaders UP-TO-DATE :app:compileReleaseShaders UP-TO-DATE :app:generateReleaseAssets UP-TO-DATE :app:mergeReleaseAssets UP-TO-DATE :app:transformClassesWithDexBuilderForRelease UP-TO-DATE :app:transformDexArchiveWithExternalLibsDexMergerForRelease UP-TO-DATE :app:transformDexArchiveWithDexMergerForRelease UP-TO-DATE :app:compileReleaseNdk NO-SOURCE :app:mergeReleaseJniLibFolders UP-TO-DATE :app:transformNativeLibsWithMergeJniLibsForRelease UP-TO-DATE :app:transformNativeLibsWithStripDebugSymbolForRelease UP-TO-DATE :app:checkReleaseLibraries UP-TO-DATE :app:processReleaseJavaRes NO-SOURCE :app:transformResourcesWithMergeJavaResForRelease UP-TO-DATE :app:validateSigningRelease UP-TO-DATE :app:packageRelease UP-TO-DATE :app:installRelease 03:57:26 V/ddms: execute: running am get-config 03:57:27 V/ddms: execute 'am get-config' on 'emulator-5554' : EOF hit. Read: -1 03:57:27 V/ddms: execute: returning Installing APK 'release-1.0.apk' on 'Nexus_5X_API_24(AVD) - 7.0' for app:release 03:57:27 D/release-1.0.apk: Uploading release-1.0.apk onto device 'emulator-5554' 03:57:27 D/Device: Uploading file onto device 'emulator-5554' 03:57:27 D/ddms: Reading file permision of /Users/ali/Documents/project/TestGradle/app/build/outputs/apk/release/release-1.0.apk as: rw-r--r-- 03:57:27 V/ddms: execute: running pm install -r -t "/data/local/tmp/release-1.0.apk" 03:57:28 V/ddms: execute 'pm install -r -t "/data/local/tmp/release-1.0.apk"' on 'emulator-5554' : EOF hit. Read: -1 03:57:28 V/ddms: execute: returning 03:57:28 V/ddms: execute: running rm "/data/local/tmp/release-1.0.apk" 03:57:28 V/ddms: execute 'rm "/data/local/tmp/release-1.0.apk"' on 'emulator-5554' : EOF hit. Read: -1 03:57:28 V/ddms: execute: returning Installed on 1 device. :app:runRelease conio >> run com.example.ali.testgradle Starting: Intent { cmp=com.example.ali.testgradle/.MainActivity } |