文章目录
需求
现在我有那么一种需求,得把项目中的每个module打成jar包然后转换为安卓可用的jar包,并通过adb上传到某个机器中。
分解步骤:
- 将module打为jar包
- 转换jar包
- 上传jar包
每个步骤实现都很简单,但不能放在一起使用就会变得繁琐。
之前我是这么做的:
- 使用IDE本身artifacts生成jar包
- 使用cmd命令行dx工具转换jar包
- 使用cmd命令行adb工具push上传jar包
直到我想起了使用gradle。
Task
task是gradle构建的核心之一,是任务执行的真正实体,理论上可以使用task实现任何对项目的操作。一般日常使用build/clean操作也各自对应着自己的task。
使用Task实现
定义对应的task来实现是个不错的选择,只需要在module相应的build.gradle里面写下这么一串东西就行了。
def dir1 = 'build/intermediates/compile_library_classes/debug/'
def dir2 = 'build/intermediates/packaged-classes/debug/'
def jarName = "compare.jar"
def dexName = "compare_dex.jar"
task deleteOldJar(type: Delete) {
delete 'dex/' + jarName
delete 'dex/' + dexName
}
//复制本身编译好的jar包,更换路径即可
task exportJar(type: Copy) {
//原地址
if (file(dir1).exists()) {
println(dir1)
from(dir1)
}
if (file(dir2).exists()) {
println(dir1)
from(dir2)
}
into('dex/')
include('classes.jar')
//重命名jar
rename('classes.jar', jarName)
}
//将普通jar包用dx工具转换为Android可识别的jar包
task jar2dex(type: Exec) {
workingDir 'dex/'
commandLine "cmd", "/c", "dx", "--dex", "--output", dexName, jarName
}
//push到网关盒子
task pushDex(type: Exec) {
workingDir 'dex/'
commandLine "cmd", "/c", "adb", "push", dexName, "/sdcard/jinxingateway/module/" + dexName
}
//if (file(dir1).exists() || file(dir2).exists()) {
// exportJar.dependsOn(deleteOldJar)
//} else {
exportJar.dependsOn(deleteOldJar, build)
//}
if (!file('dex/' + jarName).exists()) {
jar2dex.dependsOn(exportJar)
}
if (!file('dex/' + dexName).exists()) {
pushDex.dependsOn(jar2dex)
}
看起来还不错。
不过问题来了,我有多个module。
自定义Task
每个module需要变化的其实只有jar包名字和一些路径,理论自定义task可以解决问题。
于是踌躇满志写下这么一个Task:
class DeleteOldTask extends Delete{
String dexDir
String jarName
String dexName
@TaskAction
def cleanOld(){
Deleter deleter = this.getDeleter()
deleter.delete(new File("${dexDir}${jarName}"))
deleter.delete(new File("${dexDir}${dexName}"))
}
}
等一下,就算是这样,每个module中还是得配置每一个task:
task deleteOld(type:DeleteOldTask){
dexDir 'dex/'
jarName 'compare.jar'
dexName 'compare_dex.jar'
}
这才一个任务,理论上还得有其他对应的任务。
那不行,得找个简洁的方式,只配置一次的。
于是只有自定义一个插件了。
Plugin
plugin在这里可以理解为一个task集合,与project相关联,可以更广泛更灵活的调用项目资源。
官方文档给出了3种自定义方式,第一种在build.gradle中直接新建,但只仅限于当前module;第二种在buildSrc目录中新建,可以在整个rootproject中使用;第三种单独打为jar包上传maven,可以在任意项目中使用。
第一种适用范围太小,无法满足需求;
第三种适用范围大,但打成jar包上传maven又变得繁琐,且无法实时修改;
综合考虑还是使用第二种buildSrc方式。
自定义插件
首先明确环境,因为gradle更新较为频繁,新版本可能支持新的语法,IDE支持的版本各自不同,这也是为了避免语法或API不同带来的困扰。
这里使用的是Intellij IDEA 2018,gradle插件版本3.5.4,gradle版本6.1.1,groovy版本3.0.8(编译时可能会让配置groovySDK)。
构建buildSrc
在项目根目录新建buildSrc目录,据官方文档所言,gradle会自动编译此目录中的文件,我们只需要添加固定的一些配置即可。
目录结构是这样的:
明确包名为com.xter.plugin,主类为DexPlugin,DexPlugin的相对目录为
【项目根目录\buildSrc\src\main\groovy\com\xter\plugin】;
明确属性配置为com.xter.plugin.properties,其相对目录为
【项目根目录\buildSrc\src\main\resources\META-INF\gradle-plugins\com.xter.plugin.properties】;
com.xter.plugin.properties文件内容为:
implementation-class=com.xter.plugin.DexPlugin
build.gradle文件内容为:
apply plugin: 'groovy'
repositories {
mavenCentral()
}
dependencies {
testImplementation 'junit:junit:4.13.2'
}
sourceSets {
main {
groovy {
srcDir 'src/main/groovy'
}
resources {
srcDir 'src/main/resources'
}
}
}
注意resources中的properties文件名最好使用代码包名命名,也就是src/main/groovy下的目录来命名,某些情况下使用自定义properties文件名会导致无法识别;
这上面的配置完成后,就可以在项目中的任意module中导入了:
apply plugin: 'com.xter.plugin'
编写extension
为什么不先写插件呢,因为插件是主体,要用到extension传入的参数;
extension在这里可以理解为配置参数的一种手段,我想在module中配置这么一段参数就能实现上面所有task:
dexPlugin{
local{
dexDir "dex/"
jarName 'compare.jar'
dexName "compare_dex.jar"
}
remote{
dirName "sdcard/jinxingateway/module/"
dexName "compare_dex.jar"
}
}
那么首先就得编写相应的extension,这里建立了3个类,DexConfig/LocalConfig/RemoteConfig;
package com.xter.plugin
import org.gradle.util.ConfigureUtil
class DexConfig {
LocalConfig localConfig = new LocalConfig()
RemoteConfig remoteConfig = new RemoteConfig()
void local(Closure c) {
ConfigureUtil.configure(c, localConfig)
}
void remote(Closure c) {
ConfigureUtil.configure(c, remoteConfig)
}
}
DexConfig嵌套了其他2个类,local和remote方法传入闭包函数,这决定了可以使用两个关键字来配置下一级的节点参数;
local{
}
remote{
}
接下来是LocalConfig:
package com.xter.plugin
class LocalConfig {
String dexDir
String jarName
String dexName
void dexDir(String dexDir) {
this.dexDir = dexDir
}
void jarName(String jarName) {
this.jarName = jarName
}
void dexName(String dexName) {
this.dexName = dexName
}
}
同样是对应方法名,使其可以配置相应参数,每个方法相当于一个set方法:
dexDir "dex/"
jarName 'compare.jar'
dexName "compare_dex.jar"
最后是RemoteConfig:
package com.xter.plugin
class RemoteConfig {
String dirName
String dexName
void dirName(String dirName) {
this.dirName = dirName
}
void dexName(String dexName) {
this.dexName = dexName
}
}
同LocalConfig,不再赘述。
那么使这个配置的根节点dexPlugin生效呢呢?就要在插件源码中去配置了。
编写插件主体
插件均实现Plugin接口,一般情况下都是Plugin,覆写apply方法即可:
class DexPlugin implements Plugin<Project> {
@Override
void apply(Project project) {
//配置extension,用于传参
def ex = project.extensions.create("dexPlugin", DexConfig)
}
}
这里的project就指的是引入插件的module所在了。
使用project.extensions.create即可配置上面的根节点,只要名称对应即可生效,拿到的ex就能获取配置参数。
在moudle配置的dexPlugin是这样的:
dexPlugin{
local{
dexDir "dex/"
jarName "compare.jar"
dexName "compare_dex.jar"
}
remote{
dirName "sdcard/jinxingateway/module/"
dexName "compare_dex.jar"
}
}
那么可以根据这个ex来获取相应的值:
ex.localConfig.jarName //值为"compare.jar"
ex.remoteConfig.dexName //值为"compare_dex.jar"
其实就是调用类的成员变量。
接下来在apply方法中创建任务:
//删除旧有jar包
Task taskCleanOld = project.tasks.create(group: group, name: 'cleanOld', type: Delete, action: {
delete jarPath
delete dexPath
println("delete ${jarPath}")
println("delete ${dexPath}")
})
//将jar包转换为Android可用的jar包
Task taskJar2Dex = project.tasks.create(group: group, name: 'jar2dex', type: Exec, action: {
println("start jar2dex")
workingDir ex.localConfig.dexDir
commandLine "cmd", "/c", "dx", "--dex", "--output", ex.localConfig.dexName, ex.localConfig.jarName
println("jar2dex end")
})
//push相应dex包
Task taskPushDex = project.tasks.create(group: group, name: 'pushDex', type: Exec, action: {
workingDir ex.localConfig.dexDir
commandLine "cmd", "/c", "adb", "push", ex.localConfig.dexName, "${ex.remoteConfig.dirName}${ex.remoteConfig.dexName}"
})
1个Delete任务,2个Exec任务,使用的是
project.tasks.create((Map<String, ?> options)
来创建,其他参数大同小异,重点是这个action,action代表着在执行阶段才真正执行。
我们知道gradle的构建分为三个阶段:
1.Initialization | 初始化
2.Configuration | 配置
3.Execution | 执行
extension参数是在配置阶段传入的,所以理论上需要用到extension参数的任务,最好都放在执行阶段再去使用其参数。
但有一个任务却无法使用action方式使其在执行阶段再使用参数,那就是上面的生成jar包任务exportJar,这个任务本身是一个Copy任务,但Copy任务只能在配置阶段就生成,不能在执行阶段再添加参数,因此只能放在配置阶段,但extension参数也是在配置阶段才传入的,所以这里要求必须在配置阶段完成后,再创建Copy任务,否则无法获取传入的extension参数。
于是就用到了afterEvaluate监听:
project.afterEvaluate {
println("configured done")
def jarPath = "${ex.localConfig.dexDir}${ex.localConfig.jarName}"
def dexPath = "${ex.localConfig.dexDir}${ex.localConfig.dexName}"
//生成新Jar包
taskExportJar = project.tasks.create(group: group, name: 'exportJar', type: Copy) {
if (srcClassesDir != null) {
println(srcClassesDir)
from(srcClassesDir)
into(ex.localConfig.dexDir)
include('classes.jar')
rename('classes.jar', ex.localConfig.jarName)
duplicatesStrategy('include')
println(jarPath)
}
}
}
project.afterEvaluate代表着配置阶段完成,因此将Copy任务放到这里进行生成是最佳选择。
这里使用的是
project.tasks.create(Map<String, ?> options, Closure configureClosure)
来创建任务,从API也可以看出,后面跟的闭包其实就是配置语句。
整个插件代码是这样的:
/**
* 用于生成、转换、上传jar包
*/
class DexPlugin implements Plugin<Project> {
//定义group,若不自行定义会自行分到'other'组
String group = 'xter'
@Override
void apply(Project project) {
//配置extension,用于传参
def ex = project.extensions.create("dexPlugin", DexConfig)
//编译目录,用于判断是否需要build生成
def srcClassesDir = getClassesDir()
def jarPath
def dexPath
//因为是copy任务,copy任务只能在配置阶段完成编写
Task taskExportJar
//配置阶段完成后的监听,可用来取参数赋值
project.afterEvaluate {
println("configured done")
jarPath = "${ex.localConfig.dexDir}${ex.localConfig.jarName}"
dexPath = "${ex.localConfig.dexDir}${ex.localConfig.dexName}"
//生成新Jar包
taskExportJar = project.tasks.create(group: group, name: 'exportJar', type: Copy) {
if (srcClassesDir != null) {
println(srcClassesDir)
from(srcClassesDir)
into(ex.localConfig.dexDir)
include('classes.jar')
rename('classes.jar', ex.localConfig.jarName)
duplicatesStrategy('include')
println(jarPath)
}
}
//如果尚未build,先行build,build之后需要再次手动执行此任务
if (srcClassesDir == null) {
//查找build任务,在未build之前要先行依赖
Task taskBuild = project.tasks.findByName('build')
taskExportJar.dependsOn(taskBuild)
}
}
//删除旧有jar包
Task taskCleanOld = project.tasks.create(group: group, name: 'cleanOld', type: Delete, action: {
delete jarPath
delete dexPath
println("delete ${jarPath}")
println("delete ${dexPath}")
})
//将jar包转换为Android可用的jar包
Task taskJar2Dex = project.tasks.create(group: group, name: 'jar2dex', type: Exec, action: {
println("start jar2dex")
workingDir ex.localConfig.dexDir
commandLine "cmd", "/c", "dx", "--dex", "--output", ex.localConfig.dexName, ex.localConfig.jarName
println("jar2dex end")
})
//push相应dex包
Task taskPushDex = project.tasks.create(group: group, name: 'pushDex', type: Exec, action: {
workingDir ex.localConfig.dexDir
commandLine "cmd", "/c", "adb", "push", ex.localConfig.dexName, "${ex.remoteConfig.dirName}${ex.remoteConfig.dexName}"
})
project.afterEvaluate {
//jar不存在,先行生成jar
if (!new File(jarPath).exists()) {
taskJar2Dex.dependsOn(taskExportJar)
}
//dex不存在,先行转换
if (!new File(dexPath).exists()) {
taskPushDex.dependsOn(taskJar2Dex)
}
}
}
/**
* 版本较高的AS中没有bundles目录
* @return classes.jar所在目录
*/
static String getClassesDir() {
def dir1 = 'build/intermediates/compile_library_classes/debug/'
def dir2 = 'build/intermediates/packaged-classes/debug/'
File file1 = new File(dir1)
if (file1.exists()) {
return dir1
}
File file2 = new File(dir2)
if (file2.exists()) {
return dir2
}
}
}
最后应用在某个module:
apply plugin: 'com.android.library'
apply plugin: 'com.xter.plugin'
dexPlugin{
println("dexxxx")
local{
dexDir "dex/"
jarName 'compare.jar'
dexName "compare_dex.jar"
}
remote{
dirName "sdcard/jinxingateway/module/"
dexName "compare_dex.jar"
}
println("dexxxx")
}
编译通过后,在IDE右方gradle即可找到相应任务了:
双击执行即可。
注意事项
由于gradle版本更迭较快,容易踩坑,加之gradle的报错信息有限,有时候会导致找不到问题来源,所以这里也记录一下编写本插件的踩过一些坑。
重名
自定义的任务名最好不要与系统内置任务重名,比如build、compile;
自定义的变量名也最好不要与内置的一些字符重名,比如srcDir、outputDir;
生命周期
明确gradle构建的生命周期三个阶段,初始化、配置、执行。
初始化阶段,即读取setting.gradle创建项目project以及各module的project实体;
配置阶段,所有project包括module中的task将被创建,task拓扑图将建立,配置项将生效,配置语句将被执行;
执行阶段,各任务的执行语句将被执行,大多是标记了@TaskAction注解的方法语句;
以本插件为例,另行添加一个task:
task testJar(){
group 'xter'
println("----")
}
运行结果是这样的
> Configure project :compare
dexxxx
dexxxx
----
configured done
build/intermediates/packaged-classes/debug/
dex/compare.jar
> Task :compare:testJar UP-TO-DATE
可以看到配置阶段就已经把配置语句执行完了,而本身并没有执行语句。
这里也引出了另一个事项,就是哪些是配置语句,哪些是执行语句。
配置语句与执行语句
哪些是配置语句,哪些是执行语句,其实从源码就可以得见一二:
Task task(Map<String, ?> args, String name, Closure configureClosure);
Task task(String name, Closure configureClosure)
一般使用上述API创建Task的,都是在写配置语句,可以看到API名称已经命名为configureClosure了,所只要API中的闭包参数为这个名称的,基本都是配置语句;
而如本文开头的自定义任务,标记了@TaskAction注解的,一般就是执行语句;
@TaskAction
def cleanOld(){
}
也包括创建任务时使用map传入的action参数:
Task task(Map<String, ?> args, String name)
最终都会变为执行语句。
这里可以看一个完整的task.create调用的doCreate方法:
private Task doCreate(Map<String, ?> options, final Action<? super Task> configureAction) {
//略
if (!GUtil.isTrue(name)) {
throw new InvalidUserDataException("The task name must be provided.");
} else {
final Class<? extends TaskInternal> type = (Class)Cast.uncheckedCast(actualArgs.get("type"));
final TaskIdentity<? extends TaskInternal> identity = TaskIdentity.create(name, type, this.project);
return (Task)this.buildOperationExecutor.call(new CallableBuildOperation<Task>() {
public Builder description() {
return DefaultTaskContainer.realizeDescriptor(identity, replace, true);
}
public Task call(BuildOperationContext context) {
try {
//略
//获取执行操作语句
Object action = actualArgs.get("action");
if (action instanceof Action) {
Action<? super Task> taskAction = (Action)Cast.uncheckedCast(action);
//插入执行之前
task.doFirst(taskAction);
} else if (action != null) {
Closure closure = (Closure)action;
//插入执行之前
task.doFirst(closure);
}
DefaultTaskContainer.this.addTask(task, replace);
//配置任务会在创建的时候就执行
configureAction.execute(task);
context.setResult(DefaultTaskContainer.REALIZE_RESULT);
return task;
} catch (Throwable var9) {
throw DefaultTaskContainer.this.taskCreationException(name, var9);
}
}
});
}
}
主要关注其中两种语句,一是doFirst:
task.doFirst(taskAction);
doFirst和doLast都是执行阶段的语句,前者会在任务本身执行语句之前执行,后者会在任务本身执行语句之后执行;
也就是一个任务可以有3个序列:
1 doFirst
2 @TackAction
3 doLast
比如创建下面这个任务:
task testJar(){
group 'xter'
println("----")
doFirst{
println("exec first")
}
doLast{
println("exec last")
}
}
在配置语句中配置了doFirst和doLast,执行输出是这样的:
> Configure project :compare
dexxxx
dexxxx
----
configured done
> Task :compare:testJar
exec first
exec last
:compare:testJar spend 2ms
因为这个任务本身未配置@TaskAction,即本身并没有执行语句,所以只有doFirst和doLast以及配置语句执行了。
另一种就是配置语句了:
configureAction.execute(task);
显然这就是为什么创建任务时配置语句会被执行的元凶了,当然我们也可以理解为创建任务需要对任务进行配置,因此配置语句会被执行。
extension写法
如果extension的配置类如下面所写:
class LocalConfig {
String dexDir
String jarName
String dexName
}
那么是在build.gradle中的配置中是不能省略参数的等号的,也就是说
local{
dexDir "dex/"
jarName 'compare.jar'
dexName "compare_dex.jar"
}
这样进行赋值传参会报错:
Could not find method dexDir() for arguments [dex/] on object of type com.xter.plugin.LocalConfig.
必须使用等号:
local{
dexDir="dex/"
jarName='compare.jar'
dexName="compare_dex.jar"
}
这就是需要设定同名方法的原因,同名方法本质上可以理解为一个setter方法,而没有方法就变成了直接赋值,因此需要等号。
task创建
别看上面的task创建花里胡哨的,其实严格来讲就两种。
第一种实现的Project接口:
public interface Project extends Comparable<Project>, ExtensionAware, PluginAware {
Task task(String var1) throws InvalidUserDataException;
Task task(Map<String, ?> var1, String var2) throws InvalidUserDataException;
Task task(Map<String, ?> var1, String var2, Closure var3);
Task task(String var1, Closure var2);
Task task(String var1, Action<? super Task> var2);
}
在自家project创建就是这样:
task testJar(){}
task testTar(type:Delete){}
在其他地方自定义插件中,需要project参数就是这样:
project.task("testJar"){}
第二种实现的TaskContainer接口:
public interface TaskContainer extends TaskCollection<Task>, PolymorphicDomainObjectContainer<Task> {
Task create(Map<String, ?> var1) throws InvalidUserDataException;
Task create(Map<String, ?> var1, Closure var2) throws InvalidUserDataException;
Task create(String var1, Closure var2) throws InvalidUserDataException;
Task create(String var1) throws InvalidUserDataException;
}
这里的create就对应着上面一段doCreate方法的源码实现;
具体运用就如本文中一样:
project.tasks.create(name:"testJar"){}
在自家project也照样可以使用:
tasks.create("testJar"){}
当然TaskContainer接口中还有register方法其实也可以用来创建任务,不过这种方法相对来说可传参数不多,但有一个特点就是一定会排在任务列表的最末端。
DefaultTaskContainer.this.addTask(task, replace); //create最终会调用addInternal
DefaultTaskContainer.this.addLaterInternal(provider); //register调用
Copy任务的特殊性
在了解其特殊性之前我尝试过这样去创建:
project.tasks.create(group: group, name: 'exportJar', type: Copy, action: {
if (srcClassesDir != null) {
println(srcClassesDir)
from(srcClassesDir)
into(ex.localConfig.dexDir)
include('classes.jar')
rename('classes.jar', ex.localConfig.jarName)
duplicatesStrategy('include')
println(jarPath)
}
})
同样是使用action去创建,期待能与其他任务类型比如Delete在执行阶段生效,以防止参数未传入时为空,以及更方便地调用;
但失败了,action并未执行;
就目前来看,Copy任务只能在配置阶段去配置参数,也就是本文中要将其放入project.afterEvaluate监听中的原因;
也就是说,如果在配置阶段配置了相关语句,再在action中添加相同的语句,两者就均会得到执行了,当前这个时候的执行语句已经没有必要编写了;
在官方文档中暂时没有找到相关说明,但在源码中发现一些端倪:
protected AbstractCopyTask() {
this.rootSpec = createRootSpec();
rootSpec.addChildSpecListener((path, spec) -> {
if (getState().getExecuting()) {
throw new GradleException("You cannot add child specs at execution time. Consider configuring this task during configuration time or using a separate task to do the configuration.");
}
//略
}
Copy继承自AbstractCopyTask,而后期构造方法中有上面这么一段代码,会监听配置的子节点,也就是from/into这些语句,如果判断为执行阶段,那么就会抛出相关异常;
换言之,如果在执行阶段发现有配置(节点)语句,就会抛出异常;
这或许能解释会什么在action中配置一些语句并未生效,不过尚有疑问的是,在实际运行中并未看到异常,只是单纯的未执行。但未抛出异常也有可能是IDE对gradle的异常支持不理想的缘故。
此外,Copy任务在gradle7.0以上是要显式配置duplicatesStrategy参数,否则会报错,当然本文是使用的6.1.1,尚无这个问题,反正先配上再说。
以上。