[读书笔记] 深入探索Android热修复技术原理 (手淘技术团队)
熱修復技術介紹
探索之路
最開始,手淘是基于Xposed進行了改進,產生了針對Android Dalvik虛擬機運行時的Java Method Hook技術——Dexposed。
但該方案對于底層Dalvik結構過于依賴,最終無法兼容Android 5.0 以后的ART虛擬機,因此作罷。
后來支付寶提出了新的熱修復方案AndFix。
AndFix同樣是一種底層替換的方案,也達到了運行時生效即時修復的效果,并且重要的是,做到了Dalvik和ART環境的全版本兼容
阿里百川結合手淘在實際工程中使用AndFix的經驗,對相關業務邏輯解耦后,推出了阿里百川HotFix方案,并得到了良好的反響。
此時的百川HotFix已經是一個很不錯的產品了,對基本的代碼修復需求都可以解決,安全性和易用性都做的比較好。然而,它所依賴基石,AndFix本身是有局限性的。且不說其底層固定結構的替換方案不好,其使用范圍也存在著諸多限制,雖然可以通過改造代碼繞過限制來達到相同的修復目的,但這種方式即不優雅也不方便。而更大的問題,AndFix只提供了代碼層面的修復,對于資源和so的修復都未能實現。
在Android平臺上,業界除了阿里系之外,比較著名的修復還有:騰訊QQ空間的超級補丁技術、微信的Tinker、餓了么的Amigo、美團的Robust等等。不過他們各自有自身的局限性,或者不夠穩定,或者補丁過大,或者效率低下,或者使用起來過去繁瑣,大部分技術上看起來似乎可行,但實際體驗并不好。
終于在2017年6月,阿里巴巴手淘技術團隊聯合阿里云正式發布了新一代的非侵入式的Android熱修復方案——Sophix。
Sophix的橫空出世,打破了各家熱修復技術紛爭的局面。因為我們可以滿懷信心的說,在Android熱修復的三大領域:代碼修復、資源修復、so修復方面,以及方案的安全性和易用性方面,Sophix都做到了業界領先。
Sophix的誕生,期初是對原先的阿里百川的HotFix 1.X版本進行升級衍進。
Sophix保留了阿里百川HotFix的服務端整套請求流程,以及安全校驗部分。而原本的熱修復方案,主要限制在于AndFix本身。AndFix自身的限制幾乎是無法繞過的,在運行時對原有類機構是已經固化在內存中的,它的一些動態屬性很難進行擴展。 并且由于Android系統的碎片化,廠商的虛擬機底層結構都不是確定的,因此直接基于原先機制進行擴展的風險很大。方案對比
| Dex修復 | 同時支持即時生效和冷啟動修復 | 冷啟動修復 | 冷啟動修復 |
| 資源更新 | 差量包,不用合成 | 差量包,需要合成 | 全量包,不用合成 |
| SO庫更新 | 插樁實現,開發透明 | 替換接口,開發不透明 | 插樁實現,開發透明 |
| 性能損耗 | 低,僅冷啟動情況下有些損耗 | 高,有合成操作 | 低,全量替換 |
| 四大組件 | 不能增加 | 不能增加 | 能增加 |
| 生成補丁 | 直接選擇已經編好的新舊包在本地生成 | 編譯新包時設置基線包 | 上傳完整新包到服務端 |
| 補丁大小 | 小 | 小 | 大 |
| 接入成本 | 傻瓜式接入 | 復雜 | 一般 |
| Android版本 | 全部支持 | 全部支持 | 全部支持 |
| 安全機制 | 加密傳輸及簽名校驗 | 加密傳輸及簽名校驗 | 加密傳輸及簽名校驗 |
| 服務端支持 | 支持服務端控制 | 支持服務端控制 | 支持服務端控制 |
可以看到,Sophix在各個指標上都占優勢。而其中唯一不支持的地方就是四大組件的修復。
這是因為,如果要修復四大組件,必須在AndroidManifest里面預先插入代理組件,并且盡可能聲明所有權限、而這么做就會給原先的app添加很多臃腫的代碼,對app運行流程的侵入性很強,所以,本著對開發者透明與代碼極簡的原則,這里不做多余處理。
設計理念
Sophix的核心設計理念——就是非侵入性。
在Sophix中,唯一需要的就是初始化和請求補丁兩行代碼,甚至連入口Application類我們都不做任何修改,這樣就給了開發者最大的透明度和自由度。
代碼修復
代碼修復有兩大主要方案,一種是阿里系的底層替換方案,另一種是騰訊系的類加載方案。
兩種方案各有優劣:
- 底層替換方案限制頗多,但時效性最好,加載輕快,立即見效。
- 類加載方案時效性差,需要重新冷啟動才能見效,但修復范圍廣,限制少。
說說Tinker:
微信的Tinker方案是完整的全量dex加載,并且可謂是將補丁合成做到了極致,然而我們發現,精密的武器并非適用于所有戰場。
Tinker的合成方案,是 從dex的方法和指令維度 進行全量合成,整個過程都是自己研發。
雖然可以很大的節省空間,但對于dex內容的比較粒度過細,實現較為復雜,性能消耗嚴重。
實際上,dex的大小占整個apk的比例是比價低的,一個app里面的dex文件大小并不是主要部分,而占空間大的主要還是資源文件。
因此,Tinker方案的時空代價轉換的性價比不高。
其實,dex比較的最佳粒度,應該是在類的維度。它即不像方法和指令維度那樣的細微,也不像bsbiff比較那樣的粗糙。在類的維度,可以達到時間和空間平衡的最佳效果。
既然兩種方案各有其特點,把他們聯合起來就是最好的選擇了。
Sophix的代碼修復體系正式同時涵蓋了兩種方案。在補丁生成階段,補丁工具會根據實際代碼的變動情況進行自動選擇:
- 針對小修改,在底層替換方案限制范圍內,就直接采用底層替換修復。
- 對于代碼修復超出底層替換限制的,會使用類加載替換,雖然及時性沒有那么好,但總歸可以達到熱修復的目的。
另外,在運行時還會再判斷所運行的機型是否支持熱修復,這樣即使機型底層虛擬機構造不支持,還是會走類加載修復,從而達到最好的兼容性。
資源修復
目前市面上的很多熱修復方案基本上都參考了Instant Run的是實現。
簡單來說,Instant Run中的資源熱修復方案分為兩步:
其實,在該方案中有大量的代碼都是在處理兼容性問題和找到所有AssetManager的引用。真正替換的代碼其實很簡單。
Sophix并沒有直接采用Instant Run技術,而是構造了一個package id為0x66的資源包,其實這個資源包里面只有修改了的資源項,直接在原有的AssetManager中addAssetPath就可以了。
由于補丁包的Package Id為0x66,不與目前已經加載的0x7f資源段沖突,因此直接加入到已有的AssetManager中就可以直接使用了。
SO庫修復
SO庫的修復本質上是對native方法的修復和替換。
我們采用的是類似 類修復反射注入方式 。把補丁so庫插入到nativeLibraryDirdetories數組的最前面。就能夠達到加載so庫的時候是補丁so庫,而不是原來so庫的目錄,從而達到修復的目的。
采用這種方案,完全有Sophix啟動期間反射注入pathc中的so庫。對開發者透明。
不用像其他方案那樣需要手動的替換系統的System.load來實現替換目的。
代碼修復技術詳解
底層替換原理
AndFix方案引發的思考
在各種Android熱修復方案中,AndFix的即時生效令人印象深刻,它稍顯另類,并不需要重新啟動,而是在加載補丁后直接對方法進行替換就可以完成修復,然而它的使用限制也遭遇到更多的質疑。
怎么做到即時生效?
在app運行到一半的時候,所有需要發生變更的類已經被加載過了,在Android上是無法對一個類進行卸載的。AndFix采用的方法是,在已經加載了的類中直接在native層替換到原有方法,是在原來類的基礎上進行修改的。
每一個Java方法在ART中都對應著一個ArtMethod,ArtMethod記錄了這個Java方法的所有信息,包括所有類、訪問權限、代碼執行地址等等。
通過env->FromReflectedMethod,可以由Method對象得到這個方法對應的ArtMethod的真正其實地址。然后把它強轉為ArtMethod支持,從而對所有成員進行修改。
這樣全部替換完之后就完成了熱修復邏輯。以后調用這個方法時就會直接走到新方法中了。
虛擬機調用方法的原理分析
為什么替換ArtMethod數據后可以實現熱修復呢?這需要從虛擬機調用方法的原理說起。
以Android6.0實現為例。
ArtMethod結構中最重要的兩個字段entry_point_from_interprete_和entry_point_from_quick_compiled_code_。
ART中可以采用解釋模式或者AOT機器碼模式執行
解釋模式: 就是取出Dex Code,逐條的解釋執行就行了。如果方法的調用者是以解釋模式運行的,在調用這個方法是,就去取得這個方法的`entry_potin_from_interpreter_`,然后跳轉過去執行。AOT模式: 預先編譯好Dex Code對應的機器碼,然后運行期直接執行機器碼就行了,不需要一條條的解釋執行Dex Code。如果方法的調用者是以AOT機器碼執行的,在調用這個方法是,就是跳轉到`entry_point_from_quick_compiled_code_`執行。那我們是不是只需要替換這幾個字段就可以了呢?
并沒有這么簡單。以為不論是解釋模式還是AOT模式,在運行期間還會需要用到ArtMethod的里面的其他成員字段的。
其實這樣正式native替換方式兼容性問題的原因。
Sophix沒有選擇將Artmethod的所有成員都進行替換,而是把ArtMethod作為整天進行替換。
這里需要強調的是求解ArtMethod結構的內存占用大小。
由于我們是在運行時生效(各家ROM都會有多多少少的改動),且sizeofsizeof()工作原理是在編譯期,因此我們無法直接使用該表達式。
Sophix采用了比較聰明的辦法:利用現行結構的特定,使用兩個ArtMethod之前的偏移量來動態計算ArtMethod的數據結構大小。但這里需要依賴存放ArtMethod的數據結構是線性的。
替換后方法訪問權限問題
1、類內部
上述提到,我們整個替換ArtMethod的內容,但新替換的方法的所屬類和原來方法的所屬類,是不同的類。
被替換的方法有權限訪問其他的private方法嗎?
通過觀察Dex Code和Native Code,可以推測,在dex2oat生成AOT機器碼時是有做一些檢查和優化的,由于dex2oat編譯機器碼時確認了兩個方法同屬一個類,所以機器碼中就不存在權限檢查相關代碼。
2、同包名下
但是并不是所有方法都如同類內部直接訪問那樣順利的。
補丁類正在訪問同包名下的類時會報出訪問異常。
具體的校驗邏輯是在虛擬機代碼的Class::IsInSamePackage中,關鍵點在于比較兩個Class的所屬的ClassLoader。
因此這里還需要將新類的ClassLoader設置為與原來一致。
3、反射調用非靜態方法
當一個非靜態方法被熱替換后,再反射調用這個方法,會拋出異常。
在反射Invoke一個方法時,在底層會掉哦用到InvokeMethod -> VerifyObejctIsClass函數做驗證。
由于熱替換方案鎖替換的非靜態方法,在反射調用者,由于VerifyObjectIsCLass時,舊類和新類不匹配,就會導致驗證不通過。
如果是靜態方法,會有同樣的問題嗎?
當然沒有,靜態方法會在類的級別直接進行調用的,不需要接受對象實例作為參數,不會有這方面的檢查。
因此,對于這種反射調用非靜態方法的問題,Sophix會采用另一種冷啟動機制對付,最后會有介紹。
即時生效帶來的限制
除了反射的問題,即時生效直接在運行期修改底層結構的熱修復方法,都存在著一個限制,那就是只能替換方法。對于補丁里面如果存在方法的增加或者減少,以及成員字段的增加和減少情況都是不適用的。
原因是這樣的,一旦補丁類中出現了方法的增減,會導致整個類以及整個dex方法數的變化。方法數的變化伴隨著方法索引的變化,這樣在訪問時就無法所引導正確的方法了。
如果字段發生了增減,和方法變化差不多,所有字段的索引都會發生變化。
并且更加嚴重的是,如果程序運行中間某個類突然增加了一個字段,那么對于原來已經生成的實例,還是原來的結構,已是無法改變的了,而新方法在使用到老實例時,訪問新增字段就會產生不可預期的結果。
因此綜合來說,即時生效方案只有在下面兩種情況下是不適用的:
雖然有一些使用限制,但一旦滿足使用條件,這種熱修復方式還是十分出眾的,補丁小,加載迅速,能夠實時生效,無需重啟app,并且有著完美的設備兼容性(整個copy Method結構)。
Java語言的編譯實現所帶來的挑戰
Sophix一直秉承 粒度小、注重快捷熱修復、無侵入適合原生工程。因為這個原則,我們在研發過程中遇到很多 編譯期 的問題,引用印象深刻。
內部類
問題:有時候會發現,修改外部類某個方法邏輯為訪問內部類的某個方法時,最后打包出來的補丁包竟然提示新增了一個方法。
因此我們很有必要了解內部類在編譯期間是怎么編譯的。
首先需要知道 ** 內部類會在編譯期會被編譯為跟外部類一樣的頂級類。 **
靜態內部類和非靜態內部類的區別。
它們的區別其實大家都很熟悉,非靜態類持有外部類的引用,靜態內部類不持有外部類的引用。
既然內部類跟外部類一樣都是頂級類,那是不是意味著對方private的method/field是沒法被訪問到的,事實上外部類為了訪問內部類私有的域和方法,編譯期會自動外內部類生成access&**相關方法。
因此,如果補丁類中修改的方法中添加了需要訪問內部類私有數據或者方法的代碼的話,那么編譯期間會新增access&**方法,供內部類被訪問使用。
如果想通過熱部署修復的新方法需要訪問內部類的私有域或方法,那么我們應該防止生成access&**相關方法。
Sophix有以下建議:
- 外部類如果有內部類,把外部類所有的method/fidle的private訪問權限修改為projected或者默認訪問權限或者public。
- 同時把內部類的所有的method/field的private訪問修改為projected或者模式訪問權限或者public。
匿名內部類
匿名內部類其實也是個內部類,自然也會有上一小節中說到的限制和應對策略。
但它還會有其他的限制。
匿名內部類的命名規則
匿名內部類顧名思義就是沒有名字。
命名格式一般都是外部類&numble,后面的numble,是編譯器根據匿名內部類在外部類中出現的先后關系,一次累加命名。
解決方案
新增/減少匿名內部類,實際上對于熱部署來說是無解的,因為補丁工具拿到的已經是編譯后的.class文件,所以根本無法區分,所以這種情況下,應極力避免插入一個新的匿名內部類。
當然,如果匿名內部類是插入到外部類的末尾,那么是允許的。(確是很容易犯錯的)
靜態域/靜態代碼塊
實際上,熱部署方案除了不支持method/field的新增,同時也是不支持<clinit>的修復,因為這個方法是dalvik虛擬機中類進行初始化的時候調用。
在Java源碼中并沒有clinit這個方法,這個方法是android編譯器自動合成的方法。
通過測試發現,靜態field的初始化和靜態代碼塊實際上都會被編譯器便已在<clinit>這個方法。
靜態代碼塊和靜態域初始化在clinit中的先后關系就是兩者出現在源碼中的先后關系。
類加載然后進行類初始化的時候,會去調用clinit方法,一個類僅加載一次。
在下面三種情況下,會嘗試加載一個類:(在面試中被問到過) 1. new一個類的對象; 2. 調用類的靜態方法; 3. 獲取類靜態域的值;首先判斷這個類有沒有被加載過,如果沒有,執行的流程是`dvmResolveClass -> dvmLinkClass -> dvmInitClass `。 類的初始化是在dvmInitClass。這個函數會首先嘗試對父類進行初始化,然后調用本類的clinit方法。非靜態域/非靜態代碼塊
非靜態域初始化和非靜態代碼塊被編譯器翻譯在<init>默認午餐構造函數中。
實際上,如果存在有參構造函數,那么每個有參構造函數都會執行一個非靜態域的初始化和非靜態代碼塊。
構造函數會被android編譯器自動翻譯成<init>方法
前面說過<clinit>方法在類加載初始化的時候被調用,那么<init>構造函數方法肯定是對類對象進行初始化的時候被調用的。
簡單來說,new一個對象就會對這個對象進行初始化,并調用這個對象相應的構造函數。
我們查看代碼String s = new String("test")編譯之后的樣子。
new-instance v0, Ljava/lang/String; invoke-direct {v0}, Ljava/lang/String;-><init>()V首先會執行new-instance指令,主要為對象分配內存,同時,如果類之前沒加載過,嘗試加載類;
然后執行invoke-direct指令調用類的init構造函數方法,執行對象初始化。
解決辦法
Sophix不支持clinit方法的熱部署,任何靜態field初始化和靜態代碼塊的變更都會被翻譯到clinit方法中,所以最終會導致熱部署失敗,只能冷啟動。
非靜態field和非靜態代碼塊的變更被翻譯到<init>構造函數中,熱部署模式下只是視為一個普通方法的變更,此時對熱部署是沒有影響的。
final static 域編譯
final static域首先是一個靜態域,所以我們自然認為由于會被翻譯到clinit方法中,所以自然不支持熱部署。
但是測試發現,final static修飾的基本類型/String 常量類型,匪夷所思的竟然并沒有翻譯到clinit方法中。
事實上,類加載初始化dvmInitClass在執行clinit方法之前,首先會執行initSFields, 這個方法的作用主要是給static域賦予默認值。如果是引用類型,那么默認值為NULL。final static 修飾的原始類型 和 String 類型域(非引用類型),并不會翻譯在clinit方法中,而是在類初始化執行initSFields方法時得到了初始化賦值。 final static 修飾的引用類型,初始化仍然在clinit方法中。我們在Android性能優化的相關文檔中經常看到,如果一個field是常量,那么推薦盡量使用static final作為修飾符。
很明顯這句話不大對,得到優化的僅僅是final static原始類型和String類型域(非引用類型),如果是引用類型,實際上是不會得到任何優化的。
final static String類型的變量,編譯期間會被有優化成const-string指令,但是該在指令拿到的只是字符串常量在dex文件結構中字符常量區的索引id,所以需要額外的一次字符串查找。
dex文件中有一塊區域存儲著程序所有的字符串常量, 最終這塊區域會被虛擬機完整加載到內存中,這塊區域也就是通常所說的“字符串常量區”內存。因此,我們可以得到以下結論
- 修改final static基本類型或者String類型域(非引用類型),由于編譯器間引用到基本類型的地方會被立即數替換,引用到String類型域的地方會被常量池索引id替換,所以在熱部署模式下,最終所有引用到該final static域的方法都會被替換。實際上此時仍然可以走熱部署。
- 修改final static引用類型域,是不允許的,因為這個field的初始化會被翻譯到clinit方法中,所以此時沒法走熱部署。
方法混淆
其實除了上面提到的內部類/匿名內部類可能會造成method新增之后,代碼混淆也可能會導致方法的內聯和剪裁,那么最后可能也會導致method的新增/減少。
實際上只要混淆配置文件加上-dontoptimize這項就不會去做方法的剪裁和內聯。
一般情況下項目的混淆配置都會使用到android sdk默認的混淆配置文件proguard-android-optimize.txt或者 proguard-android.txt,兩者的區別就是后者應用了-dontoptimize這一項配置,而前者沒有使用。
ProGuard_build_process實際上,圖上的幾個步驟都是可以選擇的,其中對熱部署可能會產生嚴重影響的主要在optimization階段。
optimization step: 進一步優化代碼,不是入口點的類和方法可以被設置成private、static或final,無用的參數可能會被有移除,并且一些地方可能會被內聯。
可以看到optimization階段,除了會做方法的剪裁和內聯可能導致方法的新增/減少之外,還可能把方法的修飾符優化成 private/static/final。熱補丁部署模式下,混淆配置最好都加上-dontoptimize配置。
`` : 針對.class文件的預校驗,在.class文件中加上StackMa/StackMapTable信息,這樣Hotspot VM在類加載時候執行類校驗階段會省去一些步驟,因此類加載將更快。
我們知道android虛擬機執行的dex文件,編譯期間dx工具會把所有的.class文件優化成.dex文件,所以混淆庫的預校驗在android中是沒有任何意義的,反而會拖累打包速度。
android虛擬機中有自己的一套代碼校驗邏輯(dvmVerifyClass)。所以android中混淆配置一般都需要加上-dontpreverify配置。
switch case語句編譯
編譯規則:
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;} }編譯出來的結果:
# virtual methods .method public testContinue () Vconst/4 v1, 0x2.local v1, "temp":Iconst/4 v0, 0x0.local v0, "result":Ipacked-switch v1, :pswitch_data_0:pswitch_0return-void:pswitch_1const/4 v0, 0x1:pswitch_2const/4 v0, 0x3:pswitch_3const/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 () Vconst/4 v1, 0x2.local v1, "temp":Iconst/4 v0, 0x0.local v0, "result":Isparse-switch v1, :sswitch_data_0:sswitch_0const/4 v0, 0x1:sswitch_1const/4 v0, 0x3:sswitch_2const/16 v0, 0xa:sswitch_data_0.sparse-switch0x1 -> :sswitch_00x3 -> ::switch_10xa -> :sswitch_2.end sparse-switch .end methodtestContinue方法的switch case語句被翻譯成packed-switch指令,testNotContinue方法的switch case語句被翻譯成sparse-switch指令。
比較下差異:
testContinue的switch的case項是幾個比較連續的值,中間的差值用:pswitch_0補齊,:pswitch_0標簽處直接return-void。
testNotContinue的swtich語句的case項不夠連續,所以編譯期間編譯為sparse-switch指令。
怎么才算比較連續的case是由編譯器來決定的。
如何應對熱部署
一個資源id肯定是const final static變量,此時如果switch case 語句會被翻譯成packed-switch指令,所以補丁包這個時候如果不做處理就無法做到資源id的完全替換。
解決方案其實很暴力,修改smali反編譯流程,碰到packed-switch指令強轉為sparse-switch指令;
做完資源id的暴力替換,然后再回編譯smali為dex;
泛型編譯
泛型是java5才開始引入的。泛型的使用也可能會導致method的新增。
Java語言的泛型基本上都是在編譯器中實現的。
由編譯器執行類型檢查和類型推斷,然后生成普通的非泛型的字節碼,就是虛擬機完全無感知泛型的存在。
這種實現技術成為擦除(erasure)。編譯器使用泛型類型信息保證類型安全,然后在生成字節碼之前將其清除。由于泛型是在java 5中才引入的,擴展虛擬機指令集來支持泛型是讓人無法接受的,因為這會為Java廠商升級其JVM造成難以逾越的障礙,因此才采用了可以完全在編譯器中實現的擦除方法。
類型擦除與多態的沖突
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; // 跟父類返回值不一樣,為什么重寫父類get方法?public Number get() {return n;} // 跟父類方法參數不一樣,為什么重寫set方法?public void set(Number n) {this.n = n;} }class C extends A {private Number n; // 跟父類返回值不一樣,為什么**重寫**父類get方法?public Number get() {return n;} // 跟父類方法參數不一樣,為什么**重載**set方法?public void set(Number n) {this.n = n;} }為什么類B的set和get方法可以用@Override而不報錯。
@Override表明這個方法是重寫,我們知道重寫的意思是子類中的方法簽名和返回類型都必須一致。
但是很明顯的,B的方法無法對A的set/get方法進行重寫的。其實我們的本意是重寫實現多態,可是類型擦除后,只能變成了重載。
這樣,類型擦除就和多態有了沖突。
實際上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 v0return-object v0 .end method.method public set(Ljava/lang/Number;) V .method public bridge synthetic set(Ljava/lang/Object;) Vcheck-cast p1, Ljava/lang/Number;invoke-virtual {p0, p1}, Lcom/taobao/test/B;->set(Ljava/lang/Number;)Vreturn void .end method我們發現編譯器會自動生成兩個bridage方法來重寫父類方法,同時這兩個方法實際上調用B.set(Ljava/lang/Number;)和B.get()Ljava/lang/Number這兩個重載方法。
子類中真正重寫基類方法的是編譯器自動合成的bridge方法。而類B定義的get和set方法上面的@Override只不過是假象。虛擬機巧妙的使用橋方法的方式來解決了類型擦除和多態的沖突。
也就是說,類B中的字節碼中get()Ljava/lang/Number;和get(0Ljava/lang/Object;是同時存在的,這就顛覆了我們的認知,因為在正常代碼中他們是無法同時存在的。
因此,虛擬機為了實現泛型的多態做了一個看起來“不合法”的事情,然后交給虛擬機自己去區別處理了。
Lambda表達式編譯
Lambda表達式是 java 7才引入的一種表達式,類似于匿名內部類實際上由于匿名內部類有很大的區別。
Lambda表達式的使用也可能導致方法的新增/減少,導致最后走不了熱部署模式。
lambda為Java添加了缺失的函數式編程特點,Java現在提供的最接近閉包的概念便是Lambda表達式。Java編譯器將lambda表達式編譯成類的私有方法,使用了Java7的invokedynamic字節碼來動態綁定這個方法。在Java 7 JVM中增加了一個新的指令invokedynamic,用于支持動態語言。 即允許方法調用可以在運行時指定類和方法,不必在編譯的時候確定。字節碼中每條invokedynamic指令出現的位置稱為一個動態聯調點, invokedynamic指令后面都會跟一個指向常量池的調用點限定符(#3, #6),這個限定符會被解析成一個動態調用點。熱部署應對方案
- 增加/減少一個Lambda表達式會導致類方法比較錯亂,所以都會導致熱部署失敗
- 修改一個Lambda表達式基于前面的分析,可能會導致新增field,所以此時也會導致熱部署失敗。
訪問權限對熱替換的影響
一個類的加載,必須經歷resolve->link->init三個階段,** 父類/實現接口權限控制檢查** 主要發生在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)) { // 檢查父類的訪問權限ALOGW("Superclass of '%s' (%s) is not accessible", clazz->descriptor, clazz->super->descriptor);dvmThrowIllegalAccessError("superclass not accessible");goto bail;}} }在上述代碼實例上可以看到,一個類的link階段,會一次對當前類實現的接口和父類進行訪問權限檢查。
接下來看一下dvmCheckClassAccess的具體實現:
bool dvmCheckClassAccess(const ClassObject* accessFrom, const ClassObject* clazz) {if (dvmIsPublicClass(clazz)) { // 如果父類是public類,直接return truereturn 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 falsereturn 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; }我們可以看到如果當前類和實現接口/父類是否public,同時負責加載兩者的classLoader不一樣的情況下,直接return false.
所以如果此時不進行任何處理的話,那么在類的加載階段就會報錯。
而Sophix修復方案是基于新classLoader加載補丁類,所以在patch過程就會報錯。
如果補丁類中存在非public類的訪問,非public方法/域的調用,那么都會失敗。
更為致命的是,在補丁加載是檢測不出來的,補丁會被正常加載,但是在運行階段會直接crash。
由于補丁類在單獨的dex中,所以要加載這個dex的話,肯定要進行dexopt的。
dexopt過程中會執行dvmVerifyClass校驗dex中的每個類。
方法調用鏈: dvmVerifyClass校驗類 ->verifyMethod校驗類中的每個方法 ->dvmVerifyCodeFlow ->doCodeVerification對每個方法的邏輯進行校驗 ->verifyInstruction實際上就是校驗每個指令。<clinit>方法
熱部署模式下的特殊性:不允許類結構變更以及不允許變更<clinit>方法
所以補丁工具如果發現了這幾種限制,那么此時只能走冷啟動重啟生效。
冷啟動幾乎是沒有限制的,可以做到任何場景的修復。
可能在有時候在源碼層上來看沒有增加method/field,但是實際上由于要滿足java的各種語法特性的需求,所以編譯器會在編譯期間為我們自動合成一些method和field,最后就有可能觸發了上面提到的幾個限制情況。
冷啟動類加載原理
概述
對比不同冷啟動方案
| 原理 | 為了解決Dalvik下unexpected dex problem異常而采用插樁的方式,單獨放在一個幫助類在獨立的dex讓其他類調用,阻止了類被打上CLASS_ISPREVERIFIED標志從而規避問題的出現。最后加載補丁dex得到dexFile對象作為參數構建一個Element對象插入到dex-Elements數組的最前面。 | 提供dex差量包,整體替換dex的方案。差量的方式給出patch.dex,然后將patch.dex與應用的classes.dex合并成一個完整的dex,完整dex加載得到的dexFile對象最為參數構建一個Element對象然后整體替換掉就的dex-Elements數組 |
| 優點 | 沒有合成整包,產物比較小,比較靈活 | 自研dex差異算法,補丁包很小,dex merge成完整dex,Dalvik不影響類加載性能,Art下也不存在包含父類/引用類的情況 |
| 缺點 | Dalvik下影響類加載性能,Art下類地址寫死,導致必須包含父類/引用,最后補丁包很大 | dex合并內存并消耗vm heap上,容易OOM,最后導致dex合并失敗 |
兩種方案對Sophix都不適用。它需要的是一種既能無侵入打包。
插樁
眾所周知,如果僅僅把補丁類打入補丁包中而不做任何處理的話,那么運行時類加載的時候機會異常退出。
加載一個dex文件到本地內存的時候,如果不存在odex文件,那么首先會執行dexopt,dexopt的入口在dalvik/opt/OptMain.cpp的main方法,最后調用verifyAndOptimizeClass執行真正的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)) { // 執行類的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); // 執行類的Optimize/* 類被打上CLASS_ISOPTIMIZED標識 */((DexClassDef*)pClassDef)->accessFlags |= CLASS_ISOPTIMIZED; }} }apk第一次安裝的時候,會對原dex執行dexopt。
此時假如apk只存在一個dex,所以dvmVerifyClass(clazz)結果為true。所以apk中所有的類都會被打上CLASS_ISPREVERIFIED 標志,接下來執行dvmOptimizeClass,類接著被打上CLASS_ISOPTIMIZED標志。
- dvmVerifyClass : 類校驗,類校驗的目的是為了防止類被篡改校驗類的合法性。它會對類的每個方法進行校驗,這里我們只需要知道如果類中的所有方法中直接引用到的類(第一層級關系,不會進行遞歸搜索)和當前類都在同一個dex的話,dvmVerifyClass就返回true。
- dvmOptimizeClass : 類優化,這個過程會把部分指令優化成虛擬機內部指令,比如方法調用指令:invoke-*指令變成了invoke-*-quick,quick指令會從類的vtable表中直接取,vtable簡單來說就是累的所有方法的一張大表(包括集成自父類的方法)。因此加快了方法的執行效率。
加入A類是補丁類,放在單獨的dex中。類B中的某個方法引用到補丁類A,所以執行到該方法會嘗試解析類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,所以會拋異常。
插樁
為了解決這個問題,一個無關幫助類放到一個單獨的dex中,原dex中所有類的構造函數都引用這個類,而一般的方法都需要侵入dex打包流程,利用.class字節碼修改技術,在所有.class文件構造函數中引用這個幫助類,插樁由此而來。
插樁缺點
給類加載效率帶來比較嚴重的影響。
由于一個類的加載通常有三個階段:dvmResolveClass->dvmLinkClass->dvmInitClass。
dvmInitClass階段在類解析完畢嘗試初始化類的時候執行,主要是完成父類的初始化,當前類的初始化,以及static變量的初始化復制等操作。
在初始化操作之外,如果類沒被打上CLASS_ISPREVERIFIED/CLASS_ISOPTIMIZED標志,那么類的Verify和Optimize都將會在類的初始化階段進行。
正常情況下,類的verify和Optimize都僅僅只是在apk第一次安裝執行dexopt的時候進行。
類的verify實際上是很重的,因為會對類的所有方法執所有指令都進行校驗,單個類的加載看起來并不耗時,但是如果同時時間點加載大量類的情況下,這個耗時就會被放大。
所以這也是插樁給類的加載效率打來比較大影響的后果。
性能影響
由于插樁會導致所有類都非preverify,因此在加載每個類的時候還需要做verify和optimize操作。
微信做過一次測試:
| 700個類 | 84ms | 685ms |
| 啟動耗時 | 4934ms | 7240ms |
平均每個類verify+optmize(跟類的大小有關系)的耗時并不長,而且這個耗時每個類只有一次。但由于應用剛啟動時一般會同時加載大量的類,很容易出現白屏,讓人無法容忍。
避免插樁
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數組,resClass結果不為NULLresClass = 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變量設為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前后呼應,說白了就是get為null后就去set。dvmDexSetResolvedClass(pDvm, classIdx, resClass);}return resClass; }如何讓dvmDexGetResolvedClass返回的結果不為null呢?
只需要調用過一次dvmDexSetResolvedClass(pDvmDex, classIdx, resClass);就行了。
舉個例子具體說明一下:
public class B{public static void test() {A.a();} }我們此時需要patch的類是A,所以類A被打入到一個獨立的補丁dex中。那么執行到類B方法中的A.a()代碼是就會嘗試去解析類A,
此時,dvmResolveClass(const ClassObject* referrer, u4 classIdx, bool fromUnverifiedConstant)各個參數分別是:
- referrer:這里傳入的是類B
- classIdx:類A在原dex文件結構類區中的索引id
- fromUnverifiedConstant:是否const-class/instance-of指令。
此時調用的是A的靜態a方法,invoke-static指令不屬于const-class/instance-of這兩個指令中的一個。不做處理的話,dvmDexGetResolvedClass一開始是null的。然后A是從補丁dex中解加載解析,B是在原Dex中,A在補丁Dex中,所以B->pDvmDex!=A->pDvmDex,接下來執行到 dvmThrowIllegalAccessError從而會拋出運行時異常。
所以我們需要做的是,必須要在一開始的時候就把補丁類添加到原來dex的pResClasses數組中。
這樣就確保了執行B類test方法的時候,dvmDexGetResolvedClass不為null,就不會執行后面的校驗邏輯了。
具體做法:
1、首先通過補丁工具反編譯dex為smali文件,拿到:
- preResolveClz:需要patch的類A的描述符,非必須,為了調試方便加上該參數而已。(比如實例中的類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函數指針。
- 首先預加載引用類android/support/annotation/AnimRes,這樣dvmFindLoadedClass("android/support/annotation/AnimRes")才不為null,
- dvmFindLoadedClass執行結果得到的ClassObject作為第一個參數調用dvmResolveClass(AnimRes, 2455, true)即可。
下面是該方案的JNI代碼部分實現,實際上preResolveClz參數是非必須的。
jboolean resolveCodePathClasses(JNIEvn *env, jclass clz, jstring preResolveClz, jstring refererClz, jlong classIdx, dexstuff_t *dexstuff) {LOGD("start resolveCodePathClasses");// 調用dvmFindLoadedClassClassObject* refererObj = dexstuff->dvmFindLoadedClass_fnPtr(Jstring2CStr(env, refererClz));if (strlen(refererObj->descriptor) == 0) {return JNI_FALSE;}// 調用dvmResolveClass/** 這里的調用需要注意:* 1. dvmResolveClass的第三個參數必須是true* 2. 多dex場景下,dvmResolveClass的第一個參數referrer類必須跟待patch的類在同一個dex中,但是他們不需要存在引用關系,任何一個在同一個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某個系統方法。而是從native層直接調用,同時不需要插樁。
但QFix卻有它獨特的缺陷:
由于是在dexopt后進行繞過的,dexopt會改變原先的很多邏輯,許多odex層面的優化會寫死字典和方法的訪問偏移,這就會導致比較嚴重的問題。
多態對冷啟動啟動類加載的影響
重新認識多態:
實現多態的技術一般叫做動態綁定,是值在執行期間判斷所引用對象的實際類型,根據其實際類型調用其相應方法。
舉個栗子是最好說明方法:
public class B extends A {String name = "B name";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() {} }輸出結果:
A name B a_tl...有次可以看到name是沒有多態性的,這里分析下方法多態性的實現:
首先new B()執行會加載類B,方法調用鏈:dvmResolveClass->dvmLinkClass->createVtable。
此時會為類B創建一個vtable。
在虛擬機加載每個類都會為這個類生成一張vtable表,vtable說白了就是當前類的所有virtual方法的一個數組,當前類和所有所有集成父類public/protected/default方法就是virtual方法。
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()發生了什么
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); // 是否已經解析過該方法if (baseMethod == NULL) {baseMethod = dvmResolveMethod(current->clazz, ref, METHOD_VIRTUAL);// 沒有解析過該方法調用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()實際上調用的就是B.a_t1方法。這樣就首先了方法的多態。
多態在冷啟動方案中的坑
dex在第一次加載的時候,會執行dexopt,dexopt有兩個過程:verify+optimize.
- dvmVerifyClass:類校驗,類校驗的目的是防止類被篡改校驗類的合法性。此時會對類的每個方法進行校驗,這里我們只需要知道如果類的素有方法中直接飲用到的類和當前類都在同一個dex中的話,dvmVerifyClass就返回true。
- dvmOptimizeClass:類優化,簡單來說這個過程會把部分指令優化成虛擬機內部指令,比如說方法調用指令: invoke-virtual-quick + 立即數,quick指令會從類的vtable類中直接取,vtable簡單來說就是類的所有方法的一張大表(包括繼承自父類的方法)。因此加快了方法的執行效率。
很簡單的例子:
我們增加一個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");} }修復后的apk中新增了a_t1()方法,Demo不做任何修復,我們會發現應用補丁后的Demo.test_addMothod()得到的結果是 “A t1”,這表明obj.a_t2執行的竟然是a_t1方法。
這恰恰說明了opt過程對invoke指令優化,原來t2的立即數在補丁類中對應到了t1中。
因此QFix方案要繞過opt過程進行處理是非常危險的,除了多態可能還會有其他坑,而且opt過程不可控可能在不同版本面臨適配。
Sophix處理辦法
由于QFix無法繞過的缺陷,因此Sophix并沒有采納學習,而是根據google 開源的dexmerge方案而自研了一套完整的DEX方案。
補丁共用
前文中講熱替換的時候,雖然替換的是ArtMethod,但補丁的粒度卻是類。
我們的為了減少補丁包的體積,我們不可為熱冷替換方案準備兩套方案。
因此Sohpix的熱部署的補丁是能夠降級直接走冷啟動的(共用)。
冷啟動方案
Sophix的冷啟動方案是作為熱部署方案的替補或者是說互補方案。 具體實施方案對Dalvik和Art下分別做了處理。
- Dalvik下采用自行研發的全量的DEX方案
- Art下虛擬機本身有已經支持多dex加載,該場景下的具體方案就是把補丁dex重命名為classes.dex(主dex)來加載。
先整理一下冷啟動方案
對于Android下的冷啟動類加載修復,最早的實現方案是QQ空間提出的dex插入方案,該方案的主要思想是,把新補丁dex插入到ClassLoader索引路徑的最前面。這樣在load一個class時,就會優先找到補丁中的。
后來微信的Tinker和手Q的QFix方案都基于該方案做了改進,而這類插入dex的方案,都會遇到一個主要的問題,就是如何解決Dalvik虛擬機下的pre-verify問題。
如果一個類中直接引用到的所有非系統類都和該類在同一個dex里的話,那么這個類就會被打上CLASS_ISPREVERIFIED,具體判定代碼可見虛擬機中的verifyAndOptmizeClass函數。
我們來列舉一下騰訊的三大修復方案是如何解決這個問題的:
- QQ空間的處理方式,是在每個類中插入一個來自其他dex的hack.class,由此讓所有類里面都無法滿足pre-verified條件(侵入打包流程,添加冗余代碼,且會影響loadclass性能)
- QFix的方式就是取得虛擬機中的某些底層函數,提前resolve所有補丁類,以此繞過Pre-verify檢查(需要獲取底層虛擬機的函數,不夠穩定可靠,無法新增public函數)
- Tinker的方式,是合成全量dex文件,這樣所有class都在全量dex中解決,從而消除class重復而帶來的沖突(從指令維度進行合成,實現較為復雜,性較比不高)
全量DEX方案
一般來說,合成完整dex,思路就是把原來的dex和patch里的dex重新合并成一個。
然而我們可以逆向思維,既然補丁中已經有需要變動的類,那么原來基線包中dex中的重復的class就可以刪掉了,這樣更不用全量插樁來解決pre-verfy問題了。
參照Android原生multi-dex的實現再來看這個方案就比較好理解了。
multi-dex方案就是把一個apk中用到的類拆分到多個dex文件中,每個dex中都只包含了部分的類定義,單個dex也可以加載,因為只要把所有dex都load進去,本dex中不存在的類就可以在運行期間在其他的dex中找到。
因此同理,在基線包里面去掉了補丁中的class后,原先需要發生變更的舊的class時就會自動找到補丁dex,補丁中的新class在需要用到不變的class時也會找到基線包dex的class。
這樣的話,基線包里面不適用補丁類的class仍舊可以按照原來的邏輯來做odex,最大保證了dexopt的效果。
這么一來,我們不再需要像傳統合成的思路那樣判斷類的增加和修改情況,而且也不需要處理合成時方法數超出的情況批注:只能說一定范圍上,不用考慮方法數問題,對于dex的結構也不用進行破壞性重構。
現在,合成完整dex的問題就簡化成了——如何在基線包dex里面去掉補丁包中包含的所有類。
需要注意的是,sophix并沒有將某個class的所有信息都從dex中移除,因為如果這么做,可能會導致dex的各個部分都發生變化,從而需要大量調整offset,這樣就變得費時費力了,因此我們需要做的就是讓解析這個dex的時候找不到這個class的定義就行了。因此,只需要移除定義的入口,對于class的具體內容不進行刪除,這樣能最大可能的減少offset的修改。
雖然這樣做會把這個被移除類的無用信息殘留在dex文件中,但這些信息占不了大多空間,并且對dex的處理速度是提升很大的,這種移除類操作的方式變得十分輕快。
android multidex機制對于Application的處理方式為:
將Application用到的類都打包到主dex中,因此只要把熱修復的初始化放到attachBaseContext的最前面就基本上不會出問題了。
DexFile loadDex在Dalvik和Art下的工作細節
DexFile.loadDex嘗試把一個dex文件解析加載到native內存都發生了什么?
不管Dalvik或者Art虛擬機,他們都調用了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加載到內存。如果此時壓縮文件有多dex,那么其他的dex文件會被直接忽略。
ART虛擬機:
方法調用連:DexFile_oepnDexFileName -> openDexFilesFromOat -> LoadDexFiles
可以從代碼上看的出來,Art下面已經默認支持加載壓縮文件中的多個dex,首先肯定要先加載primary dex,其實就是classes.dex,后續會加載其他的dex。
所以補丁類放到classes.dex就可以實現補丁類先加載,后續在其他dex中的補丁類是不會被重復加載的。
對比Tinker方案
在Dalvik
走普通的multidex方案,需要手動加載,補丁包確保要放置到dexElements數組的最前面。
在Art下面:
我們只需要把補丁dex命名為classes.dex。原apk中的apk一次命名為classes(2,3,4...).dex就好了,然后一起打包為一個壓縮文件。然后通過DexFile.loadDex得到DexFile對象,最后把該DexFile對象整個替換掉就的dexElements數組就可以了。
原文地址: https://www.jianshu.com/p/5646b3b57f77?utm_campaign=maleskine&utm_content=note&utm_medium=seo_notes&utm_source=recommendation方案不同點
作者:primLooper
鏈接:https://www.jianshu.com/p/5646b3b57f77
來源:簡書
著作權歸作者所有。商業轉載請聯系作者獲得授權,非商業轉載請注明出處。
總結
以上是生活随笔為你收集整理的[读书笔记] 深入探索Android热修复技术原理 (手淘技术团队)的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: PaddlePaddle文本卷积实现情感
- 下一篇: 《A Berkeley View of