讀書筆記,寫寫畫畫記憶更深刻,如果還能梳理一下的話,那就更好了。
熱修復(fù)技術(shù)介紹
探索之路
最開始,手淘是基于Xposed進行了改進,產(chǎn)生了針對Android Dalvik虛擬機運行時的Java Method Hook技術(shù)——Dexposed。
但該方案對于底層Dalvik結(jié)構(gòu)過于依賴,最終無法兼容Android 5.0 以后的ART虛擬機,因此作罷。
后來支付寶提出了新的熱修復(fù)方案AndFix。
AndFix同樣是一種底層替換的方案,也達到了運行時生效即時修復(fù)的效果,并且重要的是,做到了Dalvik和ART環(huán)境的全版本兼容
阿里百川結(jié)合手淘在實際工程中使用AndFix的經(jīng)驗,對相關(guān)業(yè)務(wù)邏輯解耦后,推出了阿里百川HotFix方案,并得到了良好的反響。
此時的百川HotFix已經(jīng)是一個很不錯的產(chǎn)品了,對基本的代碼修復(fù)需求都可以解決,安全性和易用性都做的比較好。然而,它所依賴基石,AndFix本身是有局限性的。且不說其底層固定結(jié)構(gòu)的替換方案不好,其使用范圍也存在著諸多限制,雖然可以通過改造代碼繞過限制來達到相同的修復(fù)目的,但這種方式即不優(yōu)雅也不方便。而更大的問題,AndFix只提供了代碼層面的修復(fù),對于資源和so的修復(fù)都未能實現(xiàn)。
在Android平臺上,業(yè)界除了阿里系之外,比較著名的修復(fù)還有:騰訊QQ空間的超級補丁技術(shù)、微信的Tinker、餓了么的Amigo、美團的Robust等等。不過他們各自有自身的局限性,或者不夠穩(wěn)定,或者補丁過大,或者效率低下,或者使用起來過去繁瑣,大部分技術(shù)上看起來似乎可行,但實際體驗并不好。
終于在2017年6月,阿里巴巴手淘技術(shù)團隊聯(lián)合阿里云正式發(fā)布了新一代的非侵入式的Android熱修復(fù)方案——Sophix。
Sophix的橫空出世,打破了各家熱修復(fù)技術(shù)紛爭的局面。因為我們可以滿懷信心的說,在Android熱修復(fù)的三大領(lǐng)域:代碼修復(fù)、資源修復(fù)、so修復(fù)方面,以及方案的安全性和易用性方面,Sophix都做到了業(yè)界領(lǐng)先。
Sophix的誕生,期初是對原先的阿里百川的HotFix 1.X版本進行升級衍進。
Sophix保留了阿里百川HotFix的服務(wù)端整套請求流程,以及安全校驗部分。
而原本的熱修復(fù)方案,主要限制在于AndFix本身。
AndFix自身的限制幾乎是無法繞過的,在運行時對原有類機構(gòu)是已經(jīng)固化在內(nèi)存中的,它的一些動態(tài)屬性很難進行擴展。
并且由于Android系統(tǒng)的碎片化,廠商的虛擬機底層結(jié)構(gòu)都不是確定的,因此直接基于原先機制進行擴展的風險很大。
方案對比
方案對比 | Sophix | Tinker | Amigo |
---|---|---|---|
Dex修復(fù) | 同時支持即時生效和冷啟動修復(fù) | 冷啟動修復(fù) | 冷啟動修復(fù) |
資源更新 | 差量包,不用合成 | 差量包,需要合成 | 全量包,不用合成 |
SO庫更新 | 插樁實現(xiàn),開發(fā)透明 | 替換接口,開發(fā)不透明 | 插樁實現(xiàn),開發(fā)透明 |
性能損耗 | 低,僅冷啟動情況下有些損耗 | 高,有合成操作 | 低,全量替換 |
四大組件 | 不能增加 | 不能增加 | 能增加 |
生成補丁 | 直接選擇已經(jīng)編好的新舊包在本地生成 | 編譯新包時設(shè)置基線包 | 上傳完整新包到服務(wù)端 |
補丁大小 | 小 | 小 | 大 |
接入成本 | 傻瓜式接入 | 復(fù)雜 | 一般 |
Android版本 | 全部支持 | 全部支持 | 全部支持 |
安全機制 | 加密傳輸及簽名校驗 | 加密傳輸及簽名校驗 | 加密傳輸及簽名校驗 |
服務(wù)端支持 | 支持服務(wù)端控制 | 支持服務(wù)端控制 | 支持服務(wù)端控制 |
可以看到,Sophix在各個指標上都占優(yōu)勢。而其中唯一不支持的地方就是四大組件的修復(fù)。
這是因為,如果要修復(fù)四大組件,必須在AndroidManifest里面預(yù)先插入代理組件,并且盡可能聲明所有權(quán)限、而這么做就會給原先的app添加很多臃腫的代碼,對app運行流程的侵入性很強,所以,本著對開發(fā)者透明與代碼極簡的原則,這里不做多余處理。
設(shè)計理念
Sophix的核心設(shè)計理念——就是非侵入性。
在Sophix中,唯一需要的就是初始化和請求補丁兩行代碼,甚至連入口Application類我們都不做任何修改,這樣就給了開發(fā)者最大的透明度和自由度。
代碼修復(fù)
代碼修復(fù)有兩大主要方案,一種是阿里系的底層替換方案,另一種是騰訊系的類加載方案。
兩種方案各有優(yōu)劣:
- 底層替換方案限制頗多,但時效性最好,加載輕快,立即見效。
- 類加載方案時效性差,需要重新冷啟動才能見效,但修復(fù)范圍廣,限制少。
底層替換方案是在已經(jīng)加載了的類中直接替換掉原有的方法,是在原來類的基礎(chǔ)上進行修改。無法實現(xiàn)對原有類方法和字段的增減。
底層替換最為人狗命的地方是底層替換的不穩(wěn)定性。
類加載方案的原理是app重新啟動后讓ClassLoader去加載新的類。在app運行過程中,所有需要發(fā)生變更的類已經(jīng)被加載過了,在Android上無法對一個類進行卸載的。
如果不重啟,原來的類還在虛擬機中,就無法加載新類。
因此,只有在下次重啟的時候,在還沒走到業(yè)務(wù)邏輯之前搶先加載補丁中的新類,這樣后續(xù)訪問這個類時,就會使用新類,從而達到熱修復(fù)的目的。
說說Tinker:
微信的Tinker方案是完整的全量dex加載,并且可謂是將補丁合成做到了極致,然而我們發(fā)現(xiàn),精密的武器并非適用于所有戰(zhàn)場。
Tinker的合成方案,是 從dex的方法和指令維度 進行全量合成,整個過程都是自己研發(fā)。
雖然可以很大的節(jié)省空間,但對于dex內(nèi)容的比較粒度過細,實現(xiàn)較為復(fù)雜,性能消耗嚴重。
實際上,dex的大小占整個apk的比例是比價低的,一個app里面的dex文件大小并不是主要部分,而占空間大的主要還是資源文件。
因此,Tinker方案的時空代價轉(zhuǎn)換的性價比不高。
其實,dex比較的最佳粒度,應(yīng)該是在類的維度。它即不像方法和指令維度那樣的細微,也不像bsbiff比較那樣的粗糙。在類的維度,可以達到時間和空間平衡的最佳效果。
既然兩種方案各有其特點,把他們聯(lián)合起來就是最好的選擇了。
Sophix的代碼修復(fù)體系正式同時涵蓋了兩種方案。在補丁生成階段,補丁工具會根據(jù)實際代碼的變動情況進行自動選擇:
- 針對小修改,在底層替換方案限制范圍內(nèi),就直接采用底層替換修復(fù)。
- 對于代碼修復(fù)超出底層替換限制的,會使用類加載替換,雖然及時性沒有那么好,但總歸可以達到熱修復(fù)的目的。
另外,在運行時還會再判斷所運行的機型是否支持熱修復(fù),這樣即使機型底層虛擬機構(gòu)造不支持,還是會走類加載修復(fù),從而達到最好的兼容性。
資源修復(fù)
目前市面上的很多熱修復(fù)方案基本上都參考了Instant Run的是實現(xiàn)。
簡單來說,Instant Run中的資源熱修復(fù)方案分為兩步:
- 構(gòu)造一個新的AssetManager,并通過反射調(diào)用addAssetPath,把這個完整的新資源包加入到AssetManager中,這樣就得到了一個含有所有新資源的AssetManager。
- 找到所有之前引用到原有AssetManager的地方,通過反射,把引用處替換為新的AssetManager。
其實,在該方案中有大量的代碼都是在處理兼容性問題和找到所有AssetManager的引用。真正替換的代碼其實很簡單。
Sophix并沒有直接采用Instant Run技術(shù),而是構(gòu)造了一個package id為0x66的資源包,其實這個資源包里面只有修改了的資源項,直接在原有的AssetManager中addAssetPath就可以了。
由于補丁包的Package Id為0x66,不與目前已經(jīng)加載的0x7f資源段沖突,因此直接加入到已有的AssetManager中就可以直接使用了。
SO庫修復(fù)
SO庫的修復(fù)本質(zhì)上是對native方法的修復(fù)和替換。
我們采用的是類似 類修復(fù)反射注入方式 。把補丁so庫插入到nativeLibraryDirdetories數(shù)組的最前面。就能夠達到加載so庫的時候是補丁so庫,而不是原來so庫的目錄,從而達到修復(fù)的目的。
采用這種方案,完全有Sophix啟動期間反射注入pathc中的so庫。對開發(fā)者透明。
不用像其他方案那樣需要手動的替換系統(tǒng)的System.load來實現(xiàn)替換目的。
代碼修復(fù)技術(shù)詳解
底層替換原理
AndFix方案引發(fā)的思考
在各種Android熱修復(fù)方案中,AndFix的即時生效令人印象深刻,它稍顯另類,并不需要重新啟動,而是在加載補丁后直接對方法進行替換就可以完成修復(fù),然而它的使用限制也遭遇到更多的質(zhì)疑。
怎么做到即時生效?
在app運行到一半的時候,所有需要發(fā)生變更的類已經(jīng)被加載過了,在Android上是無法對一個類進行卸載的。AndFix采用的方法是,在已經(jīng)加載了的類中直接在native層替換到原有方法,是在原來類的基礎(chǔ)上進行修改的。
每一個Java方法在ART中都對應(yīng)著一個ArtMethod,ArtMethod記錄了這個Java方法的所有信息,包括所有類、訪問權(quán)限、代碼執(zhí)行地址等等。
通過env->FromReflectedMethod
,可以由Method對象得到這個方法對應(yīng)的ArtMethod的真正其實地址。然后把它強轉(zhuǎn)為ArtMethod支持,從而對所有成員進行修改。
這樣全部替換完之后就完成了熱修復(fù)邏輯。以后調(diào)用這個方法時就會直接走到新方法中了。
虛擬機調(diào)用方法的原理分析
為什么替換ArtMethod數(shù)據(jù)后可以實現(xiàn)熱修復(fù)呢?這需要從虛擬機調(diào)用方法的原理說起。
以Android6.0實現(xiàn)為例。
ArtMethod結(jié)構(gòu)中最重要的兩個字段entry_point_from_interprete_
和entry_point_from_quick_compiled_code_
。
ART中可以采用解釋模式或者AOT機器碼模式執(zhí)行
解釋模式:
就是取出Dex Code,逐條的解釋執(zhí)行就行了。如果方法的調(diào)用者是以解釋模式運行的,在調(diào)用這個方法是,就去取得這個方法的`entry_potin_from_interpreter_`,然后跳轉(zhuǎn)過去執(zhí)行。
AOT模式:
預(yù)先編譯好Dex Code對應(yīng)的機器碼,然后運行期直接執(zhí)行機器碼就行了,不需要一條條的解釋執(zhí)行Dex Code。如果方法的調(diào)用者是以AOT機器碼執(zhí)行的,在調(diào)用這個方法是,就是跳轉(zhuǎn)到`entry_point_from_quick_compiled_code_`執(zhí)行。
那我們是不是只需要替換這幾個字段就可以了呢?
并沒有這么簡單。以為不論是解釋模式還是AOT模式,在運行期間還會需要用到ArtMethod的里面的其他成員字段的。
其實這樣正式native替換方式兼容性問題的原因。
Sophix沒有選擇將Artmethod的所有成員都進行替換,而是把ArtMethod作為整天進行替換。
這里需要強調(diào)的是求解ArtMethod結(jié)構(gòu)的內(nèi)存占用大小。
由于我們是在運行時生效(各家ROM都會有多多少少的改動),且sizeofsizeof()
工作原理是在編譯期,因此我們無法直接使用該表達式。
Sophix采用了比較聰明的辦法:利用現(xiàn)行結(jié)構(gòu)的特定,使用兩個ArtMethod之前的偏移量來動態(tài)計算ArtMethod的數(shù)據(jù)結(jié)構(gòu)大小。但這里需要依賴存放ArtMethod的數(shù)據(jù)結(jié)構(gòu)是線性的。
替換后方法訪問權(quán)限問題
1、類內(nèi)部
上述提到,我們整個替換ArtMethod的內(nèi)容,但新替換的方法的所屬類和原來方法的所屬類,是不同的類。
被替換的方法有權(quán)限訪問其他的private方法嗎?
通過觀察Dex Code和Native Code,可以推測,在dex2oat生成AOT機器碼時是有做一些檢查和優(yōu)化的,由于dex2oat編譯機器碼時確認了兩個方法同屬一個類,所以機器碼中就不存在權(quán)限檢查相關(guān)代碼。
2、同包名下
但是并不是所有方法都如同類內(nèi)部直接訪問那樣順利的。
補丁類正在訪問同包名下的類時會報出訪問異常。
具體的校驗邏輯是在虛擬機代碼的Class::IsInSamePackage中,關(guān)鍵點在于比較兩個Class的所屬的ClassLoader。
因此這里還需要將新類的ClassLoader設(shè)置為與原來一致。
3、反射調(diào)用非靜態(tài)方法
當一個非靜態(tài)方法被熱替換后,再反射調(diào)用這個方法,會拋出異常。
在反射Invoke一個方法時,在底層會掉哦用到InvokeMethod -> VerifyObejctIsClass函數(shù)做驗證。
由于熱替換方案鎖替換的非靜態(tài)方法,在反射調(diào)用者,由于VerifyObjectIsCLass時,舊類和新類不匹配,就會導(dǎo)致驗證不通過。
如果是靜態(tài)方法,會有同樣的問題嗎?
當然沒有,靜態(tài)方法會在類的級別直接進行調(diào)用的,不需要接受對象實例作為參數(shù),不會有這方面的檢查。
因此,對于這種反射調(diào)用非靜態(tài)方法的問題,Sophix會采用另一種冷啟動機制對付,最后會有介紹。
即時生效帶來的限制
除了反射的問題,即時生效直接在運行期修改底層結(jié)構(gòu)的熱修復(fù)方法,都存在著一個限制,那就是只能替換方法。對于補丁里面如果存在方法的增加或者減少,以及成員字段的增加和減少情況都是不適用的。
原因是這樣的,一旦補丁類中出現(xiàn)了方法的增減,會導(dǎo)致整個類以及整個dex方法數(shù)的變化。方法數(shù)的變化伴隨著方法索引的變化,這樣在訪問時就無法所引導(dǎo)正確的方法了。
如果字段發(fā)生了增減,和方法變化差不多,所有字段的索引都會發(fā)生變化。
并且更加嚴重的是,如果程序運行中間某個類突然增加了一個字段,那么對于原來已經(jīng)生成的實例,還是原來的結(jié)構(gòu),已是無法改變的了,而新方法在使用到老實例時,訪問新增字段就會產(chǎn)生不可預(yù)期的結(jié)果。
因此綜合來說,即時生效方案只有在下面兩種情況下是不適用的:
- 引起了原有類發(fā)生結(jié)構(gòu)變化的修改
- 修復(fù)了的非靜態(tài)類會被反射調(diào)用
雖然有一些使用限制,但一旦滿足使用條件,這種熱修復(fù)方式還是十分出眾的,補丁小,加載迅速,能夠?qū)崟r生效,無需重啟app,并且有著完美的設(shè)備兼容性(整個copy Method結(jié)構(gòu))。
Java語言的編譯實現(xiàn)所帶來的挑戰(zhàn)
Sophix一直秉承 粒度小、注重快捷熱修復(fù)、無侵入適合原生工程。因為這個原則,我們在研發(fā)過程中遇到很多 編譯期 的問題,引用印象深刻。
內(nèi)部類
問題:有時候會發(fā)現(xiàn),修改外部類某個方法邏輯為訪問內(nèi)部類的某個方法時,最后打包出來的補丁包竟然提示新增了一個方法。
因此我們很有必要了解內(nèi)部類在編譯期間是怎么編譯的。
首先需要知道 ** 內(nèi)部類會在編譯期會被編譯為跟外部類一樣的頂級類。 **
靜態(tài)內(nèi)部類和非靜態(tài)內(nèi)部類的區(qū)別。
它們的區(qū)別其實大家都很熟悉,非靜態(tài)類持有外部類的引用,靜態(tài)內(nèi)部類不持有外部類的引用。
既然內(nèi)部類跟外部類一樣都是頂級類,那是不是意味著對方private的method/field是沒法被訪問到的,事實上外部類為了訪問內(nèi)部類私有的域和方法,編譯期會自動外內(nèi)部類生成access&**
相關(guān)方法。
因此,如果補丁類中修改的方法中添加了需要訪問內(nèi)部類私有數(shù)據(jù)或者方法的代碼的話,那么編譯期間會新增access&**
方法,供內(nèi)部類被訪問使用。
如果想通過熱部署修復(fù)的新方法需要訪問內(nèi)部類的私有域或方法,那么我們應(yīng)該防止生成access&**
相關(guān)方法。
Sophix有以下建議:
- 外部類如果有內(nèi)部類,把外部類所有的method/fidle的private訪問權(quán)限修改為projected或者默認訪問權(quán)限或者public。
- 同時把內(nèi)部類的所有的method/field的private訪問修改為projected或者模式訪問權(quán)限或者public。
匿名內(nèi)部類
匿名內(nèi)部類其實也是個內(nèi)部類,自然也會有上一小節(jié)中說到的限制和應(yīng)對策略。
但它還會有其他的限制。
匿名內(nèi)部類的命名規(guī)則
匿名內(nèi)部類顧名思義就是沒有名字。
命名格式一般都是外部類&numble
,后面的numble,是編譯器根據(jù)匿名內(nèi)部類在外部類中出現(xiàn)的先后關(guān)系,一次累加命名。
解決方案
新增/減少匿名內(nèi)部類,實際上對于熱部署來說是無解的,因為補丁工具拿到的已經(jīng)是編譯后的.class文件,所以根本無法區(qū)分,所以這種情況下,應(yīng)極力避免插入一個新的匿名內(nèi)部類。
當然,如果匿名內(nèi)部類是插入到外部類的末尾,那么是允許的。(確是很容易犯錯的)
靜態(tài)域/靜態(tài)代碼塊
實際上,熱部署方案除了不支持method/field的新增,同時也是不支持<clinit>
的修復(fù),因為這個方法是dalvik虛擬機中類進行初始化的時候調(diào)用。
在Java源碼中并沒有clinit這個方法,這個方法是android編譯器自動合成的方法。
通過測試發(fā)現(xiàn),靜態(tài)field的初始化和靜態(tài)代碼塊實際上都會被編譯器便已在<clinit>
這個方法。
靜態(tài)代碼塊和靜態(tài)域初始化在clinit中的先后關(guān)系就是兩者出現(xiàn)在源碼中的先后關(guān)系。
類加載然后進行類初始化的時候,會去調(diào)用clinit方法,一個類僅加載一次。
在下面三種情況下,會嘗試加載一個類:(在面試中被問到過)
1. new一個類的對象;
2. 調(diào)用類的靜態(tài)方法;
3. 獲取類靜態(tài)域的值;
首先判斷這個類有沒有被加載過,如果沒有,執(zhí)行的流程是`dvmResolveClass -> dvmLinkClass -> dvmInitClass `。
類的初始化是在dvmInitClass。這個函數(shù)會首先嘗試對父類進行初始化,然后調(diào)用本類的clinit方法。
非靜態(tài)域/非靜態(tài)代碼塊
非靜態(tài)域初始化和非靜態(tài)代碼塊被編譯器翻譯在<init>
默認午餐構(gòu)造函數(shù)中。
實際上,如果存在有參構(gòu)造函數(shù),那么每個有參構(gòu)造函數(shù)都會執(zhí)行一個非靜態(tài)域的初始化和非靜態(tài)代碼塊。
構(gòu)造函數(shù)會被android編譯器自動翻譯成<init>方法
前面說過<clinit>
方法在類加載初始化的時候被調(diào)用,那么<init>
構(gòu)造函數(shù)方法肯定是對類對象進行初始化的時候被調(diào)用的。
簡單來說,new一個對象就會對這個對象進行初始化,并調(diào)用這個對象相應(yīng)的構(gòu)造函數(shù)。
我們查看代碼String s = new String("test")
編譯之后的樣子。
new-instance v0, Ljava/lang/String;
invoke-direct {v0}, Ljava/lang/String;-><init>()V
首先會執(zhí)行new-instance
指令,主要為對象分配內(nèi)存,同時,如果類之前沒加載過,嘗試加載類;
然后執(zhí)行invoke-direct
指令調(diào)用類的init構(gòu)造函數(shù)方法,執(zhí)行對象初始化。
解決辦法
Sophix不支持clinit方法的熱部署,任何靜態(tài)field初始化和靜態(tài)代碼塊的變更都會被翻譯到clinit方法中,所以最終會導(dǎo)致熱部署失敗,只能冷啟動。
非靜態(tài)field和非靜態(tài)代碼塊的變更被翻譯到<init>
構(gòu)造函數(shù)中,熱部署模式下只是視為一個普通方法的變更,此時對熱部署是沒有影響的。
final static 域編譯
final static域首先是一個靜態(tài)域,所以我們自然認為由于會被翻譯到clinit方法中,所以自然不支持熱部署。
但是測試發(fā)現(xiàn),final static修飾的基本類型/String 常量類型,匪夷所思的竟然并沒有翻譯到clinit方法中。
事實上,類加載初始化dvmInitClass在執(zhí)行clinit方法之前,首先會執(zhí)行initSFields,
這個方法的作用主要是給static域賦予默認值。
如果是引用類型,那么默認值為NULL。
final static 修飾的原始類型 和 String 類型域(非引用類型),并不會翻譯在clinit方法中,而是在類初始化執(zhí)行initSFields方法時得到了初始化賦值。
final static 修飾的引用類型,初始化仍然在clinit方法中。
我們在Android性能優(yōu)化的相關(guān)文檔中經(jīng)常看到,如果一個field是常量,那么推薦盡量使用static final作為修飾符。
很明顯這句話不大對,得到優(yōu)化的僅僅是final static原始類型和String類型域(非引用類型),如果是引用類型,實際上是不會得到任何優(yōu)化的。
final static String類型的變量,編譯期間會被有優(yōu)化成const-string指令,但是該在指令拿到的只是字符串常量在dex文件結(jié)構(gòu)中字符常量區(qū)的索引id,所以需要額外的一次字符串查找。
dex文件中有一塊區(qū)域存儲著程序所有的字符串常量,
最終這塊區(qū)域會被虛擬機完整加載到內(nèi)存中,這塊區(qū)域也就是通常所說的“字符串常量區(qū)”內(nèi)存。
因此,我們可以得到以下結(jié)論
- 修改final static基本類型或者String類型域(非引用類型),由于編譯器間引用到基本類型的地方會被立即數(shù)替換,引用到String類型域的地方會被常量池索引id替換,所以在熱部署模式下,最終所有引用到該final static域的方法都會被替換。實際上此時仍然可以走熱部署。
- 修改final static引用類型域,是不允許的,因為這個field的初始化會被翻譯到clinit方法中,所以此時沒法走熱部署。
方法混淆
其實除了上面提到的內(nèi)部類/匿名內(nèi)部類可能會造成method新增之后,代碼混淆也可能會導(dǎo)致方法的內(nèi)聯(lián)和剪裁,那么最后可能也會導(dǎo)致method的新增/減少。
實際上只要混淆配置文件加上-dontoptimize
這項就不會去做方法的剪裁和內(nèi)聯(lián)。
一般情況下項目的混淆配置都會使用到android sdk默認的混淆配置文件proguard-android-optimize.txt
或者 proguard-android.txt
,兩者的區(qū)別就是后者應(yīng)用了-dontoptimize
這一項配置,而前者沒有使用。
實際上,圖上的幾個步驟都是可以選擇的,其中對熱部署可能會產(chǎn)生嚴重影響的主要在optimization階段。
optimization step
: 進一步優(yōu)化代碼,不是入口點的類和方法可以被設(shè)置成private、static或final,無用的參數(shù)可能會被有移除,并且一些地方可能會被內(nèi)聯(lián)。
可以看到optimization階段,除了會做方法的剪裁和內(nèi)聯(lián)可能導(dǎo)致方法的新增/減少之外,還可能把方法的修飾符優(yōu)化成 private/static/final。熱補丁部署模式下,混淆配置最好都加上-dontoptimize
配置。
`` : 針對.class文件的預(yù)校驗,在.class文件中加上StackMa/StackMapTable信息,這樣Hotspot VM在類加載時候執(zhí)行類校驗階段會省去一些步驟,因此類加載將更快。
我們知道android虛擬機執(zhí)行的dex文件,編譯期間dx工具會把所有的.class文件優(yōu)化成.dex文件,所以混淆庫的預(yù)校驗在android中是沒有任何意義的,反而會拖累打包速度。
android虛擬機中有自己的一套代碼校驗邏輯(dvmVerifyClass)。所以android中混淆配置一般都需要加上-dontpreverify
配置。
switch case語句編譯
編譯規(guī)則:
public void testContinue() {
int temp = 2;
int result = 0;
switch (temp) {
case 1:
result = 1;
break;
case 3:
result = 3;
break;
case 5:
result = 5;
break;
}
}
public void testNotContinue() {
int temp = 2;
int result = 0;
switch (temp) {
case 1:
result = 1;
break;
case 3:
result = 3;
break;
case 5:
result = 10;
break;
}
}
編譯出來的結(jié)果:
# virtual methods
.method public testContinue () V
const/4 v1, 0x2
.local v1, "temp":I
const/4 v0, 0x0
.local v0, "result":I
packed-switch v1, :pswitch_data_0
:pswitch_0
return-void
:pswitch_1
const/4 v0, 0x1
:pswitch_2
const/4 v0, 0x3
:pswitch_3
const/4 v0, 0x5
:pswitch_data_0
.packed-switch 0x1
:pswitch_1
:pswitch_0
:pswitch_2
:pswitch_0
:pswitch_3
.end packed-switch
.end method
.method public testNotContinue () V
const/4 v1, 0x2
.local v1, "temp":I
const/4 v0, 0x0
.local v0, "result":I
sparse-switch v1, :sswitch_data_0
:sswitch_0
const/4 v0, 0x1
:sswitch_1
const/4 v0, 0x3
:sswitch_2
const/16 v0, 0xa
:sswitch_data_0
.sparse-switch
0x1 -> :sswitch_0
0x3 -> ::switch_1
0xa -> :sswitch_2
.end sparse-switch
.end method
testContinue
方法的switch case語句被翻譯成packed-switch
指令,testNotContinue
方法的switch case語句被翻譯成sparse-switch
指令。
比較下差異:
testContinue
的switch的case項是幾個比較連續(xù)的值,中間的差值用:pswitch_0
補齊,:pswitch_0
標簽處直接return-void
。
testNotContinue
的swtich語句的case項不夠連續(xù),所以編譯期間編譯為sparse-switch
指令。
怎么才算比較連續(xù)的case是由編譯器來決定的。
如何應(yīng)對熱部署
一個資源id肯定是const final static
變量,此時如果switch case 語句會被翻譯成packed-switch
指令,所以補丁包這個時候如果不做處理就無法做到資源id的完全替換。
解決方案其實很暴力,修改smali反編譯流程,碰到packed-switch
指令強轉(zhuǎn)為sparse-switch
指令;
做完資源id的暴力替換,然后再回編譯smali為dex;
泛型編譯
泛型是java5才開始引入的。泛型的使用也可能會導(dǎo)致method的新增。
Java語言的泛型基本上都是在編譯器中實現(xiàn)的。
由編譯器執(zhí)行類型檢查和類型推斷,然后生成普通的非泛型的字節(jié)碼,就是虛擬機完全無感知泛型的存在。
這種實現(xiàn)技術(shù)成為擦除(erasure)。編譯器使用泛型類型信息保證類型安全,然后在生成字節(jié)碼之前將其清除。由于泛型是在java 5中才引入的,擴展虛擬機指令集來支持泛型是讓人無法接受的,因為這會為Java廠商升級其JVM造成難以逾越的障礙,因此才采用了可以完全在編譯器中實現(xiàn)的擦除方法。
類型擦除與多態(tài)的沖突
class A<T> {
private T t;
public T get() {
return t;
}
public void set(T t) {
this.t = t;
}
}
class B extends A<Number> {
private Number n;
@Override // 跟父類返回值不一樣,為什么重寫父類get方法?
public Number get() {
return n;
}
@Override // 跟父類方法參數(shù)不一樣,為什么重寫set方法?
public void set(Number n) {
this.n = n;
}
}
class C extends A {
private Number n;
@Override // 跟父類返回值不一樣,為什么**重寫**父類get方法?
public Number get() {
return n;
}
@Override // 跟父類方法參數(shù)不一樣,為什么**重載**set方法?
public void set(Number n) {
this.n = n;
}
}
為什么類B的set和get方法可以用@Override而不報錯。
@Override表明這個方法是重寫,我們知道重寫的意思是子類中的方法簽名和返回類型都必須一致。
但是很明顯的,B的方法無法對A的set/get方法進行重寫的。其實我們的本意是重寫實現(xiàn)多態(tài),可是類型擦除后,只能變成了重載。
這樣,類型擦除就和多態(tài)有了沖突。
實際上JVM采用了一個特殊的方法,來完成重寫這個功能,那就是bridage方法。
.method public get() Ljava/lang/Number;
.method public bridge synthetic get() Ljava/lang/Object;
invoke-virtual {p0}, Lcom/taobao/test/B;->get()Ljava/lang/Number;
move-result-object v0
return-object v0
.end method
.method public set(Ljava/lang/Number;) V
.method public bridge synthetic set(Ljava/lang/Object;) V
check-cast p1, Ljava/lang/Number;
invoke-virtual {p0, p1}, Lcom/taobao/test/B;->set(Ljava/lang/Number;)V
return void
.end method
我們發(fā)現(xiàn)編譯器會自動生成兩個bridage方法來重寫父類方法,同時這兩個方法實際上調(diào)用B.set(Ljava/lang/Number;)
和B.get()Ljava/lang/Number
這兩個重載方法。
子類中真正重寫基類方法的是編譯器自動合成的bridge方法。而類B定義的get和set方法上面的@Override只不過是假象。虛擬機巧妙的使用橋方法的方式來解決了類型擦除和多態(tài)的沖突。
也就是說,類B中的字節(jié)碼中g(shù)et()Ljava/lang/Number;和get(0Ljava/lang/Object;是同時存在的,這就顛覆了我們的認知,因為在正常代碼中他們是無法同時存在的。
因此,虛擬機為了實現(xiàn)泛型的多態(tài)做了一個看起來“不合法”的事情,然后交給虛擬機自己去區(qū)別處理了。
Lambda表達式編譯
Lambda表達式是 java 7才引入的一種表達式,類似于匿名內(nèi)部類實際上由于匿名內(nèi)部類有很大的區(qū)別。
Lambda表達式的使用也可能導(dǎo)致方法的新增/減少,導(dǎo)致最后走不了熱部署模式。
lambda為Java添加了缺失的函數(shù)式編程特點,Java現(xiàn)在提供的最接近閉包的概念便是Lambda表達式。
Java編譯器將lambda表達式編譯成類的私有方法,使用了Java7的invokedynamic字節(jié)碼來動態(tài)綁定這個方法。
在Java 7 JVM中增加了一個新的指令invokedynamic,用于支持動態(tài)語言。
即允許方法調(diào)用可以在運行時指定類和方法,不必在編譯的時候確定。
字節(jié)碼中每條invokedynamic指令出現(xiàn)的位置稱為一個動態(tài)聯(lián)調(diào)點,
invokedynamic指令后面都會跟一個指向常量池的調(diào)用點限定符(#3, #6),這個限定符會被解析成一個動態(tài)調(diào)用點。
熱部署應(yīng)對方案
- 增加/減少一個Lambda表達式會導(dǎo)致類方法比較錯亂,所以都會導(dǎo)致熱部署失敗
- 修改一個Lambda表達式基于前面的分析,可能會導(dǎo)致新增field,所以此時也會導(dǎo)致熱部署失敗。
訪問權(quán)限對熱替換的影響
一個類的加載,必須經(jīng)歷resolve->link->init三個階段,** 父類/實現(xiàn)接口權(quán)限控制檢查** 主要發(fā)生在link階段。
代碼如下:
bool dvmLinkClass(ClassObject* claszz) {
...
if (clazz->status == CLASS_IDX) {
...
if (clazz->interfaceCount > 0) {
for (i = 0; i < clazz->interfaceCount: i++) {
assert(interfaceIdxArray[i] != kDexNoIndex);
clazz->interfaces[i] = dvmResolveClass(clazz, interfaceIdxArray[i], false);
...
/* are we aoolowed to implement this interface? */
if (!dvmCheckClassAccess(clazz, clazz->interfaces[i])) {
dvmLinearReadOnly(clazz->classLoader, clazz->interfaces);
ALOGW("Interface '%s' is not accessible to '%s' ", clazz->interfaces[i]->descriptor, clazz->descriptor);
dvmThrowIllegalAccessError("interface not accessible");
goto bail;
}
}
}
}
...
if (strcmp(class->descriptor, "Ljava/lang/Object;") == 0){
...
} else {
if (clazz->super == NULL) {
dvmThrowLinkageError("no superclass defined");
goto bail;
} else if (!dvmCheckClassAccess(clazz, clazz->super)) { // 檢查父類的訪問權(quán)限
ALOGW("Superclass of '%s' (%s) is not accessible", clazz->descriptor, clazz->super->descriptor);
dvmThrowIllegalAccessError("superclass not accessible");
goto bail;
}
}
}
在上述代碼實例上可以看到,一個類的link階段,會一次對當前類實現(xiàn)的接口和父類進行訪問權(quán)限檢查。
接下來看一下dvmCheckClassAccess的具體實現(xiàn):
bool dvmCheckClassAccess(const ClassObject* accessFrom, const ClassObject* clazz) {
if (dvmIsPublicClass(clazz)) { // 如果父類是public類,直接return true
return true;
return dvmInSamePackage(accessFrom, clazz);
}
bool dvmInSamePackage(const ClassObject* class1, const ClassObject* class2) {
/* quick test for instr-class access */
if (class1 == class2) {
return true;
}
/* class loaders must match */
if (class1->classLoader != class2->classLoader) { // classLoader不一致,直接return false
return false;
}
if (dvmIsArrayClass(class1))
class1 = class1->elementClass;
if (dvmIsArrayClass(class2))
class2 = class2->elementClass;
/* check again */
if (class1 == class2)
return true;
int commonLen;
commonLen = strcmpCount(class1->descriptor, class2->descriptor);
if (strchr(class1->descriptor + commonLen, '/') != NULL ||
strchr(class2->descriptor + commonLen, '/') != NULL) {
return false;
}
return true;
}
我們可以看到如果當前類和實現(xiàn)接口/父類是否public,同時負責加載兩者的classLoader不一樣的情況下,直接return false.
所以如果此時不進行任何處理的話,那么在類的加載階段就會報錯。
而Sophix修復(fù)方案是基于新classLoader加載補丁類,所以在patch過程就會報錯。
如果補丁類中存在非public類的訪問,非public方法/域的調(diào)用,那么都會失敗。
更為致命的是,在補丁加載是檢測不出來的,補丁會被正常加載,但是在運行階段會直接crash。
由于補丁類在單獨的dex中,所以要加載這個dex的話,肯定要進行dexopt的。
dexopt過程中會執(zhí)行dvmVerifyClass校驗dex中的每個類。
方法調(diào)用鏈:
dvmVerifyClass校驗類
->verifyMethod校驗類中的每個方法
->dvmVerifyCodeFlow
->doCodeVerification對每個方法的邏輯進行校驗
->verifyInstruction實際上就是校驗每個指令。
<clinit>方法
熱部署模式下的特殊性:不允許類結(jié)構(gòu)變更以及不允許變更
<clinit>
方法
所以補丁工具如果發(fā)現(xiàn)了這幾種限制,那么此時只能走冷啟動重啟生效。
冷啟動幾乎是沒有限制的,可以做到任何場景的修復(fù)。
可能在有時候在源碼層上來看沒有增加method/field,但是實際上由于要滿足java的各種語法特性的需求,所以編譯器會在編譯期間為我們自動合成一些method和field,最后就有可能觸發(fā)了上面提到的幾個限制情況。
冷啟動類加載原理
概述
對比不同冷啟動方案
QQ空間 | Tinker | |
---|---|---|
原理 | 為了解決Dalvik下unexpected dex problem異常而采用插樁的方式,單獨放在一個幫助類在獨立的dex讓其他類調(diào)用,阻止了類被打上CLASS_ISPREVERIFIED標志從而規(guī)避問題的出現(xiàn)。最后加載補丁dex得到dexFile對象作為參數(shù)構(gòu)建一個Element對象插入到dex-Elements數(shù)組的最前面。 | 提供dex差量包,整體替換dex的方案。差量的方式給出patch.dex,然后將patch.dex與應(yīng)用的classes.dex合并成一個完整的dex,完整dex加載得到的dexFile對象最為參數(shù)構(gòu)建一個Element對象然后整體替換掉就的dex-Elements數(shù)組 |
優(yōu)點 | 沒有合成整包,產(chǎn)物比較小,比較靈活 | 自研dex差異算法,補丁包很小,dex merge成完整dex,Dalvik不影響類加載性能,Art下也不存在包含父類/引用類的情況 |
缺點 | Dalvik下影響類加載性能,Art下類地址寫死,導(dǎo)致必須包含父類/引用,最后補丁包很大 | dex合并內(nèi)存并消耗vm heap上,容易OOM,最后導(dǎo)致dex合并失敗 |
dex merge in Tinker:
dex merge操作是在java層面進行,所有對象的分配都是在java heap上。
如果此時進程申請的java heap對象超過了vm heap規(guī)定的大小,那么進程發(fā)生OOM,
系統(tǒng)memory killer可能會殺掉該進程,導(dǎo)致dex合成失敗。
另外一方面,我們知道jni層面`c++ new/malloc`申請的內(nèi)存,分配在`native heap`,
native heap 的增長并不受vm heap大小的限制,只受限于RAM。
如果RAM不足那么進程也會被殺死導(dǎo)致閃退。
所以如果只是從dex merge方面思考,在jni層面進行dex merge,從而可以避免OOM提高dex合并的成功率。
理論上當然可以,只是jni層實現(xiàn)起來比較復(fù)雜而已。
兩種方案對Sophix都不適用。它需要的是一種既能無侵入打包。
插樁
眾所周知,如果僅僅把補丁類打入補丁包中而不做任何處理的話,那么運行時類加載的時候機會異常退出。
加載一個dex文件到本地內(nèi)存的時候,如果不存在odex文件,那么首先會執(zhí)行dexopt,dexopt的入口在dalvik/opt/OptMain.cpp
的main方法,最后調(diào)用verifyAndOptimizeClass
執(zhí)行真正的verify/optimize操作。
/*
* Verify and/or optimize a specific class.
*/
static void verifyAndOptimizeClass(DexFile* pDexFile, ClassObject* clazz,
const DexClassDef* pClassDef, bool doVerify, bool doOpt) {
const char* classDescriptor;
bool verified = false;
classDescriptor = dexStringByTypeIdx(pDexFile, pClassDef->classIdx);
if (doVerify) {
if (dvmVerifyClass(clazz)) { // 執(zhí)行類的Verify
((DexClassDef*)pClassDef)->accessFlags |= CLASS_ISPREVERIFIED;
verified = true;
}
}
if (doOpt) {
bool needVerify = (gDvm.dexOptMode == OPTIMIZE_MODE_VERIFIED ||
gDvm.dexOptMode == OPT-MIZE_MODE_FULL);
if (!verified && needVerify) {
...
} else {
dvmOptimizeClass(clazz, false); // 執(zhí)行類的Optimize
/* 類被打上CLASS_ISOPTIMIZED標識 */
((DexClassDef*)pClassDef)->accessFlags |= CLASS_ISOPTIMIZED;
}
}
}
apk第一次安裝的時候,會對原dex執(zhí)行dexopt。
此時假如apk只存在一個dex,所以dvmVerifyClass(clazz)結(jié)果為true。所以apk中所有的類都會被打上CLASS_ISPREVERIFIED
標志,接下來執(zhí)行dvmOptimizeClass,類接著被打上CLASS_ISOPTIMIZED
標志。
- dvmVerifyClass : 類校驗,類校驗的目的是為了防止類被篡改校驗類的合法性。它會對類的每個方法進行校驗,這里我們只需要知道如果類中的所有方法中直接引用到的類(第一層級關(guān)系,不會進行遞歸搜索)和當前類都在同一個dex的話,dvmVerifyClass就返回true。
- dvmOptimizeClass : 類優(yōu)化,這個過程會把部分指令優(yōu)化成虛擬機內(nèi)部指令,比如方法調(diào)用指令:
invoke-*
指令變成了invoke-*-quick
,quick指令會從類的vtable表中直接取,vtable簡單來說就是累的所有方法的一張大表(包括集成自父類的方法)。因此加快了方法的執(zhí)行效率。
加入A類是補丁類,放在單獨的dex中。類B中的某個方法引用到補丁類A,所以執(zhí)行到該方法會嘗試解析類A。
ClassObject* dvmResolveClass(const ClassObject* referrer, u4 classIdx, bool fromUnverifiedConstant) {
....
/* 如果類被打上CLASS_ISPREVERIFIED標志 */
if (!fromUnverifiedConstant && IS_CLASS_FLAG(referrer, CLASS_ISPREVERIFIED)) {
if (referrer->pDvmDex != resClassCheck->pDvmDex &&
resClassCheck->classLoader != NULL) {
dvmThrowIllegalAccessError("Class ref in pre-verified class resolved to unexpected implementation");
}
}
....
}
類B由于被打上CLASS_ISPREVERIFIED標志,接下來referrer是類B,resClassCheck是補丁類A, 他們屬于不同的dex,所以會拋異常。
插樁
為了解決這個問題,一個無關(guān)幫助類放到一個單獨的dex中,原dex中所有類的構(gòu)造函數(shù)都引用這個類,而一般的方法都需要侵入dex打包流程,利用.class字節(jié)碼修改技術(shù),在所有.class文件構(gòu)造函數(shù)中引用這個幫助類,插樁由此而來。
插樁缺點
給類加載效率帶來比較嚴重的影響。
由于一個類的加載通常有三個階段:dvmResolveClass->dvmLinkClass->dvmInitClass。
dvmInitClass階段在類解析完畢嘗試初始化類的時候執(zhí)行,主要是完成父類的初始化,當前類的初始化,以及static變量的初始化復(fù)制等操作。
在初始化操作之外,如果類沒被打上CLASS_ISPREVERIFIED/CLASS_ISOPTIMIZED標志,那么類的Verify和Optimize都將會在類的初始化階段進行。
正常情況下,類的verify和Optimize都僅僅只是在apk第一次安裝執(zhí)行dexopt的時候進行。
類的verify實際上是很重的,因為會對類的所有方法執(zhí)所有指令都進行校驗,單個類的加載看起來并不耗時,但是如果同時時間點加載大量類的情況下,這個耗時就會被放大。
所以這也是插樁給類的加載效率打來比較大影響的后果。
性能影響
由于插樁會導(dǎo)致所有類都非preverify,因此在加載每個類的時候還需要做verify和optimize操作。
微信做過一次測試:
測試場景 | 不插樁 | 插樁 |
---|---|---|
700個類 | 84ms | 685ms |
啟動耗時 | 4934ms | 7240ms |
平均每個類verify+optmize(跟類的大小有關(guān)系)的耗時并不長,而且這個耗時每個類只有一次。但由于應(yīng)用剛啟動時一般會同時加載大量的類,很容易出現(xiàn)白屏,讓人無法容忍。
避免插樁
QFix方案處理辦法
手Q輕量級QFix熱補丁方案提供了一種不一樣的思路。
ClassObject* dvmResolveClass(const ClassObject* referrer, u4 classIdx, bool fromUnverifiedConstant) {
DvmDex* pDvmDex = referrer -> pDvmDex;
ClassObject* resClass;
const char* className;
/*
* Check the table first -- this gets called from the other "resolve"
* methods;
*/
// 提前把patch類加入到pDvmDex.pResClasses數(shù)組,resClass結(jié)果不為NULL
resClass = dvmDexGetResolvedClass(pDvmDex, classIdx);
if (resClass != NULL) {
return resClass;
}
className = dexStringByTypeIdx(pDvmDex->pDexFile, classIdx);
if (className[0] != '\0' && className[1] == '\0') {
/* primitive type */
resClass = dvmFindPrimitiveClass(className[0]);
} else {
resClass = dvmFindClassNoInit(className, referrer->classLoader);
}
if (resClass != NULL) {
// fromUnverifiedConstant變量設(shè)為true,繞過dex一致性校驗
if (!fromUnverifiedConstant && IS_CLASS_FLAG_SET(referrer, CLASS_ISPREVERIFIED)) {
ClassObject* resClassCheck = resClass;
if (dvmIsArrayClass(resClassCheck)) {
resClassCheck = resClassCheck->elementClass;
}
if (referrer->pDvmDex != resClassCheck->pDvmDex && resClassCheck->ClassLoader != NULL) {
dvmThrowIllegalAccessError("Class ref in pre-verified class resolved to unexpected", "implementation");
return NULL;
}
}
// 這里的dvmDexSetResolvedClass與前面的dvmDexGetResolvedClass前后呼應(yīng),說白了就是get為null后就去set。
dvmDexSetResolvedClass(pDvm, classIdx, resClass);
}
return resClass;
}
如何讓dvmDexGetResolvedClass返回的結(jié)果不為null呢?
只需要調(diào)用過一次dvmDexSetResolvedClass(pDvmDex, classIdx, resClass);
就行了。
舉個例子具體說明一下:
public class B{
public static void test() {
A.a();
}
}
我們此時需要patch的類是A,所以類A被打入到一個獨立的補丁dex中。那么執(zhí)行到類B方法中的A.a()代碼是就會嘗試去解析類A,
此時,dvmResolveClass(const ClassObject* referrer, u4 classIdx, bool fromUnverifiedConstant)
各個參數(shù)分別是:
- referrer:這里傳入的是類B
- classIdx:類A在原dex文件結(jié)構(gòu)類區(qū)中的索引id
- fromUnverifiedConstant:是否const-class/instance-of指令。
此時調(diào)用的是A的靜態(tài)a方法,invoke-static
指令不屬于const-class/instance-of
這兩個指令中的一個。不做處理的話,dvmDexGetResolvedClass一開始是null的。然后A是從補丁dex中解加載解析,B是在原Dex中,A在補丁Dex中,所以B->pDvmDex!=A->pDvmDex
,接下來執(zhí)行到 dvmThrowIllegalAccessError
從而會拋出運行時異常。
所以我們需要做的是,必須要在一開始的時候就把補丁類添加到原來dex的pResClasses數(shù)組中。
這樣就確保了執(zhí)行B類test方法的時候,dvmDexGetResolvedClass
不為null,就不會執(zhí)行后面的校驗邏輯了。
具體做法:
1、首先通過補丁工具反編譯dex為smali文件,拿到:
- preResolveClz:需要patch的類A的描述符,非必須,為了調(diào)試方便加上該參數(shù)而已。(比如實例中的類A)
- refererClz:需要patch的類A所在dex的任何一個類的描述符,注意這里不限定補丁類A的某個依賴類,實際上只要同一個dex中的任何一個類就可以。所以我們拿原dex中的第一個類即可。(一般來說第一個類是
Landroid/support/annotation/AnimRes;
) - classIdx:需要patch的類A在原dex文件中的類索引id(這里以2455作為示例)
2、然后通過dlopen拿到libdvm.so庫的句柄,通過dlsym拿到該so庫的 dvmResolveClass/dvmFindLoadedClass
函數(shù)指針。
- 首先預(yù)加載引用類
android/support/annotation/AnimRes
,這樣dvmFindLoadedClass("android/support/annotation/AnimRes")
才不為null, -
dvmFindLoadedClass
執(zhí)行結(jié)果得到的ClassObject作為第一個參數(shù)調(diào)用dvmResolveClass(AnimRes, 2455, true)
即可。
下面是該方案的JNI代碼部分實現(xiàn),實際上preResolveClz參數(shù)是非必須的。
jboolean resolveCodePathClasses(JNIEvn *env, jclass clz, jstring preResolveClz,
jstring refererClz, jlong classIdx, dexstuff_t *dexstuff) {
LOGD("start resolveCodePathClasses");
// 調(diào)用dvmFindLoadedClass
ClassObject* refererObj = dexstuff->dvmFindLoadedClass_fnPtr(Jstring2CStr(env, refererClz));
if (strlen(refererObj->descriptor) == 0) {
return JNI_FALSE;
}
// 調(diào)用dvmResolveClass
/*
* 這里的調(diào)用需要注意:
* 1. dvmResolveClass的第三個參數(shù)必須是true
* 2. 多dex場景下,dvmResolveClass的第一個參數(shù)referrer類必須跟待patch的類在同一個dex中,但是他們不需要存在引用關(guān)系,任何一個在同一個dex中的類作為referrer都可以。
* 3. referrer類必須要提前加載。
*/
ClassObject* resolveClass = dexstuff->dvmResolveClass_fnPtr(refererObj, classIdx, true);
LOGD("classIdx ClassObject : %s \n", resolveCLass->descriptor);
if (strlen(resolveClass->descriptor) == 0) {
return JNI_FALSE;
}
return JNI_TRUE;
}
這個思路與native hook方案處理方式不同,不會去hook某個系統(tǒng)方法。而是從native層直接調(diào)用,同時不需要插樁。
但QFix卻有它獨特的缺陷:
由于是在dexopt后進行繞過的,dexopt會改變原先的很多邏輯,許多odex層面的優(yōu)化會寫死字典和方法的訪問偏移,這就會導(dǎo)致比較嚴重的問題。
多態(tài)對冷啟動啟動類加載的影響
重新認識多態(tài):
實現(xiàn)多態(tài)的技術(shù)一般叫做動態(tài)綁定,是值在執(zhí)行期間判斷所引用對象的實際類型,根據(jù)其實際類型調(diào)用其相應(yīng)方法。
舉個栗子是最好說明方法:
public class B extends A {
String name = "B name";
@Override
void a_t1() {
System.out.println("B a_tl...");
}
void b_t1() {}
public static void main(String[] args) {
A obj = new B();
System.out.println(obj.name);
obj.a_tl();
}
}
class A {
String name = "A name";
void a_t1() {
System.out.println("A a_tl...");
}
void a_t2() {}
}
輸出結(jié)果:
A name
B a_tl...
有次可以看到name是沒有多態(tài)性的,這里分析下方法多態(tài)性的實現(xiàn):
首先new B()
執(zhí)行會加載類B,方法調(diào)用鏈:dvmResolveClass->dvmLinkClass->createVtable
。
此時會為類B創(chuàng)建一個vtable。
在虛擬機加載每個類都會為這個類生成一張vtable表,vtable說白了就是當前類的所有virtual方法的一個數(shù)組,當前類和所有所有集成父類
public/protected/default
方法就是virtual方法。
vtable數(shù)組生成的代碼就不在這里分析了,有興趣的去原書籍查找。
其過程可以簡單用文字類描述一下:
- 整個復(fù)制父類vtable的vtable
- 遍歷子類virtual方法集合,如果方法一致,說明是重新,那么相同索引位置處,子類重寫方法覆蓋掉vtable中父類的方法。
- 方法原型不一致,那么把該方法添加到vtable的末尾
那么上述示例中,A和B的vtable分別是:
A -> vtable = {A.a_t1, A.a_t2}
B -> vtable = {B.a_t1, a.a_t2, B.b_t1}
我們來看下obj.a_t1()發(fā)生了什么
GOTO_TARGET(invokeVirtual, bool methodCallange, bool) {
Method* baseMethod;
Object* thisPtr;
EXPORT_PC();
vsrc1 = INST_AA(inst); /* AA (COUNT) OR BA(COUNT + arg 5) */
ref = FETCH(1); /* method ref */
vdst = FETCH(2); /* 4 regs -or- first reg */
/*
* The object against which we are executing a method is always
* in the first arguent
*/
if (methodCallRange) {
thisPtr = (Object*) GET_REGISTER(vdst);
} else {
thisPtr = (Object*) GET_REGISTER(vdst & 0x0f); // 當前對象
}
/*
* Resolve the method. this is the correct method for the static
* type of the object. we also verify access permissions here.
*/
baseMethod = dvmDexGetResolvedMethod(methodClassDex, ref); // 是否已經(jīng)解析過該方法
if (baseMethod == NULL) {
baseMethod = dvmResolveMethod(current->clazz, ref, METHOD_VIRTUAL);
// 沒有解析過該方法調(diào)用dvmResolveMethod, baseMethod得到的是當然是A.a_t1方法。
if (base == NULL) {
ILOGV("+ unknown method or access denied");
GOTO_exceptionThrown();
}
}
/*
* Combine the object we found with the vtable offset in the
* method
*/
assert(baseMethod->methodIndex < thisPtr->clazz->vtableCount);
methodToCall = thisPtr->clazz->vtable[baseMethod->methodIndex];
// A.a_t1方法在類A的vtable中索引去類B的vtable中查找
GOTO_invokeMethod(methodCallRange, methodToCall, vsrc1, vdst);
}
GOTO_TARGET_END
首先obj引用類型是基類A,所以上述代碼中baseMethod 拿到的A.a_t1
,baesMethod->methodIndex
是該方法在類A中的vtable中的索引0,obj的實際類型是類B,所以thisPtr->clazz
就是類B,那么B.vtable[0]
就是B.a_t1
方法,所以obj.a_t1()實際上調(diào)用的就是B.a_t1方法。這樣就首先了方法的多態(tài)。
多態(tài)在冷啟動方案中的坑
dex在第一次加載的時候,會執(zhí)行dexopt,dexopt有兩個過程:verify+optimize.
- dvmVerifyClass:類校驗,類校驗的目的是防止類被篡改校驗類的合法性。此時會對類的每個方法進行校驗,這里我們只需要知道如果類的素有方法中直接飲用到的類和當前類都在同一個dex中的話,dvmVerifyClass就返回true。
- dvmOptimizeClass:類優(yōu)化,簡單來說這個過程會把部分指令優(yōu)化成虛擬機內(nèi)部指令,比如說方法調(diào)用指令:
invoke-virtual-quick
+ 立即數(shù),quick指令會從類的vtable類中直接取,vtable簡單來說就是類的所有方法的一張大表(包括繼承自父類的方法)。因此加快了方法的執(zhí)行效率。
很簡單的例子:
我們增加一個virtual方法:
public class Demo {
public static void test_addMethod() {
A obj = new A();
obj.a_t2();
}
}
class A {
int a = 0;
// 新增a_t1方法
void a_t1() {
Log.d("Sophix", "A a_t1");
}
void a_t2() {
Log.d("Sophix", "A a_t2");
}
}
修復(fù)后的apk中新增了a_t1()方法,Demo不做任何修復(fù),我們會發(fā)現(xiàn)應(yīng)用補丁后的Demo.test_addMothod()
得到的結(jié)果是 “A t1”
,這表明obj.a_t2
執(zhí)行的竟然是a_t1方法。
這恰恰說明了opt過程對invoke指令優(yōu)化,原來t2的立即數(shù)在補丁類中對應(yīng)到了t1中。
因此QFix方案要繞過opt過程進行處理是非常危險的,除了多態(tài)可能還會有其他坑,而且opt過程不可控可能在不同版本面臨適配。
Sophix處理辦法
由于QFix無法繞過的缺陷,因此Sophix并沒有采納學(xué)習,而是根據(jù)google 開源的dexmerge方案而自研了一套完整的DEX方案。
補丁共用
前文中講熱替換的時候,雖然替換的是ArtMethod,但補丁的粒度卻是類。
我們的為了減少補丁包的體積,我們不可為熱冷替換方案準備兩套方案。
因此Sohpix的熱部署的補丁是能夠降級直接走冷啟動的(共用)。
冷啟動方案
Sophix的冷啟動方案是作為熱部署方案的替補或者是說互補方案。 具體實施方案對Dalvik和Art下分別做了處理。
- Dalvik下采用自行研發(fā)的全量的DEX方案
- Art下虛擬機本身有已經(jīng)支持多dex加載,該場景下的具體方案就是把補丁dex重命名為classes.dex(主dex)來加載。
先整理一下冷啟動方案
對于Android下的冷啟動類加載修復(fù),最早的實現(xiàn)方案是QQ空間提出的dex插入方案,該方案的主要思想是,把新補丁dex插入到ClassLoader索引路徑的最前面。這樣在load一個class時,就會優(yōu)先找到補丁中的。
后來微信的Tinker和手Q的QFix方案都基于該方案做了改進,而這類插入dex的方案,都會遇到一個主要的問題,就是如何解決Dalvik虛擬機下的pre-verify問題。
如果一個類中直接引用到的所有非系統(tǒng)類都和該類在同一個dex里的話,那么這個類就會被打上CLASS_ISPREVERIFIED
,具體判定代碼可見虛擬機中的verifyAndOptmizeClass
函數(shù)。
我們來列舉一下騰訊的三大修復(fù)方案是如何解決這個問題的:
- QQ空間的處理方式,是在每個類中插入一個來自其他dex的hack.class,由此讓所有類里面都無法滿足pre-verified條件(侵入打包流程,添加冗余代碼,且會影響loadclass性能)
- QFix的方式就是取得虛擬機中的某些底層函數(shù),提前resolve所有補丁類,以此繞過Pre-verify檢查(需要獲取底層虛擬機的函數(shù),不夠穩(wěn)定可靠,無法新增public函數(shù))
- Tinker的方式,是合成全量dex文件,這樣所有class都在全量dex中解決,從而消除class重復(fù)而帶來的沖突(從指令維度進行合成,實現(xiàn)較為復(fù)雜,性較比不高)
全量DEX方案
一般來說,合成完整dex,思路就是把原來的dex和patch里的dex重新合并成一個。
然而我們可以逆向思維,既然補丁中已經(jīng)有需要變動的類,那么原來基線包中dex中的重復(fù)的class就可以刪掉了,這樣更不用全量插樁來解決pre-verfy問題了。
參照Android原生multi-dex的實現(xiàn)再來看這個方案就比較好理解了。
multi-dex方案就是把一個apk中用到的類拆分到多個dex文件中,每個dex中都只包含了部分的類定義,單個dex也可以加載,因為只要把所有dex都load進去,本dex中不存在的類就可以在運行期間在其他的dex中找到。
因此同理,在基線包里面去掉了補丁中的class后,原先需要發(fā)生變更的舊的class時就會自動找到補丁dex,補丁中的新class在需要用到不變的class時也會找到基線包dex的class。
這樣的話,基線包里面不適用補丁類的class仍舊可以按照原來的邏輯來做odex,最大保證了dexopt的效果。
這么一來,我們不再需要像傳統(tǒng)合成的思路那樣判斷類的增加和修改情況,而且也不需要處理合成時方法數(shù)超出的情況批注:只能說一定范圍上,不用考慮方法數(shù)問題
,對于dex的結(jié)構(gòu)也不用進行破壞性重構(gòu)。
現(xiàn)在,合成完整dex的問題就簡化成了——如何在基線包dex里面去掉補丁包中包含的所有類。
需要注意的是,sophix并沒有將某個class的所有信息都從dex中移除,因為如果這么做,可能會導(dǎo)致dex的各個部分都發(fā)生變化,從而需要大量調(diào)整offset,這樣就變得費時費力了,因此我們需要做的就是讓解析這個dex的時候找不到這個class的定義就行了。因此,只需要移除定義的入口,對于class的具體內(nèi)容不進行刪除,這樣能最大可能的減少offset的修改。
雖然這樣做會把這個被移除類的無用信息殘留在dex文件中,但這些信息占不了大多空間,并且對dex的處理速度是提升很大的,這種移除類操作的方式變得十分輕快。
android multidex機制對于Application的處理方式為:
將Application用到的類都打包到主dex中,因此只要把熱修復(fù)的初始化放到attachBaseContext的最前面就基本上不會出問題了。
DexFile loadDex在Dalvik和Art下的工作細節(jié)
DexFile.loadDex嘗試把一個dex文件解析加載到native內(nèi)存都發(fā)生了什么?
不管Dalvik或者Art虛擬機,他們都調(diào)用了DexFile.openDexFileNative這個native方法。
在Dalvik虛擬機下面:
static void Dalvik_dalvik_system_DexFile_openDexFileNative(const u4* arg, JValue* pResult) {
if (hasDexExtension(sourceName) &&
dvmRawDexFileOpen(sourceName, outputName, &pRawDexFile, false) == 0) { // 加載一個原始dex文件
ALOGV("Open DEX file '%s' (DEX)", sourceName);
pDexOrJar = (DexOrJar*) malloc(sizeof(DexOrJar));
pDexOrJar -> isDex = true;
pDexOrJar -> pRawDexFile = pRawDexFile;
pDexOrJar -> pDexMemory = NULL;
} else if (dvmJarFileOpen(sourceName, outputName, &pJar) == 0) {
ALOGV("Open DES file'%s' (Jar)", sourceName);
pDexOrJar = (DexOrJar*) malloc(sizeof(DexOrJar));
pDexOrJar -> isDex = false;
pDexOrJar -> pJarFile = pJarFile;
pDexOrJar -> mDexMemory = NULL;
} else {
ALOGV("Unable to open DEX file '%s'", sourceName);
dvmThrowIOException("unable to open DEX file");
}
}
int dvmJarFileOpen(const char* fileName, const char* odexOutputName, JarFile** ppJarFile, bool isBootstrap)
...
else {
ZipEntry entry;
tryArchive:
/*
* Pre-created .odex absent or stale. Look inside the jar for a
* "classes.dex".
*/
entry = dexZipFindEntry(&archive, kDexInJarName); // kDexJarName == "classes.dex", 說明只加載一個dex
...
}
static const char* kDexInJarName = "classes.dex";
很明顯Dalvik嘗試加載一個壓縮文件的時候只會把classes.dex
加載到內(nèi)存。如果此時壓縮文件有多dex,那么其他的dex文件會被直接忽略。
ART虛擬機:
方法調(diào)用連:DexFile_oepnDexFileName -> openDexFilesFromOat -> LoadDexFiles
std::vector<std::unique_ptr<const DexFile>> oatFileAssistant::LoadDexFiles(
const OatFile& oat_file, const char* dex_location) {
// Load the primary dex file.
const OatFile::OatDexFile* oat_dex_file = oat_file.GetOatDexFile(dex_location, nullptr, false);
std::unique_ptr<const DexFile> dex_file = oat_dex_file -> OpenDexFile(&error_msg);
dex_files.push_back(str::move(dex_file));
// Load secondary multidex files
for (size_t i = 1; ; i++) {
std::string secondary_dex_location = DexFile::GetMultiDexLocation(i, dex_location);
oat_dex_file = oat_file.GetOatDexFile(secondary_dex_location.c_str(), nullptr, false);
dex_file = oat_dex_file->OpenDexFile(&error_msg);
dex_file.push_back(std::move(dex_file));
}
return dex_files;
}
可以從代碼上看的出來,Art下面已經(jīng)默認支持加載壓縮文件中的多個dex,首先肯定要先加載primary dex,其實就是classes.dex
,后續(xù)會加載其他的dex。
所以補丁類放到classes.dex
就可以實現(xiàn)補丁類先加載,后續(xù)在其他dex中的補丁類是不會被重復(fù)加載的。
對比Tinker方案
在Dalvik
走普通的multidex方案,需要手動加載,補丁包確保要放置到dexElements數(shù)組的最前面。
在Art下面:
我們只需要把補丁dex命名為classes.dex。原apk中的apk一次命名為classes(2,3,4...).dex就好了,然后一起打包為一個壓縮文件。然后通過DexFile.loadDex得到DexFile對象,最后把該DexFile對象整個替換掉就的dexElements數(shù)組就可以了。