【组件健壮性】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)
相关文章
|
26天前
|
存储 Java 关系型数据库
高效连接之道:Java连接池原理与最佳实践
在Java开发中,数据库连接是应用与数据交互的关键环节。频繁创建和关闭连接会消耗大量资源,导致性能瓶颈。为此,Java连接池技术通过复用连接,实现高效、稳定的数据库连接管理。本文通过案例分析,深入探讨Java连接池的原理与最佳实践,包括连接池的基本操作、配置和使用方法,以及在电商应用中的具体应用示例。
47 5
|
15天前
|
存储 算法 Java
大厂面试高频:什么是自旋锁?Java 实现自旋锁的原理?
本文详解自旋锁的概念、优缺点、使用场景及Java实现。关注【mikechen的互联网架构】,10年+BAT架构经验倾囊相授。
大厂面试高频:什么是自旋锁?Java 实现自旋锁的原理?
|
15天前
|
Java
Java之CountDownLatch原理浅析
本文介绍了Java并发工具类`CountDownLatch`的使用方法、原理及其与`Thread.join()`的区别。`CountDownLatch`通过构造函数接收一个整数参数作为计数器,调用`countDown`方法减少计数,`await`方法会阻塞当前线程,直到计数为零。文章还详细解析了其内部机制,包括初始化、`countDown`和`await`方法的工作原理,并给出了一个游戏加载场景的示例代码。
Java之CountDownLatch原理浅析
|
17天前
|
Java 索引 容器
Java ArrayList扩容的原理
Java 的 `ArrayList` 是基于数组实现的动态集合。初始时,`ArrayList` 底层创建一个空数组 `elementData`,并设置 `size` 为 0。当首次添加元素时,会调用 `grow` 方法将数组扩容至默认容量 10。之后每次添加元素时,如果当前数组已满,则会再次调用 `grow` 方法进行扩容。扩容规则为:首次扩容至 10,后续扩容至原数组长度的 1.5 倍或根据实际需求扩容。例如,当需要一次性添加 100 个元素时,会直接扩容至 110 而不是 15。
Java ArrayList扩容的原理
|
27天前
|
缓存 Java 数据库
Android的ANR原理
【10月更文挑战第18天】了解 ANR 的原理对于开发高质量的 Android 应用至关重要。通过合理的设计和优化,可以有效避免 ANR 的发生,提升应用的性能和用户体验。
56 8
|
24天前
|
存储 Java 关系型数据库
在Java开发中,数据库连接是应用与数据交互的关键环节。本文通过案例分析,深入探讨Java连接池的原理与最佳实践
在Java开发中,数据库连接是应用与数据交互的关键环节。本文通过案例分析,深入探讨Java连接池的原理与最佳实践,包括连接创建、分配、复用和释放等操作,并通过电商应用实例展示了如何选择合适的连接池库(如HikariCP)和配置参数,实现高效、稳定的数据库连接管理。
44 2
|
27天前
|
Java 数据格式 索引
使用 Java 字节码工具检查类文件完整性的原理是什么
Java字节码工具通过解析和分析类文件的字节码,检查其结构和内容是否符合Java虚拟机规范,确保类文件的完整性和合法性,防止恶意代码或损坏的类文件影响程序运行。
|
24天前
|
算法 Java 数据库连接
Java连接池技术,从基础概念出发,解析了连接池的工作原理及其重要性
本文详细介绍了Java连接池技术,从基础概念出发,解析了连接池的工作原理及其重要性。连接池通过复用数据库连接,显著提升了应用的性能和稳定性。文章还展示了使用HikariCP连接池的示例代码,帮助读者更好地理解和应用这一技术。
37 1
|
30天前
|
存储 安全 Java
深入理解Java中的FutureTask:用法和原理
【10月更文挑战第28天】`FutureTask` 是 Java 中 `java.util.concurrent` 包下的一个类,实现了 `RunnableFuture` 接口,支持异步计算和结果获取。它可以作为 `Runnable` 被线程执行,同时通过 `Future` 接口获取计算结果。`FutureTask` 可以基于 `Callable` 或 `Runnable` 创建,常用于多线程环境中执行耗时任务,避免阻塞主线程。任务结果可通过 `get` 方法获取,支持阻塞和非阻塞方式。内部使用 AQS 实现同步机制,确保线程安全。
|
1月前
|
安全 Java 网络安全
Android远程连接和登录FTPS服务代码(commons.net库)
Android远程连接和登录FTPS服务代码(commons.net库)
24 1
下一篇
无影云桌面