今天就不推薦了,因為本篇文章非常長,也有一定的深度,但是真的是一篇好文,值得細細學習~~
本文由typ0520投稿。
typ0520的博客地址:
http://www.jianshu.com/u/99c659a8bb36
公司的項目代碼比較多,每次調試改動java文件後要將近2分鐘才能跑起來,實在受不了。
在網上找了一大堆配置參數也沒有很明顯的效果, 嘗試使用instant run效果也不怎麼樣,然後又嘗試使用freeline編譯速度還可以但是不穩定,每次失敗後全量編譯很耗費時間,既然沒有好的方案就自己嘗試做。
項目地址:
https://github.com/typ0520/fastdex
優化構建速度首先需要找到那些環節導致構建速度這麼慢,把下面的代碼放進app/build.gradle裡把時間花費超過50ms的任務時間列印出來
執行./gradlew assembleDebug,經過漫長的等待得到以下輸出
從上面的輸出可以發現總的構建時間為100秒左右(上面的輸出不是按照真正的執行順序輸出的),transformClassesWithDexForDebug任務是最慢的耗費了65秒,它就是我們需要重點優化的任務。
首先講下構建過程中主要任務的作用,方便理解後面的hook點
mergeDebugResources的作用
解壓所有的aar包輸出到app/build/intermediates/exploded-aar,並且把所有的資源文件合併到app/build/intermediates/res/merged/debug目錄裡;
processDebugManifest的作用
把所有aar包裡的AndroidManifest.xml中的節點,合併到項目的AndroidManifest.xml中,並根據app/build.gradle中當前buildType的manifestPlaceholders配置內容替換manifest文件中的佔位符,最後輸出到app/build/intermediates/manifests/full/debug/AndroidManifest.xml
processDebugResources的作用
調用aapt生成項目和所有aar依賴的R.java,輸出到app/build/generated/source/r/debug目錄
生成資源索引文件app/build/intermediates/res/resources-debug.ap_
把符號表輸出到app/build/intermediates/symbols/debug/R.txt
compileDebugJavaWithJavac的作用
這個任務是用來把java文件編譯成class文件,輸出的路徑是app/build/intermediates/classes/debug
編譯的輸入目錄有
項目源碼目錄,默認路徑是app/src/main/java,可以通過sourceSets的dsl配置,允許有多個(列印project.android.sourceSets.main.java.srcDirs可以查看當前所有的源碼路徑,具體配置可以參考android-doc(http://tools.android.com/tech-docs/new-build-system/user-guide#TOC-Sourcesets-and-Dependencies)
app/build/generated/source/aidl
app/build/generated/source/buildConfig
app/build/generated/source/apt(繼承javax.annotation.processing.AbstractProcessor做動態代碼生成的一些庫,輸出在這個目錄,具體可以參考Butterknife 和 Tinker的代碼
transformClassesWithJarMergingForDebug的作用
把compileDebugJavaWithJavac任務的輸出app/build/intermediates/classes/debug,和app/build/intermediates/exploded-aar中所有的classes.jar和libs裡的jar包作為輸入,合併起來輸出到app/build/intermediates/transforms/jarMerging/debug/jars/1/1f/combined.jar,我們在開發中依賴第三方庫的時候有時候報duplicate entry:xxx 的錯誤,就是因為在合併的過程中在不同jar包裡發現了相同路徑的類
transformClassesWithMultidexlistForDebug的作用
這個任務花費的時間也很長將近8秒,它有兩個作用
掃描項目的AndroidManifest.xml文件和分析類之間的依賴關係,計算出那些類必須放在第一個dex裡面,最後把分析的結果寫到app/build/intermediates/multi-dex/debug/maindexlist.txt文件裡面
生成混淆配置項輸出到app/build/intermediates/multi-dex/debug/manifest_keep.txt文件裡
項目裡的代碼入口是manifest中application節點的屬性android.name配置的繼承自Application的類,在android5.0以前的版本系統只會加載一個dex(classes.dex),classes2.dex ..classesN.dex 一般是使用android.support.multidex.MultiDex加載的,所以如果入口的Application類不在classes.dex裡5.0以下肯定會掛掉,另外當入口Application依賴的類不在classes.dex時初始化的時候也會因為類找不到而掛掉,還有如果混淆的時候類名變掉了也會因為對應不了而掛掉,綜上所述就是這個任務的作用
transformClassesWithDexForDebug的作用
這個任務的作用是把包含所有class文件的jar包轉換為dex,class文件越多轉換的越慢
輸入的jar包路徑是app/build/intermediates/transforms/jarMerging/debug/jars/1/1f/combined.jar
輸出dex的目錄是build/intermediates/transforms/dex/debug/folders/1000/1f/main
注意編寫gradle插件時如果需要使用上面這些路徑不要硬編碼的方式寫死,最好從Android gradle api中去獲取路徑,防止以後發生變化
結合上面的這些信息重點需要優化的是transformClassesWithDexForDebug這個任務,我的思路是:
第一次全量打包執行完transformClassesWithDexForDebug任務後把生成的dex緩存下來,並且在執行這個任務前對當前所有的java源文件做快照,以後補丁打包的時候通過當前所有的java文件信息和之前的快照做對比,找出變化的java文件進而得到那些class文件發生變化,然後把app/build/intermediates/transforms/jarMerging/debug/jars/1/1f/combined.jar中沒有變化的class移除掉,僅把變化class送去生成dex,然後選擇一種熱修複方案把這個dex當做補丁dex加載進來,有思路了後面就是攻克各個技術點。
(1)如何拿到transformClassesWithDexForDebug任務執行前後的生命周期參考了Tinker項目的代碼,找到下面的實現
把上面的代碼放進app/build.gradle執行./gradlew assembleDebug
從上面的日誌輸出證明這個hook點是有效的,在全量打包時執行transform前可以對java源碼做快照,執行完以後把dex緩存下來;在補丁打包執行transform之前對比快照移除沒有變化的class,執行完以後合併緩存的dex放進dex輸出目錄
執行下面的代碼可以獲取所有的項目源碼目錄
project.android.sourceSets.main.java.srcDirs.each { srcDir-> println("==srcDir: ${srcDir}")}
sample工程沒有配置sourceSets,因此輸出的是app/src/main/java
給源碼目錄做快照,直接通過文件複製的方式,把所有的srcDir目錄下的java文件複製到快照目錄下
(這裡有個坑,不要使用project.copy {}它會使文件的lastModified值發生變化,直接使用流copy並且要用源文件的lastModified覆蓋目標文件的lastModified)
通過java文件的長度和上次修改時間兩個要素對比可以得知同一個文件是否發生變化,通過快照目錄沒有某個文件而當前目錄有某個文件可以得知增加了文件,通過快照目錄有某個文件但是當前目錄沒有可以得知刪除文件(為了效率可以不處理刪除,僅造成緩存裡有某些用不到的類而已)
舉個例子來說假如項目源碼的路徑為/Users/tong/fastdex/app/src/main/java,做快照時把這個目錄複製到/Users/tong/fastdex/app/build/fastdex/snapshoot下,當前快照裡的文件樹為
com└── dx168 └── fastdex └── sample ├── CustomView.java ├── MainActivity.java └── SampleApplication.java
如果當前源碼路徑的內容發生變化,當前的文件樹為
com└── dx168 └── fastdex └── sample ├── CustomView.java ├── MainActivity.java(內容已經被修改) ├── New.java └── SampleApplication.java
通過文件遍歷對比可以得到這個變化的相對路徑列表
通過這個列表進而可以得知變化的class有
但是java文件編譯的時候如果有內部類還會有其它的一些class輸出,比如拿R文件做下編譯,它的編譯輸出如下
另外如果使用了butterknife,還會生成binder類,比如編譯MainActivity.java時生成了
com/dx168/fastdex/sample/MainActivity$$ViewBinder.class
結合上面幾點可以獲取所有變化class的匹配模式
com/dx168/fastdex/sample/MainActivity.class
com/dx168/fastdex/sample/MainActivity$*.class
com/dx168/fastdex/sample/New.class
com/dx168/fastdex/sample/New$*.class
有了上面的匹配模式就可以在補丁打包執行transform前把app/build/intermediates/transforms/jarMerging/debug/jars/1/1f/combined.jar中沒有變化的class全部移除掉
然後就可以使用patchJar作為輸入jar生成補丁dex
注: 這種映射方案如果開啟了混淆就對應不上了,需要解析混淆以後產生的mapping文件才能解決,不過我們也沒有必要在開啟混淆的buildType下做開發開發調試,所以暫時可以不做這個事情
(3)熱修複方案的選擇
有了補丁dex,就可以選擇一種熱修複方案把補丁dex加載進來,這裡方案有好幾種,為了簡單直接選擇android.support.multidex.MultiDex以dex插樁的方式來加載,只需要把dex按照google標準(classes.dex、classes2.dex、classesN.dex)排列好就行了,這裡有兩個技術點
由於patch.dex和緩存下來dex裡面有重複的類,當加載引用了重複類的類時會造成pre-verify的錯誤,具體請參考QQ空間團隊寫的安卓App熱補丁動態修復技術介紹(https://mp.weixin.qq.com/s?__biz=MzI1MTA1MzM2Nw==&mid=400118620&idx=1&sn=b4fdd5055731290eef12ad0d17f39d4a)
這篇文章詳細分析了造成pre-verify錯誤的原因,文章裡給的解決方案是往所有引用被修復類的類中插入一段代碼,並且被插入的這段代碼所在的類的dex必須是一個單獨的dex,這個dex我們事先準備好,叫做fastdex-runtime.dex(https://github.com/typ0520/fastdex/tree/master/runtime/src/main/java/com/dx168/fastdex/runtime),它的代碼結構是
AntilazyLoad.java就是在注入時被引用的類
MultiDex.java是用來加載classes2.dex - classesN.dex的包,為了防止項目沒有依賴MultiDex,所以把MultiDex的代碼copy到了我們的package下
FastdexApplication.java的作用後面在說
結合我們的項目需要在全量打包前把app/build/intermediates/transforms/jarMerging/debug/jars/1/1f/combined.jar中所有的項目代碼的class全部動態插入代碼(第三方庫由於不在我們的修復範圍內所以為了效率忽略掉),具體的做法是往所有的構造方法中添加對com.dx168.fastdex.runtime.antilazyload.AntilazyLoad的依賴,如下面的代碼所示
動態往class文件中插入代碼使用的是asm(http://asm.ow2.org/),我把做測試的時候找到的一些相關資料和代碼都放到了github上面點我查看(https://github.com/typ0520/fastdex-test-project/tree/master/asm-test),代碼比較多隻貼出來一部分,具體請查看ClassInject.groovy(https://github.com/typ0520/fastdex/blob/master/buildSrc/src/main/groovy/com/dx168/fastdex/build/util/ClassInject.groovy)
處理完pre-verify問題,接下來又出現坑了,當補丁dex打好後假如緩存的dex有兩個(classes.dex classes2.dex),那麼合併dex後的順序就是
fastdex-runtime.dex 、patch.dex、classes.dex 、classes2.dex (patch.dex必須放在緩存的dex之前才能被修復)
fastdex-runtime.dex => classes.dexpatch.dex => classes2.dexclasses.dex => classes3.dexclasses2.dex => classes4.dex
在講解transformClassesWithMultidexlistForDebug任務時有說過程序入口Application的問題,假如patch.dex中不包含入口Application,apk啟動的時候肯定會報類找不到的錯誤,那麼怎麼解決這個問題呢
第一個方案:
把transformClassesWithMultidexlistForDebug任務中輸出的maindexlist.txt中所有的class都參與patch.dex的生成
第二種方案:
對項目的入口Application做代理,並把這個代理類放在第一個dex裡面,項目的dex按照順序放在後面
第一種方案方案由於必須讓maindexlist.txt中大量的類參與了補丁的生成,與之前儘量減少class文件參與dex生成的思想是相衝突的,效率相對於第二個方案比較低,另外一個原因是無法保證項目的Application中使用了MultiDex;
第二種方案沒有上述問題,但是如果項目代碼中有使用getApplication()做強轉就會出問題(參考issue#2 https://github.com/typ0520/fastdex/issues/2),instant run也會有同樣的問題,它的做法是hook系統的api運行期把Application還原回來,所以強轉就不會有問題了,請參考MonkeyPatcher.java(https://android.googlesource.com/platform/tools/base/+/gradle_2.2.0/instant-run/instant-run-server/src/main/java/com/android/tools/fd/runtime/MonkeyPatcher.java需要翻牆才能打開,如果看不了就參考FastdexApplication.java https://github.com/typ0520/fastdex/blob/master/runtime/src/main/java/com/dx168/fastdex/runtime/FastdexApplication.java的monkeyPatchApplication方法)
綜上所述最終選擇了第二種方案以下是fastdex-runtime.dex中代理Application的代碼
根據之前的任務說明生成manifest文件的任務是processDebugManifest,我們只需要在這個任務執行完以後做處理,創建一個實現類為FastdexManifestTask的任務,核心代碼如下
使用下面的代碼把這個任務加進去並保證在processDebugManifest任務執行完畢後執行
處理完以後manifest文件application節點android.name屬性的值就變成了com.dx168.fastdex.runtime.FastdexApplication,並且把原來項目的Application的名字寫入到meta-data中,用來運行期給FastdexApplication去讀取
開發完以上功能後做下面的四次打包做時間對比(其實只做一次並不是太準確,做幾十次測試取時間的平均值這樣才最準)
1.刪除build目錄第一次全量打包(不開啟fastdex)
2.刪除build目錄第一次全量打包(開啟fastdex)
3.在開啟fastdex第一次全量打包完成後,關掉fastdex修改sample工程的MainActivity.java
4.在開啟fastdex第一次全量打包完成後,仍然開啟fastdex修改sample工程的MainActivity.java
打包編號總時間transform時間1
1 mins 46.678s
61770 ms
2
1 mins 57.764s
78990 ms
3
1 mins 05.394s
60718 ms
4
16.5s
1005 ms
通過1和2對比發現,開啟fastdex進行第一次全量的打包時的時間花費比不開啟多了10秒左右,這個主要是注入代碼和IO上的開銷
通過2和3對比發現,開啟fastdex進行補丁打包時的時間花費比不開啟快了60秒左右,這就是期待已久的構建速度啊^_^
剛激動一會就尼瑪報了一個錯誤,當修改activity_main.xml時往裡面增加一個控制項
打出來的包啟動的時候就直接crash掉了
錯誤信息裡的意思是為CustomView的tv1欄位,尋找id=2131493007的view時沒有找到,先反編譯報錯的apk,找到報錯的地方
CustomView$$ViewBinder.bind
CustomView$$ViewBinder這個類是ButterKnife動態生成的,這個值的來源是CustomView的tv1欄位上面的註解,CustomView.class反編譯後如下
看到這裡是不是覺得奇怪,CustomView的源碼明明是
在編譯以後R.id.tv1怎麼就變成數字2131493007了呢,原因是java編譯器做了一個性能優化,如果發現源文件引用的是一個帶有final描述符的常量,會直接做值copy
反編譯最後一次編譯成功時的R.class結果如下(
app/build/intermediates/classes/debug/com/dx168/fastdex/sample/R.class)
經過分析,當全量打包時R.id.tv1 = 2131493007,由於R文件中的id都是final的,所以引用R.id.tv1的地方都被替換為它對應的值2131493007了;
當在activity_layout.xml中添加名字為tv2的控制項,然後進行補丁打包時R.id.tv1的值變成了2131493008,而緩存的dex對應節點的值還是2131493007,所以在尋找id為2131493007對應的控制項時因為找不到而掛掉
我的第一個想法是如果在執行完processDebugResources任務後,把R文件裡id類的所有欄位的final描述符去掉就可以把值copy這個編譯優化繞過去 =>
去掉以後在執行compileDebugJavaWithJavac時編譯出錯了
出錯的原因是註解只能引用帶final描述符的常量,除此之外switch語句的case也必須引用常量,具體請查看oracle對常量表達式(http://docs.oracle.com/javase/specs/jls/se8/html/jls-15.html#jls-15.28)的說明
如果採取這個方案,對id的引用就不能使用常量表達式,像ButterKnife這樣的view依賴注入的框架都不能用了,限制性太大這個想法就放棄了
還有一個思路就是修改aapt的源碼,使多次打包時名字相同id的值保持一致,這個肯定能解決不過工作量太大了就沒有這樣做,之後採用了一個折中的辦法,就是每次把項目中的所有類(除去第三方庫)都參與dex的生成,雖然解決了這個問題但效率一下子降低好多,需要將近40秒才能跑起來還是很慢
這個問題困擾了好久,直到tinker開源後閱讀它的源碼TinkerResourceIdTask.groovy時,發現它們也碰到了同樣的問題,並有了一個解決方案,我們的場景和tinker場景在這個問題上是一模一樣的,直接照抄代碼就解決了這個問題,重要的事情說三遍,感謝tinker、感謝tinker、感謝tinker!!
tinker的解決方案是,打補丁時根據用戶配置的resourceMapping文件(每次構建成功後輸出的app/build/intermediates/symbols/debug/R.txt),生成public.xml和ids.xml然後放進app/build/intermediates/res/merged/debug/values目錄裡,aapt在處理的時候會根據文件裡的配置規則去生成,具體這塊的原理請看老羅的文章Android應用程式資源的編譯和打包過程分析(http://blog.csdn.net/luoshengyang/article/details/8744683)(在裡面搜索public.xml)這裡面有詳細的說明
同上並結合我們的場景,第一次全量打包成功以後把app/build/intermediates/symbols/debug/R.txt緩存下來,補丁打包在執行processResources任務前,根據緩存的符號表R.txt去生成public.xml和ids.xml然後放進app/build/intermediates/res/merged/debug/values目錄裡,這樣相同名字的id前後的兩次構建值就能保持一致了,代碼如下FastdexResourceIdTask.groovy
如果項目中的資源特別多,第一次補丁打包生成public.xml和ids.xml時會佔用一些時間,最好做一次緩存,以後的補丁打包直接使用緩存的public.xml和ids.xml**
解決了上面的原理性問題後,接下來繼續做優化,上面有講到 transformClassesWithMultidexlistForDebug任務的作用,由於採用了隔離Application的做法,所有的項目代碼都不在classes.dex中,這個用來分析那些項目中的類需要放在classes.dex的任務就沒有意義了,直接禁掉它
禁掉以後,執行./gradle assembleDebug,在構建過程中掛掉了
從上面的日誌的第一行發現transformClassesWithMultidexlistForDebug任務確實禁止掉了,後面跟著一個SKIPPED的輸出,但是執行transformClassesWithDexForDebug任務時報app/build/intermediates/multi-dex/debug/maindexlist.txt (No such file or directory)
,原因是transformClassesWithDexForDebug任務會檢查這個文件是否存在,既然這樣就在執行transformClassesWithDexForDebug任務前創建一個空文件,看是否還會報錯,代碼如下
再次執行./gradle assembleDebug
這次構建成功說明創建空文件的這種方式可行;
我們公司的項目在使用的過程中,發現補丁打包時雖然只改了一個java類,但構建時執行compileDebugJavaWithJavac任務還是花了13秒
經過分析由於我們使用了butterknife和tinker,這兩個裡面都用到了javax.annotation.processing.AbstractProcessor這個接口做代碼動態生成,所以項目中的java文件如果很多,挨個掃描所有的java文件並且做操作會造成大量的時間浪費,其實他們每次生成的代碼幾乎都是一樣的,因此如果補丁打包時能把這個任務換成自己的實現,僅編譯和快照對比變化的java文件,並把結果輸出到app/build/intermediates/classes/debug,覆蓋原來的class,能大大提高效率,部分代碼如下,詳情看FastdexCustomJavacTask.groovy
執行./gradlew assembleDebug ,再來一次
一下子快了10秒左右,good
既然有緩存,就有緩存過期的問題,假如我們添加了某個第三方庫的依賴(依賴關係發生變化),並且在項目代碼中引用了它,如果不清除緩存打出來的包運行起來後肯定會包類找不到,所以需要處理這個事情。
首先怎麼拿到依賴關係呢?通過以下代碼可以獲取一個依賴列表
輸入如下
可以在第一次全量打包時,和生成項目源碼目錄快照的同一個時間點,獲取一份當前的依賴列表並保存下來,當補丁打包時在獲取一份當前的依賴列表,與之前保存的作對比,如果發生變化就把緩存清除掉
另外最好提供一個主動清除緩存的任務
先來一個清除所有緩存的任務
project.tasks.create("fastdexCleanAll", FastdexCleanTask)
然後在根據buildType、flavor創建對應的清除任務
提高穩定性和容錯性,這個是最關鍵的
目前補丁打包的時候,是把沒有變化的類從app/build/intermediates/transforms/jarMerging/debug/jars/1/1f/combined.jar中移除,如果能hook掉transformClassesWithJarMergingForDebug這個任務,僅把發生變化的class參與combined.jar的生成,能夠在IO上省出很多的時間
目前給項目源碼目錄做快照,使用的是文件copy的方式,如果能僅僅只把需要的信息寫在文本文件裡,能夠在IO上省出一些時間
目前還沒有對libs目錄中發生變化做監控,後續需要補上這一塊
apk的安裝速度比較慢(尤其是ART下由於在安裝時對應用做AOT編譯,所以造成安裝速度特別慢,具體請參考張邵文大神的文章Android N混合編譯與對熱補丁影響解析),通過socket把代碼補丁和資源補丁發送給app,做到免安裝
合併所有的class文件生成一個jar包
掃描所有的項目代碼並且在構造方法裡添加對com.dx168.fastdex.runtime.antilazyload.AntilazyLoad類的依賴這樣做的目的是為了解決class verify的問題,安卓App熱補丁動態修復技術介紹(https://mp.weixin.qq.com/s?__biz=MzI1MTA1MzM2Nw==&mid=400118620&idx=1&sn=b4fdd5055731290eef12ad0d17f39d4a)
對項目代碼做快照,為了以後補丁打包時對比那些java文件發生了變化
對當前項目的所以依賴做快照,為了以後補丁打包時對比依賴是否發生了變化,如果變化需要清除緩存
調用真正的transform生成dex
緩存生成的dex,並且把fastdex-runtime.dex插入到dex列表中,假如生成了兩個dex,classes.dex classes2.dex 需要做一下操作
fastdex-runtime.dex => classes.dex
classes.dex => classes2.dex
classes2.dex => classes3.dex
然後運行期在入口Application(com.dx168.fastdex.runtime.FastdexApplication)使用MultiDex把所有的dex加載進來
@see com.dx168.fastdex.build.transform.FastdexTransform
7.保存資源映射表,為了保持id的值一致,詳情看
@see com.dx168.fastdex.build.task.FastdexResourceIdTask
1.檢查緩存的有效性
2.掃描所有變化的java文件並編譯成class
3.合併所有變化的class並生成jar包
4.生成補丁dex
5.把所有的dex按照一定規律放在
transformClassesWithMultidexlistFor${variantName}任務的輸出目錄
fastdex-runtime.dex => classes.dex
patch.dex => classes2.dex
dex_cache.classes.dex => classes3.dex
dex_cache.classes2.dex => classes4.dex
dex_cache.classesN.dex => classes(N + 2).dex
3月29日,100offer 邀請了被稱作「架構師的搖籃」的阿里中間件參加知乎 Live。
阿里中間件承載了世界上最有挑戰的場景,應對了一次次雙十一的流量洪峰,他們對人才的要求指引優秀架構師之路的方向。
掃描二維碼,參與 Live 會讓你了解怎麼樣的架構師才能勝任頂尖團隊中的工作
如果你有想學習的文章直接留言,我會整理徵稿。如果你有好的文章想和大家分享歡迎投稿,直接向我投遞文章連結即可。
歡迎長按下圖->識別圖中二維碼或者掃一掃關注我的公眾號: