Kotlin 代码检查在美团的探索与实践
背景
Kotlin 有着诸多的特性,比如空指针安全、方法扩展、支持函数式编程、丰富的语法糖等。这些特性使得 Kotlin 的代码比 Java 简洁优雅许多,提高了代码的可读性和可维护性,节省了开发时间,提高了开发效率。这也是我们团队转向 Kotlin 的原因,但是在实际的使用过程中,我们发现看似写法简单的 Kotlin 代码,可能隐藏着不容忽视的额外开销。本文剖析了Kotlin 的隐藏开销,并就如何避免开销进行了探索和实践。
Kotlin 的隐藏开销
伴生对象
伴生对象通过在类中使用 companion object 来创建,用来替代静态成员,类似于 Java 中的静态内部类。所以在伴生对象中声明常量是很常见的做法,但如果写法不对,可能就会产生额外开销。
比如下面这段声明 Version 常量的代码:
class Demo {
fun getVersion(): Int {
return Version
}
companion object {
private val Version = 1
}
}
表面上看还算简洁,但是将这段 Kotlin 代码转化成等同的 Java 代码后,却显得晦涩难懂:
public class Demo {
private static final int Version = 1;
public static final Demo.Companion Companion = new Demo.Companion();
public final int getVersion() {
return Companion.access$getVersion$p(Companion);
}
public static int access$getVersion$cp() {
return Version;
}
public static final class Companion {
private static int access$getVersion$p(Companion companion) {
return companion.getVersion();
}
private int getVersion() {
return Demo.access$getVersion$cp();
}
}
}
与 Java 直接读取一个常量不同,Kotlin 访问一个伴生对象的私有常量字段需要经过以下方法:
-
调用伴生对象的静态方法
-
调用伴生对象的实例方法
-
调用主类的静态方法
-
读取主类中的静态字段
为了访问一个常量,而多花费调用 4 个方法的开销,这样的 Kotlin 代码无疑是低效的。
我们可以通过以下解决方法来减少生成的字节码:
-
对于基本类型和字符串,可以使用 const 关键字将常量声明为编译时常量。
-
对于公共字段,可以使用 @JvmField 注解。
-
对于其他类型的常量,最好在它们自己的主类对象而不是伴生对象中来存储公共的全局常量。
Lazy(())委托属性
lazy() 委托属性可以用于只读属性的惰性加载,但是在使用 lazy() 时经常被忽视的地方就是有一个可选的 model 参数:
-
LazyThreadSafetyMode.SYNCHRONIZED:初始化属性时会有双重锁检查,保证该值只在一个线程中计算,并且所有线程会得到相同的值。
-
LazyThreadSafetyMode.PUBLICATION:多个线程会同时执行,初始化属性的函数会被多次调用,但是只有第一个返回的值被当做委托属性的值。
-
LazyThreadSafetyMode.NONE:没有双重锁检查,不应该用在多线程下。
lazy() 默认情况下会指定 LazyThreadSafetyMode.SYNCHRONIZED ,这可能会造成不必要线程安全的开销,应该根据实际情况,指定合适的 model 来避免不需要的同步锁。
基本类型数组
在 Kotlin 中有 3 种数组类型:
-
IntArray , FloatArray ,其他:基本类型数组,被编译成 int[] , float[] ,其他
-
Array<T> :非空对象数组
-
Array<T?> :可空对象数组
使用这三种类型来声明数组,可以发现它们之间的区别:
等同的 Java 代码:
后面两种方法都对基本类型做了装箱处理,产生了额外的开销。
所以当需要声明非空的基本类型数组时,应该使用 xxxArray,避免自动装箱。
For 循环
Kotlin 提供了downTo 、step 、until 、reversed 等函数来帮助开发者更简单的使用 For 循环,如果单一的使用这些函数确实是方便简洁又高效,但要是将其中两个结合呢?比如下面这样:
上面的 For 循环中结合使用了downTo 和 step ,那么等同的 Java 代码又是怎么实现的呢?
重点看这行代码:
IntProgression var10000 = RangesKt.step(RangesKt.downTo(10, 1), 2);
这行代码就创建了两个 IntProgression 临时对象,增加了额外的开销。
Kotlin 检查工具的探索
Kotlin 的隐藏开销不止上面列举的几个,为了避免开销,我们需要实现这样一个工具,实现 Kotlin 语法的检查,列出不规范的代码并给出修改意见。同时为了保证开发同学的代码都是经过工具检查的,整个检查流程应该自动化。
再进一步考虑,Kotlin 代码的检查规则应该具有扩展性,方便其他使用方定制自己的检查规则。
基于此,整个工具主要包含下面三个方面的内容:
-
解析 Kotlin 代码
-
编写可扩展的自定义代码检查规则
-
检查自动化
结合对工具的需求,在经过思考和查阅资料之后,确定了三种可供选择的方案
ktlint
ktlint 是一款用来检查 Kotlin 代码风格的工具,和我们的工具定位不同,需要经过大量的改造工作才行。
detekt
detekt 是一款用来静态分析 Kotlin 代码的工具,符合我们的需求,但是不太适合 Android 工程,比如无法指定 variant(变种)检查。另外,在整个检查流程中,一份 kt 文件只能检查一次,检查结果(当时)只支持控制台输出,不便于阅读。
改造 Lint
改造 Lint 来增加 Lint 对 Kotlin 代码检查的支持,一方面 Lint 提供的功能完全可以满足我们的需求,同时还能支持资源文件和 class 文件的检查,另一方面改造后的 Lint 和 Lint 很相似,学习上手的成本低。
相对于前两种方案,方案 3 的成本收益比最高,所以我们决定改造 Lint 成 Kotlin Lint(KLint)插件。
先来大致了解下 Lint 的工作流程,如下图:
很显然,上图中的红框部分需要被改造以适配 Kotlin,主要工作有以下 3 点:
-
创建 KotlinParser 对象,用来解析 Kotlin 代码
-
从 aar 中获取自定义 KLint 规则的 jar 包
-
Detector 类需要定义一套新的接口方法来适配遍历 Kotlin 节点回调时的调用
Kotlin 代码解析
和 Java 一样,Kotlin 也有自己的抽象语法树。可惜的是目前还没有解析 Kotlin 语法树的单独库,只能通过 Kotlin 编译器这个库中的相关类来解析。KLint 用的是 kotlincompilerembeddable:1.1.25 库。
public KtFile parseKotlinToPsi(@NonNull File file) {
try {
org.jetbrains.kotlin.com.intellij.openapi.project.Project ktProject = KotlinCoreEnvironment.Companion.createForProduction(() -> {
}, new CompilerConfiguration(), CollectionsKt.emptyList()).getProject();
this.psiFileFactory = PsiFileFactory.getInstance(ktProject);
return (KtFile) psiFileFactory.createFileFromText(file.getName(), KotlinLanguage.INSTANCE, readFileToString(file, "UTF-8"));
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
//可忽视,只是将文件转成字符流
public static String readFileToString(File file, String encoding) throws IOException {
FileInputStream stream = new FileInputStream(file);
String result = null;
try {
result = readInputStreamToString(stream, encoding);
} finally {
try {
stream.close();
} catch (IOException e) {
// ignore
}
}
return result;
}
以上这段代码可以封装成 KotlinParser 类,主要作用是将.Kt 文件转化成 KtFile 对象。
在检查 Kotlin 文件时调用 KtFile.acceptChildren(KtVisitorVoid) 后, KtVisitorVoid 便会多次回调遍历到的各个节点(Node)的方法:
KtVisitorVoid visitorVoid = new KtVisitorVoid(){
@Override
public void visitClass(@NotNull KtClass klass) {
super.visitClass(klass);
}
@Override
public void visitPrimaryConstructor(@NotNull KtPrimaryConstructor constructor) {
super.visitPrimaryConstructor(constructor);
}
@Override
public void visitProperty(@NotNull KtProperty property) {
super.visitProperty(property);
}
...
};
ktPsiFile.acceptChildren(visitorVoid);
自定义 KLint 规则的实现
自定义 KLint 规则的实现参考了 Android自定义Lint实践 这篇文章。
上图展示了aar 中允许包含的文件,aar 中可以包含 lint.jar,这也是 Android自定义Lint实践 这篇文章采用的实现方式。但是 klint.jar 不能直接放入 aar 中,当然更不应该将 klint.jar 重命名成 lint.jar 来实现目的。
最后采用的方案是:
-
通过创建 klintrules 这个空的 aar,将 klint.jar 放入 assets 中;
-
修改 KLint 代码实现从 assets 中读取 klint.jar ;
-
项目依赖 klintrules aar 时使用 debugCompile 来避免把 klint.jar 带到 release 包。
Detector 类中接口方法的定义
既然是对 Kotlin 代码的检查,自然 Detector 类要定义一套新的接口方法。先来看一下 Java 代码检查规则提供的方法:
相信写过 Lint 规则的同学对上面的方法应该非常熟悉。为了尽量降低 KLint 检查规则编写的学习成本,我们参照 JavaPsiScanner 接口,定义了一套非常相似的接口方法:
KLint 的实现
通过对上述 3 个主要方面的改造,完成了KLint 插件。
由于 KLint 和 Lint 的相似,KLint 插件简单易上手:
-
和 Lint 相似的编写规范(参考最后一节的代码);
-
支持 @SuppressWarnings("") 等 Lint 支持的注解;
-
具有和 Lint 的 Options 相同功能的 klintOptions,如下:
mtKlint {
klintOptions {
abortOnError false
htmlReport true
htmlOutput new File(project.getBuildDir(), "mtKLint.html")
}
}
检查自动化
-
关于自动检查有两个方案:
-
在开发同学 commit/push 代码时,触发 precommit/pushhook 进行检查,检查不通过不允许 commit/push;
-
在创建 pull request 时,触发 CI 构建进行检查,检查不通过不允许 merge。
这里更偏向于方案 2,因为 precommit/pushhook 可以通过 noverify 命令绕过,我们希望所有的 Kotlin 代码都是通过检查的。
KLint 插件本身支持通过./gradlew mtKLint 命令运行,但是考虑到几乎所有的项目在 CI 构建上都会执行Lint 检查,把 KLint 和 Lint 绑定在一起可以省去 CI 构建脚本接入 KLint 插件的成本。
通过以下代码,将 lint task 依赖 klint task ,实现在执行Lint 之前先执行KLint 检查:
//创建KLint task,并设置被Lint task依赖
KLint klintTask = project.getTasks().create(String.format(TASK_NAME, ""), KLint.class, new KLint.GlobalConfigAction(globalSco
pe, null, KLintOptions.create(project)))
Set<Task> lintTasks = project.tasks.findAll {
it.name.toLowerCase().equals("lint")
}
lintTasks.each { lint ->
klintTask.dependsOn lint.taskDependencies.getDependencies(lint)
lint.dependsOn klintTask
}
//创建Klint变种task,并设置被Lint变种task依赖
for (Variant variant : androidProject.variants) {
klintTask = project.getTasks().create(String.format(TASK_NAME, variant.name.capitalize()), KLint.class, new KLint.GlobalConfigAction(globalScope, variant, KLintOptions.create(project)))
lintTasks = project.tasks.findAll {
it.name.startsWith("lint") && it.name.toLowerCase().endsWith(variant.name.toLowerCase())
}
lintTasks.each { lint ->
klintTask.dependsOn lint.taskDependencies.getDependencies(lint)
lint.dependsOn klintTask
}
}
检查实时化
虽然实现了检查的自动化,但是可以发现执行自动检查的时机相对滞后,往往是开发同学准备合代码的时候,这时再去修改代码成本高并且存在风险。CI 上的自动检查应该是作为是否有“漏网之鱼”的最后一道关卡,而问题应该暴露在代码编写的过程中。基于此,我们开发了Kotlin 代码实时检查的 IDE 插件。
通过这款工具,实现在 Android Studio 的窗口实时报错,帮助开发同学第一时间发现问题及时解决。
Kotlin 代码检查实践
KLint 插件分为 Gradle 插件和 IDE 插件两部分,前者在 build.gradle 中引入,后者通过 AndroidStudio 安装使用。
KLint 规则的编写
针对上面列举的 lazy()中未指定 mode 的 case,KLint 实现了对应的检查规则:
public class LazyDetector extends Detector implements Detector.KtPsiScanner {
public static final Issue ISSUE = Issue.create(
"Lazy Warning",
"Missing specify `lazy` mode ",
"see detail: https://wiki.sankuai.com/pages/viewpage.action?pageId=1322215247",
Category.CORRECTNESS,
6,
Severity.ERROR,
new Implementation(
LazyDetector.class,
EnumSet.of(Scope.KOTLIN_FILE)));
@Override
public List<Class<? extends PsiElement>> getApplicableKtPsiTypes() {
return Arrays.asList(KtPropertyDelegate.class);
}
@Override
public KtVisitorVoid createKtPsiVisitor(KotlinContext context) {
return new KtVisitorVoid() {
@Override
public void visitPropertyDelegate(@NotNull KtPropertyDelegate delegate) {
boolean isLazy = false;
boolean isSpeifyMode = false;
KtExpression expression = delegate.getExpression();
if (expression != null) {
PsiElement[] psiElements = expression.getChildren();
for (PsiElement psiElement : psiElements) {
if (psiElement instanceof KtNameReferenceExpression) {
if ("lazy".equals(((KtNameReferenceExpression) psiElement).getReferencedName())) {
isLazy = true;
}
} else if (psiElement instanceof KtValueArgumentList) {
List<KtValueArgument> valueArguments = ((KtValueArgumentList) psiElement).getArguments();
for (KtValueArgument valueArgument : valueArguments) {
KtExpression argumentValue = valueArgument.getArgumentExpression();
if (argumentValue != null) {
if (argumentValue.getText().contains("SYNCHRONIZED") ||
argumentValue.getText().contains("PUBLICATION") ||
argumentValue.getText().contains("NONE")) {
isSpeifyMode = true;
}
}
}
}
}
if (isLazy && !isSpeifyMode) {
context.report(ISSUE, expression,context.getLocation(expression.getContext()), "Specify the appropriate thread safety mode to avoid locking when it’s not needed.");
}
}
}
};
}
}
检查结果
Gradle 插件和 IDE 插件共用一套规则,所以上面的规则编写一次,就可以同时在两个插件中使用:
-
CI 上自动检查对应的检测结果的 HTML 页面:
-
Android Studio 上对应的实时报错信息:
总结
借助 KLint 插件,编写检查规则来约束不规范的 Kotlin 代码,一方面避免了隐藏开销,提高了Kotlin 代码的性能,另一方面也帮助开发同学更好的理解 Kotlin。
参考资料
作者介绍
-
周佳,美团前端 Android 开发工程师,2016年毕业于南京信息工程大学,同年加入美团到店餐饮事业群,参与大众点评美食频道的日常开发工作。
更多内容推荐
-
Rust 异步编程之 tokio 运行时(六)
2021 年 8 月 26 日
-
Kotlin 对战 Java:新秀会击败老将吗?
本文介绍了什么是 Kotlin,用代码示例展示了 Kotlin 与 Java 的主要区别,并在多个功能方面对 Kotlin 与 Java 进行了比较。
-
Duolingo 如何将 Android App 全部迁至 Kotlin
Duolingo无缝地将其全部Java Android App迁移到Kotlin,带来的好处包括提高了开发人员的工作效率和幸福感。
-
第 24 讲 | 有哪些方法可以在运行时动态生成一个 Java 类?
有了上一讲的类加载的学习基础后,我想是时候该进行深入分析动态代理和字节码操作方面的技术了。
2018 年 6 月 30 日
-
打通前端与原生的桥梁:JavaScriptCore 能干哪些事情?
总结来说,JavaScriptCore 提供了前端与原生相互调用的接口。
2019 年 6 月 11 日
-
Dagger: 一种 Android 平台的依赖注入框架
Dagger的新库是“一种针对Android和Java的快速依赖注入器”,Dagger支持的功能仅是Google Guice的子集,通过专注于一种简化的功能集以一种不同的方式达到了更好的性能。它最明显的不足是缺少对于方法和字段的注入支持,牺牲了这项功能却提升了错误检查及探测方面的能力
-
SubstrateVM:AOT 编译框架
SubstrateVM的设计初衷是提供一个高启动性能、低内存开销,和能够无缝衔接C代码的Java运行时。它是一个独立的运行时,拥有自己的内存管理等组件。
2018 年 10 月 12 日
-
写给服务器端 Java 开发人员的 Kotlin 简介
Kotlin是JVM上比较新的语言之一,来自IntelliJ开发商JetBrains。它是一种静态类型语言,旨在提供一种混合OO和FP的编程风格。Kotlin编译器生成的字节码与JVM兼容,可以在JVM上运行及与现有的库互操作。我们将介绍Java开发人员可能感兴趣的主要特性。
-
滴滴开源 DroidAssist : 轻量级 Android 字节码编辑插件
近日,滴滴发布的开源项目 DroidAssist ,提供了一种简单易用、无侵入、配置化、轻量级的 Java 字节码操作方式。
-
Joinpoint Before Advice AspectJ 实现
2021 年 1 月 28 日
-
安卓上的 Kotlin:安卓 KTX、Kotlin Bootcamp Udacity 等等
最近,谷歌展示了一系列努力来改善Kotlin开发人员在安卓平台上的体验,其中包括安卓KTX、Kotlin Bootcamp Udacity课程、Lint支持等等。
-
Kotlin Native 新增 Objective-C 互操作能力以及对 WebAssembly 的支持
根据JetBrains技术主管Nikolay Igotti的介绍,Kotlin/Native 0.4已经可用于为iOS和macOS开发原生应用。此外该版本还为WebAssembly平台提供了实验性支持。
-
JetBrains 发布 Kotlin 1.2.30
最近,JetBrains发布了Kotlin 1.2.30。该版本是在1.2.20版本发布一个半月之后,作为bug修复和工具更新而发布的。
-
使用 Clojure 构建原生 Android 应用
在Android平台上使用Clojure进行开发,在过去几年中取得了长足的进步,让开发人员可以把它用于完整的应用,比如SwiftKey的Clarity Keyboard。在本文中,我们将检阅当前在Android平台上支持Clojure的工具的情况。
-
Kotlin:Android 世界的 Swift
Kotlin是一门与Swift类似的静态类型JVM语言,由JetBrains设计开发并开源。与Java相比,Kotlin的语法更简洁、更具表达性,而且提供了更多的特性,比如,高阶函数、操作符重载、字符串模板。它与Java高度可互操作,可以同时用在一个项目中。
-
练习 Sample 跑起来 | 唯鹿同学的练习手记 第 3 辑
学霸又交作业了,快看看22、27期和ASM的练习Sample是如何跑起来的吧。
2019 年 3 月 28 日
-
实战 Kotlin@Android(一):项目配置和语言转换
在过去的一年中,在Android开发圈有一个越来越火的话题,就是JetBrains开发的新JVM语言Kotlin。这个团队还开发了IntelliJ Idea,也就是Android Studio的基础。Kotlin旨在通过全新的语言特色来替代老旧而不cool的Java,又由于Kotlin可以100%兼容Java,所以你在项目中可以想用多少用多少。而又因为Kotlin的标准库很小,很适合在资源有限的移动设备上开发使用。
-
Android 中的单元测试
由 于Instrument Test使用和运行的不便,在Android项目中对代码添加测试变得非常困难。本文基于项目实践,描述了在实际项目中如何借助于MVP模式和 Robolectric框架,实现逻辑和视图的分离,为代码添加有效完备的单元测试,并简单介绍了Robolectric的实现原理以及如何对其进行扩 展。
评论