【组件健壮性】Android Java代码热修复的原理

简介: 总结Android Java代码三种热修复方式,包括自定义ClassLoader、插桩式、底层替换,并给出原理和实施流程。

应用场景

解决的问题

  • 应用发布后出现bug,修复流程又要经过开发、测试、灰度、发布整个链路,流程周期比较长,代价比较大。
  • 比较小的改动或需要立即生效的功能,想要立即触达用户,整个链路成本比较高。

解决的范围

  • Android中Java代码的热修复


方式1:自定义ClassLoader

Java ClassLoader双亲委派模型

  • Java类的双亲委派原理:某个类加载器在接到加载类的请求时,首先将加载任务委托给父类加载器,依次递归,如果父类加载器可以完成类加载任务,就成功返回;只有父类加载器无法完成此加载任务时,才自己去加载。

protected Class loadClass(String name, boolean resolve)

       throws ClassNotFoundException

   {

       synchronized (getClassLoadingLock(name)) {

           // First, check if the class has already been loaded

           Class c = findLoadedClass(name);

           if (c == null) {

               long t0 = System.nanoTime();

               try {

                   if (parent != null) {

                       c = parent.loadClass(name, false);

                   } else {

                       c = findBootstrapClassOrNull(name);

                   }

               } catch (ClassNotFoundException e) {

               }


               if (c == null) {

                   long t1 = System.nanoTime();

                   c = findClass(name);

               }

           }

           if (resolve) {

               resolveClass(c);

           }

           return c;

       }

   }


  • Android中类的加载原理:和Java类的加载机制基本一致,Java类将代码编译成class文件,JVM加载class文件;而Android多出的一步就是将class文件转换为dex文件,通过dalvik或者Art虚拟机加载,Android也有自己的类加载器。每个 Class 对象的内部都有一个 classLoader 字段来标识自己是由哪个 ClassLoader 加载的。

class Class {

   ...

       private transient ClassLoader classLoader;

   ...

}

Android中的classloader

ClassLoader是一个抽象类,而它的具体实现类主要有:

  • BootClassLoader:用于加载Android Framework层class文件。
  • PathClassLoader:用于Android应用程序类加载器。可以加载指定的dex,以及jar、zip、apk中的classes.dex
  • DexClassLoader:用于加载指定的dex,以及jar、zip、apk中的classes.dex

他们之间的继承关系是:

public class DexClassLoader extends BaseDexClassLoader {

   public DexClassLoader(String dexPath, String optimizedDirectory,

       String librarySearchPath, ClassLoader parent) {

       super(dexPath, new File(optimizedDirectory), librarySearchPath, parent);

   }

}


public class PathClassLoader extends BaseDexClassLoader {

   public PathClassLoader(String dexPath, ClassLoader parent) {

       super(dexPath, null, null, parent);

   }


   public PathClassLoader(String dexPath, String librarySearchPath, ClassLoader parent){

        super(dexPath, null, librarySearchPath, parent);

   }

}


自定义ClassLoader

  • 思路:利用双亲委派的原理,父ClassLoader会优先加载,因此创建父ClassLoader用于加载patch类。
  • 方法: 创建自定义的ClassLoader用于加载补丁class,自定义ClassLoader继承于BaseDexClassLoader,并使DexClassLoader和PathClassLoader继承于自定义ClassLoader。这样在load class时,会优先调用自定义ClassLoader去加载类。
  • 具体代码:
  • 1. 创建自定义ClassLoader,定义class文件路径

ClassLoader customClassLoader = new PathClassLoader("/sdcard/xx.dex", getClassLoader())

  • 2. findClass()定义加载class方法

public DexPathList(ClassLoader definingContext, String dexPath,

           String librarySearchPath, File optimizedDirectory) {

   //.........

   // splitDexPath 实现为返回 List.add(dexPath)

   // makeDexElements 会去 List.add(dexPath) 中使用DexFile加载dex文件返回 Element数组

   this.dexElements = makeDexElements(splitDexPath(dexPath), optimizedDirectory,

                                          suppressedExceptions, definingContext);

   //.........

   

}


