背景
在kotlin语言已经渗透至各领域的环境下,比如服务端,android,跨平台Kmm,native for kotlin,几乎所有的领域都可以用kotlin去编写了,当然还有不成熟的地方,但是JB的目标是很一致的!我们最常用的gradle构建工具,也支持kotlin好久了,但是由于编译速度或者转换成本的原因,真正实现kts转换的项目很少。在笔者的mac m1 中使用最新版的AS去编译build.gradle.kts,速度已经是和用groovy写的gradle脚本不相上下了,所以就准备写了这篇文章,希望做一个记录与分享。
groovy | kotlin |
---|---|
好处:构建速度较快,运用广泛,动态灵活 | 好处:编译时完成所有,语法简洁,android项目中可用一套语言开发构建脚本与app编写 |
坏处:语法糖的作用下,很难理解gradle运行的全貌,作用单一,维护成本较高 | 坏处:编译略慢于groovy,学习资料较少 |
虽然主流的gradle脚本编写依旧是groovy,但是android开发者官网也在推荐迁移到kotlin
编译前准备
这里推荐看看这篇文章,里面也涵盖了很多干货,
全局替换‘’为“”
在kotlin中,表示一个字符串用“”,不同于groovy的‘ ’,所以我们需要全局替换。可以通过快捷方式command/control+shift+R 全局替换,选中匹配正则表达式并设定file mask 为 *.gradle:
正则表达式
'(.*?[^\\])'
作用范围为
"$1"
全局替换方法调用
在groovy中,方法是可以隐藏(),举个例子
apply plugin: "com.android.application"
这里实际上是调用apply方法,然后命名参数是plugin,内容围为"com.android.application",然而在kotlin语法中,我们需要以()或者invoke的方式才能调用一个方法,所以我们要给所有的groovy函数调用添加()
正则表达式
(\w+) (([^=\{\s]+)(.*))
作用范围为
$1($2)
很遗憾的是,这个对于多行来说还是存在不足的,所以我们全局替换后还需要手动去修正部分内容即可,这里我们只要记得一个原则即可,想要调用一个kotlin函数,把参数包裹在()内即可,比如调用一个task函数,那么参数即为
task(sourcesJar(type: Jar) {
from(android.sourceSets.main.java.srcDirs)
classifier = "sources"
})
gradle kt化
接下来我们只需要把build.gradle 更改为文件名称为build.gradle.kts 即可,由于我们修改文件为了build.gradle.kts,所以当前就以kts脚本进行编译,所以很多的参数都是处于找不到状态的,即使sync也会报错,所以我们需要把报错的地方先注释掉,然后再进行sync操作,如果成功的话,AS就会帮我们进行一次编译,此时就可以有代码提示了。
开始前准备
以kotlin的方式编译,此时函数就处于可点击查看状态,区别于groovy,因为groovy是动态类型语言,所以很多做了很多语法糖,但是也给我们在debug阶段带来了很多困难,比如没有提示等等,因为groovy只需要保证在运行时找到函数即可,而kotlin却不一样,所以很多动态生成的函数就无法在编译时直接使用了,比如
对于这种动态函数,kotlin for gradle 其实也给我们内置了很多参数来对应着groovy的动态函数,下面我们来从以下方面去实践吧,tip:以下是gradle脚本编写常用
ext
我们在groovy脚本中,可以定义额外的变量在ext{}中,那么这个在kotlin中可以使用吗?嘿嘿,能用我就不会提到对吧!对的,不可以,因为ext也是一个动态函数,我们kotlin可没法用呀!那怎么办!别怕,kts中给我们定义了一个类似的变量,即extra,我们可以通过by extra去定义,然后就可以自由用我们的myNewProperty变量啦!
val myNewProperty by extra("initial value")
但是,如果我们在其他的gradle.kts脚本中用myNewProperty这个变量,那么也会找不到,因为myNewProperty这个的作用域其实只在当前文件中,确切来说是我们的build.gradle 最后会被编译生成一个Build_Init的类,这个类里面的东西能用的前提是,被先编译过!如果当前编译中的module引用了未被编译的module的变量,这当然不可行啦!当然,还是有对策的,我们可以在BuildScr这个module中定义自定义的函数,因为BuildScr这个module被定义在第一个先执行的module,所以我们后面的module就可以引用到这个“第一个module”的变量的方式去引用自定义的变量!
task
- 新建task
groovy版本
task clean(type: Delete) {
delete rootProject.buildDir
}
比如clean就是一个我们自定义的task,转换为kotlin后其实也很简单,task是一个函数名,Delete是task的类型,clean是自定义名称
task<Delete>("clean",{
delete(rootProject.buildDir)
})
当然,我们的task类型可能在编写的由于泛型推断,隐藏了具体的类型,这个时候我们可以通过
./gradlew help --task task名
去查看相应的类型
- 已有task修改 对于有些是已经在gradle编译时存在的函数任务,比如
groovy版本
wrapper{
gradleVersion = "7.1.1"
distributionType = Wrapper.DistributionType.BIN
}
这个我们kotlin版本的build.gradle能不能识别呢?其实是不可以的,因为编译器也不知道从哪里去找wrapper的定义,因为这个函数在groovy中隐藏了作用域,其实它存在于TaskContainerScope这个作用域中,所以对于所有的的task,其实都是执行在这里面的,我们可以通过tasks去找到
tasks {
named<Wrapper>("wrapper") {
gradleVersion = "7.1.1"
distributionType = Wrapper.DistributionType.BIN
}
}
这种方式,去找到一个我们想要的task,并配置其内容
- 生命周期函数 我们可以通过函数调用的方式去配置相应的生命周期函数,比如doLast
tasks.create("greeting") {
doLast { println("Hello, World!") }
}
再比如dependOn
task<Jar>("javadocJar", {
dependsOn(tasks.findByName("javadoc"))
})
动态函数
sourceSets就是一个典型的动态函数,为什么这么说,因为很多plugin都有自己的设置,比如Groovy的sourceSets,再比如Android的SourceSets,它其实是一个接口,正在实现其实是在plugin中。如果我们需要自定义配置一些东西,比如配置jniLibs的libs目录,直接迁移到kts就会出现main找不到的情况,这里是因为main不是一个内置的函数,但是存在相应的成员,这个时候我们可以通过by getting方式去获取,只要我们的变量在作用域内是存在的(编译阶段会添加),就可以获取到。如果我们想要生成其他成员,也可以通过by creating{}方式去生成一个没有的成员
sourceSets{
val main by getting{
jniLibs.srcDirs("src/main/libs")
jni.srcDirs()
}
}
也可以通过getByName方式去获取
sourceSets.getByName("main")
plugins
在比较旧的版本中,我们AS默认创建引入一个plugin的方式是
apply plugin: 'com.android.application'
其实这也是依赖了groovy的动态编译机制,这里针对的是,比如android{}作用域,如果我们转换成了build.gradle.kts,我们会惊讶的发现,android{}这个作用域居然爆红找不到了!这个时候我们需要改写成
plugins {
id("com.android.application")
}
就能够找到了,那么这背后的原理是什么呢?我们有必要去探究一下gradle的内部实现。
说了这么多的应用层写法,了解我的小伙伴肯定知道,原理解析肯定是放在最后啦!但是gradle是一个庞大的工程,单单靠着干唠是写不完的,所以我选出了最重要的一个例子,即plugins的解析,希望能够抛砖引玉,一起学习下去吧!
Plugins解析
我们可以通过在gradle文件中设置断点,然后debug运行gradle调试来学习gradle,最终在编译时,我们会走到DefaultScriptPluginFactory中进行相应的任务生成,我们来看看
DefaultScriptPluginFactory
final ScriptTarget initialPassScriptTarget = initialPassTarget(target);
ScriptCompiler compiler = scriptCompilerFactory.createCompiler(scriptSource);
// 第一个阶段Pass 1, extract plugin requests and plugin repositories and execute buildscript {}, ignoring (i.e. not even compiling) anything else
CompileOperation<?> initialOperation = compileOperationFactory.getPluginsBlockCompileOperation(initialPassScriptTarget);
Class<? extends BasicScript> scriptType = initialPassScriptTarget.getScriptClass();
ScriptRunner<? extends BasicScript, ?> initialRunner = compiler.compile(scriptType, initialOperation, baseScope, Actions.doNothing());
initialRunner.run(target, services);
PluginRequests initialPluginRequests = getInitialPluginRequests(initialRunner);
PluginRequests mergedPluginRequests = autoAppliedPluginHandler.mergeWithAutoAppliedPlugins(initialPluginRequests, target);
PluginManagerInternal pluginManager = topLevelScript ? initialPassScriptTarget.getPluginManager() : null;
pluginRequestApplicator.applyPlugins(mergedPluginRequests, scriptHandler, pluginManager, targetScope);
// 第二个阶段Pass 2, compile everything except buildscript {}, pluginManagement{}, and plugin requests, then run
final ScriptTarget scriptTarget = secondPassTarget(target);
scriptType = scriptTarget.getScriptClass();
CompileOperation<BuildScriptData> operation = compileOperationFactory.getScriptCompileOperation(scriptSource, scriptTarget);
final ScriptRunner<? extends BasicScript, BuildScriptData> runner = compiler.compile(scriptType, operation, targetScope, ClosureCreationInterceptingVerifier.INSTANCE);
if (scriptTarget.getSupportsMethodInheritance() && runner.getHasMethods()) {
scriptTarget.attachScript(runner.getScript());
}
if (!runner.getRunDoesSomething()) {
return;
}
Runnable buildScriptRunner = () -> runner.run(target, services);
boolean hasImperativeStatements = runner.getData().getHasImperativeStatements();
scriptTarget.addConfiguration(buildScriptRunner, !hasImperativeStatements);
}
可以看到,源码中特别注释了,编译时的两个阶段,我们可以看到,所有的script(指函数调用),都是分别经过了阶段1和阶段2之后才真正生效的。
那么为什么android作用域在apply plugin的方式不行,plugins方式却可以呢?其实就是两个运行阶段不一致的问题。groovy可以在运行时动态找到android 这个函数,即使两者都在阶段2运行,因为groovy语法本身的特性,即使android这个函数没有定义我们也可以引用,也是在运行时阶段报错。而kotlin不一样,kotlin需要在编译的时候需要找到我们要引用的函数,即android,所以同一个阶段即plugin都没有生效(需要执行完阶段才生效),我们当然也找不到android函数,那为什么plugins又可以呢?其实很容易想到,因为plugins是在第一阶段中执行并生效的,而android引用在第二个阶段,我们接着看源码
重点关注一下compileOperationFactory.getPluginsBlockCompileOperation方法,这个方法的实现类是DefaultCompileOperationFactory,在这里我们可以看到里面定义了两个阶段
public class DefaultCompileOperationFactory implements CompileOperationFactory {
private static final StringInterner INTERNER = new StringInterner();
private static final String CLASSPATH_COMPILE_STAGE = "CLASSPATH";
private static final String BODY_COMPILE_STAGE = "BODY";
private final BuildScriptDataSerializer buildScriptDataSerializer = new BuildScriptDataSerializer();
private final DocumentationRegistry documentationRegistry;
public DefaultCompileOperationFactory(DocumentationRegistry documentationRegistry) {
this.documentationRegistry = documentationRegistry;
}
public CompileOperation<?> getPluginsBlockCompileOperation(ScriptTarget initialPassScriptTarget) {
InitialPassStatementTransformer initialPassStatementTransformer = new InitialPassStatementTransformer(initialPassScriptTarget, documentationRegistry);
SubsetScriptTransformer initialTransformer = new SubsetScriptTransformer(initialPassStatementTransformer);
String id = INTERNER.intern("cp_" + initialPassScriptTarget.getId());
return new NoDataCompileOperation(id, CLASSPATH_COMPILE_STAGE, initialTransformer);
}
public CompileOperation<BuildScriptData> getScriptCompileOperation(ScriptSource scriptSource, ScriptTarget scriptTarget) {
BuildScriptTransformer buildScriptTransformer = new BuildScriptTransformer(scriptSource, scriptTarget);
String operationId = scriptTarget.getId();
return new FactoryBackedCompileOperation<>(operationId, BODY_COMPILE_STAGE, buildScriptTransformer, buildScriptTransformer, buildScriptDataSerializer);
}
}
getPluginsBlockCompileOperation中创建了一个InitialPassStatementTransformer类对象,我们关注transform方法的内容,即如果找到了plugins,我们就进行接下来的transform操作transformPluginsBlock,这就验证了,plugins的确在第一个阶段即classpath阶段运行
@Override
public Statement transform(SourceUnit sourceUnit, Statement statement) {
...
if (scriptBlock.getName().equals(PLUGINS)) {
return transformPluginsBlock(scriptBlock, sourceUnit, statement);
}
...
总结
文章列出来了几个关键的迁移了,相信大部分的问题都可以解决了,的确在迁移到kotlin之后,还是存在一定的迁移成本的,大部分就只能生啃官网介绍,希望看完都有收获吧!
引用&参考
我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿。