Android熱修復(fù)技術(shù)總結(jié)
插件化和熱修復(fù)技術(shù)是Android開發(fā)中比較高級的知識點,是中級開發(fā)人員通向高級開發(fā)中必須掌握的技能,插件化的知識可以查我我之前的介紹:Android插件化。本篇重點講解熱修復(fù),并對當前流行的熱修復(fù)技術(shù)做一個簡單的總結(jié)。
熱修復(fù)
什么是熱修復(fù)?
簡單來講,為了修復(fù)線上問題而提出的修補方案,程序修補過程無需重新發(fā)版!
技術(shù)背景
在正常軟件開發(fā)流程中,線下開發(fā)->上線->發(fā)現(xiàn)bug->緊急修復(fù)上線。不過對于這種方式代價太大。
而熱修復(fù)的開發(fā)流程顯得更加靈活,無需重新發(fā)版,實時高效熱修復(fù),無需下載新的應(yīng)用,代價小,最重要的是及時的修復(fù)了bug。

當前熱門的熱修復(fù)技術(shù)
當前熱門的熱修復(fù)技術(shù)有:
- QQ空間超級補丁、微信[Tinker]
- 阿里的Sophix、阿里Hotfix
- 餓了么Amigo
- 美團Robust
- 360RePlugin
- …
熱修復(fù)技術(shù)
要弄清熱修復(fù)技術(shù)的原理,就要先弄清Android的ClassLoader機制,相關(guān)文章可以閱讀之前的介紹:ClassLoader類加載機制。Android的ClassLoader分為PathClassLoader和DexClassLoader,它們都都繼承自BaseDexClassLoader,其中PathClassLoader用來加載系統(tǒng)類和應(yīng)用類;DexClassLoader用來加載jar、apk、dex文件。例如下面要介紹的阿里的Andfix和Sophix的原理如下:
AndFix
AndFix:由補丁類的classLoader加載補丁類,在native層針對不同Android架構(gòu)中的不同的ArtMethod結(jié)構(gòu)調(diào)用對應(yīng)的replaceMethod方法按照定義好的ArtMethod結(jié)構(gòu)一一替換方法的所有信息如所屬類、訪問權(quán)限、代碼內(nèi)存地址等。
穩(wěn)定性較差,會受到國內(nèi)ROM廠商對ArtMethod結(jié)構(gòu)更改的影響,所以這正是AndFix不支持很多機型的原因。
Sophix
Sophix:由補丁類的classLoader加載補丁類,在native層直接memcpy(smeth,dmth,sizeof(ArtMethod))替換整個artMethod的結(jié)構(gòu)。初始化類時會為這個類分配空間,AllocArtMethodArray會緊挨著的new出來放入art中的方法數(shù)組中。通過計算輔助類的前后兩個方法的起始地址就可以計算出artMethod結(jié)構(gòu)的大小了。
注:補丁類初始化時,也會分配自己的artMethod空間,拿這個修復(fù)過的新ArtMethod去替換舊ArtMethod的內(nèi)容,不用管ArtMethod的結(jié)構(gòu)。穩(wěn)定性大大提高!
java
內(nèi)部類編譯
靜態(tài)內(nèi)部類/非靜態(tài)內(nèi)部類區(qū)別
內(nèi)部類會被編譯器生成同外部類一樣的頂級類。只不過非靜態(tài)內(nèi)部類會持有外部類的引用。這也是Android性能優(yōu)化建議Handler使用靜態(tài)內(nèi)部類,防止外部類Activity不能被回收導(dǎo)致造成OOM。
內(nèi)部類和外部類互相訪問
內(nèi)部類和外部類互相訪問private方法和字段時,會自動在對應(yīng)類為對方生成public的access&**方法。
熱部署解決方案
外部類如果有內(nèi)部類把所有的field/method的private訪問權(quán)限改成proteced或者public內(nèi)部類將所有的field/method的private訪問權(quán)限改成proteced或者public。
匿名內(nèi)部類編譯
匿名內(nèi)部類命名規(guī)則
外部類&number。number即編譯器根據(jù)匿名內(nèi)部類出現(xiàn)在外部類中的順序,依次累加。
熱部署解決方案
新增/減少匿名內(nèi)部類對熱部署是無解的,因為補丁修復(fù)工具拿到的是class文件,無法區(qū)別DexFileDemo&1和DexFileDemo&2,會導(dǎo)致類的順序亂套。如果匿名內(nèi)部類插入到末尾則是允許。
域編譯
靜態(tài)field,非靜態(tài)field編譯
熱部署不支持field/method增加和刪除和 clinit方法的修改,靜態(tài)field的初始化和靜態(tài)代碼塊會被編譯在編譯器合成的方法clinit中,非靜態(tài)字段的初始化會被編譯在編譯器生成的init無參構(gòu)造函數(shù)中,
靜態(tài)field,靜態(tài)代碼塊
clinit方法會在類加載階段的類初始化時調(diào)用,clinit中靜態(tài)field和靜態(tài)代碼塊的出現(xiàn)順序就是二者在源碼中出現(xiàn)的順序。因為類已經(jīng)加載過了,所以就算修復(fù)了clinit方法也不會生效了。
dvmResolveClass->dvmLinkClass->dvmInitClass,然后執(zhí)行clinit方法
以下情況會去加載一個類
- new 一個類的對象時new instance
- 調(diào)用類的靜態(tài)方法(invoke static)
- 獲取類的靜態(tài)域的值(sget)
非靜態(tài)field,非靜態(tài)代碼塊
類的構(gòu)造函數(shù)會被編譯器翻譯成init方法,會先進行非靜態(tài)field和非靜態(tài)代碼塊的初始化。它們出現(xiàn)的順序也是和在源碼中出現(xiàn)的順序一樣。
執(zhí)行new instance指令時,如果類沒有加載過,就嘗試加載類。然后對對象內(nèi)存分配,再然后執(zhí)行invoke direct指令調(diào)用類的init構(gòu)造函數(shù)進行初始化
熱部署解決方案
不支持對靜態(tài)字段和靜態(tài)代碼塊的修改,會導(dǎo)致熱部署失敗,只能冷啟動生效。支持非靜態(tài)字段和非靜態(tài)代碼塊修改,熱部署只是將init構(gòu)造函數(shù)作為普通的方法變更。
final static 域編譯
final static 域編譯規(guī)則
final static引用類型初始化仍在clinit中final static基本類型和String類型,類加載初始化dvminitClass在執(zhí)行clinit方法之前,先執(zhí)行initSFields,這個方法為static域賦予默認值。引用類型默認NULL,final static修飾的基本類型和String類型會在這里初始化賦值。
final static 域優(yōu)化原理
- inal static基本類型執(zhí)行const/4指令,操作數(shù)在dex中的位置(encoded_array_item)就是在opcode后一個字節(jié)。
- final static String類型執(zhí)行const-string指令,本質(zhì)同上只不過拿到的是字符串常量在dex文件結(jié)構(gòu)中字符串常量區(qū)的索引id。dex文件有一塊區(qū)域存儲所有的字符串常量會被完整的加載到虛擬機內(nèi)存中-字符串常量區(qū)。
- final static引用類型執(zhí)行sget指令,首先調(diào)用dvmDexGetResolveField看這個域是否之前解析過,沒有的話調(diào)用dvmDexResolveField嘗試解析域,如果這個靜態(tài)域所在的類沒有解析過,嘗試調(diào)用dvmResolveClass,拿到這個sField,然后通過dvmDexGetResolveField(sField)獲取這個靜態(tài)值。
熱部署解決方案
- final static基本類型/string類型最終引用的類型會被熱部署替換掉。
- final static引用類型因為會被翻譯到clinit方法中,熱部署失敗。
泛型編譯
為什么需要泛型
Java泛型完全有編譯器實現(xiàn),由編譯器執(zhí)行類型檢查和類型推斷,生成非泛型字節(jié)碼,稱之為擦除。
沒有泛型之前想要實現(xiàn)類泛型,利用所有類的父類時Object進行強轉(zhuǎn),這完全依賴程序員的自主性,很容易出現(xiàn)ClassCastException。泛型的出現(xiàn)解決了類型檢查和類型推斷的問題。
泛型類型擦除
Java字節(jié)碼中不包含泛型類型信息,想要區(qū)別類型定義可以限定泛型類型
類型擦除與多態(tài)的沖突和解決
父類是泛型類有setNumber(T value),子類想override setNumber(Number value)。然而實際父類的方法實際是setNumber(Object value),子類想重寫卻變成了重載,這就出現(xiàn)了類型擦除和多態(tài)之間的沖突。然而編譯器自動幫我們合成了Bridge方法實現(xiàn)了重載,在子類中生成了相同簽名bridge方法,內(nèi)部實際調(diào)用子類的重寫方法。
泛型類型轉(zhuǎn)換
編譯器如果發(fā)現(xiàn)變量聲明加上了泛型信息,編譯器自動加上了check-cast的強制轉(zhuǎn)換,因為編譯器會為泛型做類型檢查,所以自動的強制轉(zhuǎn)換不會出現(xiàn)ClassCastException。
熱部署解決方案
如果父類補丁變成了增加了泛型則會增加Bridge方法,造成熱部署失敗。
將方法從void get(B t) 變成 B extends Number void get(B t)方法邏輯不會發(fā)生變化,但是方法的簽名會發(fā)生變化,這種情況熱修復(fù)沒有意義,需要避免這種情況的發(fā)生。
Lambda表達式編譯
Lambda表達式編譯規(guī)則
Lamda表達式具有函數(shù)式編程的特點,是Java中最接近閉包的概念。函數(shù)式接口:一個接口具有唯一一個抽象方法
Java中的Runable和Comparator都是典型的函數(shù)式接口
Lamada表達式和匿名內(nèi)部類的區(qū)別:
- this關(guān)鍵字指包圍Lamada表達式的類而不是指向匿名內(nèi)部類自己
- 編譯方式,Java編譯器將Lamda表達式編譯成類的私有方法,使用了Java7的invokedynamic動態(tài)綁定這個私有方法。而匿名內(nèi)部類則是生成外部類&number的新類.編譯器都會在類下生成lamdamain*{ }私有靜態(tài)方法,這個方法實現(xiàn)了lamda表達式的邏輯,引用的變量都會變成方法的參數(shù)。
在HostSpot VM下解釋class文件的lamda表達式:
- invokeDynamic指令調(diào)用java/lang/invoke/LamdaMetafactory的metafactory這個靜態(tài)方法。這個方法會在運行時生成實現(xiàn)函數(shù)式接口的具體類,這個具體類會調(diào)用那個靜態(tài)私有方法。
- 在Android虛擬機下解釋dex文件中的lamda表達式:則是在優(yōu)化成dex文件的時候就生成了這個具體類。
熱部署解決方案
新增lamada表達式會導(dǎo)致外部類新增一個輔助方法。修改的lamda表達式邏輯引用了外部變量,會導(dǎo)致輔助類持有了外部對象,會新增這個外部對象的變量。也是會導(dǎo)致熱修復(fù)失敗。
Sophix與QQ超級補丁和Tinker技術(shù)比較
針對現(xiàn)在市面上比較流行的熱修復(fù)方案,這里選擇Sophix、QQ超級補丁和Tinker進行簡單的介紹。前面說過,類似于qq空間和微信的實現(xiàn)方式都需要重新啟動才能修復(fù)bug,而阿里的Sophix采用的是非浸入式的方式不需要冷啟動。
QQ空間超級補丁
QQ空間超級補丁采用的插樁方式,入侵打包流程,單獨放一個幫助類在獨立的dex中讓其他類調(diào)用,阻止類在dexopt時被打傷CLASS_ISPREVERIFIED標記。其原理如下圖:

