QQ音樂Android端120萬行代碼,編譯耗時是怎樣優化的 1. 序言 2. 問題分析 3. 優化思路 4. 增量編譯的誕生 5. 核心原理 6. 結語

介紹QQ音樂團隊在增量編譯組件研發上的探索與實踐。
原文:QQ音樂Android編譯提速之路

1. 序言

工程編譯,是Android應用開發工作中的重要一環。而隨着工程代碼量膨脹,編譯耗時也越來越長,拖慢了開發效率。

這個問題在中大型團隊中並不少見。以QQ音樂爲例,Android工程代碼量達到120萬行以上,每修改一行代碼,都要等待4分鐘以上才能在手機上看到改動效果。

爲了應對這個問題,我們自研推出了一款增量編譯組件。經過一年時間的不斷優化,組件已經可以支撐團隊內的日常開發工作,有效提升了本地開發場景下的編譯效率

本文將會介紹QQ音樂團隊在增量編譯組件研發上的探索與實踐歷程。

2. 問題分析

本地開發過程中,我們會不斷重複 修改代碼-編譯工程-安裝APK-運行驗證 這一過程。

因此,可以從編譯與安裝兩個緯度來分析編譯慢的原因。

首先是編譯階段。

其主要流程是,先收集工程中的所有資源文件進行編譯,得到資源包以及資源索引類。隨後資源索引類會跟隨工程的所有代碼文件,一起被編譯爲字節碼文件,字節碼文件還需要被進一步編譯爲Dex文件,這樣才能被Android虛擬機所識別。

待資源包和Dex文件都準備好後,會被打包壓縮到一起,執行簽名、對齊等流程,最終完成編譯,得到一個APK安裝包。

在這個過程中,不論是資源編譯還是代碼編譯,耗時都是與待編譯的文件數量成正比的。我們在開發過程中,一般只會改動極少數的代碼文件,然後觸發編譯。理想的情況是,編譯工具應當只編譯這些被改動的文件。但是由於代碼的依賴關係,這在原生工具下很難實現。

Android Gradle Plugin自3.0版本開始,開始廢棄compile關鍵字,並引入implementation關鍵字來聲明依賴,是希望可以從module的粒度,去加快大型項目的編譯速度。不過對於一些並未拆分多module的單一工程項目來說,使用效果並不理想。

再來看安裝階段。

安裝包首先需要通過ADB工具傳輸到手機上,然後系統對其進行簽名校驗。校驗成功後,還需要進行一系列文件解壓、拷貝的操作。例如拷貝Dex文件、so文件等。

此外,如果是在系統版本爲5.0、6.0的手機上,由於系統採用了AOT機制,安裝過程中會進行預編譯,將Dex中的字節碼變成機器碼,以提高應用運行時的效率,這就導致了安裝耗時進一步被拉長。

可以看到,安裝包體積、手機系統版本,都會影響到安裝階段的耗時。

3. 優化思路

根據上述分析,主要有三類解決方案。

工欲善其事,必先利其器,首先可以嘗試對工程的構建工具鏈進行優化。

常見的方式是升級Android Gradle Plugin、Gradle等工具的版本、調整構建參數等。不過實踐後發現,他們帶來的優化效果並不理想。

當然,除了Gradle構建工具外,也可以考慮使用Facebook的Buck作爲構建工具。根據官方介紹, 它利用多模塊、多任務並行編譯的思想,可以大幅度縮短編譯耗時。

不過對於大型項目來說,要遷移構建工具,成本是極高的。目前使用的衆多插件、周邊開發工具鏈,都是基於Gradle體系的,遷移的話就會失去這些功能的支持;此外,如果工程還涉及到其他團隊、項目的協作,構建方案也是無法隨意更換的。

另外一種思路是,對工程代碼進行優化,儘可能減少參與編譯的代碼數量

這裏可以做的事情很多,比如梳理業務刪除冗餘代碼、進行多工程拆分、實施組件化(模塊化)改造等;但是,由於代碼耦合深、開發節奏緊等客觀因素的存在,代碼優化的難度通常比較大,各個方案的實施週期會比較長。所以並不能在短期內,快速解決編譯緩慢的問題

那麼,能不能提供一個編譯工具:在本地開發期間,每次僅編譯被改動過的少量代碼,而且最好可以跳過APK的安裝過程,僅推送與加載新改動的代碼。這樣就可以從編譯與安裝兩個緯度,去大幅縮減編譯耗時。

