Kotlin代碼檢查在美團的探索與實踐

背景

Kotlin有着諸多的特性,好比空指針安全、方法擴展、支持函數式編程、豐富的語法糖等。這些特性使得Kotlin的代碼比Java簡潔優雅許多,提升了代碼的可讀性和可維護性,節省了開發時間,提升了開發效率。這也是咱們團隊轉向Kotlin的緣由,可是在實際的使用過程當中,咱們發現看似寫法簡單的Kotlin代碼,可能隱藏着不容忽視的額外開銷。本文剖析了Kotlin的隱藏開銷,並就如何避免開銷進行了探索和實踐。html

Kotlin的隱藏開銷

伴生對象

伴生對象經過在類中使用companion object來建立,用來替代靜態成員,相似於Java中的靜態內部類。因此在伴生對象中聲明常量是很常見的作法,但若是寫法不對,可能就會產生額外開銷。好比下面這段聲明Version常量的代碼:前端

class Demo {

    fun getVersion(): Int {
        return Version
    }

    companion object {
        private val Version = 1
    }
}

表面上看還算簡潔,可是將這段Kotlin代碼轉化成等同的Java代碼後,卻顯得晦澀難懂:android

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訪問一個伴生對象的私有常量字段須要通過如下方法:git

  • 調用伴生對象的靜態方法
  • 調用伴生對象的實例方法
  • 調用主類的靜態方法
  • 讀取主類中的靜態字段

爲了訪問一個常量,而多花費調用4個方法的開銷,這樣的Kotlin代碼無疑是低效的。github

咱們能夠經過如下解決方法來減小生成的字節碼:express

  1. 對於基本類型和字符串,可使用const關鍵字將常量聲明爲編譯時常量。
  2. 對於公共字段,可使用@JvmField註解。
  3. 對於其餘類型的常量,最好在它們本身的主類對象而不是伴生對象中來存儲公共的全局常量。

Lazy()委託屬性

lazy()委託屬性能夠用於只讀屬性的惰性加載,可是在使用lazy()時常常被忽視的地方就是有一個可選的model參數:編程

  • LazyThreadSafetyMode.SYNCHRONIZED:初始化屬性時會有雙重鎖檢查,保證該值只在一個線程中計算,而且全部線程會獲得相同的值。
  • LazyThreadSafetyMode.PUBLICATION:多個線程會同時執行,初始化屬性的函數會被屢次調用,可是隻有第一個返回的值被當作委託屬性的值。
  • LazyThreadSafetyMode.NONE:沒有雙重鎖檢查,不該該用在多線程下。

lazy()默認狀況下會指定LazyThreadSafetyMode.SYNCHRONIZED,這可能會形成沒必要要線程安全的開銷,應該根據實際狀況,指定合適的model來避免不須要的同步鎖。api

基本類型數組

在Kotlin中有3種數組類型:數組

  • IntArrayFloatArray,其餘:基本類型數組,被編譯成int[]float[],其餘
  • Array<T>:非空對象數組
  • Array<T?>:可空對象數組

使用這三種類型來聲明數組,能夠發現它們之間的區別:安全

Kotlin聲明的數組

等同的Java代碼:

等同Java聲明的數組

後面兩種方法都對基本類型作了裝箱處理,產生了額外的開銷。
因此當須要聲明非空的基本類型數組時,應該使用xxxArray,避免自動裝箱。

for循環

Kotlin提供了downTostepuntilreversed等函數來幫助開發者更簡單的使用For循環,若是單一的使用這些函數確實是方便簡潔又高效,但要是將其中兩個結合呢?好比下面這樣:

上面的For循環中結合使用了downTostep,那麼等同的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的工做流程,以下圖:

Lint流程圖

很顯然,上圖中的紅框部分須要被改造以適配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實踐這篇文章。

上圖展現了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接口,定義了一套很是類似的接口方法:

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代碼時,觸發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插件。

KLint IDE插件

經過這款工具,實如今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頁面:

檢測結果的html頁面

  • Android Studio上對應的實時報錯信息:

實時報錯信息

總結

藉助KLint插件,編寫檢查規則來約束不規範的Kotlin代碼,一方面避免了隱藏開銷,提升了Kotlin代碼的性能,另外一方面也幫助開發同窗更好的理解Kotlin。

參考資料

做者介紹

周佳,美團點評前端Android開發工程師,2016年畢業於南京信息工程大學,同年加入美團點評到店餐飲事業羣,參與大衆點評美食頻道的平常開發工做。

相關文章
相關標籤/搜索