介紹QQ音樂團隊在增量編譯組件研發上的探索與實踐。java
工程編譯,是Android應用開發工做中的重要一環。而隨着工程代碼量膨脹,編譯耗時也愈來愈長,拖慢了開發效率。android
這個問題在中大型團隊中並很多見。以QQ音樂爲例,Android工程代碼量達到120萬行以上,每修改一行代碼,都要等待4分鐘以上才能在手機上看到改動效果。數組
爲了應對這個問題,咱們自研推出了一款增量編譯組件。通過一年時間的不斷優化,組件已經能夠支撐團隊內的平常開發工做,有效提高了本地開發場景下的編譯效率。緩存
本文將會介紹QQ音樂團隊在增量編譯組件研發上的探索與實踐歷程。markdown
本地開發過程當中,咱們會不斷重複 修改代碼-編譯工程-安裝APK-運行驗證 這一過程。數據結構
所以,能夠從編譯與安裝兩個緯度來分析編譯慢的緣由。app
首先是編譯階段。框架
其主要流程是,先收集工程中的全部資源文件進行編譯,獲得資源包以及資源索引類。隨後資源索引類會跟隨工程的全部代碼文件,一塊兒被編譯爲字節碼文件,字節碼文件還須要被進一步編譯爲Dex文件,這樣才能被Android虛擬機所識別。模塊化
待資源包和Dex文件都準備好後,會被打包壓縮到一塊兒,執行簽名、對齊等流程,最終完成編譯,獲得一個APK安裝包。函數
在這個過程當中,不管是資源編譯仍是代碼編譯,耗時都是與待編譯的文件數量成正比的。咱們在開發過程當中,通常只會改動極少數的代碼文件,而後觸發編譯。理想的狀況是,編譯工具應當只編譯這些被改動的文件。可是因爲代碼的依賴關係,這在原生工具下很難實現。
Android Gradle Plugin自3.0版本開始,開始廢棄compile關鍵字,並引入implementation關鍵字來聲明依賴,是但願能夠從module的粒度,去加快大型項目的編譯速度。不過對於一些並未拆分多module的單一工程項目來講,使用效果並不理想。
再來看安裝階段。
安裝包首先須要經過ADB工具傳輸到手機上,而後系統對其進行簽名校驗。校驗成功後,還須要進行一系列文件解壓、拷貝的操做。例如拷貝Dex文件、so文件等。
此外,若是是在系統版本爲5.0、6.0的手機上,因爲系統採用了AOT機制,安裝過程當中會進行預編譯,將Dex中的字節碼變成機器碼,以提升應用運行時的效率,這就致使了安裝耗時進一步被拉長。
能夠看到,安裝包體積、手機系統版本,都會影響到安裝階段的耗時。
根據上述分析,主要有三類解決方案。
工欲善其事,必先利其器,首先能夠嘗試對工程的構建工具鏈進行優化。
常見的方式是升級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年初,咱們開啓了增量編譯組件的自研之路。
在2019年6月份,增量編譯組件完成了首版開發,開始正式接入QQ音樂工程。
接入後,對於本地開發的提速效果是比較明顯的。據團隊實際數據統計,進行一次全量編譯的耗時約爲418秒,而增量編譯單次耗時僅需13秒。以天爲單位計算,每一個人花在工程編譯上的總時長,由3.95小時,下降至了1.02小時,效率提高達到74%。
增量編譯組件徹底基於Gradle標準,實現爲一個Gradle插件,具有良好的多平臺兼容性,並且對於目標工程的侵入性極低。使用者只須要接入咱們的Gradle插件,便可經過執行特定的Gradle任務,進入增量編譯模式。
在功能的支持上,組件支持Java、Kotlin等代碼文件以及全部類型資源文件的快速編譯。在今年年初,加入了DataBinding的增量支持。並且,爲了進一步減小使用成本,咱們還在最新版本中提供了配套的Android Studio插件,開發者能夠經過可視化的方式,更方便的使用組件功能。
下圖描述了組件的總體原理,咱們將開發週期分爲編譯期和運行期。
首次編譯(亦可稱全量編譯),須要完整編譯工程,獲得原始安裝包,耗時與原生的打包任務持平。後續再觸發編譯,將會進入耗時極短的增量編譯模式,組件會負責收集改動過的代碼進行編譯,獲得增量產物,並推送到手機上。
運行期則負責將手機上的增量產物進行動態加載運行。
在本文的後續內容中,將介紹幾個重點模塊的實現。
首先須要考慮的問題是,如何識別出用戶改動了哪些文件?
咱們的作法是,在每次編譯成功後,收集全部工程文件的最後修改時間,保存爲一份文件快照。在下次編譯開始時,組件會生成最新的文件快照,與上一次的文件快照進行比對,就能夠收集到用戶改動過的文件了。
爲了可以單獨編譯這些文件,還須要解決類引用的問題。
在首次完整編譯工程時,組件會收集全部生成的class文件,放到緩存目錄中。在編譯被改動的文件時,會調用原生的javac或者是kotlinc程序,將剛纔的緩存目錄做爲classpath傳遞進去,就能夠解決編譯時代碼引用的問題了。
上文中,提供classpath可使編譯階段成功執行,卻沒法確保運行期的代碼邏輯是正確的。舉個例子,某個類修改了某個方法的參數列表,那麼除了這個類須要被編譯外,依賴這個類的其餘類,也是須要從新編譯的。不然,就會在運行期,出現NoSuchMethodException。
所以,因爲代碼之間相互依賴關係的存在,僅僅收集被用戶改動的代碼來編譯,是不夠的。還可能須要找出它的子依賴集,歸入編譯範圍。
沿着這個思路,還須要考慮兩個問題:
想獲取這兩項信息,都須要對類的內部結構進行分析,提取出類名、類的修飾符、成員變量、方法等數據。咱們的作法是,引入ASM工具對class文件進行解析,而後將解析出來的信息,保存到自定義的ResolvedClass數據結構中。
接下來的解決方案是這樣的:
在全量編譯期間,組件會同步啓動一個獨立的進程,對全部的class文件進行遍歷分析,獲得對應的ResolvedClass信息,並保存在本地文件中。其中,若是發現某個類引用了另外一個類,那麼就會把當前類的類名,添加到被引用類的子依賴集列表中(resolvedBy字段)。
觸發增量編譯後,組件首先編譯改動類,獲得新的class文件。而後啓動代碼依賴分析流程,解析出新的ResolvedClass,將其與全量編譯期解析出來的舊ResolvedClass進行比對,就能夠獲得這個類的改動類型了。
當發現當前類的改動類型在下表中,組件纔會獲取其子依賴集,啓動第二輪編譯,獲得子依賴集對應的class文件。
經過上面的方式,咱們在確保編譯正確的前提下,儘量地減小了須要編譯的代碼數量。
隨後,增量編譯期間生成的全部class文件,會被dx工具進一步地編譯爲Dex文件,而後經過ADB推送到手機上,等待被動態加載。
這一塊的基本思路,與代碼增量是相似的。即先收集被改動的資源,而後進行編譯。
原生的資源編譯流程主要採用的是aapt,或者是aapt2 。
一開始,咱們工程使用的仍然是aapt,基於它去資源增量的難度相對較大。由於aapt工具是不支持單個資源編譯的。Freeline經過修改aapt的源碼,實現了單個資源的增量功能。不過他們的這部分方案沒有開源,而且改動後仍然不支持帶ID資源的刪除,因此沒有考慮在組件中引入。
再來看看aapt2。與aapt最大的不一樣在於,它是自然支持單個資源編譯的。其內部把資源的打包分紅了 編譯(compile)與連接(link) 兩步,在編譯階段,負責將單個或者多個資源編譯爲二進制文件;連接階段,則負責合併全部二進制文件再打包。
因而,咱們首先升級工程的工具鏈,引入了aapt2,而後組件也基於此從新設計了資源增量方案。
在工程首次編譯結束以後,組件會將全部編譯好的資源二進制文件都收集到一個緩存目錄中。後續改動資源時,會先調用aapt2的編譯功能,將改動的資源編譯成爲二進制文件。而後將新的二進制文件拷貝到資源緩存目錄中,覆蓋掉同名文件。
接着,會針對這個目錄,採用aapt2的連接功能,打包生成最後的增量資源包,並推送到手機上,等待被動態加載。
經過這樣改造後,QQ音樂工程中資源增量編譯階段的耗時,由原來的32秒下降到了12秒,效率獲得進一步提高。
資源編譯過程當中,有一個文件是須要特別關注的:R.java文件。
爲了讓開發者可以在代碼中引用資源,資源編譯器會在編譯的過程當中,爲每個資源分配索引ID,並以公有靜態常量的方式保存在R.java文件中。開發者只須要在代碼中經過R.color.text等形式,便可引用到對應的資源。
而編譯器編譯源代碼時,若是發現某處代碼引用了常量(同時使用static和final兩個關鍵字來修飾),且該常量爲字面值形式的原始數據類型或字符串時,編譯器就會將此處的常量引用替換爲常量值。
也就是說,代碼中相似R.color.text的引用,在class文件中都會被替換成爲對應的數字。
資源編譯的過程當中,資源是按照名稱排序後,按序遞增分配索引的。若是新增或者刪除資源,會致使其後續資源的索引出現錯位。
在這種場景下,若是某個類引用到索引變化了的資源,就須要從新參與編譯。不然,就會在運行時遇到資源引用錯亂的問題。
可是這就會致使大量的類須要在增量過程當中參與編譯,和咱們的初衷是相違背的。
因此,須要將R.java中的ID進行固定。簡單來講,就是使得兩次編譯之間,對於同一個資源,分配到的ID是不變的。其實在熱修復場景下,也具備相同的訴求。對於補丁包,是有嚴格的大小要求的。若是咱們要對資源進行熱修復,不可能把全部用到該資源的代碼都從新編譯歸入補丁包中下發,因此也須要進行資源ID固定。
相對應的解決方案也是業界比較通用的。若嘗試輸出aapt2命令行工具的幫助文檔,能夠發現有兩個參數:
所以,咱們能夠在編譯資源的時候,給aapt2注入emit-ids參數,在指定文件中輸出資源名稱到資源ID之間的映射關係。並在下次啓動aapt2時,經過stable-ids傳入剛纔的映射關係,達到資源ID固定的效果。
編譯完成後,能夠獲得若干個增量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類,會分爲兩種狀況:
針對這個問題,Tinker的解決方案是,首先複製原生類加載器的Dex數組,去徹底新建一個自定義的類加載器。而後把應用進程引用的全部類加載器,都指向自定義的類加載器,負責後續的全部類加載以及補丁代碼注入行爲。
由於熱代碼不會被插入到自定義類加載器的ClassTable緩存中,所以後續的補丁代碼加載,就不會受到熱代碼干擾,能夠正常生效了。
不過,增量編譯組件是面向本地開發的debug包,因此,也能夠採用更爲簡單的方案:由組件自動在AndroidManifest.xml中指定android:vmSafeMode="true" 便可。這個開關會停用AOT編譯器。熱代碼不能生成,也就不會遇到上述問題了。
資源的動態加載則相對簡單。主要是參考Instant Run,經過反射調用AssetsManager的addAssets方法,將增量資源包加載到內存中來,獲得新的Resources對象,而後替換掉ActivityThread等全部持有Resources的地方便可。這也是大部分熱修復框架中的基本思路。
回顧增量編譯組件的實踐之路,實際上是對於Android應用編譯、熱修復、字節碼插樁、Gradle等技術的綜合運用。對於大型工程說,能夠快速低成本的實現本地開發效率的提高。
同時,對於編譯速度的優化,咱們還有幾個建議。首先是建議及時升級最新的編譯工具鏈,沿用官方最新的優化成果。並使用Gradle提供的profile構建分析工具,進行鍼對性的任務分析,解決腳本中一些不合理的耗時。同時,也建議同步進行模塊化改造,進行代碼分拆等。這一步持續的時間可能較長,可是後期收益不只僅是編譯效率上的提高,還有業務模塊級別的代碼複用能力提高。
目前組件已經接入QQ音樂、全民K歌等團隊中應用,並已在公司範圍內進行開源。增量編譯組件還有部分特性須要進一步開發。如四大組件增量支持、Module增量支持等。同時,咱們也正在經過實際開發工做場景中暴露出來的問題,不斷去優化組件。
待進一步完善後,將會執行組件外部開源計劃。咱們指望在開源後,能夠幫助更多有須要的團隊,可以作到無縫集成,無需考慮細節實現,便可輕鬆提高開發效率。
QQ音樂招聘Android/iOS客戶端開發,點擊 這裏 投遞簡歷~
也可將簡歷發送至郵箱:tmezp@tencent.com