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:非空物件陣列
-
Array:可空物件陣列
使用這三種型別來宣告陣列,可以發現它們之間的區別:
等同的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
是一款用來檢查Kotlin程式碼風格的工具,和我們的工具定位不同,需要經過大量的改造工作才行。
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用的是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來實現目的。
最後採用的方案是:
-
透過建立klintrules這個空的aar,將klint.jar放入assets中;
-
修改KLint程式碼實現從assets中讀取klint.jar;
-
專案依賴klintrulesaar時使用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程式碼時,觸發pre-commit/push-hook進行檢查,檢查不透過不允許commit/push;
-
在建立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外掛。
透過這款工具,實現在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: ", 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年畢業於南京資訊工程大學,同年加入美團點評到店餐飲事業群,參與大眾點評美食頻道的日常開發工作。
來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/31077337/viewspace-2157448/,如需轉載,請註明出處,否則將追究法律責任。
相關文章
- 美團多場景建模的探索與實踐
- 美團外賣Android Lint程式碼檢查實踐Android
- 美團BERT的探索和實踐
- 美團外賣小程式的探索與實踐丨掘金開發者大會
- Code:美團程式碼託管平臺的演進與實踐
- 美團搜尋多業務商品排序探索與實踐排序
- 美團外賣廣告智慧算力的探索與實踐(二)
- Flutter原理與美團的實踐Flutter
- 美團掃碼付小程式的優化實踐優化
- 美團知識圖譜問答技術實踐與探索
- 美團儲存雲原生探索和實踐
- 美團搜尋中查詢改寫技術的探索與實踐
- 深度學習在美團配送ETA預估中的探索與實踐深度學習
- 低程式碼與大語言模型的探索實踐模型
- 美團外賣推薦情境化智慧流量分發的實踐與探索
- 異構廣告混排在美團到店業務的探索與實踐
- 美團針對Redis Rehash機制的探索和實踐Redis
- Flink在美團的實踐與應用
- 廣告平臺化的探索與實踐 | 美團外賣廣告工程實踐專題連載
- 阿里云云效智慧化程式碼平臺的探索與實踐阿里
- 美圖個性化推薦的實踐與探索
- 美團下一代服務治理系統 OCTO2.0 的探索與實踐
- OceanBase 的探索與實踐
- ChatGPT的探索與實踐ChatGPT
- 美團智慧客服核心技術與實踐
- 美團外賣基於GPU的向量檢索系統實踐GPU
- 從設計到程式碼:探索高效的前端開發工具與實踐前端
- Flutter探索與實踐Flutter
- 美團掃碼付的前端可用性保障實踐前端
- 美團掃碼付的前端可用性保障實踐!前端
- 美團外賣小程式的探索和實踐(演講內容整理)丨掘金開發者大會
- Presto在滴滴的探索與實踐REST
- 彈性探索與實踐
- Jenkins的Pipeline指令碼在美團餐飲SaaS中的實踐Jenkins指令碼
- 情感分析技術在美團的探索與應用
- 開源實踐 | 攜程在 OceanBase 的探索與實踐
- 開源實踐 | 攜程在OceanBase的探索與實踐
- Flutter Web在美團外賣的實踐FlutterWeb