zoukankan      html  css  js  c++  java
  • 热修复技术学习总结

    前言

    前段时间,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_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数组

  • 相关阅读:
    SQLServer查看死锁
    css图片叠加和底部定位
    vuejs
    如何优雅的使用vue+vux开发app -03
    ECMAScript 6 入门
    VUX 移动前端框架使用文档
    如何优雅的使用vue+vux开发app -02
    vuejs切换视图同时保持状态
    vuejs与服务器通信
    vuejs件同一个挂载点上切换组
  • 原文地址:https://www.cnblogs.com/linghu-java/p/9667572.html
Copyright © 2011-2022 走看看