來源:http://www.jianshu.com/p/ed03e8e4b08f

插件化和熱修復技術是Android開發中比較高級的知識點,是中級開發人員通向高級開發中必須掌握的技能,插件化的知識可以查我我之前的介紹:Android插件化。本篇重點講解熱修復,並對當前流行的熱修復技術做一個簡單的總結。

熱修復什麼是熱修復?

簡單來講,爲了修復線上問題而提出的修補方案,程序修補過程無需重新發版!

技術背景

在正常軟件開發流程中,線下開發->上線->發現bug->緊急修復上線。不過對於這種方式代價太大。

Android熱修復技術總結

而熱修復的開發流程顯得更加靈活,無需重新發版,實時高效熱修復,無需下載新的應用,代價小,最重要的是及時的修復了bug。

Android熱修復技術總結

當前熱門的熱修復技術

當前熱門的熱修復技術有:

  • QQ空間超級補丁、微信Tinker
  • 餓了麼Amigo
  • 美團Robust
  • 360RePlugin
  • 滴滴出行VirtualAPK

熱修復技術要弄清熱修復技術的原理,就要先弄清Android的ClassLoader機制,相關文章可以閱讀之前的介紹:ClassLoader類加載機制。Android的ClassLoader分爲PathClassLoader和DexClassLoader,它們都都繼承自BaseDexClassLoader,其中PathClassLoader用來加載系統類和應用類;DexClassLoader用來加載jar、apk、dex文件。例如下面要介紹的阿里的Andfix和Sophix的原理如下:

AndFix

AndFix:由補丁類的classLoader加載補丁類,在native層針對不同Android架構中的不同的ArtMethod結構調用對應的replaceMethod方法按照定義好的ArtMethod結構一一替換方法的所有信息如所屬類、訪問權限、代碼內存地址等。

穩定性較差,會受到國內ROM廠商對ArtMethod結構更改的影響,所以這正是AndFix不支持很多機型的原因。

Sophix

Sophix:由補丁類的classLoader加載補丁類,在native層直接memcpy(smeth,dmth,sizeof(ArtMethod))替換整個artMethod的結構。初始化類時會爲這個類分配空間,AllocArtMethodArray會緊挨着的new出來放入art中的方法數組中。通過計算輔助類的前後兩個方法的起始地址就可以計算出artMethod結構的大小了。

注:補丁類初始化時,也會分配自己的artMethod空間,拿這個修復過的新ArtMethod去替換舊ArtMethod的內容,不用管ArtMethod的結構。穩定性大大提高!

java內部類編譯靜態內部類/非靜態內部類區別

內部類會被編譯器生成同外部類一樣的頂級類。只不過非靜態內部類會持有外部類的引用。這也是Android性能優化建議Handler使用靜態內部類,防止外部類Activity不能被回收導致造成OOM。

內部類和外部類互相訪問

內部類和外部類互相訪問private方法和字段時,會自動在對應類爲對方生成public的access&**方法。

熱部署解決方案

外部類如果有內部類把所有的field/method的private訪問權限改成proteced或者public內部類將所有的field/method的private訪問權限改成proteced或者public。

匿名內部類編譯匿名內部類命名規則

外部類&number。number即編譯器根據匿名內部類出現在外部類中的順序,依次累加。

熱部署解決方案

新增/減少匿名內部類對熱部署是無解的,因爲補丁修復工具拿到的是class文件,無法區別DexFileDemo&1和DexFileDemo&2,會導致類的順序亂套。如果匿名內部類插入到末尾則是允許。

域編譯靜態field,非靜態field編譯

熱部署不支持field/method增加和刪除和 clinit方法的修改,靜態field的初始化和靜態代碼塊會被編譯在編譯器合成的方法clinit中,非靜態字段的初始化會被編譯在編譯器生成的init無參構造函數中,

靜態field,靜態代碼塊

clinit方法會在類加載階段的類初始化時調用,clinit中靜態field和靜態代碼塊的出現順序就是二者在源碼中出現的順序。因爲類已經加載過了,所以就算修復了clinit方法也不會生效了。

