使用 Kotlin 重寫 AOSP 日歷應用
兩年前,Android 開源項目 (AOSP) 應用團隊開始使用 Kotlin 替代 Java 重構 AOSP 應用。之所以重構主要有兩個原因: 一是確保 AOSP 應用能夠遵循 Android 最佳實踐,另外則是提供優先使用 Kotlin 進行應用開發的良好范例。Kotlin 之所以具有強大的吸引力,原因之一是其簡潔的語法,很多情況下用 Kotlin 編寫的代碼塊的代碼數量相比于功能相同的 Java 代碼塊要更少一些。此外,Kotlin 這種具有豐富表現力的編程語言還具有其他各種優點,例如:
- 空安全: 這一概念可以說是根植于 Kotlin 之中,從而幫助避免破壞性的空指針異常;
- 并發: 正如 Google I/O 2019 中關于 Android 的描述,結構化并發 (structured concurrency) 能夠允許使用協程簡化后臺的任務管理;
- 兼容 Java: 尤其是在這次的重構項目中,Kotlin 與 Java 語言的兼容性能夠讓我們一個文件一個文件地進行 Kotlin 轉換。
AOSP 團隊在去年夏天發表了一篇文章,詳細介紹了 AOSP 桌面時鐘應用的轉換過程。而今年,我們將 AOSP 日歷應用從 Java 轉換成了 Kotlin。在這次轉換之前,應用的代碼行數超過 18,000 行,在轉換后代碼庫減少了約 300 行。在這次的轉換中,我們沿襲了同 AOSP 桌面時鐘轉換過程中類似的技術,充分利用了 Kotlin 與 Java 語言的互操作性,對代碼文件一一進行了轉換,并在過程中使用獨立的構建目標將 Java 代碼文件替換為對應的 Kotlin 代碼文件。因為團隊中有兩個人在進行此項工作,所以我們在 Android.bp 文件中為每個人創建了一個 exclude_srcs 屬性,這樣兩個人就可以在減少代碼合并沖突的前提下,都能夠同時進行重構并推送代碼。此外,這樣還能允許我們進行增量測試,快速定位錯誤出現在哪些文件。
在轉換任意給定的文件時,我們一開始先使用 Android Studio Kotlin 插件中提供的從 Java 到 Kotlin 的自動轉換工具。雖然該插件成功幫助我們轉換了大部份的代碼,但是還是會遇到一些問題,需要開發者手動解決。需要手動更改的部分,我們將會在本文接下來的章節中列出。
在將每個文件轉換為 Kotlin 之后,我們手動測試了日歷應用的 UI 界面,運行了單元測試,并運行了 Compatibility Test Suite (CTS) 的子集來進行功能驗證,以確保不需要再進行任何的回歸測試。
自動轉換之后的步驟
上面提到,在使用自動轉換工具之后,有一些反復出現的問題需要手動定位解決。在 AOSP 桌面時鐘文章中,詳細介紹了其中遇到的一些問題以及解決方法。如下列出了一些在進行 AOSP 日歷轉換過程中遇到的問題。
用 open 關鍵詞標記父類
我們遇到的問題之一是 Kotlin 父類和子類之間的相互調用。在 Kotlin 中,要將一個類標記為可繼承,必須得在類的聲明中添加 open 關鍵字,對于父類中被子類覆蓋的方法也要這樣做。但是在 Java 中的繼承是不需要使用到 open 關鍵字的。由于 Kotlin 和 Java 能夠相互調用,這個問題直到大部分代碼文件轉換到了 Kotlin 才出現。
例如,在下面的代碼片段中,聲明了一個繼承于 SimpleWeeksAdapter 的類:
- class MonthByWeekAdapter(context: Context?, params:
- HashMap<String?, Int?>) : SimpleWeeksAdapter(context as Context, params) {//方法體}
由于代碼文件的轉換過程是一次一個文件進行的,即使是完全將 SimpleWeeksAdapter.kt 文件轉換成 Kotlin,也不會在其類的聲明中出現 open 關鍵詞,這樣就會導致一個錯誤。所以之后需要手動進行 open 關鍵詞的添加,以便讓 SimpleWeeksAdapter 類可以被繼承。這個特殊的類聲明如下所示:
- open class SimpleWeeksAdapter(context: Context, params: HashMap?) {//方法體}
override 修飾符
同樣地,子類中覆蓋父類的方法也必須使用 override 修飾符來進行標記。在 Java 中,這是通過 @Override 注解來實現的。然而,雖然在 Java 中有相應的注解實現版本,但是自動轉換過程中并沒有為 Kotlin 方法聲明中添加 override 修飾符。解決的辦法是在所有適當的地方手動添加 override 修飾符。
覆寫父類中的屬性
在重構過程中,我們還遇到了一個屬性覆寫的異常問題,當一個子類聲明了一個變量,而在父類中存在一個非私有的同名變量時,我們需要添加一個 override 修飾符。然而,即使子類的變量同父類變量的類型不同,也仍然要添加 override 修飾符。在某些情況下,添加 override 仍不能解決問題,尤其是當子類的類型完全不同的時候。事實上,如果類型不匹配,在子類的變量前添加 override 修飾符,并在父類的變量前添加 open 關鍵字,會導致一個錯誤:
- type of *property name* doesn’t match the type of the overridden var-property
這個報錯很讓人疑惑,因為在 Java 中,以下代碼可以正常編譯:
- public class Parent {
- int num = 0;
- }
- class Child extends Parent {
- String num = "num";
- }
而在 Kotlin 中相應的代碼就會報上面提到的錯誤:
- class Parent {
- var num: Int = 0
- }
- class Child : Parent() {
- var num: String = "num"
- }
這個問題很有意思,目前我們通過在子類中對變量重命名來規避了這個沖突。上面的 Java 代碼會被 Android Studio 目前提供的代碼轉換器轉換為有問題的 Kotlin 代碼,這甚至被報告為是一個 bug 了。
import 語句
在我們轉換的所有文件中,自動轉換工具都傾向于將 Java 代碼中的所有 import 語句截斷為 Kotlin 文件中的第一行。最開始這導致了一些很讓人抓狂的錯誤,編譯器會在整個代碼中報 "unknown references" 的錯誤。在意識到這個問題后,我們開始手動地將 Java 中的 import 語句粘貼到 Kotlin 代碼文件中,并單獨對其進行轉換。
暴露成員變量
默認情況下,Kotlin 會自動地為類中的實例變量生成 getter 和 setter 方法。然而,有些時候我們希望一個變量僅僅只是一個簡單的 Java 成員變量,這可以通過使用 @JvmField 注解來實現。
@JvmField 注解的作用是 "指示 Kotlin 編譯器不要為這個屬性生成 getter 和 setter 方法,并將其作為一個成員變量允許其被公開訪問"。這個注解在 CalendarData 類中特別有用,它包含了兩個 static final 變量。通過對使用 val 聲明的只讀變量使用 @JvmField 注解,我們確保了這些變量可以作為成員變量被其他類訪問,從而實現了 Java 和 Kotlin 之間的兼容性。
對象中的靜態方法
在 Kotlin 對象中定義的函數必須使用 @JvmStatic 進行標記,以允許在 Java 代碼中通過方法名,而非實例化來對它們進行調用。也就是說,這個注解使其具有了類似 Java 的方法行為,即能夠通過類名調用方法。根據 Kotlin 的文檔,"編譯器會為對象的外部類生成一個靜態方法,而對于對象本身會生成一個實例方法。"我們在 Utils 文件中遇到了這個問題,當完成轉換后,Java 類就變成了 Kotlin 對象。隨后,所有在對象中定義的方法都必須使用 @JvmStatic 標記,這樣就允許在其他文件中使用 Utils.method() 這樣的語法來進行調用。值得一提的是,在類名和方法名之間使用 .INSTANCE (即 Utils.INSTANCE.method()) 也是一種選擇,但是這不太符合常見的 Java 語法,需要改變所有對 Java 靜態方法的調用。
性能評估分析
所有的基準測試都是在一臺 96 核、176 GiB 內存的機器上進行的。本項目中分析用到的主要指標有所減少的代碼行數、目標 APK 的文件大小、構建時間和首屏從啟動到顯示的時間。在對上述每個因素進行分析的同時,我們還收集了每個參數的數據并以表格的方式進行了展示。
減少的代碼行數
從 Java 完全轉換到 Kotlin 后,代碼行數從 18,004 減少到了 17,729。這比原來的 Java 代碼量減少了大約 1.5%。雖然減少的代碼量并不可觀,但對于一些大型應用來說,這種轉換對于減少代碼行數的效果可能更為顯著,可參閱 AOSP 桌面時鐘文中所舉的例子。
目標 APK 大小
使用 Kotlin 編寫的應用 APK 大小是 2.7 MB,而使用 Java 編寫的應用 APK 大小是 2.6 MB。可以說這個差異基本可以忽略不計了,由于包含了一些額外的 Kotlin 庫,所以 APK 體積上的增加,實際上是可以預期的。這種大小的增加可以通過使用 Proguard 或 R8 來進行優化。
編譯時間
Kotlin 和 Java 應用的構建時間是通過取 10 次從零進行完整構建的時間的平均值來計算的 (不包含異常值),Kotlin 應用的平均構建時間為 13 分 27 秒,而 Java 應用的平均構建時間為 12 分 6 秒。據一些資料 (如 "Java 和 Kotlin 的區別" 以及 "Kotlin 和 Java 在編譯時間上的對比") 顯示,Kotlin 的編譯時間事實上比 Java 要更耗時,特別是對于從零開始的構建。一些分析斷言,Java 的編譯速度會快 10-15%,又有一些分析稱這一數據為 15-20%。拿我們的例子進行從零開始完整構建所花費的時間來說,Java 的編譯速度比 Kotlin 快 11.2%,盡管這個微小的差異并不在上述范圍內,但這有可能是因為 AOSP 日歷是一個相對較小的應用,僅有 43 個類。盡管從零開始的完整構建比較慢,但是 Kotlin 仍然在其他方面占有優勢,這些優勢更應該被考慮到。例如,Kotlin 相對于 Java,更簡潔的語法通常可以保證較少的代碼量,這使得 Kotlin 代碼庫更易維護。此外,由于 Kotlin 是一種更為安全有效的編程語言,我們可以認為完整構建時間較慢的問題可以忽略不計。
首屏顯示的時間
我們使用了這種方法來測試應用從啟動到完全顯示首屏所需要的時間,經過 10 次試驗后我們發現,使用 Kotlin 應用的平均時間約為 197.7 毫秒,而 Java 的則為 194.9 毫秒。這些測試都是在 Pixel 3a XL 設備上進行的。從這個測試結果可以得出結論,與 Kotlin 應用相比,Java 應用可能具有微小的優勢;然而,由于平均時間非常接近,這個差異幾乎可以忽略不計。因此,可以說 AOSP 日歷應用轉換到 Kotlin,并沒有對應用的初始啟動時間產生負面影響。
結論
將 AOSP 日歷應用轉換為 Kotlin 大約花了 1.5 個月 (6 周) 的時間,由 2 名實習生負責該項目的實施。一旦我們對代碼庫更加熟悉并更加善于解決反復出現的編譯時、運行時和語法問題時,效率肯定會變得更高。總的來說,這個特殊的項目成功地展示了 Kotlin 如何影響現有的 Android 應用,并在對 AOSP 應用進行轉換的路途中邁出了堅實的一步。