10 月,开发者不可错过的开源大数据大会-2021 WeDataSphere 社区大会深圳站 了解详情
写点什么

Kotlin 代码检查在美团的探索与实践

2020 年 2 月 20 日

Kotlin代码检查在美团的探索与实践

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 代码无疑是低效的。


我们可以通过以下解决方法来减少生成的字节码:


  1. 对于基本类型和字符串,可以使用 const 关键字将常量声明为编译时常量。

  2. 对于公共字段,可以使用 @JvmField 注解。

  3. 对于其他类型的常量,最好在它们自己的主类对象而不是伴生对象中来存储公共的全局常量。


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 代码的检查规则应该具有扩展性,方便其他使用方定制自己的检查规则。


基于此,整个工具主要包含下面三个方面的内容:


  1. 解析 Kotlin 代码

  2. 编写可扩展的自定义代码检查规则

  3. 检查自动化


结合对工具的需求,在经过思考和查阅资料之后,确定了三种可供选择的方案


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 来实现目的。


最后采用的方案是:


  1. 通过创建 klintrules 这个空的 aar,将 klint.jar 放入 assets 中;

  2. 修改 KLint 代码实现从 assets 中读取 klint.jar ;

  3. 项目依赖 klintrules aar 时使用 debugCompile 来避免把 klint.jar 带到 release 包。


Detector 类中接口方法的定义

既然是对 Kotlin 代码的检查,自然 Detector 类要定义一套新的接口方法。先来看一下 Java 代码检查规则提供的方法:



相信写过 Lint 规则的同学对上面的方法应该非常熟悉。为了尽量降低 KLint 检查规则编写的学习成本,我们参照 JavaPsiScanner 接口,定义了一套非常相似的接口方法:



KLint 的实现

通过对上述 3 个主要方面的改造,完成了KLint 插件。



由于 KLint 和 Lint 的相似,KLint 插件简单易上手:


  1. 和 Lint 相似的编写规范(参考最后一节的代码);

  2. 支持 @SuppressWarnings("") 等 Lint 支持的注解;

  3. 具有和 Lint 的 Options 相同功能的 klintOptions,如下:


mtKlint {    klintOptions {        abortOnError false        htmlReport true        htmlOutput new File(project.getBuildDir(), "mtKLint.html")    }}
复制代码


检查自动化

  • 关于自动检查有两个方案:


  1. 在开发同学 commit/push 代码时,触发 precommit/pushhook 进行检查,检查不通过不允许 commit/push;

  2. 在创建 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(globalScope, 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年毕业于南京信息工程大学,同年加入美团到店餐饮事业群,参与大众点评美食频道的日常开发工作。


2020 年 2 月 20 日 21:51230

评论

发布
暂无评论
发现更多内容

WorkPlus高端制造行业数字化解决方案—华晨宝马

WorkPlus Lite

开源 即时通讯 IM 案例分享 汽车

用户体验

Ryan Zheng

2021Android面经:算法题+JVM+自定义View

欢喜学安卓

android 程序员 面试 移动开发

GIS可视化框架:更便捷的地图数据可视化工具

鲸品堂

GIS 可视化 应用场景

通过Linux实现更好的即时通讯传递

WorkPlus Lite

阅读

图像直方图反向投影,Python OpenCV 取经之旅第 28 天

梦想橡皮擦

7月日更

hive的安装部署

大数据技术指南

hive 7月日更

平安社区建设,潍坊智慧平安社区建设解决方案

13823153121

WorkPlus房地产行业数字化解决方案—富力地产

WorkPlus Lite

开源 远程办公 即时通讯 IM

Go 学习笔记之 Slice

架构精进之路

7月日更

bzz分币挖矿系统开发需求

获客I3O6O643Z97

区块链+ BZZ节点矿池 BZZ节点挖矿

生命科学领域新工具:北鲲云超算平台,梦启航的地方

北鲲云

CODING 带你看腾讯新闻 7 日 DevOps 实践

CODING DevOps

DevOps CODING 腾讯新闻

如何利用云原生技术构建现代化应用

阿里巴巴中间件

云计算 阿里云 云原生 中间件 云原生架构

突破AI工业化瓶颈,专业数据服务平台化是关键

澳鹏Appen

人工智能 机器学习 数据标注 训练数据 标注平台

Demo

Command

#架构实战营

用中国话学this指向(旧知识新讲)

加百利

JavaScript 前端 this 7月日更

一名老党员,申请入驻~

正儿八经的仙儿

2021 - iOS开发面试 【字节·百度】 上海区面经与侧重点分享

iOSer

ios 百度 面试 字节 iOS 知识体系

2021世界人工智能大会开幕 百度飞桨荣获“SAIL之星”奖项

百度大脑

人工智能 飞桨

网络攻防学习笔记 Day69

穿过生命散发芬芳

网络攻防 7月日更

10年阿里开发架构师经验分享:掌握这个提升路径

欢喜学安卓

android 程序员 面试 移动开发

爱奇艺多语言台词机器翻译技术实践

爱奇艺技术产品团队

机器翻译 nlp 模型 BERT

Camtasia实用技巧之时间轴

淋雨

视频剪辑 录屏软件 Camtaisa

2021年度“CCF-百度松果基金” 百度携手CCF为AI科研提供资金支持

百度大脑

人工智能 百度 松果基金

Hive窗口函数保姆级教程

五分钟学大数据

hive 7月日更

对象存储手把手教二 | Bucket 跨区域容灾和用户数据复制

QingStor分布式存储

云原生 对象存储 分布式存储 云存储

Hive 原理实践

云祁

大数据 hive 7月日更

永续合约交易所搭建,合约平台开发

13823153121

解读多云跨云下的容器治理与实践

华为云开发者社区

云原生 华为云 多云 跨云 容器治理

2021 WAIC | EMQ 映云科技:面向云原生的云边协同物联网解决方案

EMQ映云科技

云原生 云端 端边云协同架构 边云协同

Kotlin代码检查在美团的探索与实践-InfoQ