public Class findClass(String name, List suppressed) {

    //从element中获得代表Dex的 DexFile

   for (Element element : dexElements) {

       DexFile dex = element.dexFile;

       if (dex != null) {

           //查找class

           Class clazz = dex.loadClassBinaryName(name, definingContext, suppressed);

           if (clazz != null) {

               return clazz;

           }

       }

   }

   if (dexElementsSuppressedExceptions != null) {

       suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions));

   }

   return null;

}



安全问题

  • 问题:如果两个相关联的类在不同的dex中就会报错,这个校验是因为直接引用到的类(第一层级关系,不会进行递归搜索)和clazz都在同一个dex中的话,那么这个类就会被打上CLASS_ISPREVERIFIED标志:

  • 解决方法:
  • 防止类被打上CLASS_ISPREVERIFIED标志,需要在patch往所有类的构造函数里面插入了一段代码:

if (ClassVerifier.PREVENT_VERIFY) {

   System.out.println(AntilazyLoad.class);

}

这样当安装apk的时候,classes.dex内的类都会引用一个其他dex中的AntilazyLoad类,这样就防止了类被打上

局限性

  • 可能需要重启应用:当app运行到一半时,所需发生变更的类已经被加载过,而在Android上无法对一个类进行卸载操作,若不重启,原来的类还存储于虚拟机中,新类无法被加载。
  • 去除CLASS_ISPREVERIFIED标志,会导致性能轻微下降。



方式2:插桩式

热修复流程

  • 插桩式与自定义ClassLoader的原理类似,但没有创建自定义ClassLoader,而是利用PathClassLoader可以按顺序加载dex文件的特点,将patch.dex插入到数组中第一个位置加载。
  • 如下图:在PathClassLoader中的Element数组为:[patch.dex , classes.dex , classes2.dex]。如果存在Key.class位于patch.dex与classes2.dex中都存在一份,当进行类查找时,循环获得dexElements中的DexFile,查找到了Key.class则立即返回,不会再管后续的element中的DexFile是否能加载到Key.class了。

Element数组操作

PathClassLoader,继承自 BaseDexClassLoader,在 BaseDexClassLoader 里,有一个 DexPathList 变量,在 DexPathList 的实现里,有一个 Element[] dexElements 变量,这里面保存了所有的 dex,下面是系统

public class PathClassLoader extends BaseDexClassLoader {

}


public class BaseDexClassLoader extends ClassLoader {

   private final DexPathList pathList;

}


final class DexPathList {

   // 保存了 dex 的列表

   private Element[] dexElements;


   public Class findClass(String name, List suppressed) {

       // 遍历 dexElements

       for (Element element : dexElements) {

           DexFile dex = element.dexFile;


           if (dex != null) {

               // 从 DexFile 中查找 Class

               Class clazz = dex.loadClassBinaryName(name, definingContext, suppressed);

               if (clazz != null) {

                   return clazz;

               }

           }

       }

       // ...

       return null;

   }

}


具体实现的方法

