學習或使用一門新的編程語言時,了解這門語言所提供的功能,以及了解這些功能是否有相關聯的開銷,都是十分重要的環節。
這方面的問題在 Kotlin 中顯得更加有趣,因為 Kotlin 最終會編譯為 Java 字節碼,但是它卻提供了 Java 所沒有的功能。那么 Kotlin 是怎么做到的呢?這些功能有沒有額外開銷?如果有,我們能做些什么來優化它嗎?
接下來的內容與 Kotlin 中枚舉 (enums) 和 when 語句 (java 中的 switch 語句) 有關。我會討論一些和 when 語句相關的潛在開銷,以及 Android R8 編譯器是如何優化您的應用并減少這些開銷的。
編譯器
首先,我們講一講 D8 和 R8。
事實上,有三個編譯器參與了 Android 應用中 Kotlin 代碼的編譯。
1. Kotlin 編譯器
Kotlin 編譯器將會首先運行,它會把您寫的代碼轉換為 Java 字節碼。雖然聽起來很棒,但可惜的是 Android 設備上并不運行 Java 字節碼,而是被稱為 DEX 的 Dalvik 可執行文件。Dalvik 是 Android 最初所使用的運行時。而 Android 現在的運行時,則是從 Android 5.0 Lollipop 開始使用的 ART (Android Runtime),不過 ART 依然在運行 DEX 代碼 (如果替換后的運行時無法運行原有的可執行文件的話,就毫無兼容性可言了)。
2. D8
D8 是整個鏈條中的第二個編譯器,它把 Java 字節碼轉換為 DEX 代碼。到了這一步,您已經有了能夠運行在 Android 中的代碼。不過,您也可以選擇繼續使用第三個編譯器—— R8。
3. R8 (可選,但推薦使用)
R8 以前是用來優化和縮減應用體積的,它基本上就是 ProGuard 的一個替代方案。R8 不是默認開啟的,如果您希望使用它 (例如您想要這里討論到的那些優化時),就需要啟用它。在模塊的 build.gradle 里添加 minifyEnabled = true ,就可以強制打開 R8 。它將在所有其他編譯工作后執行,來保證您獲得的是一個縮減和優化過的應用。
android {
buildTypes {
release {
minifyEnabled true
proguardFiles getDefaultProguardFile(
‘proguard-android-optimize.txt’),
‘proguard-rules.pro’
}
}
}
枚舉
現在,讓我們討論一下枚舉。
無論在 Java 還是 Kotlin 中,枚舉的功能和消耗本質上都是一樣的。有趣的地方在于引入了 R8 之后,我們能對其中的一些開銷做些什么。
枚舉本身不包含任何隱藏開銷。使用 Kotlin 時,也僅僅是將其轉換為 Java 編程語言中的枚舉而已,并沒有多大開銷。(我們曾經提到避免使用枚舉,但那是很多年前的事了,而且運行時也與今日不同。所以現在使用枚舉沒什么問題。)
但當您配合枚舉使用 when 語句時,就會引入額外的開銷。
首先,我們來看一個枚舉的示例:
enum class BlendMode {
OPAQUE,
TRANSPARENT,
FADE,
ADD
}
這個枚舉中包含四個值。這些值是什么無關緊要,這里僅作為示例。
枚舉 + when
接下來,我們使用一個 when 語句來轉換這個枚舉:
fun blend(b: BlendMode) {
when (b) {
BlendMode.OPAQUE -> src()
BlendMode.TRANSPARENT -> srcOver()
BlendMode.FADE -> srcOver()
BlendMode.ADD -> add()
}
}
對應枚舉的每一個值,我們都去調用另一個方法。
如果您去看這段代碼編譯成的 Java 字節碼 (您可以通過 Android Studio 的查看字節碼功能直接看到 (Tools -> Kotlin -> Show Kotlin Bytecode),然后點擊 "Decompile" 按鈕),就會看到下面這樣的代碼:
public static void blend(@NotNull BlendMode b) {
switch (BlendingKt$WhenMappings.
$EnumSwitchMapping$0[b.ordinal()]) {
case 1: {
src();
break;
}
// ...
}
}
這段代碼中沒有對枚舉直接使用 switch 語句,而是調用了一個數組。這個數組是從哪來的呢?
而且這個數組存儲在一個被生成的類文件中。這個類文件是從哪來的?
這里究竟發生了什么呢?
自動生成的枚舉映射
事實上,為了實現二進制兼容,我們不能簡單地依靠枚舉的序數值進行轉換,因為這樣的代碼十分脆弱。假設您的一個庫中包含了一個枚舉,而您改變了這個枚舉中值的順序,您就可能破壞了某個人的應用。雖然這些代碼除了順序,看起來完全相同,但就是這種順序的不同導致了對其它代碼的影響。
所以取而代之的是,編譯器將序數值與另一個值做映射,這樣一來,無論您對這些枚舉做什么修改,基于這個庫的代碼都能正常運行。
當然,這就意味著只要像這樣使用枚舉,就會額外生成其它內容。在本例中,就會生成很多代碼。
生成的代碼就像下面這樣:
public final class BlendingKt$WhenMappings {
public static final int[] $EnumSwitchMapping$0 =
new int[BlendMode.values().length];
static {
$EnumSwitchMapping$0[BlendMode.OPAQUE.ordinal()] = 1;
$EnumSwitchMapping$0[BlendMode.TRANSPARENT.ordinal()] = 2;
$EnumSwitchMapping$0[BlendMode.FADE.ordinal()] = 3;
$EnumSwitchMapping$0[BlendMode.ADD.ordinal()] = 4;
}
}
這段代碼中生成了一個BlendingKt$WhenMappings
類。這個類里面有一個存儲映射信息的數組: $EnumSwitchMapping$0
,接下來則是一些執行映射操作的靜態代碼。
示例中是只有一個 when 語句時的情況。但如果我們寫了更多的 when 語句,每個 when 語句就會生成一個對應的數組,即使這些 when 語句都在使用同一個枚舉也一樣。
雖然所有這些開銷沒什么大不了的,但是卻也意味著,在您不知情的時候,會生成一個類,而且其中還包含了一些數組,這些都會讓類加載和實例化消耗更多的時間。
幸運的是,我們可以做一些事情來減少開銷: 這就是 R8 發揮作用的時候了。
使用 R8 來解決問題
R8 是一個有趣的優化器,它能 "看" 到與應用相關的所有內容。由于 R8 可以 "看" 到無論是您自己寫的還是您依賴的庫中的所有代碼,它便可以根據這些信息決定做哪些優化。比如,它能避免枚舉映射造成的開銷: 它不需要那些映射信息,因為它知道這些代碼只會以既定的方式使用這些枚舉,所以它可以直接調用序數值。
下面是 R8 優化過的代碼反編譯后的樣子:
public static void blend(@NotNull BlendMode b) {
switch (b.ordinal()) {
case 0: {
src();
break;
}
// ...
}
}
這樣就避免了生成類和映射數組,而且只創建了您所需的最佳代碼。
探索 R8 與 Kotlin,然后用 Kotlin 寫出更好的應用吧。
更多信息
更多 R8 相關信息,請查看以下資源:
- 官方文檔 | D8
https://developer.android.google.cn/studio/command-line/d8 - 官方文檔 | 縮減、混淆、優化您的應用
https://developer.android.google.cn/studio/build/shrink-code - Jake Wharton 的博客,詳細介紹了 D8 和 R8 的工作原理,并為各種功能提供了示例,以及如何直接運行編譯器、如何獲得反編譯的結果等
https://jakewharton.com/blog/