無需通過升級APK來實現BUG修復,有人選擇插件化來解決,但是對於已經開發好的APP,移植成本非常高,既要學習插件化工具,又要對老代碼進行改造。
熱修復更加輕量、靈活,直接把補丁上傳到雲端,下拉補丁後立即生效。熱修復主要有兩種方案,底層替換和類加載,一般配合使用。
- 底層替換方案:限制頗多,但時效性最好,加載輕快,立即見效。
- 類加載方案:時效性差,需要重新冷啓動才能見效,但修復範圍廣,限制少。
1.底層替換原理
直接替換ART虛擬機中的ArtMethod結構可以達到即時生效。
直接替換的難點在於獲取ArtMethod結構的大小,由於ArtMethod可能被廠商修改,不能直接使用AOSP原始的ArtMethod,因此要想辦法兼容。
memcpy(oldmeth, newmeth, sizeof(ArtMethod));
通過ART虛擬機源碼,發現類的method空間是線性的,一個接一個緊密new出來的排列在數組中的。
android9.0/art/runtime/class_linker.cc:
LengthPrefixedArray<ArtMethod>* ClassLinker::AllocArtMethodArray(Thread* self,
LinearAlloc* allocator,
size_t length) {
if (length == 0) {
return nullptr;
}
const size_t method_alignment = ArtMethod::Alignment(image_pointer_size_);
const size_t method_size = ArtMethod::Size(image_pointer_size_);
const size_t storage_size =
LengthPrefixedArray<ArtMethod>::ComputeSize(length, method_size, method_alignment);
void* array_storage = allocator->Alloc(self, storage_size);
auto* ret = new (array_storage) LengthPrefixedArray<ArtMethod>(length);
CHECK(ret != nullptr);
for (size_t i = 0; i < length; ++i) {
new(reinterpret_cast<void*>(&ret->At(i, method_size, method_alignment))) ArtMethod;
}
return ret;
}
根據這個特性可以看出,兩個相鄰ArtMethod的差值就是ArtMethod的大小,我們可以自己構造一個類來巧妙獲取。
public class NativeMethodModel {
public static void f1(){}
public static void f2(){}
}
可以在JNI層獲取它們的地址差值:
size_t firMid = (size_t) env->GetStaticMethodID(nativeMethodModelClazz, "f1", "()V");
size_t secMid = (size_t) env->GetStaticMethodID(nativeMethodModelClazz, "f2", "()V");
size_t methSize = secMid - firMid;
這個methSize就可以作爲sizeof(ArtMethod)的值了。
memcpy(oldmeth, newmeth, methSize);
訪問權限問題:
方法調用時的權限檢查
新替換的方法的所屬類,和原先方法的所屬類,是不同的類,被替換的方法有權限訪問這個類的其他private方法嗎?
通過oat code觀察,調用同一個類的私有方法,沒有任何權限檢查,可以推測是編譯時的優化,確認了兩個方法同屬一個類,所以機器碼不做任何權限檢查。同包名下的權限問題
補丁中的類在訪問同包名下的類時,會報異常,是由於補丁類是從補丁包的Classloader加載的,與原來的base包不是同一個Classloader。可以使用反射修改ClassLoader規避:
Field classLoaderField = Class.class.getDeclaredField("classLoader");
classLoaderField.setAccessible(true);
classLoaderField.set(newClass, oldClass.getClassLoader());
- 反射調用非靜態方法產生的問題
當一個非靜態方法被熱替換後,在反射調用這個方法時,會拋異常:
Caused: java.lang.IllegalArgumentException:
Excepted receiver of type com.patch.demo.BaseBug
, but got com.patch.demo.BaseBug
com.patch.demo.BaseBug是兩個不同的類,前者是被熱替換方法所屬的類,由於我們替換了ArtMethod的declaring_class_,因此就是新的補丁類。後者是被調用的實例對象所屬類,是原有的BaseBug。
靜態方法是類級別直接調用的,不需要接收對象實例作爲參數。
這種反射調用非靜態方法產生的問題可以通過冷啓動對付。
新增方法、字段的影響:
除了反射問題,補丁類裏面存在方法、字段的新增或者減少,都是不適用的。
方法、字段數量的變化,會導致dex中的方法索引、字段索引發生變化,所以無法正常替換。
2.你所不知的Java
2.1 內部類編譯
內部類在編譯期會被編譯爲跟外部類一樣的頂級類。
非靜態內部類持有外部類的引用,靜態內部類不持有外部類的引用。
外部類爲了訪問內部類私有的域/方法,編譯期間會自動爲內部類生成access&**相關方法。
如何避免生成access&**方法:
- 一個外部類如果有內部類,把所有method/field的private訪問權限改成protected或默認訪問權限或public。
- 同時把所有內部類的所有method/field的private訪問權限改成protected或默認訪問權限或public。
2.2 匿名內部類編譯
匿名內部類也屬於內部類,滿足內部類編譯的情況。
匿名內部類的名稱格式一般是外部類&numble,編譯期根據該匿名內部類在外部類中出現的先後關係,一次累加命名。
如果在已有匿名內部類之前,增加、減少一個匿名內部類,會導致錯亂,原有的xxx&1跟修改後的xxx&1根本不是一個類。
新增/減少匿名內部類,對熱修復是無解的,編譯後的.class無法區分xxx&1/xxx&2類。當然,如果匿名內部類是插入到外部類的末尾,那麼是可以的。
2.3 域編譯
2.3.1 靜態field初始化,靜態代碼塊
不支持<clinit>的熱修復,該方法是類加載的時候進行類初始化時候調用的,這個方法是android編譯器自動合成的方法。
靜態field初始化和靜態代碼塊被編譯器翻譯在<clinit>方法中。靜態代碼塊和靜態域初始化在clinit中的先後關係就是兩者出現在源碼中的先後關係。
2.3.2 非靜態field初始化,非靜態代碼塊
非靜態field和非靜態代碼的編譯器翻譯在<init>默認無參構造函數中。非靜態field和非靜態代碼塊在init方法中的先後順序也跟兩者在源碼中出現的順序一致。
綜上,靜態field和靜態代碼塊,只能冷啓動生效。非靜態field和非靜態代碼塊的變更被翻譯到<init>中,熱修復可視爲一個普通方法的變更,對熱修復沒有影響。
2.4 final static域編譯
final static修飾的基本類型/String常量類型,是沒有被翻譯到<clinit>方法中的。
final static String s1 = new String("heihei");
final static String s1 = "haha";
static int i1 = 1;
final static int i2 = 2;
類加載初始化initClass在執行clinit方法之前,會先執行initSFields(art沒有獨立成方法),該方法主要就是給static域賦默認值。
010Editor查看dex文件結構,在dex的類定義區,每個類下面有一段encoded_array_item。上例初始值分別爲s
1=NULL,s2=“haha”,i1=0,i2=2。
- final static修飾的原始類型和String類型域(非引用類型),並不會被翻譯到clinit中,而是在類初始化執行initSFields中初始化賦值。
- final static修飾的引用類型,初始化仍然在clinit中。
一些android性能優化文章,如果field是常量,推薦使用final static作爲修飾符。通過源碼看這句話不太對,得到優化的僅僅是原始類型和String常量,引用類型實際上不會得到任何優化。
final static int i2 = 2;
const/4 vA, #+B //前一個字節時opcode,後一個字節前4位是寄存器v1,後4位就是立即數的值"0x02"
static int i1 = 1;
sget vAA, field@BBBB //前一個字節是opcode,後一個字節時寄存器v0,後兩個字節是i1這個field在dex文件結構中field區的索引值
smali中,final static域直接通過const/4指令,const/4指令執行很簡單。
sget指令,首先調用dvmDexGetResolvedField判斷是否解析過,沒被解析過,就調用dvmResolveStaticField嘗試解析域,如果這個靜態域所在類沒有被解析還會調用dvmResolveClass解析類。拿到sfield靜態域,然後調用dvmGetStaticFieldInt(sfield)得到靜態域的值。
final static引用類型沒有得到優化,因爲不管是不是final,最後都是通過sget-object指令去獲取該值。
綜上:
- 修改final static基本類型或者String常量類型,由於編譯期間引用到基本類型的地方被立即數替換,引用String類型的地方被常量池索引id替換,所以熱修復下,所有引用該域的地方都會被替換,是可行的。
- 修改final static引用類型域,是不允許的,因爲這個field的初始化會被翻譯到clinit方法中。
2.5 方法編譯
如果使用了混淆,可能導致方法的內聯和裁剪,最後也可能導致method的新增/減少。
2.5.1 方法內聯
- 方法沒有被其它任何地方引用到,毫無疑問,該方法會被內聯掉
- 方法足夠簡單,比如一個方法的實現就只有一行,該方法會被內聯掉,那麼任何調用該方法的地方都會被該方法的實現替換掉
- 方法只被一個地方引用到,這個地方會被方法的實現替換掉。
比如test()調用print(),print只有一個地方調用因此會被內聯。查看mapping.txt文件,沒有print方法的映射,說明被內聯掉了。
2.5.2 方法裁剪
public class BaseBug {
public static void test(Context context) {
Log.d("BaseBug", "test");
}
}
查看生成的mapping.txt文件:
void test$faab20d() -> a
context參數沒被使用,所以參數被裁剪。混淆任務首先生成test$faab20d(),然後再混淆。如果將要patch該test方法,同時恰好用到了context參數,那麼test的參數不會被裁剪,對熱修復來說就是新增了test(context)方法,只能走冷啓動。
將參數引用住,可達到不讓編譯器優化的目的:
public class BaseBug {
public static void test(Context context) {
if (Boolean.FALSE.booleanValue()) {
context.getApplicationContext();
}
Log.d("BaseBug", "test");
}
}
注意這裏不能用基本類型false,必須用包裝類Boolean,因爲基本類型也可能被優化掉。
2.5.3 熱修復處理
混淆配置文件加上-dontoptimize就不會做方法的裁剪和內聯。
2.6 switch case語句編譯
新舊資源id替換,有時竟然存在switch case語句中的id不會被替換掉的情況。
case項是連續的幾個比較相近的值1,3,5,會被翻譯爲packed-switch指令。中間差值用:pswitch_0補齊,:pswitch_0標籤處直接return-void。
case項是不夠連續的1,3,10的話,會被翻譯成sparse-switch指令。
一個資源id肯定是const final static變量,switch語句被翻譯成packed-switch指令,不做處理的話資源id就無法替換。解決方案簡單暴力,修改smali反編譯流程,碰到packed-switch指令強轉爲sparse-switch指令,:pswitch_N轉化爲:sswitch_N指令。然後做資源id的暴力替換,然後再編回smali爲dex。
2.7 泛型編譯
java泛型基本是完全在編譯器中實現的,編譯器執行類型檢查和類型推斷,然後生成普通的非泛型字節碼,虛擬機完全無感知泛型的存在。這種實現技術稱爲擦除(erasure)。
java5之前,用Object來實現類似"泛型"的功能。但是使用Object來實現泛型存在一些問題,編譯期通過,運行期可能報錯。
public class ObjectFoo {
private Object foo;
public void setFoo(Object foo) {
this.foo = foo;
}
public Object getFoo() {
return foo;
}
}
ObjectFoo foo1 = new ObjectFoo();
foo1.setFoo(new Boolean(true));
Boolean b = (Boolean)foo1.getFoo();//正確
String s = (String)foo1.getFoo();//運行時,類型轉換失敗ClassCastException異常
java5之後使用擦除方案,在編譯時進行類型安全檢測。Boolean b = genericFoo.getFoo()這裏並不需要做強制類型轉換,實際上編譯器會在字節碼中自動加上強制類型轉換。
public class GenericFoo<T> {
private T foo;
public void setFoo(T foo) {
this.foo = foo;
}
public T getFoo() {
return foo;
}
}
GenericFoo<Boolean> genericFoo = new GenericFoo();
genericFoo.setFoo(new Boolean(true));
Boolean b = genericFoo.getFoo();//正確
String s = (String)genericFoo.getFoo();//編譯不通過,incovertiable types
反編譯字節碼:
.method public getFoo()Ljava/lang/object;
.method public setFoo(Ljava/lang/object;)V
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 //跟父類返回值不一樣
public Number get() {
return n;
}
@Override //跟父類參數類型不一樣
public void set(Number n){
this.n = n;
}
}
class C extends A {
private Number n;
@Override //跟父類返回值不一樣
public Number get() {
return n;
}
//@Override 重載父類get方法,因爲參數類型不一樣
public void set(Number n){
this.n = n;
}
}
爲什麼類B的set和get方法可以用@Override而不報錯?
基類A由於類型擦除的影響,set(T t)在字節碼中實際是set(Object t),那麼B的方法set(Number n)參數不一樣,理論上應該是重載而不是重寫。但是我們本意是進行重寫,實現多態,可是類型擦除後,只能變爲了重載,這樣就有了衝突。
實際上JVM採用了一個特殊方法,就是bridge方法來重寫,然後調用實際的B.set(Ljava/lang/Number;)V。
.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/test/B;->set(Ljava/lang/Number;)V
return-void
.end method
類B中的字節碼同時存在get()Ljava/lang/Number;和get()Ljava/lang/Object;,方法的重載只能以方法參數而無法以返回類型作爲重載的區分標準的,但是虛擬機卻是允許這樣的,因爲虛擬機通過參數類型和返回類型共同來確定一個方法,所以編譯器爲了實現泛型的多態允許做這個看起來"不合法"的事情。
熱修復,如果新增了bridge方法,只能走冷啓動修復。
2.8 Lambda表達式編譯
函數式接口:是一個接口,該接口具有唯一的一個抽象方法。
跟匿名內部類的區別:
- 關鍵字this:匿名類的this指向匿名類,而lambda的this指向包圍lambda表達式的類。
- 編譯方式:lambda編譯成類的私有方法,使用java7新加的invokedynamic指令來動態綁定方法。匿名內部類被編譯成"外部類&numble"的新類。
Sun/Oracle Hotspot VM:自動生成私有靜態"lambda$xx$()"方法,這個方法的實現其實就是lambda表達式裏的邏輯,invokedynamic執行metafactory運行時生成一個函數式接口具體類,具體類會調用私有靜態"lambda$x$()"方法。
android:java8的新特性,需要jack(java android compiler kit)支持,Jack(.java->.jack->.dex),不再有.class文件。編譯期也會爲外部類合成一個static輔助方法,該方法內部邏輯實現lambda表達式。.dex執行lambda跟普通方法一樣,沒有運行時類,編譯期生成新類。
熱修復:
- 新增一個lambda表達式,會導致外部類新增一個輔助方法,無法熱修復,只能走冷啓動。
- 只修改原有lambda表達式內部邏輯,由於jack是編譯期自動生成輔助類(相當於metafactory動態生成的類),該輔助類是非靜態的,如果輔助類訪問外部的非靜態field/method就必須持有外部類的引用,會導致合成val$this變量,新增field無法熱修復。
2.9 訪問權限檢查
- 類加載,當前類和實現接口/父類是非public,同時加載兩者的classLoader不一樣的話,會校驗失敗
- 如果補丁類中存在非public類的訪問/非public方法/域的調用,那麼也會失敗。而且在補丁加載階段檢測不出來,運行階段會直接crash異常退出。
3.冷啓動類加載原理
如果僅僅把補丁類打入補丁包,運行時類加載的時候會異常退出:
- 加載一個dex,如果不存在odex文件,那麼首先會執行dexopt,會進行verify/optimize操作。執行類的Verify,類被打上CLASS_ISPREVERIFIED標誌;執行類的Optimize(優化指令,例如invoke-變成invoke--quick,quick從類的vtable直接取),類被打上CLASS_ISOPTIMIZED標誌。
- 加入A類是補丁類,類B引用到補丁類A,由於類B被打上了CLASS_ISPREVERIFIED標誌,referre是類B,resClassCheck是補丁類A,它們屬於不同dex,所以拋ThrowIllegalAccessError。
- 解決這個問題,一個單獨無關幫助類放到一個單獨的dex中,原dex中所有類的構造函數都引用這個類,一般實現方法都是侵入dex打包流程,利用.class字節碼修改技術,在所有.class文件的構造函數中引用這個幫助類。這樣VerifyClass類校驗返回false,原dex中所有類都沒有CLASS_ISPREVERIFIED標誌,因此解決運行時異常。但是對加載效率影響很大,將在InitClass階段進行Verify和Optimize。
Art下冷啓動:
- Dalvik把dex文件解析加載到native內存,如果是壓縮文件中有多個dex,那麼除了classes.dex之外的其它dex被直接忽略掉。
- Art下默認支持壓縮文件中包含多個dex。首先加載primary dex其實就是classes.dex,然後加載其它dex。所以補丁類放到classes.dex即可,後續出現在其它dex中的"補丁類"不會被重複加載。解決方案:只要把補丁dex命名爲classes.dex,原APK中的dex依次命名爲classes(2,3,4).dex,然後一起打包爲一個壓縮文件。然後DexFile.loadDex得到DexFile對象,最後把該DexFile對象整個替換舊的dexElements數組就可以了。
在把dex加載native內存之前,如果dex不存在對應的odex,那麼dalvik下會執行dexopt,art下會執行dexoat,最後得到的都是一個優化後的odex。虛擬機執行的也是這個odex而不是dex。
如果dex很大將會非常耗時,會阻塞loadDex線程,一般是主線程。解決辦法:可以把loadDex當做一個事務來看,如果中途被打斷,那麼就刪除odex文件,重啓的時候如果發現存在odex文件,loadDex完之後,反射注入/替換dexElements數組,實現patch。如果不存在odex文件,那麼重啓另一個子線程loadDex,重啓之後再生效。還需要對odex文件進行md5校驗,防止被篡改。
4.多態對冷啓動類加載的影響
手Q的QFix爲了避免補丁類不在同一個dex問題,把補丁A類添加到原來dex的pResClasses數組中,這樣就確保執行B類test方法時,dvmDexGetResolvedClass不爲null,就不會執行後面類A和類B的dex一致性校驗了。
然而,QFix是在dexopt之後進行繞過的,dexopt會改變原先的很多邏輯,許多odex層面的優化會寫死字段和方法的訪問偏移,這就會導致比較嚴重的BUG。
當前類和所有繼承父類的public/protected/default方法就是virtual方法,private/static不屬於。
Vtable:
- 整個複製父類vtable到子類vtable
- 遍歷子類的virtual方法集合,如果方法原型一致,說明是重寫父類方法,那麼相同索引位置處,子類重寫方法覆蓋掉vtable中父類的方法
- 方法原型不一致,那麼把該方法添加到vtable的末尾
public class Demo {
public static void test_addMethod() {
A obj = new A();
obj.a_t2();
}
}
Optimize階段,優化invoke-virtual爲invoke-virtual-quick,這個指令後面跟的立即數就是該方法在類vtable中索引值。
假如修復後的apk新增了a_t1方法,patch前類A的vtable值是vtable[0]=a_t2,patch後類新增了a_t1,那麼變爲vtable[0]=a_t1、vtable[1]=a_t2。但是obj.a_t2()這行代碼在odex中的指令其實是invoke-virtual-quick A.vtable[0],所以patch前調用的a_t2,patch後調用的a_t1,導致錯亂。
5.Dalvik下完整DEX方案的新探索
全量合成可以避免多態的影響,微信的Tinker合成方案,是從dex的方法和指令維度進行全量合成,雖然可以很大地節省空間,但由於對dex內容的比較粒度過細,實現較爲複雜,性能消耗比較嚴重。實際上dex佔APK比例是比較低的,資源文件纔是大頭。
dex比較的最佳粒度,應該是類的維度。一般思路是把原來的dex和patch裏的dex重新合併,其實可以在原先的dex去掉補丁中也有的class,這樣補丁+去除補丁的原基線,就是新APP的所有類。
移除類,只需要移除定義的入口,對於Class的具體內容不進行刪除,這樣可以最大可能地減少offset的修改。
一個dex裏面一共有pHeader->classDefsSize個類定義,從pHeader->classDefsOff偏移處開始,一個接一個地線性排列着。
所以從pHeader->classDefsOff處遍歷,刪除補丁中的類,再修改pHeader->classDefsSize即可。
在加載補丁後,如果Application類使用其他新dex裏的類,由於不在同一個dex裏,如果Application被打上pre-verified標誌,就會拋異常。解法很簡單,直接清除掉pre-verified標誌就行了。
類的標誌,位於ClassObject的accessFlag成員。
CLASS_ISPREVERIFIED = (1<<16);
//jni層清除
clazzObj->accessFlags &= ~CLASS_ISPREVERIFIED;
Dalvik虛擬機如果發現某個類沒有pre-verrified,就會在初始化這個類時做Verify操作,這將掃描這類的所有代碼,在掃描過程中對這個類代碼裏使用到的類都要進行dvmOptResolveClass操作,它會在Resolve的時候對使用到的類進行初始化,而這個邏輯是發生在Application類初始的時候。此時補丁還沒進行加載,所以就會提前加載到原始dex中的類。接下來補丁加載完畢,這些已加載的類如果用到了新dex中的類,並且又是pre-verified時就會報錯。
- 讓Application用到的所有非系統類都和Application位於同一個dex裏,這就可以保證pre-verified標誌被打上,避免進入dvmOptResolveClass。補丁加載完後,再清楚pre-verified標誌。
- 把Application裏除了熱修復框架代碼外,其他代碼都剝離開,單獨放到一個其他類裏面。這樣可以保證單獨拿出來的類和Application處於同一個dex的機率比較大,想更保險的話,Application可以採用反射方式訪問這個單獨類,這樣就徹底把Application和其它類隔絕開了。
參考
阿里Sophix《深入探索Android熱修復技術原理》