這其實就是增量編譯工具的核心思想。對於工具的接入方來說,不需要大刀闊斧地升級工具鏈或者進行工程改造,即可在較低的成本下,快速提高本地開發效率。

截止目前,業界主要有兩款方案可以參考。

Instant Run是Google推出的第一代增量編譯方案。不過在大型項目中,它帶來的提速效果並不明顯,甚至在某些場景下會讓構建時間變得更長。

首先,在Gradle 4.6以前,如果項目中使用了註解處理器,那麼每次代碼修改都要進行全量編譯。此外,若是修改的類中,包含有公有靜態常量,那麼也同樣會導致本次修改需要進行全量編譯。

Instant Run在使用過程中,有時也會遇到一些兼容性問題,但由於它是集成在Android Studio內部的,對於我們來說是一個黑盒,無法自行定位解決問題,只能被動地反饋問題與等待新版本發佈。所以綜合來看,這個方案並不合適引入。

在最新的Android Studio中,Instant Run已經被廢棄,取而代之的,是Apply Changes方案,它是基於JVMTI技術來實現的。不過僅支持 Android 8.0 或者更高版本的手機,實測在工程中帶來的提速效果也不明顯。

另一個就是阿里推出的Freeline方案了,它可以充分利用緩存文件,在幾秒鐘內迅速地對代碼的改動進行編譯並部署到設備上,提速效果十分明顯。不過它同樣存在着一些不可忽視的問題。首先是不支持Kotlin,這在Kotlin已經被谷歌官宣爲Android開發首選語言的今天,是比較致命的。另外,不支持刪除帶id的資源,否則可能導致資源編譯流程出錯。

另外一個潛在的問題是,爲了確保編譯速度,Freeline是犧牲了一部分正確性的。例如,在改動公有靜態常量的時候,只會編譯對應的類文件,而引用到該常量的其他類,並不會參與編譯的。由於常量內聯優化的存在,就可能導致這些類在運行時,使用的仍然是舊的值,進而出現改動不生效的問題。

綜合上述,目前業界已有的解決方案,並不能滿足我們的需求。所以在2019年初,我們開啓了增量編譯組件的自研之路。

4. 增量編譯的誕生

在2019年6月份,增量編譯組件完成了首版開發,開始正式接入QQ音樂工程。

接入後,對於本地開發的提速效果是比較明顯的。據團隊實際數據統計,進行一次全量編譯的耗時約爲418秒,而增量編譯單次耗時僅需13秒。以天爲單位計算,每個人花在工程編譯上的總時長,由3.95小時,降低至了1.02小時,效率提升達到74%

增量編譯組件完全基於Gradle標準,實現爲一個Gradle插件,具備良好的多平臺兼容性,而且對於目標工程的侵入性極低。使用者只需要接入我們的Gradle插件,即可通過執行特定的Gradle任務,進入增量編譯模式。

在功能的支持上,組件支持Java、Kotlin等代碼文件以及所有類型資源文件的快速編譯。在今年年初,加入了DataBinding的增量支持。而且,爲了進一步減少使用成本,我們還在最新版本中提供了配套的Android Studio插件,開發者可以通過可視化的方式,更方便的使用組件功能。

下圖描述了組件的整體原理,我們將開發週期分爲編譯期和運行期。

首次編譯(亦可稱全量編譯),需要完整編譯工程,得到原始安裝包,耗時與原生的打包任務持平。後續再觸發編譯,將會進入耗時極短的增量編譯模式,組件會負責收集改動過的代碼進行編譯,得到增量產物,並推送到手機上。

運行期則負責將手機上的增量產物進行動態加載運行。

在本文的後續內容中,將介紹幾個重點模塊的實現。

5. 核心原理

代碼編譯

(1)獲取改動文件並進行編譯

首先需要考慮的問題是,如何識別出用戶改動了哪些文件?

我們的做法是,在每次編譯成功後,收集所有工程文件的最後修改時間,保存爲一份文件快照。在下次編譯開始時,組件會生成最新的文件快照,與上一次的文件快照進行比對,就可以收集到用戶改動過的文件了。

爲了能夠單獨編譯這些文件,還需要解決類引用的問題。

在首次完整編譯工程時,組件會收集所有生成的class文件,放到緩存目錄中。在編譯被改動的文件時,會調用原生的javac或者是kotlinc程序,將剛纔的緩存目錄作爲classpath傳遞進去,就可以解決編譯時代碼引用的問題了。