dvmResolveClass->dvmLinkClass->dvmInitClass,然後執行clinit方法

以下情況會去加載一個類

1.new 一個類的對象時new instance

2.調用類的靜態方法(invoke static)

3.獲取類的靜態域的值(sget)

非靜態field,非靜態代碼塊

類的構造函數會被編譯器翻譯成init方法,會先進行非靜態field和非靜態代碼塊的初始化。它們出現的順序也是和在源碼中出現的順序一樣。

執行new instance指令時,如果類沒有加載過,就嘗試加載類。然後對對象內存分配,再然後執行invoke direct指令調用類的init構造函數進行初始化

熱部署解決方案

不支持對靜態字段和靜態代碼塊的修改,會導致熱部署失敗,只能冷啓動生效。支持非靜態字段和非靜態代碼塊修改,熱部署只是將init構造函數作爲普通的方法變更。

final static 域編譯final static 域編譯規則

final static引用類型初始化仍在clinit中final static基本類型和String類型,類加載初始化dvminitClass在執行clinit方法之前,先執行initSFields,這個方法爲static域賦予默認值。引用類型默認NULL,final static修飾的基本類型和String類型會在這裏初始化賦值。

final static 域優化原理

final static基本類型執行const/4指令,操作數在dex中的位置(encoded_array_item)就是在opcode後一個字節。

final static String類型執行const-string指令,本質同上只不過拿到的是字符串常量在dex文件結構中字符串常量區的索引id。dex文件有一塊區域存儲所有的字符串常量會被完整的加載到虛擬機內存中-字符串常量區。

final static引用類型執行sget指令,首先調用dvmDexGetResolveField看這個域是否之前解析過,沒有的話調用dvmDexResolveField嘗試解析域,如果這個靜態域所在的類沒有解析過,嘗試調用dvmResolveClass,拿到這個sField,然後通過dvmDexGetResolveField(sField)獲取這個靜態值。

熱部署解決方案

final static基本類型/string類型最終引用的類型會被熱部署替換掉。

final static引用類型因爲會被翻譯到clinit方法中,熱部署失敗。

泛型編譯爲什麼需要泛型

Java泛型完全有編譯器實現,由編譯器執行類型檢查和類型推斷,生成非泛型字節碼,稱之爲擦除。

沒有泛型之前想要實現類泛型,利用所有類的父類時Object進行強轉,這完全依賴程序員的自主性,很容易出現ClassCastException。泛型的出現解決了類型檢查和類型推斷的問題。

泛型類型擦除

Java字節碼中不包含泛型類型信息,想要區別類型定義可以限定泛型類型

類型擦除與多態的衝突和解決

父類是泛型類有setNumber(T value),子類想override setNumber(Number value)。然而實際父類的方法實際是setNumber(Object value),子類想重寫卻變成了重載,這就出現了類型擦除和多態之間的衝突。然而編譯器自動幫我們合成了Bridge方法實現了重載,在子類中生成了相同簽名bridge方法,內部實際調用子類的重寫方法。

泛型類型轉換

編譯器如果發現變量聲明加上了泛型信息,編譯器自動加上了check-cast的強制轉換,因爲編譯器會爲泛型做類型檢查,所以自動的強制轉換不會出現ClassCastException。

熱部署解決方案

如果父類補丁變成了增加了泛型則會增加Bridge方法,造成熱部署失敗。

將方法從void get(B t) 變成 B extends Number void get(B t)方法邏輯不會發生變化,但是方法的簽名會發生變化,這種情況熱修復沒有意義,需要避免這種情況的發生。

Lambda表達式編譯Lambda表達式編譯規則

Lamda表達式具有函數式編程的特點,是Java中最接近閉包的概念。函數式接口:一個接口具有唯一一個抽象方法

Java中的Runable和Comparator都是典型的函數式接口

Lamada表達式和匿名內部類的區別:

1.this關鍵字指包圍Lamada表達式的類而不是指向匿名內部類自己

2.編譯方式,Java編譯器將Lamda表達式編譯成類的私有方法,使用了Java7的invokedynamic動態綁定這個私有方法。而匿名內部類則是生成外部類&number的新類.編譯器都會在類下生成lamda$main$**{ * }私有靜態方法,這個方法實現了lamda表達式的邏輯,引用的變量都會變成方法的參數。

在HostSpot VM下解釋class文件的lamda表達式:

invokeDynamic指令調用java/lang/invoke/LamdaMetafactory的metafactory這個靜態方法。這個方法會在運行時生成實現函數式接口的具體類,這個具體類會調用那個靜態私有方法。

在Android虛擬機下解釋dex文件中的lamda表達式:則是在優化成dex文件的時候就生成了這個具體類。

熱部署解決方案

新增lamada表達式會導致外部類新增一個輔助方法。修改的lamda表達式邏輯引用了外部變量,會導致輔助類持有了外部對象,會新增這個外部對象的變量。也是會導致熱修復失敗。

Sophix與QQ超級補丁和Tinker技術比較

針對現在市面上比較流行的熱修復方案,這裏選擇Sophix、QQ超級補丁和Tinker進行簡單的介紹。前面說過,類似於qq空間和微信的實現方式都需要重新啓動才能修復bug,而阿里的Sophix採用的是非浸入式的方式不需要冷啓動。

QQ空間超級補丁

QQ空間超級補丁採用的插樁方式,入侵打包流程,單獨放一個幫助類在獨立的dex中讓其他類調用,阻止類在dexopt時被打傷CLASS_ISPREVERIFIED標記。其原理如下圖:

Android熱修復技術總結

加載補丁dex得到dexFile對象作爲參數構建一個Element對象插入到dexElement數組最前面。

Tinker提供差量包,整體替換dex的方案。將patch.dex與應用的class.dex合併生成一個完整的dex,加載完整的dex得到dexFile對象爲參數構建一個Element對象替換dexElements數組。

官方multiDex沒有補丁查詢更新,下載補丁待下次啓動時生效。

其流程可以總結爲如下圖所示:

Android熱修復技術總結

不過細心的讀者會發現,QQ空間超級補丁在使用 過程中還存在如下問題:

1.不支持即時生效,必須通過重啓才能生效。

2.爲了實現修復這個過程,必須在應用中加入兩個dex!dalvikhack.dex中只有一個類,對性能影響不大,但是對於patch.dex來說,修復的類到了一定數量,就需要花不少的時間加載。對手淘這種航母級應用來說,啓動耗時增加2s以上是不能夠接受的事。

3.在ART模式下,如果類修改了結構,就會出現內存錯亂的問題。爲了解決這個問題,就必須把所有相關的調用類、父類子類等等全部加載到patch.dex中,導致補丁包異常的大,進一步增加應用啓動加載的時候,耗時更加嚴重。

針對上面的問題,騰訊出了QFix方案。

在native層提前調用dvmResolveClass,是的在dvmResolve中調用dvmDexGetResolve不爲null,也避免了校驗一致性的問題。

這個方案要求傳遞的在多dex情況下,referrer類必須跟patch類是同一個dex。fromUnverifiedConstant必須爲true。referrer必須提前加載。

這方案還要一些問題,在dexopt之後繞過,但是dexopt會改變很多原先的邏輯,許多odex層面的優化會寫死字段和訪問方法的偏移。這會造成很嚴重的BUG。

微信Tinker

微信針對QQ空間超級補丁技術的不足提出了一個提供DEX差量包,整體替換DEX的方案。主要的原理是與QQ空間超級補丁技術基本相同,區別在於不再將patch.dex增加到elements數組中,而是差量的方式給出patch.dex,然後將patch.dex與應用的classes.dex合併,然後整體替換掉舊的DEX文件,以達到修復的目的。其原理圖如下:

Android熱修復技術總結

微信的熱修復的流程如圖所示:

Android熱修復技術總結

不過微信的方案仍然會有如下問題:

1.與超級補丁技術一樣,不支持即時生效,必須通過重啓應用的方式才能生效。

2.需要給應用開啓新的進程才能進行合併,並且很容易因爲內存消耗等原因合併失敗。

3.合併時佔用額外磁盤空間,對於多DEX的應用來說,如果修改了多個DEX文件,就需要下發多個patch.dex與對應的classes.dex進行合併操作時這種情況會更嚴重,因此合併過程的失敗率也會更高。

HotFix

阿里的HotFix方案,相對於QQ空間超級補丁技術和微信Tinker來說,定位於緊急BUG修復的場景下,能夠最及時的修復BUG,下拉補丁立即生效無需等待。

Android熱修復技術總結

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

Android熱修復技術總結

對於實現方法的替換,需要在Native層操作,主要經過三個步驟:

Android熱修復技術總結

不過HotFix也有不足:

1.不支持新增字段,以及修改方法,也不支持對資源的替換。

2.由於廠商的自定義ROM,對少數機型暫不支持。兼容性差。

綜上,對於上面的幾種框架技術總結如下:

Android熱修復技術總結

熱修復方案總結

代碼修復有兩大主要方案:一種是阿里系的底層替換方案,另一種是騰訊系的類加載方案。底層替換方案限制頗多,但時效性最好,加載輕快,立即見效。類加載方案時效性差,需要重新冷啓動才能見效,但修復範圍廣,限制少。

底層替換方案

底層替換方案是在已經加載了的類中直接替換掉原有方法,是在原來類的基礎上進行修改的。因而無法實現對與原有類進行方法和字段的增減,因爲這樣將破壞原有類的結構。

一旦補丁類中出現了方法的增加和減少,就會導致這個類以及整個Dex的方法數的變化。方法數的變化伴隨着方法索引的變化,這樣在訪問方法時就無法正常地索引到正確的方法了。

如果字段發生了增加和減少,和方法變化的情況一樣,所有字段的索引都會發生變化。並且更嚴重的問題是,如果在程序運行中間某個類突然增加了一個字段,那麼對於原先已經產生的這個類的實例,它們還是原來的結構,這是無法改變的。而新方法使用到這些老的實例對象時,訪問新增字段就會產生不可預期的結果。

這是這類方案的固有限制,而底層替換方案最爲人詬病的地方,在於底層替換的不穩定性。

傳統的底層替換方式,不論是Dexposed、Andfix或者其他安全界的Hook方案,都是直接依賴修改虛擬機方法實體的具體字段。例如,改Dalvik方法的jni函數指針、改類或方法的訪問權限等等。這樣就帶來一個很嚴重的問題,由於Android是開源的,各個手機廠商都可以對代碼進行改造,而Andfix裏ArtMethod的結構是根據公開的Android源碼中的結構寫死的。如果某個廠商對這個ArtMethod結構體進行了修改,就和原先開源代碼裏的結構不一致,那麼在這個修改過了的設備上,通用性的替換機制就會出問題。這便是不穩定的根源。

而我們也對代碼的底層替換原理重新進行了深入思考,從克服其限制和兼容性入手,以一種更加優雅的替換思路,實現了即時生效的代碼熱修復。sophix實現的是一種無視底層具體結構的替換方式,也就是把原先這樣的逐一替換:

這麼一來,我們不僅解決了兼容性問題,並且由於忽略了底層ArtMethod結構的差異,對於所有的Android版本都不再需要區分,代碼量大大減少。即使以後的Android版本不斷修改ArtMethod的成員,只要保證ArtMethod數組仍是以線性結構排列,就能直接適用於將來的Android 8.0、9.0等新版本,無需再針對新的系統版本進行適配了。

類加載方案

