热修复技术学习总结

前言css

前段时间,Android平台上涌现了一系列热修复方案,如阿里的Andfix、微信的Tinker、QQ空间的Nuva、手Q的QFix等等。java

其中,Andfix的即时生效使人印象深入,它稍显另类,并不须要从新启动,而是在加载补丁后直接对方法进行替换就能够完成修复,然而它的使用限制也遭遇到更多的质疑。android

咱们也对代码的native替换原理从新进行了深刻思考,从克服其限制和兼容性入手,以一种更加优雅的替换思路,实现了即时生效的代码热修复。api

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对应的就是新方法,新方法存在于补丁包的新类中,也就是补丁方法。app

@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_replace_artmethod

变成了这样的总体替换 
my_replace_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的空间结构。

sizeof_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.BaseBugcom.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数组

相关文章
相关标签/搜索