(2)進行代碼依賴分析

上文中,提供classpath可以使編譯階段成功執行,卻無法確保運行期的代碼邏輯是正確的。舉個例子,某個類修改了某個方法的參數列表,那麼除了這個類需要被編譯外,依賴這個類的其他類,也是需要重新編譯的。否則,就會在運行期,出現NoSuchMethodException。

因此,由於代碼之間相互依賴關係的存在,僅僅收集被用戶改動的代碼來編譯,是不夠的。還可能需要找出它的子依賴集,納入編譯範圍。

沿着這個思路,還需要考慮兩個問題:

  • 如何得到改動類的變化類型? 修改方法內部實現等類型的改動,是不會影響到其子依賴集的。在確保編譯正確的前提下,爲了儘可能地減少參與編譯的代碼數量,我們需要得到被改動類的變化類型,才能夠決定是否需要將其子依賴集重新進行編譯。
  • 如何得到改動類的子依賴集? 這個很好理解,只有計算出某個類的子依賴集,組件才能知道要編譯什麼。

想獲取這兩項信息,都需要對類的內部結構進行分析,提取出類名、類的修飾符、成員變量、方法等數據。我們的做法是,引入ASM工具對class文件進行解析,然後將解析出來的信息,保存到自定義的ResolvedClass數據結構中。

接下來的解決方案是這樣的:

  1. 在全量編譯期間,組件會同步啓動一個獨立的進程,對所有的class文件進行遍歷分析,得到對應的ResolvedClass信息,並保存在本地文件中。其中,如果發現某個類引用了另一個類,那麼就會把當前類的類名,添加到被引用類的子依賴集列表中(resolvedBy字段)。

  2. 觸發增量編譯後,組件首先編譯改動類,得到新的class文件。然後啓動代碼依賴分析流程,解析出新的ResolvedClass,將其與全量編譯期解析出來的舊ResolvedClass進行比對,就可以得到這個類的改動類型了。

當發現當前類的改動類型在下表中,組件纔會獲取其子依賴集,啓動第二輪編譯,得到子依賴集對應的class文件。

通過上面的方式,我們在確保編譯正確的前提下,儘可能地減少了需要編譯的代碼數量。

隨後,增量編譯期間生成的所有class文件,會被dx工具進一步地編譯爲Dex文件,然後通過ADB推送到手機上,等待被動態加載。

資源編譯

(1)資源增量

這一塊的基本思路,與代碼增量是類似的。即先收集被改動的資源,然後進行編譯。

原生的資源編譯流程主要採用的是aapt,或者是aapt2 。

一開始,我們工程使用的仍然是aapt,基於它去資源增量的難度相對較大。因爲aapt工具是不支持單個資源編譯的。Freeline通過修改aapt的源碼,實現了單個資源的增量功能。不過他們的這部分方案沒有開源,並且改動後仍然不支持帶ID資源的刪除,所以沒有考慮在組件中引入。

再來看看aapt2。與aapt最大的不同在於,它是天然支持單個資源編譯的。其內部把資源的打包分成了 編譯(compile)與鏈接(link) 兩步,在編譯階段,負責將單個或者多個資源編譯爲二進制文件;鏈接階段,則負責合併所有二進制文件再打包。

於是,我們首先升級工程的工具鏈,引入了aapt2,然後組件也基於此重新設計了資源增量方案。

在工程首次編譯結束之後,組件會將所有編譯好的資源二進制文件都收集到一個緩存目錄中。後續改動資源時,會先調用aapt2的編譯功能,將改動的資源編譯成爲二進制文件。然後將新的二進制文件拷貝到資源緩存目錄中,覆蓋掉同名文件。

接着,會針對這個目錄,採用aapt2的鏈接功能,打包生成最後的增量資源包,並推送到手機上,等待被動態加載。

通過這樣改造後,QQ音樂工程中資源增量編譯階段的耗時,由原來的32秒降低到了12秒,效率得到進一步提升。

(2)資源ID固定

資源編譯過程中,有一個文件是需要特別關注的:R.java文件。

爲了讓開發者能夠在代碼中引用資源,資源編譯器會在編譯的過程中,爲每一個資源分配索引ID,並以公有靜態常量的方式保存在R.java文件中。開發者只需要在代碼中通過R.color.text等形式,即可引用到對應的資源。

