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

Stella981
• 阅读 714

背景

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种数组类型:

  • IntArrayFloatArray,其他:基本类型数组,被编译成int[]float[],其他
  • Array<T>:非空对象数组
  • Array<T?>:可空对象数组

使用这三种类型来声明数组,可以发现它们之间的区别:

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

等同的Java代码:

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

后面两种方法都对基本类型做了装箱处理,产生了额外的开销。
所以当需要声明非空的基本类型数组时,应该使用xxxArray,避免自动装箱。

for循环

Kotlin提供了downTostepuntilreversed等函数来帮助开发者更简单的使用For循环,如果单一的使用这些函数确实是方便简洁又高效,但要是将其中两个结合呢?比如下面这样:

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

上面的For循环中结合使用了downTostep,那么等同的Java代码又是怎么实现的呢?

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

重点看这行代码:

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代码检查在美团的探索与实践

很显然,上图中的红框部分需要被改造以适配Kotlin,主要工作有以下3点:

  • 创建KotlinParser对象,用来解析Kotlin代码
  • 从aar中获取自定义KLint规则的jar包
  • Detector类需要定义一套新的接口方法来适配遍历Kotlin节点回调时的调用

Kotlin代码解析

和Java一样,Kotlin也有自己的抽象语法树。可惜的是目前还没有解析Kotlin语法树的单独库,只能通过Kotlin编译器这个库中的相关类来解析。KLint用的是kotlin-compiler-embeddable:1.1.2-5库。

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实践这篇文章。

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

上图展示了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. 项目依赖klintrulesaar时使用debugCompile来避免把klint.jar带到release包。

Detector类中接口方法的定义

既然是对Kotlin代码的检查,自然Detector类要定义一套新的接口方法。先来看一下Java代码检查规则提供的方法: https://tech.meituan.com/img/Kotlin-code-inspect/4.png)

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

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

KLint的实现

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

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

由于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代码时,触发pre-commit/push-hook进行检查,检查不通过不允许commit/push;
    2. 在创建pull request时,触发CI构建进行检查,检查不通过不允许merge。

    这里更偏向于方案2,因为pre-commit/push-hook可以通过--no-verify命令绕过,我们希望所有的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插件。

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

通过这款工具,实现在Android Studio的窗口实时报错,帮助开发同学第一时间发现问题及时解决。

Kotlin代码检查实践

KLint插件分为Gradle插件和IDE插件两部分,前者在build.gradle中引入,后者通过Android Studio安装使用。

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页面:

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

  • Android Studio上对应的实时报错信息:

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

总结

借助KLint插件,编写检查规则来约束不规范的Kotlin代码,一方面避免了隐藏开销,提高了Kotlin代码的性能,另一方面也帮助开发同学更好的理解Kotlin。

参考资料

作者介绍

周佳,美团点评前端Android开发工程师,2016年毕业于南京信息工程大学,同年加入美团点评到店餐饮事业群,参与大众点评美食频道的日常开发工作。 Kotlin代码检查在美团的探索与实践

点赞
收藏
评论区
推荐文章
blmius blmius
3年前
MySQL:[Err] 1292 - Incorrect datetime value: ‘0000-00-00 00:00:00‘ for column ‘CREATE_TIME‘ at row 1
文章目录问题用navicat导入数据时,报错:原因这是因为当前的MySQL不支持datetime为0的情况。解决修改sql\mode:sql\mode:SQLMode定义了MySQL应支持的SQL语法、数据校验等,这样可以更容易地在不同的环境中使用MySQL。全局s
皕杰报表之UUID
​在我们用皕杰报表工具设计填报报表时,如何在新增行里自动增加id呢?能新增整数排序id吗?目前可以在新增行里自动增加id,但只能用uuid函数增加UUID编码,不能新增整数排序id。uuid函数说明:获取一个UUID,可以在填报表中用来创建数据ID语法:uuid()或uuid(sep)参数说明:sep布尔值,生成的uuid中是否包含分隔符'',缺省为
待兔 待兔
4个月前
手写Java HashMap源码
HashMap的使用教程HashMap的使用教程HashMap的使用教程HashMap的使用教程HashMap的使用教程22
Jacquelyn38 Jacquelyn38
3年前
2020年前端实用代码段,为你的工作保驾护航
有空的时候,自己总结了几个代码段,在开发中也经常使用,谢谢。1、使用解构获取json数据let jsonData  id: 1,status: "OK",data: 'a', 'b';let  id, status, data: number   jsonData;console.log(id, status, number )
Java修道之路,问鼎巅峰,我辈代码修仙法力齐天
<center<fontcolor00FF7Fsize5face"黑体"代码尽头谁为峰,一见秃头道成空。</font<center<fontcolor00FF00size5face"黑体"编程修真路破折,一步一劫渡飞升。</font众所周知,编程修真有八大境界:1.Javase练气筑基2.数据库结丹3.web前端元婴4.Jav
Stella981 Stella981
3年前
Kotlin学习笔记
1概述这篇文章首先会介绍Kotlin的特点,接着介绍Kotlin与Java的语法比较。2Kotlin特点一门现代化的编程语言可开发跨平台应用,web,Socket,安卓,js,NativeApp等静态编程语言,性能基本与原声Java相当100%兼容Java(说是兼容但实际上
Wesley13 Wesley13
3年前
Java日期时间API系列31
  时间戳是指格林威治时间1970年01月01日00时00分00秒起至现在的总毫秒数,是所有时间的基础,其他时间可以通过时间戳转换得到。Java中本来已经有相关获取时间戳的方法,Java8后增加新的类Instant等专用于处理时间戳问题。 1获取时间戳的方法和性能对比1.1获取时间戳方法Java8以前
Wesley13 Wesley13
3年前
00:Java简单了解
浅谈Java之概述Java是SUN(StanfordUniversityNetwork),斯坦福大学网络公司)1995年推出的一门高级编程语言。Java是一种面向Internet的编程语言。随着Java技术在web方面的不断成熟,已经成为Web应用程序的首选开发语言。Java是简单易学,完全面向对象,安全可靠,与平台无关的编程语言。
Wesley13 Wesley13
3年前
MySQL部分从库上面因为大量的临时表tmp_table造成慢查询
背景描述Time:20190124T00:08:14.70572408:00User@Host:@Id:Schema:sentrymetaLast_errno:0Killed:0Query_time:0.315758Lock_
Python进阶者 Python进阶者
10个月前
Excel中这日期老是出来00:00:00,怎么用Pandas把这个去除
大家好,我是皮皮。一、前言前几天在Python白银交流群【上海新年人】问了一个Pandas数据筛选的问题。问题如下:这日期老是出来00:00:00,怎么把这个去除。二、实现过程后来【论草莓如何成为冻干莓】给了一个思路和代码如下:pd.toexcel之前把这