【组件健壮性】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)
相关文章
|
1天前
|
Java 数据库连接 Maven
mybatis使用一:springboot整合mybatis、mybatis generator,使用逆向工程生成java代码。
这篇文章介绍了如何在Spring Boot项目中整合MyBatis和MyBatis Generator,使用逆向工程来自动生成Java代码,包括实体类、Mapper文件和Example文件,以提高开发效率。
mybatis使用一:springboot整合mybatis、mybatis generator,使用逆向工程生成java代码。
|
1天前
|
存储 缓存 Java
java基础:IO流 理论与代码示例(详解、idea设置统一utf-8编码问题)
这篇文章详细介绍了Java中的IO流,包括字符与字节的概念、编码格式、File类的使用、IO流的分类和原理,以及通过代码示例展示了各种流的应用,如节点流、处理流、缓存流、转换流、对象流和随机访问文件流。同时,还探讨了IDEA中设置项目编码格式的方法,以及如何处理序列化和反序列化问题。
17 1
java基础:IO流 理论与代码示例(详解、idea设置统一utf-8编码问题)
|
3天前
|
并行计算 Java API
探索Java中的Lambda表达式:简化代码,提高可读性
【10月更文挑战第5天】Lambda表达式在Java 8中引入,旨在简化集合操作和并行计算。本文通过介绍Lambda表达式的基本概念、语法结构以及实际应用示例,展示了如何利用这一特性编写更加简洁、易读的代码。我们将从Lambda的基础入手,逐步深入到其在函数式接口中的应用,并探讨其对Java编程范式的影响。
|
3天前
|
Android开发 Swift iOS开发
探索安卓与iOS开发的差异:从代码到用户体验
【10月更文挑战第5天】在移动应用开发的广阔天地中,安卓和iOS两大平台各占半壁江山。它们在技术架构、开发环境及用户体验上有着根本的不同。本文通过比较这两种平台的开发过程,揭示背后的设计理念和技术选择如何影响最终产品。我们将深入探讨各自平台的代码示例,理解开发者面临的挑战,以及这些差异如何塑造用户的日常体验。
|
4天前
|
消息中间件 存储 Java
大数据-58 Kafka 高级特性 消息发送02-自定义序列化器、自定义分区器 Java代码实现
大数据-58 Kafka 高级特性 消息发送02-自定义序列化器、自定义分区器 Java代码实现
18 3
|
5天前
|
分布式计算 资源调度 Hadoop
Hadoop-10-HDFS集群 Java实现MapReduce WordCount计算 Hadoop序列化 编写Mapper和Reducer和Driver 附带POM 详细代码 图文等内容
Hadoop-10-HDFS集群 Java实现MapReduce WordCount计算 Hadoop序列化 编写Mapper和Reducer和Driver 附带POM 详细代码 图文等内容
27 3
|
5天前
|
消息中间件 Java 大数据
大数据-56 Kafka SpringBoot与Kafka 基础简单配置和使用 Java代码 POM文件
大数据-56 Kafka SpringBoot与Kafka 基础简单配置和使用 Java代码 POM文件
26 2
|
5天前
|
分布式计算 NoSQL Java
Hadoop-32 ZooKeeper 分布式锁问题 分布式锁Java实现 附带案例和实现思路代码
Hadoop-32 ZooKeeper 分布式锁问题 分布式锁Java实现 附带案例和实现思路代码
20 2
|
9天前
|
安全 算法 Java
数据库信息/密码加盐加密 —— Java代码手写+集成两种方式,手把手教学!保证能用!
本文提供了在数据库中对密码等敏感信息进行加盐加密的详细教程,包括手写MD5加密算法和使用Spring Security的BCryptPasswordEncoder进行加密,并强调了使用BCryptPasswordEncoder时需要注意的Spring Security配置问题。
45 0
数据库信息/密码加盐加密 —— Java代码手写+集成两种方式,手把手教学!保证能用!
|
8天前
|
Java 数据安全/隐私保护 容器
java当中组件和窗口的相容问题(里面包含了这些方法的作用)
Java窗口和组件的布局指南,教你如何打造一个既美观又实用的GUI界面。
17 0