加載補丁dex得到dexFile對象作為參數(shù)構(gòu)建一個Element對象插入到dexElement數(shù)組最前面。
Tinker提供差量包,整體替換dex的方案。將patch.dex與應(yīng)用的class.dex合并生成一個完整的dex,加載完整的dex得到dexFile對象為參數(shù)構(gòu)建一個Element對象替換dexElements數(shù)組。
官方multiDex沒有補丁查詢更新,下載補丁待下次啟動時生效。
其流程可以總結(jié)為如下圖所示:

不過細心的讀者會發(fā)現(xiàn),QQ空間超級補丁在使用 過程中還存在如下問題:
- 不支持即時生效,必須通過重啟才能生效。
- 為了實現(xiàn)修復(fù)這個過程,必須在應(yīng)用中加入兩個dex!dalvikhack.dex中只有一個類,對性能影響不大,但是對于patch.dex來說,修復(fù)的類到了一定數(shù)量,就需要花不少的時間加載。對手淘這種航母級應(yīng)用來說,啟動耗時增加2s以上是不能夠接受的事。
- 在ART模式下,如果類修改了結(jié)構(gòu),就會出現(xiàn)內(nèi)存錯亂的問題。為了解決這個問題,就必須把所有相關(guān)的調(diào)用類、父類子類等等全部加載到patch.dex中,導(dǎo)致補丁包異常的大,進一步增加應(yīng)用啟動加載的時候,耗時更加嚴重。
針對上面的問題,騰訊出了QFix方案。
在native層提前調(diào)用dvmResolveClass,是的在dvmResolve中調(diào)用dvmDexGetResolve不為null,也避免了校驗一致性的問題。
這個方案要求傳遞的在多dex情況下,referrer類必須跟patch類是同一個dex。fromUnverifiedConstant必須為true。referrer必須提前加載。
這方案還要一些問題,在dexopt之后繞過,但是dexopt會改變很多原先的邏輯,許多odex層面的優(yōu)化會寫死字段和訪問方法的偏移。這會造成很嚴重的BUG。
微信Tinker
微信針對QQ空間超級補丁技術(shù)的不足提出了一個提供DEX差量包,整體替換DEX的方案。主要的原理是與QQ空間超級補丁技術(shù)基本相同,區(qū)別在于不再將patch.dex增加到elements數(shù)組中,而是差量的方式給出patch.dex,然后將patch.dex與應(yīng)用的classes.dex合并,然后整體替換掉舊的DEX文件,以達到修復(fù)的目的。其原理圖如下:
微信的熱修復(fù)的流程如圖所示:

不過微信的方案仍然會有如下問題:
- 與超級補丁技術(shù)一樣,不支持即時生效,必須通過重啟應(yīng)用的方式才能生效。
- 需要給應(yīng)用開啟新的進程才能進行合并,并且很容易因為內(nèi)存消耗等原因合并失敗。
- 合并時占用額外磁盤空間,對于多DEX的應(yīng)用來說,如果修改了多個DEX文件,就需要下發(fā)多個patch.dex與對應(yīng)的classes.dex進行合并操作時這種情況會更嚴重,因此合并過程的失敗率也會更高。
HotFix
阿里的HotFix方案,相對于QQ空間超級補丁技術(shù)和微信Tinker來說,定位于緊急BUG修復(fù)的場景下,能夠最及時的修復(fù)BUG,下拉補丁立即生效無需等待。

AndFix不同于QQ空間超級補丁技術(shù)和微信Tinker通過增加或替換整個DEX的方案,提供了一種運行時在Native修改Filed指針的方式,實現(xiàn)方法的替換,達到即時生效無需重啟,對應(yīng)用無性能消耗的目的。其原理如下:

對于實現(xiàn)方法的替換,需要在Native層操作,主要經(jīng)過三個步驟:
不過HotFix也有不足:
- 不支持新增字段,以及修改方法,也不支持對資源的替換。
- 由于廠商的自定義ROM,對少數(shù)機型暫不支持。兼容性差。
綜上,對于上面的幾種框架技術(shù)總結(jié)如下:
熱修復(fù)方案總結(jié)
代碼修復(fù)有兩大主要方案:一種是阿里系的底層替換方案,另一種是騰訊系的類加載方案。底層替換方案限制頗多,但時效性最好,加載輕快,立即見效。類加載方案時效性差,需要重新冷啟動才能見效,但修復(fù)范圍廣,限制少。
底層替換方案
底層替換方案是在已經(jīng)加載了的類中直接替換掉原有方法,是在原來類的基礎(chǔ)上進行修改的。因而無法實現(xiàn)對與原有類進行方法和字段的增減,因為這樣將破壞原有類的結(jié)構(gòu)。
一旦補丁類中出現(xiàn)了方法的增加和減少,就會導(dǎo)致這個類以及整個Dex的方法數(shù)的變化。方法數(shù)的變化伴隨著方法索引的變化,這樣在訪問方法時就無法正常地索引到正確的方法了。
如果字段發(fā)生了增加和減少,和方法變化的情況一樣,所有字段的索引都會發(fā)生變化。并且更嚴重的問題是,如果在程序運行中間某個類突然增加了一個字段,那么對于原先已經(jīng)產(chǎn)生的這個類的實例,它們還是原來的結(jié)構(gòu),這是無法改變的。而新方法使用到這些老的實例對象時,訪問新增字段就會產(chǎn)生不可預(yù)期的結(jié)果。
這是這類方案的固有限制,而底層替換方案最為人詬病的地方,在于底層替換的不穩(wěn)定性。
傳統(tǒng)的底層替換方式,不論是Dexposed、Andfix或者其他安全界的Hook方案,都是直接依賴修改虛擬機方法實體的具體字段。例如,改Dalvik方法的jni函數(shù)指針、改類或方法的訪問權(quán)限等等。這樣就帶來一個很嚴重的問題,由于Android是開源的,各個手機廠商都可以對代碼進行改造,而Andfix里ArtMethod的結(jié)構(gòu)是根據(jù)公開的Android源碼中的結(jié)構(gòu)寫死的。如果某個廠商對這個ArtMethod結(jié)構(gòu)體進行了修改,就和原先開源代碼里的結(jié)構(gòu)不一致,那么在這個修改過了的設(shè)備上,通用性的替換機制就會出問題。這便是不穩(wěn)定的根源。
而我們也對代碼的底層替換原理重新進行了深入思考,從克服其限制和兼容性入手,以一種更加優(yōu)雅的替換思路,實現(xiàn)了即時生效的代碼熱修復(fù)。sophix實現(xiàn)的是一種無視底層具體結(jié)構(gòu)的替換方式,也就是把原先這樣的逐一替換:
這么一來,我們不僅解決了兼容性問題,并且由于忽略了底層ArtMethod結(jié)構(gòu)的差異,對于所有的Android版本都不再需要區(qū)分,代碼量大大減少。即使以后的Android版本不斷修改ArtMethod的成員,只要保證ArtMethod數(shù)組仍是以線性結(jié)構(gòu)排列,就能直接適用于將來的Android 8.0、9.0等新版本,無需再針對新的系統(tǒng)版本進行適配了。
類加載方案
類加載方案的原理是在app重新啟動后讓Classloader去加載新的類。因為在app運行到一半的時候,所有需要發(fā)生變更的類已經(jīng)被加載過了,在Android上是無法對一個類進行卸載的。如果不重啟,原來的類還在虛擬機中,就無法加載新類。因此,只有在下次重啟的時候,在還沒走到業(yè)務(wù)邏輯之前搶先加載補丁中的新類,這樣后續(xù)訪問這個類時,就會Resolve為新類。從而達到熱修復(fù)的目的。
再來看看騰訊系三大類加載方案的實現(xiàn)原理。QQ空間方案會侵入打包流程,并且為了hack添加一些無用的信息,實現(xiàn)起來很不優(yōu)雅。而QFix的方案,需要獲取底層虛擬機的函數(shù),不夠穩(wěn)定可靠,并且有個比較大的問題是無法新增public函數(shù)。
微信的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比較那般的粗糙。在類的維度,可以達到時間和空間平衡的最佳效果。基于這個準則,我們另辟蹊徑,實現(xiàn)了一種完全不同的全量dex替換方案。
sophix采用的也是全量合成dex的技術(shù),這個技術(shù)是從手淘插件化框架Atlas汲取的。直接利用Android原先的類查找和合成機制,快速合成新的全量dex。這么一來,我們既不需要處理合成時方法數(shù)超過的情況,對于dex的結(jié)構(gòu)也不用進行破壞性重構(gòu)。