public static void injectDexAtFirst(String dexPath, String defaultDexOptPath) throws NoSuchFieldException, IllegalAccessException, ClassNotFoundException {

       // 创建补丁 dex 的 classloader,目的是使用其中的补丁 dexElements

       DexClassLoader dexClassLoader = new DexClassLoader(dexPath, defaultDexOptPath, dexPath, getPathClassLoader());

       // 获取到旧的 classloader 的 pathlist.dexElements 变量

       Object baseDexElements = getDexElements(getPathList(getPathClassLoader()));

       // 获取到补丁 classloader 的 pathlist.dexElements 变量

       Object newDexElements = getDexElements(getPathList(dexClassLoader));

       // 将补丁 的 dexElements 插入到旧的 classloader.pathlist.dexElements 前面

       Object allDexElements = combineArray(newDexElements, baseDexElements);

   }


   private static PathClassLoader getPathClassLoader() {

       PathClassLoader pathClassLoader = (PathClassLoader) InsertDexUtils.class.getClassLoader();

       return pathClassLoader;

   }


   private static Object getDexElements(Object paramObject)

           throws IllegalArgumentException, NoSuchFieldException, IllegalAccessException {

       return Reflect.on(paramObject).get("dexElements");

   }


   private static Object getPathList(Object baseDexClassLoader)

           throws IllegalArgumentException, NoSuchFieldException, IllegalAccessException, ClassNotFoundException {

       return Reflect.on(baseDexClassLoader).get("pathList");

   }


   private static Object combineArray(Object firstArray, Object secondArray) {

       Class localClass = firstArray.getClass().getComponentType();

       int firstArrayLength = Array.getLength(firstArray);

       int allLength = firstArrayLength + Array.getLength(secondArray);

       Object result = Array.newInstance(localClass, allLength);

       for (int k = 0; k < allLength; ++k) {

           if (k < firstArrayLength) {

               Array.set(result, k, Array.get(firstArray, k));

           } else {

               Array.set(result, k, Array.get(secondArray, k - firstArrayLength));

           }

       }

       return result;

   }

方式3:底层替换方案

基本思路

        与上述java类加载方案不同的是,底层替换方案不会再次加载新类,而是直接在Native层修改原有类,由于是在原有类进行修改限制会比较多,不能够增减原有类的方法和字段,如果增加了方法数,那么方法索引数也会增加,这样访问方法时会无法通过索引找到正确的方法,同样的字段也是类似的情况。底层替换方案直接替换了方法,可以立即生效不需要重启。

注意:该方法只针对Art VM, JVM的底层数据结构与Art VM不同。

replaceMethod原理

虚拟机中类的实现

  • java 中的类,方法,变量,对应到虚拟机里的实现是 Class,ArtMethod,ArtField,主要C++代码如下

class Class: public Object {

   public:

   // ...

   // classloader 指针

   uint32_t class_loader_;

   // 数组的类型表示

   uint32_t component_type_;

   // 解析 dex 生成的缓存

   uint32_t dex_cache_;

   // interface table,保存了实现的接口方法

   uint32_t iftable_;

   // 类描述符,例如:java.lang.Class

   uint32_t name_;

   // 父类

   uint32_t super_class_;

   // virtual method table,虚方法表,指令 invoke-virtual 会用到,保存着父类方法以及子类复写或者覆盖的方法,是 java 多态的基础

   uint32_t vtable_;

   // public private

   uint32_t access_flags_;

   // 成员变量

   uint64_t ifields_;

   // 保存了所有方法,包括 static,final,virtual 方法

   uint64_t methods_;

   // 静态变量

   uint64_t sfields_;

   // class 当前的状态,加载,解析,初始化等等

   Status status_;

   static uint32_t java_lang_Class_;

};


class ArtField {

   public:

   uint32_t declaring_class_;

   uint32_t access_flags_;

   uint32_t field_dex_idx_;

   uint32_t offset_;

};


class ArtMethod {

   public:

   uint32_t declaring_class_;

   uint32_t access_flags_;

   // 方法字节码的偏移

   uint32_t dex_code_item_offset_;

   // 方法在 dex 中的 index

   uint32_t dex_method_index_;

   // 在 vtable 或者 iftable 中的 index

   uint16_t method_index_;

   // 方法的调用入口

   struct PACKED(4) PtrSizedFields {

       ArtMethod** dex_cache_resolved_methods_;

       GcRoot* dex_cache_resolved_types_;

       void* entry_point_from_jni_;

       void* entry_point_from_quick_compiled_code_;

   } ptr_sized_fields_;

};

Class 中的 iftable_,vtable_,methods_ 里面保存了所有的类方法,sfields_,ifields_ 保存了所有的成员变量。而在 ArtMethod 中,ptr_sized_fields_ 变量指向了方法的调用入口,也就是执行字节码的地方。在虚拟机内部,调用一个方法的时候,可以简单的理解为会找到 ptr_sized_fields_ 指向的位置,跳转过去执行对应的方法字节码或者机器码

