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,並且具有著完美的裝置相容性。對於較小程度的修復再適合不過了。
本修復方案將最先在阿里Hotfix最新版本(Sophix)上應用,由手機淘寶技術團隊與阿里雲聯合釋出。
Sophix提供了一套更加完美的客戶端服務端一體的熱更新方案。針對小修改可以採用本文這種即時生效的熱修復,並且可以結合資源修復,做到資源和程式碼的即時生效。
而如果觸及了本文提到的熱替換使用限制,對於比較大的程式碼改動以及被修復方法反射呼叫情況,Sophix也提供了另一種完整程式碼修復機制,不過是需要app重新冷啟動,來發揮其更加完善的修復及更新功能。從而可以做到無感知的應用更新。
並且Sophix做到了圖形介面一鍵打包、加密傳輸、簽名校驗和服務端控制釋出與灰度功能,讓你用最少的時間實現最強大可靠的全方位熱更新。
一張表格來說明一下各個版本熱修復的差別:
方案對比 | Andfix開源版本 | 阿里Hotfix 1.X | 阿里Hotfix最新版(Sophix) |
---|---|---|---|
方法替換 | 支援,除部分情況[0] | 支援,除部分情況 | 全部支援 |
方法增加減少 | 不支援 | 不支援 | 以冷啟動方式支援[1] |
方法反射呼叫 | 只支援靜態方法 | 只支援靜態方法 | 以冷啟動方式支援 |
即時生效 | 支援 | 支援 | 視情況支援[2] |
多DEX | 不支援 | 支援 | 支援 |
資源更新 | 不支援 | 不支援 | 支援 |
so庫更新 | 不支援 | 不支援 | 支援 |
Android版本 | 支援2.3~7.0 | 支援2.3~6.0 | 全部支援包含7.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] 提供服務端的補丁釋出和停發、版本控制和灰度功能,儲存開發者上傳的補丁包。
從現在起,讓你的APP實現隨心所欲的熱更新吧!請猛戳這裡>_<
最後,感謝團隊@悟二和@查鬱冷啟動修復及so庫更新方面的支援,以及@所為在開發過程中的問題討論與文章校稿。
原創文章,轉載請註明出處。手淘公眾號文章連結:http://mp.weixin.qq.com/s/Uv0BS67-wgvCor6Fss6ChQ
相關文章
- 深入探索Android熱修復技術原理讀書筆記 —— 程式碼熱修復技術Android筆記
- Android熱修復原理(一)熱修復框架對比和程式碼修復Android框架
- Android 熱修復Android
- Robust 2.0:支援Android R8的升級版熱修復框架Android框架
- Android熱修復原理Android
- 深入探索Android熱修復技術原理讀書筆記 —— 資源熱修復技術Android筆記
- 深入探索Android熱修復技術原理讀書筆記 —— 熱修復技術介紹Android筆記
- 筆記 深入探索Android熱修復技術原理筆記Android
- 【Android 熱修復】美團Robust熱修復框架原理解析Android框架
- Flutter Android 端熱修復(熱更新)實踐FlutterAndroid
- Android熱修復簡單總結Android
- 2018深入解析Android熱修復技術Android
- Android熱修復之Tinker整合最新詳解Android
- Android每週一輪子:Nvwa(熱修復)Android
- Android熱修復(Hot Fix)案例全剖析(一)Android
- Android進階(八)熱修復基本原理Android
- flutter在android端啟動流程和熱修復FlutterAndroid
- 效能優化 (九) APP 穩定性之熱修復原理探索優化APP
- 淺析“熱更新”(熱修復)解決方案
- webpack入門筆記——熱替換Web筆記
- robust 熱修復實踐
- Android進階之Walle多渠道打包&Tinker熱修復Android
- Nginx配置以及熱升級Nginx
- 手把手帶你打造一個 Android 熱修復框架Android框架
- 熱修復和外掛化
- 簡單易懂的tinker熱修復原理分析
- 熱修復與外掛化基礎——Java與Android的類載入器JavaAndroid
- OpenKruise :SidecarSet 助力 Mesh 容器熱升級UIIDE
- MOSN熱升級邏輯淺析
- 熱修復與外掛化基礎——Java與Android虛擬機器JavaAndroid虛擬機
- 探索webpack熱更新對程式碼打包結果的影響(二)Web
- 手動實現最簡單的Android熱修復(最新最全詳細小白教程)Android
- Android 尋找極限編碼的「快感」Android
- 熱修復(一)原理與實現詳解
- OCEval-動態執行ObjectiveC的熱修復方案Object
- Android開發之指令碼替換PackageNameAndroid指令碼Package
- 網站程式碼修改替換流程圖,輕鬆掌握程式碼修改替換流程網站流程圖
- Android 修圖(換證件照背景,汙點修復)Android
- 使用 tableflip 實現應用的優雅熱升級