而編譯器編譯源代碼時,如果發現某處代碼引用了常量(同時使用static和final兩個關鍵字來修飾),且該常量爲字面值形式的原始數據類型或字符串時,編譯器就會將此處的常量引用替換爲常量值

也就是說,代碼中類似R.color.text的引用,在class文件中都會被替換成爲對應的數字。

資源編譯的過程中,資源是按照名稱排序後,按序遞增分配索引的。如果新增或者刪除資源,會導致其後續資源的索引出現錯位。

在這種場景下,如果某個類引用到索引變化了的資源,就需要重新參與編譯。否則,就會在運行時遇到資源引用錯亂的問題。

但是這就會導致大量的類需要在增量過程中參與編譯,和我們的初衷是相違背的。

所以,需要將R.java中的ID進行固定。簡單來說,就是使得兩次編譯之間,對於同一個資源,分配到的ID是不變的。其實在熱修復場景下,也具有相同的訴求。對於補丁包,是有嚴格的大小要求的。如果我們要對資源進行熱修復,不可能把所有用到該資源的代碼都重新編譯納入補丁包中下發,所以也需要進行資源ID固定。

相對應的解決方案也是業界比較通用的。若嘗試輸出aapt2命令行工具的幫助文檔,可以發現有兩個參數:

  • --stable-ids: File containing a list of name to ID mapping.
  • --emit-ids : Emit a file at the given path with a list of name to ID mappings, suitable for use with --stable-ids.

因此,我們可以在編譯資源的時候,給aapt2注入emit-ids參數,在指定文件中輸出資源名稱到資源ID之間的映射關係。並在下次啓動aapt2時,通過stable-ids傳入剛纔的映射關係,達到資源ID固定的效果。

動態加載

(1)代碼注入

編譯完成後,可以得到若干個增量Dex包,並推送到手機的特定目錄下。

那麼在運行期,我們需要做的,是干涉原生的類加載流程,使被改動的代碼優先被加載,達到改動生效的目的。

先來看看Android原生的類加載流程。

在應用程序啓動後,會採用名爲PathClassLoader的類加載器,去加載安裝包中的Dex文件。需要加載某個類的時候,系統會從前往後依次遍歷Dex數組,直到找到對應的類。

基於此,增量組件會在應用啓動的時候,將增量Dex文件,通過反射手段插入Dex數組的最前面。後續需要加載某個類的時候,由於系統機制會從前往後遍歷,因此會優先從增量的Dex中查找並命中改動後的類。需要說明的是,所有增量的Dex,會按照生成的時間,倒序插入到Dex數組中,如inc_3.dex、inc_2.dex、inc_1.dex,這樣就可以確保一個類被多次增量修改後,被加載到的總是其最新實現。

類改動不生效問題的處理

在第一個版本發佈後,我們收到同事的反饋,在Android 7.0或者更高版本的系統上,會偶現代碼改動不生效的問題。經過分析,可以確保增量的代碼是編譯成功的,問題是出現在運行時類加載階段。

這是由於從Android 7.0開始,虛擬機的代碼編譯策略,發生了變化。

Dex中的指令,首先需要被翻譯成爲機器碼,才能被執行。隨着系統版本的更迭,對於 Dex字節碼的編譯策略,也有着不同的表現。

在5.0以下的系統中,使用的是Dalvik虛擬機。在應用運行時,每當遇到一個新類,JIT編譯器就會對這個類進行即時編譯,經過編譯後的代碼,會被優化成相當精簡的原生型指令碼,這樣在下次執行到相同邏輯的時候,速度會更快。不過由於編譯工作是在應用運行過程中進行的,且沒有緩存,這就使得應用啓動速度較慢,運行效率受到影響,而且耗電較多。

因此,在Android 5.0開始,Google採用ART虛擬機來替代了Dalvik虛擬機。和Dalvik最大的區別在於,ART虛擬機採用的是AOT提前編譯機制。系統在安裝應用的時候,會使用自帶的dex2oat工具,把安裝包中的所有Dex文件進行一次預編譯,生成一個可以在本地機器上運行的oat文件。這樣後續應用每次運行時,就不需要執行編譯了,應用的啓動與運行的效率也得到了極大的提升。但是AOT每次執行的時間太長了,給用戶直觀感受就是安裝極慢。