Method替换原理

        每次调用方法的时候,都是通过 ArtMethod 找到方法,然后跳转到其对应的字节码/机器码位置去执行,那么我们只要更改了跳转的目标位置,那么自然方法的实现也就被改变了

实现代码

  1. 找到要被替换的旧方法和新方法,可以在java层直接通过反射获取

// 创建补丁的 ClassLoader

pluginClassLoader = DexClassLoader(pluginPath, dexOutPath.absolutePath, nativeLibDir.absolutePath, this::class.java.classLoader)

// 通过补丁 ClassLoader 加载新方法

val toMethod = pluginClassLoader.loadClass("com.zy.hotfix.native_hook.PatchNativeHookUtils").getMethod("getMsg")

// 反射获取到需要修改的旧方法

val fromMethod = nativeHookUtils.javaClass.getMethod("getMsg")

  1. 调用native的C++方法替换ArtMethod 内容

nativeHookUtils.patch(fromMethod, toMethod)

Java_com_zy_hotfix_native_1hook_NativeHookUtils_patch(JNIEnv* env, jobject clazz, jobject src, jobject dest) {

   // 获取到 java 方法对应的 ArtMethod

   art::mirror::ArtMethod* smeth =

           (art::mirror::ArtMethod*) env->FromReflectedMethod(src);

   art::mirror::ArtMethod* dmeth =

           (art::mirror::ArtMethod*) env->FromReflectedMethod(dest);


   reinterpret_cast(dmeth->declaring_class_)->clinit_thread_id_ =

           reinterpret_cast(smeth->declaring_class_)->clinit_thread_id_;

   reinterpret_cast(dmeth->declaring_class_)->status_ =

           static_cast(reinterpret_cast(smeth->declaring_class_)->status_ -1);

   //for reflection invoke

   reinterpret_cast(dmeth->declaring_class_)->super_class_ = 0;


   // 替换方法中的内容

   smeth->declaring_class_ = dmeth->declaring_class_;

   smeth->access_flags_ = dmeth->access_flags_  | 0x0001;

   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->hotness_count_ = dmeth->hotness_count_;

   // 替换方法的入口

   smeth->ptr_sized_fields_.dex_cache_resolved_methods_ =

           dmeth->ptr_sized_fields_.dex_cache_resolved_methods_;

   smeth->ptr_sized_fields_.dex_cache_resolved_types_ =

           dmeth->ptr_sized_fields_.dex_cache_resolved_types_;

   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_;

}


优缺点

优点:

  • 补丁可以实时生效,不需要重启应用

缺点:

兼容性问题:

  • 问题:每个版本的ArtMethod结构体不同,厂商可以改造结构体
  • 解决方法:对ArtMethod结构体的内存整个替换,使用memcpy(smeth,dmeth, sizeof(ArtMethod))。但sizeof是编译期决定,不能运行时获取。但一个类中的ArtMethod的内存分配是连续的,可以通过两个连续方法地址差值确定sizeof(ArtMethod)