從圖中可以看到,我們重新編排了包中dex的順序。這樣,在虛擬機查找類的時候,會優(yōu)先找到classes.dex中的類,然后才是classes2.dex、classes3.dex,也可以看做是dex文件級別的類插樁方案。這個方式十分巧妙,它對舊包與補丁包中classes.dex的順序進行了打破與重組,最終使得系統(tǒng)可以自然地識別到這個順序,以實現(xiàn)類覆蓋的目的。這將會大大減少合成補丁的開銷。
資源修復(fù)
在Android熱修復(fù)的過程中,不僅需要對錯誤的代碼進行修復(fù),還需要對資源文件進行修復(fù)。目前市面上的資源熱修復(fù)方案基本上都是參考Instant Run的實現(xiàn)。Instant Run實現(xiàn)過程大概分為兩部:
- 構(gòu)造一個新的AssetManager,并通過反射條用addAssetPath,把這個完整的新資源包加入到AssetManager中。這樣就得到了一個含有所有新資源的AssetManager。
- 找到所有之前引用到原AssetManager的地方,通過反射,把引用處替換為AssetManager
這種方式下發(fā)完整的包很占用空間。而像有些方案,是先進行對資源包做差量,在運行時合成完整包再加載。這樣確實減少包的體積,但是在運行時多了合成的操作,耗費了運行時間喝內(nèi)存。合成后的包也是完整的包,仍舊會占磁盤空間。
so庫修復(fù)
so庫的修復(fù)本質(zhì)上是對native方法的修復(fù)和替換。我們知道在JNI編程中,native方法可以通過動態(tài)注冊和靜態(tài)注冊兩種方式進行。動態(tài)注冊的native方法必須實現(xiàn)JNI_OnLoad方法,同時實現(xiàn)一個JNINativeMethod[]數(shù)組,靜態(tài)注冊的native方法必須是Java+類完整路徑+方法名的格式。

動態(tài)注冊的native方法映射通過加載so庫過程中調(diào)用JNI_OnLoad方法調(diào)用完成,靜態(tài)注冊的native方法映射是在該native方法第一次執(zhí)行的時候才完成映射,當然前提是該so庫已經(jīng)load過。
我們采用的是類似類修復(fù)反射注入方式。把補丁so庫的路徑插入到nativeLibraryDirectories數(shù)組的最前面,就能夠達到加載so庫的時候是補丁so庫,而不是原來so庫的目錄,從而達到修復(fù)的目的。