類加載方案的原理是在app重新啓動後讓Classloader去加載新的類。因爲在app運行到一半的時候,所有需要發生變更的類已經被加載過了,在Android上是無法對一個類進行卸載的。如果不重啓,原來的類還在虛擬機中,就無法加載新類。因此,只有在下次重啓的時候,在還沒走到業務邏輯之前搶先加載補丁中的新類,這樣後續訪問這個類時,就會Resolve爲新類。從而達到熱修復的目的。

再來看看騰訊系三大類加載方案的實現原理。QQ空間方案會侵入打包流程,並且爲了hack添加一些無用的信息,實現起來很不優雅。而QFix的方案,需要獲取底層虛擬機的函數,不夠穩定可靠,並且有個比較大的問題是無法新增public函數。

微信的Tinker方案是完整的全量dex加載,並且可謂是將補丁合成做到了極致,然而我們發現,精密的武器並非適用於所有戰場。Tinker的合成方案,是從dex的方法和指令維度進行全量合成,整個過程都是自己研發的。

雖然可以很大地節省空間,但由於對dex內容的比較粒度過細,實現較爲複雜,性能消耗比較嚴重。實際上,dex的大小佔整個apk的比例是比較低的,一個app裏面的dex文件大小並不是主要部分,而佔空間大的主要還是資源文件。因此,Tinker方案的時空代價轉換的性價比不高。

其實,dex比較的最佳粒度,應該是在類的維度。它既不像方法和指令維度那樣的細微,也不像bsbiff比較那般的粗糙。在類的維度,可以達到時間和空間平衡的最佳效果。基於這個準則,我們另闢蹊徑,實現了一種完全不同的全量dex替換方案。

sophix採用的也是全量合成dex的技術,這個技術是從手淘插件化框架Atlas汲取的。直接利用Android原先的類查找和合成機制,快速合成新的全量dex。這麼一來,我們既不需要處理合成時方法數超過的情況,對於dex的結構也不用進行破壞性重構。

Android熱修復技術總結

從圖中可以看到,我們重新編排了包中dex的順序。這樣,在虛擬機查找類的時候,會優先找到classes.dex中的類,然後纔是classes2.dex、classes3.dex,也可以看做是dex文件級別的類插樁方案。這個方式十分巧妙,它對舊包與補丁包中classes.dex的順序進行了打破與重組,最終使得系統可以自然地識別到這個順序,以實現類覆蓋的目的。這將會大大減少合成補丁的開銷。

資源修復

在Android熱修復的過程中,不僅需要對錯誤的代碼進行修復,還需要對資源文件進行修復。目前市面上的資源熱修復方案基本上都是參考Instant Run的實現。Instant Run實現過程大概分爲兩部:

1、構造一個新的AssetManager,並通過反射條用addAssetPath,把這個完整的新資源包加入到AssetManager中。這樣就得到了一個含有所有新資源的AssetManager。

2、找到所有之前引用到原AssetManager的地方,通過反射,把引用處替換爲AssetManager

這種方式下發完整的包很佔用空間。而像有些方案,是先進行對資源包做差量,在運行時合成完整包再加載。這樣確實減少包的體積,但是在運行時多了合成的操作,耗費了運行時間喝內存。合成後的包也是完整的包,仍舊會佔磁盤空間。

so庫修復

so庫的修復本質上是對native方法的修復和替換。我們知道在JNI編程中,native方法可以通過動態註冊和靜態註冊兩種方式進行。動態註冊的native方法必須實現JNI_方法,同時實現一個JNINativeMethod[]數組,靜態註冊的native方法必須是Java+類完整路徑+方法名的格式。

Android熱修復技術總結

動態註冊的native方法映射通過加載so庫過程中調用JNI_方法調用完成,靜態註冊的native方法映射是在該native方法第一次執行的時候才完成映射,當然前提是該so庫已經load過。

我們採用的是類似類修復反射注入方式。把補丁so庫的路徑插入到nativeLibraryDirectories數組的最前面,就能夠達到加載so庫的時候是補丁so庫,而不是原來so庫的目錄,從而達到修復的目的。

Android熱修復技術總結


相關文章