相关文章
|
6天前
|
安全 Android开发 Kotlin
Android经典实战之SurfaceView原理和实践
本文介绍了 `SurfaceView` 这一强大的 UI 组件,尤其适合高性能绘制任务,如视频播放和游戏。文章详细讲解了 `SurfaceView` 的原理、与 `Surface` 类的关系及其实现示例,并强调了使用时需注意的线程安全、生命周期管理和性能优化等问题。
33 7
|
3天前
|
编解码 前端开发 Android开发
Android经典实战之TextureView原理和高级用法
本文介绍了 `TextureView` 的原理和特点,包括其硬件加速渲染的优势及与其他视图叠加使用的灵活性,并提供了视频播放和自定义绘制的示例代码。通过合理管理生命周期和资源,`TextureView` 可实现高效流畅的图形和视频渲染。
24 12
|
5天前
|
存储 Java 开发者
【Java新纪元启航】JDK 22:解锁未命名变量与模式,让代码更简洁,思维更自由!
【9月更文挑战第7天】JDK 22带来的未命名变量与模式匹配的结合,是Java编程语言发展历程中的一个重要里程碑。它不仅简化了代码,提高了开发效率,更重要的是,它激发了我们对Java编程的新思考,让我们有机会以更加自由、更加创造性的方式解决问题。随着Java生态系统的不断演进,我们有理由相信,未来的Java将更加灵活、更加强大,为开发者们提供更加广阔的舞台。让我们携手并进,共同迎接Java新纪元的到来!
29 11
|
2天前
|
并行计算 Java 开发者
探索Java中的Lambda表达式:简化代码,提升效率
Lambda表达式在Java 8中引入,旨在简化集合操作和并行计算。本文将通过浅显易懂的语言,带你了解Lambda表达式的基本概念、语法结构,并通过实例展示如何在Java项目中应用Lambda表达式来优化代码,提高开发效率。我们将一起探讨这一现代编程工具如何改变我们的Java编码方式,并思考它对程序设计哲学的影响。
|
3天前
|
安全 Java 测试技术
掌握Java的并发编程:解锁高效代码的秘密
在Java的世界里,并发编程就像是一场精妙的舞蹈,需要精准的步伐和和谐的节奏。本文将带你走进Java并发的世界,从基础概念到高级技巧,一步步揭示如何编写高效、稳定的并发代码。让我们一起探索线程池的奥秘、同步机制的智慧,以及避免常见陷阱的策略。
|
10天前
|
Java API 开发者
代码小妙招:用Java轻松获取List交集数据
在Java中获取两个 `List`的交集可以通过 `retainAll`方法和Java 8引入的流操作来实现。使用 `retainAll`方法更为直接,但会修改原始 `List`的内容。而使用流则提供了不修改原始 `List`、更为灵活的处理方式。开发者可以根据具体的需求和场景,选择最适合的方法来实现。了解和掌握这些方法,能够帮助开发者在实际开发中更高效地处理集合相关的问题。
10 1
|
12天前
|
Java
Java中的Lambda表达式:简化代码,提升效率
【8月更文挑战第31天】Lambda表达式在Java 8中引入,旨在使代码更加简洁和易读。本文将探讨Lambda表达式的基本概念、使用场景及如何通过Lambda表达式优化Java代码。我们将通过实际示例来展示Lambda表达式的用法和优势,帮助读者更好地理解和应用这一特性。
|
11天前
|
开发者 C# 存储
WPF开发者必读:资源字典应用秘籍,轻松实现样式与模板共享,让你的WPF应用更上一层楼!
【8月更文挑战第31天】在WPF开发中,资源字典是一种强大的工具,用于共享样式、模板、图像等资源,提高了应用的可维护性和可扩展性。本文介绍了资源字典的基础知识、创建方法及最佳实践,并通过示例展示了如何在项目中有效利用资源字典,实现资源的重用和动态绑定。
27 0
|
11天前
|
Java 开发者
探索Java中的Lambda表达式:简化你的代码
【8月更文挑战第31天】 在Java 8的发布中,Lambda表达式无疑是最令人兴奋的新特性之一。它不仅为Java开发者提供了一种更加简洁、灵活的编程方式,而且还极大地提高了代码的可读性和开发效率。本文将通过实际代码示例,展示如何利用Lambda表达式优化和重构Java代码,让你的编程之旅更加轻松愉快。
|
11天前
|
Java 开发者
探索Java中的Lambda表达式:简化代码的现代方法
【8月更文挑战第31天】Lambda表达式在Java 8中首次亮相,为Java开发者提供了一种更简洁、灵活的编程方式。它不仅减少了代码量,还提升了代码的可读性和可维护性。本文将通过实际示例,展示Lambda表达式如何简化集合操作和事件处理,同时探讨其对函数式编程范式的支持。