所以,從Android 7.0開始,採用了Hybrid Mode的ART虛擬機,它同時支持Interpreter、JIT、AOT三種模式。他們的交替使用,可以達到安裝時間、內存佔用、電池消耗和性能之間最好的平衡。

在應用運行時,虛擬機會先使用Interpreter去解釋與執行代碼。如果發現熱點函數,會啓用JIT編譯器,並將編譯結果存儲在本地profile文件中;當Android設備空閒或者是充電時,系統會在後臺定期針對profile文件執行AOT編譯,得到一份“熱代碼”;

在下一次應用重啓時,系統會將編譯好的熱代碼,一次性地插入到類加載器的緩存ClassTable中。後續類加載的過程中,會先從ClassTable中尋找是否有緩存,有的話則直接返回,跳過後續的類查找流程。

到這裏,我們就可以解釋,爲什麼混合編譯會引起偶現的增量代碼改動不生效問題了。

若要加載增量改動過的A類,會分爲兩種情況:

  1. 熱代碼中不包含A類:這種情況是比較理想的,系統由於在ClassTable中無法命中,就會到增量Dex中查找A類,此時增量代碼是可以生效的。
  2. 熱代碼中包含A類:系統在類加載過程中,會在ClassTable中優先命中改動前的A類,從而導致增量不生效的問題。

針對這個問題,Tinker的解決方案是,首先複製原生類加載器的Dex數組,去完全新建一個自定義的類加載器。然後把應用進程引用的所有類加載器,都指向自定義的類加載器,負責後續的所有類加載以及補丁代碼注入行爲。

因爲熱代碼不會被插入到自定義類加載器的ClassTable緩存中,因此後續的補丁代碼加載,就不會受到熱代碼干擾,可以正常生效了。

不過,增量編譯組件是面向本地開發的debug包,所以,也可以採用更爲簡單的方案:由組件自動在AndroidManifest.xml中指定android:vmSafeMode="true" 即可。這個開關會停用AOT編譯器。熱代碼不能生成,也就不會遇到上述問題了。

(2)資源注入

資源的動態加載則相對簡單。主要是參考Instant Run,通過反射調用AssetsManager的addAssets方法,將增量資源包加載到內存中來,得到新的Resources對象,然後替換掉ActivityThread等所有持有Resources的地方即可。這也是大部分熱修復框架中的基本思路。

6. 結語

回顧增量編譯組件的實踐之路,其實是對於Android應用編譯、熱修復、字節碼插樁、Gradle等技術的綜合運用。對於大型工程說,可以快速低成本的實現本地開發效率的提升。

同時,對於編譯速度的優化,我們還有幾個建議。首先是建議及時升級最新的編譯工具鏈,沿用官方最新的優化成果。並使用Gradle提供的profile構建分析工具,進行鍼對性的任務分析,解決腳本中一些不合理的耗時。同時,也建議同步進行模塊化改造,進行代碼分拆等。這一步持續的時間可能較長,但是後期收益不僅僅是編譯效率上的提升,還有業務模塊級別的代碼複用能力提升。

目前組件已經接入QQ音樂、全民K歌等團隊中應用,並已在公司範圍內進行開源。增量編譯組件還有部分特性需要進一步開發。如四大組件增量支持、Module增量支持等。同時,我們也正在通過實際開發工作場景中暴露出來的問題,不斷去優化組件。

待進一步完善後,將會執行組件外部開源計劃。我們期望在開源後,可以幫助更多有需要的團隊,能夠做到無縫集成,無需考慮細節實現,即可輕鬆提升開發效率。

筆記

【360°全方位性能調優】

這份筆記我將Android-360°全方位性能優化知識點,以及微信、淘寶、抖音、頭條、高德地圖、優酷等等億萬級用戶APP在性能優化方面的實踐經驗,整合成了一套系統的知識筆記PDF,從理論到實踐,涉及Android性能優化的所有知識點,長達721頁電子書!相信看完這份文檔,你會對Android性能調優知識體系及各種方案有更系統、更深入的理解。

需要的小夥伴點贊+關注後我的GitHub即可直接免費下載獲取~

文末

感謝大家關注我,分享Android乾貨,交流Android技術。
對文章有何見解,或者有何技術問題,都可以在評論區一起留言討論,我會虔誠爲你解答。
也歡迎大家來我的B站找我玩,有各類Android架構師進階技術難點的視頻講解,助你早日升職加薪。
B站直通車:https://space.bilibili.com/544650554

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章