热修复技术学习总结
前言
前段时间,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] 提供服务端的补丁发布和停发、版本控制和灰度功能,存储开发者上传的补丁包。
下面对阿里开放出的《深入探索Android热修复技术原理7.3Q.pdf》进行阅读后的总结性文章,原书pdf:http://pan.baidu.com/s/1dE7i8NJ
三大修复原理简要
1.代码修复
1.1 即时生效:底层替代类中的老代码,并且无视底层的具体结构。
1.2 重启生效:基于类加载机制,重新编排了包中dex的顺序。
2.资源修复
2.1 传统的资源修复是基于InstantRun的原理,就是构造一个新的AssetManager,将新的资源进行addAssetPath,然后通过反射替换掉系统中的原理的AssetManager的引用。
2.2 阿里采用的是直接将一个比系统资源包的packageId 0x7F小的packageId为0x66的资源addAssetPath到原来的AssetManager对象上即可,这个补丁资源包只包含新添加,和已修改的。
3.so修复
本质是对native方法的修复和替换,阿里采用的是类似类修复反射注入的方式,把补丁so路径插入到nativeLibrary数组的最前面。
代码热修复
1. 底层热替换原理
1.1 Andfix 原理:通过jni的replaceMethod(Method src ,Method des )->通过 env的FromReflectMethod得到ArtMethod地址,转为ArtMethod指针->挨个替换ArtMethod的中字段.
1.2 虚拟机调用方法的原理 : 最终ArtMethod中的字段(例如entry_point_from_interpreter)找到最终要执行的方法的入口地址,art可以采用解释模式或者AOT机器码模式执行。
1.3 Andfix原理兼容性的根源 : ArtMethod的结构厂商可以自己改变,就会导致替换字段信息不是代码中指定的信息,导致替换错乱
1.4 突破底层ArtMethod结构的差异 : 将ArtMethod整体替换,阿里的核心方法是memcry(smeth,dmeth,sizeOf(ArtMethod))。这里面的关键是sizeOf(ArtMethod)的实现,其原理是ArtMethod的存储接口是线性的,通过两个ArtMethod的地址差就可以。这种方式的适配不受系统的影响,稳定且兼容。
1.5 访问权限的问题 :
* 方法时访问权限 : 机器码中不存在检查权限的相关代码
* 同包名下访问权限的问题 : 由于补丁包的ClassLoader与原来的ClassLoader不一致,导致虚拟机代码的Class::IsInSamePackage校验失败。解决方案就是通过反射让补丁包的ClassLoader为系统原来的ClassLoader即可。
* 被反射调用的方法问题 : 由于ArtMethod中的declaring_class_被替换成了新的类,而反射得到的还是原来的老类,这会导致invoke时VerifyObjectClass()方法失败,而直接报错。所以这种热修复方案不能修复这种方法。
1.6 即时生效的限制:
引起类中发生结构变化的修改 : 因为一旦引起修改ArtMethod的位置将发生变化,就找不到地址了。
修复了的非静态方法被反射调用。
2. java中的秘密
2.1 内部编译类
* 内部类在编译器会被编译为跟外部类一样的类
* 静态内部类与非静态内部类,在smali中非静态内部类会自动合成this$0 域标示的是外部类的引用。
* 外部类为了访问内部类(或内部类访问外部类)的私有域,编译期间会自动为内部类(或外部类)生成access&XXX方法。
* 热修复替换时,要避免生成access&XXX方法,就要求内/外部类不能存在private的method/field。
2.2 匿名内部类
* 匿名内部类的名称格式一般为外部类&number,number根据匿名内部类出现的顺序累加记名。
* 如果在之前增加一个匿名内部类 则会导致原来的匿名内部类名称不对应。也就无法使用热修复。
* 应当极力避免插入新匿名内部类,特别是向前插。
2.3 域编译
* 热替换不支持 clint方法
* 静态域和静态代码块在clint方法中
* 非静态在init方法中
* 静态域和静态代码块不支持热替换
2.4 final static 域
* final static 原始类型和字符串在initSField而不是在clint中
* final static 引用类型在 clint方法中初始化
* 优化时final static 对于原始类型和字符串有用,引用类型其实没有用。
2.5 方法编译
* 混淆可能导致方法的内联和裁剪
* 被内联:方法没被用过,方法只有一行代码,方法只被一个地方引用过。
* 被裁剪:方法中有参数没被使用。
* 热替换解决方法:在混淆是加上配置 -dontoptimize
2.6 switch case 语句编译
* 连续几个相近的值会被编译为packed-switch指令,中间差值用pswitch-0补齐。
* 不连续边被编译为sparse-switch指令
* 热替换方案:资源id为const final static 会被编译为packed-switch指令,会存在资源id替换不完全的问题,解决方案就是修改smali反编译流程,碰到packed-switch指令强替换为sparse-switch指令,:pswitch-N标签强改为sswitch-N标签,然后做资源id的强替换,在回编译smali为dex。
2.7 泛型编译
* 泛型在编译器中实现,虚拟机无感知
* 泛型类型擦除:编译器在编译期间将泛型转为目标类型的字节码,对于虚拟机来说得到的是目标类型的字节码文件,无感知泛型。
* 泛型与多态冲突的原理及方案:
*类型擦除后 原来的set(T t)的字节码会是set(Object t) 而其子类为set(Number t),从重写的定义上来看这不是重写而是重载。这也就导致泛型和多态有冲突了
*而实际是可以重写的,其本质原因是JVM采用了bridge方法。子类真正重写父类方法是bridge方法,而在bridge方法中调用了子类的方法而已。@override只是个假象。
*泛型不需要强制类型转换的原因是:编译器如果返现有一个变量申明加上了泛型的话,编译器会自动加上chceck-cast类型转换。
2.8 Lambda 表达
* Lambda 会被;;其内部this指的是外部类对象,这点区别于内部类的this。
* 函数式接口 : 只有一个方法的接口
* 函数式接口调用时,最终会增加一个辅助方法。不能走热替换
* 修改函数式接口内部逻辑可以走热替换
2.9 访问权限检查对热替换的影响
*补丁类如果引用了非public类,最终会抛dvmThrowException
2.10 Clint方法
* 不支持clint方法的热替换
3 冷启动方案
3.1 传统实现方式的利弊
* QQ控件的插庄方案:
原理:单独放一个类在dex中,让其它类调用,防止打上CLASS_ISPREVERIFIED标志,再加载补丁dex得到dexFile对象作为参数构建一个Element对象插入到dex-Elements数组的前面。
缺点:Dalvik下影响类加载性能,Art下类地址写死,导致必须包含父类或引用,最后导致补丁包很大。
*Tinker方案:
原理: 提供dex差量包,整体替换dex的方案。差量的方式给出patch.dexm,然后将patch.dex和应用的classes.dex合并成一个完整的dex,
完整的dex加载得到的dexFile对象作为参数构建一个Elements对象然后整体替换掉旧的dex-Elements数组。
缺点: dex合并内存消耗在Vm heap上,容易OOM,最后导致dex合并失败
3.2 插桩实现的前因后果
默认一个dex时所有类会打上CLASS_ISPREVERIFIED标志,新的补丁类不在原dex中时,被调用会报dvmThrowllegalAccessError。一个单独的辅助类放到一个单独的dex中,原dex的所有类的构造函数都引用这个类,dexopt时原Dex所有类不会被打上CLASS_ISPREVERIFIED这个标志。
3.3 插桩导致类加载性能影响
采用插桩,导致所有类都是非preverify,这就使得dexopt和load class时频繁的verify和optimize。当类很多时这个操作会相当耗时,导致启动时长时间白屏。
3.4 避免插桩的QFix方案
在dexopt后进行检查绕过,会存在潜在的Bug
3.5 Art下冷启动实现
将补丁直接命名为classes.dex 将原来的一次命名为classes1.dex …classes2.dex…等。然后一起打包为一个apk。然后DexFile.loadDex得到DexFile对象,最后把该DexFile对象整体替换旧的dexElements数组