Android熱修複升級探索——追尋極致的代碼熱替換
前言
前段時間,Android平台上湧現了一係列熱修複方案,如阿裏的Andfix、微信的Tinker、QQ空間的Nuva、手Q的QFix等等。
其中,Andfix的**即時生效**令人印象深刻,它稍顯另類,並不需要重新啟動,而是在加載補丁後直接對方法進行替換就可以完成修複,然而它的使用限製也遭遇到更多的質疑。
我們也對代碼的native替換原理重新進行了深入思考,從克服其限製和兼容性入手,以一種更加優雅的替換思路,實現了即時生效的代碼熱修複。
Andfix回顧
我們先來看一下,為何唯獨Andfix能夠做到即時生效呢?
原因是這樣的,在app運行到一半的時候,所有需要發生變更的Class已經被加載過了,在Android上是無法對一個Class進行卸載的。而騰訊係的方案,都是讓Classloader去加載新的類。如果不重啟,原來的類還在虛擬機中,就無法加載新類。因此,隻有在下次重啟的時候,在還沒走到業務邏輯之前搶先加載補丁中的新類,這樣後續訪問這個類時,就會Resolve為新的類。從而達到熱修複的目的。
Andfix采用的方法是,在已經加載了的類中直接在native層替換掉原有方法,是在原來類的基礎上進行修改的。我們這就來看一下Andfix的具體實現。
其核心在於replaceMethod函數
@AndFix/src/com/alipay/euler/andfix/AndFix.java
private static native void replaceMethod(Method src, Method dest);
這是一個native方法,它的參數是在Java層通過反射機製得到的Method對象所對應的jobject。src對應的是需要被替換的原有方法。而dest對應的就是新方法,新方法存在於補丁包的新類中,也就是補丁方法。
@AndFix/jni/andfix.cpp
static void replaceMethod(JNIEnv* env, jclass clazz, jobject src,
jobject dest) {
if (isArt) {
art_replaceMethod(env, src, dest);
} else {
dalvik_replaceMethod(env, src, dest);
}
}
Android的java運行環境,在4.4以下用的是dalvik虛擬機,而在4.4以上用的是art虛擬機。
@AndFix/jni/art/art_method_replace.cpp
extern void __attribute__ ((visibility ("hidden"))) art_replaceMethod(
JNIEnv* env, jobject src, jobject dest) {
if (apilevel > 23) {
replace_7_0(env, src, dest);
} else if (apilevel > 22) {
replace_6_0(env, src, dest);
} else if (apilevel > 21) {
replace_5_1(env, src, dest);
} else if (apilevel > 19) {
replace_5_0(env, src, dest);
}else{
replace_4_4(env, src, dest);
}
}
我們以art為例,對於不同Android版本的art,底層Java對象的數據結構是不同的,因而會進一步區分不同的替換函數,這裏我們以Android 6.0為例,對應的就是replace_6_0
。
@AndFix/jni/art/art_method_replace_6_0.cpp
void replace_6_0(JNIEnv* env, jobject src, jobject dest) {
// %% 通過Method對象得到底層Java函數對應ArtMethod的真實地址。
art::mirror::ArtMethod* smeth =
(art::mirror::ArtMethod*) env->FromReflectedMethod(src);
art::mirror::ArtMethod* dmeth =
(art::mirror::ArtMethod*) env->FromReflectedMethod(dest);
... ...
// %% 把舊函數的所有成員變量都替換為新函數的。
smeth->declaring_class_ = dmeth->declaring_class_;
smeth->dex_cache_resolved_methods_ = dmeth->dex_cache_resolved_methods_;
smeth->dex_cache_resolved_types_ = dmeth->dex_cache_resolved_types_;
smeth->access_flags_ = dmeth->access_flags_;
smeth->dex_code_item_offset_ = dmeth->dex_code_item_offset_;
smeth->dex_method_index_ = dmeth->dex_method_index_;
smeth->method_index_ = dmeth->method_index_;
smeth->ptr_sized_fields_.entry_point_from_interpreter_ =
dmeth->ptr_sized_fields_.entry_point_from_interpreter_;
smeth->ptr_sized_fields_.entry_point_from_jni_ =
dmeth->ptr_sized_fields_.entry_point_from_jni_;
smeth->ptr_sized_fields_.entry_point_from_quick_compiled_code_ =
dmeth->ptr_sized_fields_.entry_point_from_quick_compiled_code_;
LOGD("replace_6_0: %d , %d",
smeth->ptr_sized_fields_.entry_point_from_quick_compiled_code_,
dmeth->ptr_sized_fields_.entry_point_from_quick_compiled_code_);
}
每一個Java方法在art中都對應著一個ArtMethod,ArtMethod記錄了這個Java方法的所有信息,包括所屬類、訪問權限、代碼執行地址等等。
通過env->FromReflectedMethod
,可以由Method對象得到這個方法對應的ArtMethod的真正起始地址。然後就可以把它強轉為ArtMethod指針,從而對其所有成員進行修改。
這樣全部替換完之後就完成了熱修複邏輯。以後調用這個方法時就會直接走到新方法的實現中了。
虛擬機調用方法的原理
為什麼這樣替換完就可以實現熱修複呢?這需要從虛擬機調用方法的原理說起。
在Android 6.0,art虛擬機中ArtMethod的結構是這個樣子的:
@art/runtime/art_method.h
class ArtMethod FINAL {
... ...
protected:
// Field order required by test "ValidateFieldOrderOfJavaCppUnionClasses".
// The class we are a part of.
GcRoot<mirror::Class> declaring_class_;
// Short cuts to declaring_class_->dex_cache_ member for fast compiled code access.
GcRoot<mirror::PointerArray> dex_cache_resolved_methods_;
// Short cuts to declaring_class_->dex_cache_ member for fast compiled code access.
GcRoot<mirror::ObjectArray<mirror::Class>> dex_cache_resolved_types_;
// Access flags; low 16 bits are defined by spec.
uint32_t access_flags_;
/* Dex file fields. The defining dex file is available via declaring_class_->dex_cache_ */
// Offset to the CodeItem.
uint32_t dex_code_item_offset_;
// Index into method_ids of the dex file associated with this method.
uint32_t dex_method_index_;
/* End of dex file fields. */
// Entry within a dispatch table for this method. For static/direct methods the index is into
// the declaringClass.directMethods, for virtual methods the vtable and for interface methods the
// ifTable.
uint32_t method_index_;
// Fake padding field gets inserted here.
// Must be the last fields in the method.
// PACKED(4) is necessary for the correctness of
// RoundUp(OFFSETOF_MEMBER(ArtMethod, ptr_sized_fields_), pointer_size).
struct PACKED(4) PtrSizedFields {
// Method dispatch from the interpreter invokes this pointer which may cause a bridge into
// compiled code.
void* entry_point_from_interpreter_;
// Pointer to JNI function registered to this method, or a function to resolve the JNI function.
void* entry_point_from_jni_;
// Method dispatch from quick compiled code invokes this pointer which may cause bridging into
// the interpreter.
void* entry_point_from_quick_compiled_code_;
} ptr_sized_fields_;
... ...
}
這其中最重要的字段就是entry_point_from_interprete_和entry_point_from_quick_compiled_code_了,從名字可以看出來,他們就是方法的執行入口。我們知道,Java代碼在Android中會被編譯為Dex Code。
art中可以采用解釋模式或者AOT機器碼模式執行。
解釋模式,就是取出Dex Code,逐條解釋執行就行了。如果方法的調用者是以解釋模式運行的,在調用這個方法時,就會取得這個方法的entry_point_from_interpreter_,然後跳轉過去執行。
而如果是AOT的方式,就會先預編譯好Dex Code對應的機器碼,然後運行期直接執行機器碼就行了,不需要一條條地解釋執行Dex Code。如果方法的調用者是以AOT機器碼方式執行的,在調用這個方法時,就是跳轉到entry_point_from_quick_compiled_code_執行。
那我們是不是隻需要替換這幾個entry_point_*入口地址就能夠實現方法替換了呢?
並沒有這麼簡單。因為不論是解釋模式或是AOT機器碼模式,在運行期間還會需要用到ArtMethod裏麵的其他成員字段。
就以AOT機器碼模式為例,雖然Dex Code被編譯成了機器碼。但是機器碼並不是可以脫離虛擬機而單獨運行的,以這段簡單的代碼為例:
public class MainActivity extends Activity {
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
}
... ...
編譯為AOT機器碼後,是這樣的:
7: void com.patch.demo.MainActivity.onCreate(android.os.Bundle) (dex_method_idx=20639)
DEX CODE:
0x0000: 6f20 4600 1000 | invoke-super {v0, v1}, void android.app.Activity.onCreate(android.os.Bundle) // method@70
0x0003: 0e00 | return-void
CODE: (code_offset=0x006fdbac size_offset=0x006fdba8 size=96)
... ...
0x006fdbe0: f94003e0 ldr x0, [sp] ;x0 = MainActivity.onCreate對應的ArtMethod指針
0x006fdbe4: b9400400 ldr w0, [x0, #4] ;w0 = [x0 + 4] = dex_cache_resolved_methods_字段
0x006fdbe8: f9412000 ldr x0, [x0, #576] ;x0 = [x0 + 576] = dex_cache_resolved_methods_數組的第72(=576/8)個元素,即對應Activity.onCreate的ArtMethod指針
0x006fdbec: f940181e ldr lr, [x0, #48] ;lr = [x0 + 48] = Activity.onCreate的ArtMethod成員的entry_point_from_quick_compiled_code_執行入口點
0x006fdbf0: d63f03c0 blr lr ;調用Activity.onCreate
... ...
這裏麵我去掉了一些校驗之類的無關代碼,可以很清楚看到,在調用一個方法時,取得了ArtMethod中的dex_cache_resolved_methods_,這是一個存放ArtMethod*的指針數組,通過它就可以訪問到這個Method所在Dex中所有的Method所對應的ArtMethod*。
Activity.onCreate的方法索引是70,由於是64位係統,因此每個指針的大小為8字節,又由於ArtMethod*元素是從這個數組的第0x2個位置開始存放的,因此偏移(70 + 2) * 8 = 576的位置正是Activity.onCreate的ArtMethod指針。
這是一個比較簡單的例子,而在實際代碼中,有許多更為複雜的調用情況。很多情況下還需要用到dex_code_item_offset_等字段。由此可以看出,AOT機器碼的執行過程,還是會有對於虛擬機以及ArtMethod其他成員字段的依賴。
因此,當把一個舊方法的所有成員字段換成都新方法後,執行時所有數據就可以保持和新方法的一致。這樣在所有執行到舊方法的地方,會取得新方法的執行入口、所屬class、方法索引號以及所屬dex信息,然後像調用舊方法一樣順滑地執行到新方法的邏輯。
兼容性問題的根源
然而,目前市麵上幾乎所有的native替換方案,比如Andfix和另一種Hook框架Legend,都是寫死了ArtMethod結構體,這會帶來巨大的兼容性問題。
從剛才的分析可以看到,雖然Andfix是把底層結構強轉為了art::mirror::ArtMethod,但這裏的art::mirror::ArtMethod並非等同於app運行時所在設備虛擬機底層的art::mirror::ArtMethod,而是Andfix自己構造的art::mirror::ArtMethod。
@AndFix/jni/art/art_6_0.h
class ArtMethod {
public:
// Field order required by test "ValidateFieldOrderOfJavaCppUnionClasses".
// The class we are a part of.
uint32_t declaring_class_;
// Short cuts to declaring_class_->dex_cache_ member for fast compiled code access.
uint32_t dex_cache_resolved_methods_;
// Short cuts to declaring_class_->dex_cache_ member for fast compiled code access.
uint32_t dex_cache_resolved_types_;
// Access flags; low 16 bits are defined by spec.
uint32_t access_flags_;
/* Dex file fields. The defining dex file is available via declaring_class_->dex_cache_ */
// Offset to the CodeItem.
uint32_t dex_code_item_offset_;
// Index into method_ids of the dex file associated with this method.
uint32_t dex_method_index_;
/* End of dex file fields. */
// Entry within a dispatch table for this method. For static/direct methods the index is into
// the declaringClass.directMethods, for virtual methods the vtable and for interface methods the
// ifTable.
uint32_t method_index_;
// Fake padding field gets inserted here.
// Must be the last fields in the method.
// PACKED(4) is necessary for the correctness of
// RoundUp(OFFSETOF_MEMBER(ArtMethod, ptr_sized_fields_), pointer_size).
struct PtrSizedFields {
// Method dispatch from the interpreter invokes this pointer which may cause a bridge into
// compiled code.
void* entry_point_from_interpreter_;
// Pointer to JNI function registered to this method, or a function to resolve the JNI function.
void* entry_point_from_jni_;
// Method dispatch from quick compiled code invokes this pointer which may cause bridging into
// the interpreter.
void* entry_point_from_quick_compiled_code_;
} ptr_sized_fields_;
};
我們再來回顧一下Android開源代碼裏麵art虛擬機裏的ArtMethod:
@art/runtime/art_method.h
class ArtMethod FINAL {
... ...
protected:
// Field order required by test "ValidateFieldOrderOfJavaCppUnionClasses".
// The class we are a part of.
GcRoot<mirror::Class> declaring_class_;
// Short cuts to declaring_class_->dex_cache_ member for fast compiled code access.
GcRoot<mirror::PointerArray> dex_cache_resolved_methods_;
// Short cuts to declaring_class_->dex_cache_ member for fast compiled code access.
GcRoot<mirror::ObjectArray<mirror::Class>> dex_cache_resolved_types_;
// Access flags; low 16 bits are defined by spec.
uint32_t access_flags_;
/* Dex file fields. The defining dex file is available via declaring_class_->dex_cache_ */
// Offset to the CodeItem.
uint32_t dex_code_item_offset_;
// Index into method_ids of the dex file associated with this method.
uint32_t dex_method_index_;
/* End of dex file fields. */
// Entry within a dispatch table for this method. For static/direct methods the index is into
// the declaringClass.directMethods, for virtual methods the vtable and for interface methods the
// ifTable.
uint32_t method_index_;
// Fake padding field gets inserted here.
// Must be the last fields in the method.
// PACKED(4) is necessary for the correctness of
// RoundUp(OFFSETOF_MEMBER(ArtMethod, ptr_sized_fields_), pointer_size).
struct PACKED(4) PtrSizedFields {
// Method dispatch from the interpreter invokes this pointer which may cause a bridge into
// compiled code.
void* entry_point_from_interpreter_;
// Pointer to JNI function registered to this method, or a function to resolve the JNI function.
void* entry_point_from_jni_;
// Method dispatch from quick compiled code invokes this pointer which may cause bridging into
// the interpreter.
void* entry_point_from_quick_compiled_code_;
} ptr_sized_fields_;
... ...
}
可以看到,ArtMethod結構裏的各個成員的大小是和AOSP開源代碼裏完全一致的。這是由於Android源碼是公開的,Andfix裏麵的這個ArtMethod自然是遵照android虛擬機art源碼裏麵的ArtMethod構建的。
但是,由於Android是開源的,各個手機廠商都可以對代碼進行改造,而Andfix裏ArtMethod的結構是根據公開的Android源碼中的結構寫死的。如果某個廠商對這個ArtMethod結構體進行了修改,就和原先開源代碼裏的結構不一致,那麼在這個修改過了的設備上,替換機製就會出問題。
比如,在Andfix替換declaring_class_
的地方,
smeth->declaring_class_ = dmeth->declaring_class_;
由於declaring_class_
是andfix裏ArtMethod的第一個成員,因此它和以下這行代碼等價:
*(uint32_t*) (smeth + 0) = *(uint32_t*) (dmeth + 0)
如果手機廠商在ArtMethod結構體的declaring_class_
前麵添加了一個字段additional_
,那麼,additional_就成為了ArtMethod的第一個成員,所以smeth + 0這個位置在這台設備上實際就變成了additional_
,而不再是declaring_class_
字段。所以這行代碼的真正含義就變成了:
smeth->additional_ = dmeth->additional_;
這樣就和原先替換declaring_class_
的邏輯不一致,從而無法正常執行熱修複邏輯。
這也正是Andfix不支持很多機型的原因,很大的可能,就是因為這些機型修改了底層的虛擬機結構。
突破底層結構差異
知道了native替換方式兼容性問題的原因,我們是否有辦法尋求一種新的方式,不依賴於ROM底層方法結構的實現而達到替換效果呢?
我們發現,這樣native層麵替換思路,其實就是替換ArtMethod的所有成員。那麼,我們並不需要構造出ArtMethod具體的各個成員字段,隻要把ArtMethod的作為整體進行替換,這樣不就可以了嗎?
因此Andfix這一係列繁瑣的替換:
// %% 把舊函數的所有成員變量都替換為新函數的。
smeth->declaring_class_ = dmeth->declaring_class_;
smeth->dex_cache_resolved_methods_ = dmeth->dex_cache_resolved_methods_;
smeth->dex_cache_resolved_types_ = dmeth->dex_cache_resolved_types_;
smeth->access_flags_ = dmeth->access_flags_;
smeth->dex_code_item_offset_ = dmeth->dex_code_item_offset_;
smeth->dex_method_index_ = dmeth->dex_method_index_;
smeth->method_index_ = dmeth->method_index_;
... ...
其實可以濃縮為:
memcpy(smeth, dmeth, sizeof(ArtMethod));
就是這樣,一句話就能取代上麵一堆代碼,這正是我們深入理解替換機製的本質之後研發出的新替換方案。
剛才提到過,不同的手機廠商都可以對底層的ArtMethod進行任意修改,但即使他們把ArtMethod改得六親不認,隻要我像這樣把整個ArtMethod結構體完整替換了,就能夠把所有舊方法成員自動對應地換成新方法的成員。
但這其中最關鍵的地方,在於sizeof(ArtMethod)。如果size計算有偏差,導致部分成員沒有被替換,或者替換區域超出了邊界,都會導致嚴重的問題。
對於ROM開發者而言,是在art源代碼裏麵,所以一個簡單的sizeof(ArtMethod)
就行了,因為這是在編譯期就可以決定的。
但我們是上層開發者,app會被下發給各式各樣的Android設備,所以我們是需要在運行時動態地得到app所運行設備上麵的底層ArtMethod大小的,這就沒那麼簡單了。
想要忽略ArtMethod的具體結構成員直接取得其size的精確值,我們還是需要從虛擬機的源碼入手,**從底層的數據結構及排列特點探尋答案**。
在art裏麵,初始化一個類的時候會給這個類的所有方法分配空間,我們可以看到這個分配空間的地方:
@android-6.0.1_r62/art/runtime/class_linker.cc
void ClassLinker::LoadClassMembers(Thread* self, const DexFile& dex_file,
const uint8_t* class_data,
Handle<mirror::Class> klass,
const OatFile::OatClass* oat_class) {
... ...
ArtMethod* const direct_methods = (it.NumDirectMethods() != 0)
? AllocArtMethodArray(self, it.NumDirectMethods())
: nullptr;
ArtMethod* const virtual_methods = (it.NumVirtualMethods() != 0)
? AllocArtMethodArray(self, it.NumVirtualMethods())
: nullptr;
... ...
類的方法有direct方法和virtual方法。direct方法包含static方法和所有不可繼承的對象方法。而virtual方法就是所有可以繼承的對象方法了。
AllocArtMethodArray函數分配了他們的方法所在區域。
@android-6.0.1_r62/art/runtime/class_linker.cc
ArtMethod* ClassLinker::AllocArtMethodArray(Thread* self, size_t length) {
const size_t method_size = ArtMethod::ObjectSize(image_pointer_size_);
uintptr_t ptr = reinterpret_cast<uintptr_t>(
Runtime::Current()->GetLinearAlloc()->Alloc(self, method_size * length));
CHECK_NE(ptr, 0u);
for (size_t i = 0; i < length; ++i) {
new(reinterpret_cast<void*>(ptr + i * method_size)) ArtMethod;
}
return reinterpret_cast<ArtMethod*>(ptr);
}
可以看到,ptr是這個方法數組的指針,而方法是一個接一個緊密地new出來排列在這個方法數組中的。這時隻是分配出空間,還沒填入真正的ArtMethod的各個成員值,不過這並不影響我們觀察ArtMethod的空間結構。
正是這裏給了我們啟示,ArtMethod們是緊密排列的,所以一個ArtMethod的大小,不就是相鄰兩個方法所對應的ArtMethod的起始地址的差值嗎?
正是如此。我們就從這個排列特點入手,自己構造一個類,以一種巧妙的方式獲取到這個差值。
public class NativeStructsModel {
final public static void f1() {}
final public static void f2() {}
}
由於f1和f2都是static方法,所以都屬於direct ArtMethod Array。由於NativeStructsModel類中隻存在這兩個方法,因此它們肯定是相鄰的。
那麼我們就可以在JNI層取得它們地址的差值:
size_t firMid = (size_t) env->GetStaticMethodID(nativeStructsModelClazz, "f1", "()V");
size_t secMid = (size_t) env->GetStaticMethodID(nativeStructsModelClazz, "f2", "()V");
size_t methSize = secMid - firMid;
然後,就以這個methSize
作為sizeof(ArtMethod)
,代入之前的代碼。
memcpy(smeth, dmeth, methSize);
問題就迎刃而解了。
值得一提的是,由於忽略了底層ArtMethod結構的差異,對於所有的Android版本都不再需要區分,而統一以memcpy
實現即可,代碼量大大減少。即使以後的Android版本不斷修改ArtMethod的成員,隻要保證ArtMethod數組仍是以線性結構排列,就能直接適用於將來的Android 8.0、9.0等新版本,無需再針對新的係統版本進行適配了。事實也證明確實如此,當我們拿到Google剛發不久的Android O(8.0)開發者預覽版的係統時,hotfix demo直接就能順利地加載補丁跑起來了,我們並沒有做任何適配工作,魯棒性極好。
訪問權限的問題
方法調用時的權限檢查
看到這裏,你可能會有疑惑:我們隻是替換了ArtMethod的內容,但新替換的方法的所屬類,和原先方法的所屬類,是不同的類,被替換的方法有權限訪問這個類的其他private方法嗎?
以這段簡單的代碼為例
public class Demo {
Demo() {
func();
}
private void func() {
}
}
Demo構造函數調用私有函數func
所對應的Dex Code和Native Code為
void com.patch.demo.Demo.<init>() (dex_method_idx=20628)
DEX CODE:
... ...
0x0003: 7010 9550 0000 | invoke-direct {v0}, void com.patch.demo.Demo.func() // method@20629
... ...
CODE: (code_offset=0x006fd86c size_offset=0x006fd868 size=140)...
... ...
0x006fd8c4: f94003e0 ldr x0, [sp] ; x0 = <init>的ArtMethod*
0x006fd8c8: b9400400 ldr w0, [x0, #4] ; w0 = dex_cache_resolved_methods_
0x006fd8cc: d2909710 mov x16, #0x84b8 ; x16 = 0x84b8
0x006fd8d0: f2a00050 movk x16, #0x2, lsl #16 ; x16 = 0x84b8 + 0x20000 = 0x284b8 = (20629 + 2) * 8,
; 也就是Demo.func的ArtMethod*相對於表頭dex_cache_resolved_methods_的偏移。
0x006fd8d4: f8706800 ldr x0, [x0, x16] ; 得到Demo.func的ArtMethod*
0x006fd8d8: f940181e ldr lr, [x0, #48] ; 取得其entry_point_from_quick_compiled_code_
0x006fd8dc: d63f03c0 blr lr ; 跳轉執行
... ...
這個調用邏輯和之前Activity的例子大同小異,需要注意的地方是,在構造函數調用同一個類下的私有方法func
時,沒有做任何權限檢查。也就是說,這時即使我把func
方法的偷梁換柱,也能直接跳過去正常執行而不會報錯。
可以推測,在dex2oat生成AOT機器碼時是有做一些檢查和優化的,由於在dex2oat編譯機器碼時確認了兩個方法同屬一個類,所以機器碼中就不存在權限檢查的相關代碼。
同包名下的權限問題
但是,並非所有方法都可以這麼順利地進行訪問的。我們發現補丁中的類在訪問同包名下的類時,會報出訪問權限異常:
Caused by: java.lang.IllegalAccessError:
Method 'void com.patch.demo.BaseBug.test()' is inaccessible to class 'com.patch.demo.MyClass' (declaration of 'com.patch.demo.MyClass'
appears in /data/user/0/com.patch.demo/files/baichuan.fix/patch/patch.jar)
雖然com.patch.demo.BaseBug
和com.patch.demo.MyClass
是同一個包com.patch.demo
下麵的,但是由於我們替換了com.patch.demo.BaseBug.test
,而這個替換了的BaseBug.test
是從補丁包的Classloader加載的,與原先的base包就不是同一個Classloader了,這樣就導致兩個類無法被判別為同包名。具體的校驗邏輯是在虛擬機代碼的Class::IsInSamePackage
中:
android-6.0.1_r62/art/runtime/mirror/class.cc
bool Class::IsInSamePackage(Class* that) {
Class* klass1 = this;
Class* klass2 = that;
if (klass1 == klass2) {
return true;
}
// Class loaders must match.
if (klass1->GetClassLoader() != klass2->GetClassLoader()) {
return false;
}
// Arrays are in the same package when their element classes are.
while (klass1->IsArrayClass()) {
klass1 = klass1->GetComponentType();
}
while (klass2->IsArrayClass()) {
klass2 = klass2->GetComponentType();
}
// trivial check again for array types
if (klass1 == klass2) {
return true;
}
// Compare the package part of the descriptor string.
std::string temp1, temp2;
return IsInSamePackage(klass1->GetDescriptor(&temp1), klass2->GetDescriptor(&temp2));
}
關鍵點在於,Class loaders must match這行注釋。
知道了原因就好解決了,我們隻要設置新類的Classloader為原來類就可以了。而這一步同樣不需要在JNI層構造底層的結構,隻需要通過反射進行設置。這樣仍舊能夠保證良好的兼容性。
實現代碼如下:
Field classLoaderField = Class.class.getDeclaredField("classLoader");
classLoaderField.setAccessible(true);
classLoaderField.set(newClass, oldClass.getClassLoader());
這樣就解決了同包名下的訪問權限問題。
反射調用非靜態方法產生的問題
當一個非靜態方法被熱替換後,在反射調用這個方法時,會拋出異常。
比如下麵這個例子:
// BaseBug.test方法已經被熱替換了。
... ...
BaseBug bb = new BaseBug();
Method testMeth = BaseBug.class.getDeclaredMethod("test");
testMeth.invoke(bb);
invoke的時候就會報:
Caused by: java.lang.IllegalArgumentException:
Expected receiver of type com.patch.demo.BaseBug,
but got com.patch.demo.BaseBug
這裏麵,expected receiver的BaseBug,和got到的BaseBug,雖然都叫com.patch.demo.BaseBug,但卻是不同的類。
前者是被熱替換的方法所屬的類,由於我們把它的ArtMethod的declaring_class_替換了,因此就是新的補丁類。而後者作為被調用的實例對象bb的所屬類,是原有的BaseBug。兩者是不同的。
在反射invoke這個方法時,在底層會調用到InvokeMethod:
jobject InvokeMethod(const ScopedObjectAccessAlreadyRunnable& soa, jobject javaMethod,
jobject javaReceiver, jobject javaArgs, size_t num_frames) {
... ...
if (!VerifyObjectIsClass(receiver, declaring_class)) {
return nullptr;
}
... ...
這裏麵會調用VerifyObjectIsClass函數做驗證。
inline bool VerifyObjectIsClass(mirror::Object* o, mirror::Class* c) {
if (UNLIKELY(o == nullptr)) {
ThrowNullPointerException("null receiver");
return false;
} else if (UNLIKELY(!o->InstanceOf(c))) {
InvalidReceiverError(o, c);
return false;
}
return true;
}
o表示Method.invoke傳入的第一個參數,也就是作用的對象。
c表示ArtMethod所屬的Class。
因此,隻有o是c的一個實例才能夠通過驗證,才能繼續執行後麵的反射調用流程。
由此可知,這種熱替換方式所替換的非靜態方法,在進行反射調用時,由於VerifyObjectIsClass時舊類和新類不匹配,就會導致校驗不通過,從而拋出上麵那個異常。
那為什麼方法是非靜態才有這個問題呢?因為如果是靜態方法,是在類的級別直接進行調用的,就不需要接收對象實例作為參數。所以就沒有這方麵的檢查了。
對於這種反射調用非靜態方法的問題,我們會采用另一種冷啟動機製對付,本文在最後會說明如何解決。
即時生效所帶來的限製
除了反射的問題,像本方案以及Andfix這樣直接在運行期修改底層結構的熱修複,都存在著一個限製,那就是隻能支持方法的替換。而對於補丁類裏麵存在方法增加和減少,以及成員字段的增加和減少的情況,都是不適用的。
原因是這樣的,一旦補丁類中出現了方法的增加和減少,就會導致這個類以及整個Dex的方法數的變化。方法數的變化伴隨著方法索引的變化,這樣在訪問方法時就無法正常地索引到正確的方法了。
而如果字段發生了增加和減少,和方法變化的情況一樣,所有字段的索引都會發生變化。並且更嚴重的問題是,如果在程序運行中間某個類突然增加了一個字段,那麼對於原先已經產生的這個類的實例,它們還是原來的結構,這是無法改變的。而新方法使用到這些老的實例對象時,訪問新增字段就會產生不可預期的結果。
不過新增一個完整的、原先包裏麵不存在的新類是可以的,這個不受限製。
總之,隻有兩種情況是不適用的:1).引起原有了類中發生結構變化的修改,2).修複了的非靜態方法會被反射調用,而對於其他情況,這種方式的熱修複都可以任意使用。
總結
雖然有著一些使用限製,但一旦滿足使用條件,這種熱修複方式是十分出眾的,它補丁小,加載迅速,能夠實時生效無需重新啟動app,並且具有著完美的設備兼容性。對於較小程度的修複再適合不過了。
本修複方案將最先在**阿裏Hotfix2.0(Sophix)**上應用,手機淘寶技術團隊近期將與阿裏雲聯合發布。
Sophix提供了一套更加完美的客戶端服務端一體的熱更新方案。針對小修改可以采用本文這種即時生效的熱修複,並且可以結合資源修複,做到資源和代碼的即時生效。
而如果觸及了本文提到的熱替換使用限製,**對於比較大的代碼改動以及被修複方法反射調用情況**,Sophix也提供了另一種完整代碼修複機製,不過是需要app重新冷啟動,來發揮其更加完善的修複及更新功能。從而可以做到無感知的應用更新。
並且Sophix做到了圖形界麵一鍵打包、加密傳輸、簽名校驗和服務端控製發布與灰度功能,讓你用最少的時間實現最強大可靠的全方位熱更新。
一張表格來說明一下各個版本熱修複的差別:
方案對比 | Andfix開源版本 | 阿裏Hotfix 1.X | 阿裏Hotfix 2.0(Sophix) |
---|---|---|---|
方法替換 | 支持,除部分情況[0] | 支持,除部分情況 | |
方法增加減少 | 以冷啟動方式支持[1] | ||
方法反射調用 | 以冷啟動方式支持 | ||
即時生效 | 視情況支持[2] | ||
多DEX | |||
資源更新 | |||
so庫更新 | |||
Android版本 | 支持2.3~7.0 | 支持2.3~6.0 | |
已有機型 | 大部分支持[3] | 大部分支持 | |
安全機製 | |||
性能損耗 | |||
生成補丁 | |||
補丁大小 | 不大,僅變動的資源和代碼[4] | ||
服務端支持 | 支持服務端控製[5] |
說明:
[0] 部分情況指的是構造方法、參數數目大於8或者參數包括long,double,float基本類型的方法。
[1] 冷啟動方式,指的是需要重啟app在下次啟動時才能生效。
[2] 對於Andfix及Hotfix 1.X能夠支持的代碼變動情況,都能做到即時生效。而對於Andfix及Hotfix 1.X不支持的代碼變動情況,會走冷啟動方式,此時就無法做到即時生效。
[3] Hotfix 1.X已經支持絕大部分主流手機,隻是在X86設備以及修改了虛擬機底層結構的ROM上不支持。
[4] 由於支持了資源和庫,如果有這些方麵的更新,就會導致的補丁變大一些,這個是很正常的。並且由於隻包含差異的部分,所以補丁已經是最大程度的小了。
[5] 提供服務端的補丁發布和停發、版本控製和灰度功能,存儲開發者上傳的補丁包。
最後,感謝團隊@悟二和@查鬱冷啟動修複及so庫更新方麵的支持,以及@所為在開發過程中的問題討論與文章校稿。
最後更新:2017-04-27 20:00:55