一.简介

1.1.定义

热修复是一种插件化的技术,当线上应用出现BUG时,无需发布新包让用户更新,而是通过补丁包让用户更新。具有及时修复BUG,用户无需下载新的应用从而达到无感知修复。

1.2.优势

  • 无需重新发布新版本,省时省力;
  • 用户无感知修复,也无需下载最新应用,代价小;
  • 修复成功率高,把损失降到最低。

    1.3.热修复能完成哪些修复

  • 代码修复

  • 资源修复
  • SO库修复

    1.4.热修复框架及分类

  • Nativehook

    • Dexpose、Andfix、阿里百川Hotfix
  • Java
    • Qzone超级补丁、QFix、Robust、Nuwa、RocooFix、Aceso、Amigo、Tinker
  • 混合

    • 阿里百川Hotfix

      1.5.原理

    • 本文介绍的热修复是一种插桩、排队的原理实现(Java层)。

    • 把一个项目解压,往往有一个分包的情况。问题引出:为什么会分包?往往一线互联网企业,项目分出来的包有classes.dex,classes1.dex,classes2.dex,classes3.dex等等,都会在Elements数组里面。我们现在要做的事情就是自己做一个patch.dex插入到数组里面。加入到第一位。
    • 插桩的原理是:Hook了ClasLoader.pathList.dexElements[],因为ClassLoader的findClass是通过遍历dexElements[]中的dex来寻找类(找到了优先加载,找不到抛出异常)。

      1.6.小知识

  • 热修复/插件化/增量更新

    • 热修复:体现的是修复线上的bug;
    • 插件化:一般用于功能的拓展;
    • 增量更新:往往是一个差分的补丁;
  • jar轻量的好处:降低耦合;
  • build.gradle更改一个版本号?可能会出现api断层。
  • javac将java编译class,dx.bat打包dex;

二.热修复实现

2.1.实现过程

  • 请求后台的接口,根据版本判断是否需要下载修复包,将服务器下载修复好的dex包,替换原有bug的java类;
  • 演示使用:将有bug的apk和没有bug的apk的差分包放入手机中。注意:因为模拟手机加载,加载修复包在6.0以上的系统需要一个运行时权限。

    2.2.实现原理

  • 思路:将修复好的classed2.dex下载到手机,并替换有bug的dex文件;

  • 注意事项:

    • 主包classed.dex不能有bug(app不能启动就闪退);
    • 必须是运行时;

      2.3.知识储备(跟着下面源码查看即可)

  • 类加载器:ClassLoader

  • 子类:BaseDexClassLoader

    2.5.源码相关及思路整理

    2.5.1.Android源码链接,在线阅读

    2.5.2.分析

  • Java虚拟机 —— JVM 是加载类的class文件的,而Android虚拟机——Dalvik/ART VM 是加载类的dex文件,而他们加载类的时候都需要ClassLoader,ClassLoader有一个子类BaseDexClassLoader,BaseDexClassLoader下有一个数组——DexPathList,是用来存放dex文件,当BaseDexClassLoader通过调用findClass方法时,实际上就是遍历数组,找到相应的dex文件,找到,则直接将它return。而热修复的解决方法就是将新的dex添加到该集合中,并且是在旧的dex的前面,所以就会优先被取出来并且return返回。

  • 查看ClassLoader的子类BaseDexClassLoader:

    1. public class BaseDexClassLoader extends ClassLoader {
    2. //pathList
    3. private final DexPathList pathList;
    4. //...省略代码
    5. public BaseDexClassLoader(String dexPath, File optimizedDirectory,
    6. String libraryPath, ClassLoader parent) {
    7. super(parent);
    8. //pathList被赋值,分析一:DexPathList
    9. this.pathList = new DexPathList(this, dexPath, libraryPath, optimizedDirectory);
    10. }
    11. //...省略代码
    12. }
  • 查看BaseDexClassLoader中的DexPathList对象,即分析一:

    1. //分析一
    2. final class DexPathList {
    3. private static final String DEX_SUFFIX = ".dex";
    4. //...省略代码
    5. /**
    6. * List of dex/resource (class path) elements.
    7. * Should be called pathElements, but the Facebook app uses reflection
    8. * to modify 'dexElements' (http://b/7726934).
    9. */
    10. private final Element[] dexElements;
    11. //...省略代码
    12. public DexPathList(ClassLoader definingContext, String dexPath,
    13. String libraryPath, File optimizedDirectory) {
    14. //...省略代码
    15. //分析二:makePathElements
    16. this.dexElements = makePathElements(splitDexPath(dexPath), optimizedDirectory,
    17. suppressedExceptions);
    18. //...省略代码
    19. }
    20. /**
    21. * Makes an array of dex/resource path elements, one per element of
    22. * the given array.
    23. */
    24. //todo 分析二makePathElements
    25. private static Element[] makePathElements(List<File> files, File optimizedDirectory,
    26. List<IOException> suppressedExceptions) {
    27. List<Element> elements = new ArrayList<>();
    28. /*
    29. * Open all files and load the (direct or contained) dex files
    30. * up front.
    31. */
    32. //遍历传递进来的文件集合
    33. for (File file : files) {
    34. File zip = null;
    35. File dir = new File("");
    36. DexFile dex = null;
    37. String path = file.getPath();
    38. String name = file.getName();
    39. if (path.contains(zipSeparator)) {
    40. String split[] = path.split(zipSeparator, 2);
    41. zip = new File(split[0]);
    42. dir = new File(split[1]);
    43. } else if (file.isDirectory()) {
    44. // We support directories for looking up resources and native libraries.
    45. // Looking up resources in directories is useful for running libcore tests.
    46. elements.add(new Element(file, true, null, null));
    47. } else if (file.isFile()) {
    48. //如果文件的文件名是以 .dex结尾的,执行loadDexFile方法
    49. if (name.endsWith(DEX_SUFFIX)) {
    50. // Raw dex file (not inside a zip/jar).
    51. try {
    52. dex = loadDexFile(file, optimizedDirectory);
    53. } catch (IOException ex) {
    54. System.logE("Unable to load dex file: " + file, ex);
    55. }
    56. } else {
    57. zip = file;
    58. try {
    59. dex = loadDexFile(file, optimizedDirectory);
    60. } catch (IOException suppressed) {
    61. /*
    62. * IOException might get thrown "legitimately" by the DexFile constructor if
    63. * the zip file turns out to be resource-only (that is, no classes.dex file
    64. * in it).
    65. * Let dex == null and hang on to the exception to add to the tea-leaves for
    66. * when findClass returns null.
    67. */
    68. suppressedExceptions.add(suppressed);
    69. }
    70. }
    71. } else {
    72. System.logW("ClassLoader referenced unknown path: " + file);
    73. }
    74. if ((zip != null) || (dex != null)) {
    75. //执行loadDexFile方法后,将dex文件加入到elements中
    76. elements.add(new Element(dir, false, zip, dex));
    77. }
    78. }
    79. //将elements返回
    80. return elements.toArray(new Element[elements.size()]);
    81. }
    82. }

2.5.3.源码涉及的对象和数组

  • BaseDexClassLoader:ClassLoader的子类

    • pathList:BaseDexClassLoader中的对象

      • dexElements:pathList中的数组
        • classes.dex,classes2.dex… //存放在数组中的dex文件

          2.5.4.补充知识

    • 下载的apk一般放在sdcard/download目录(不安全),那么app安装成功,会不会同时复制一份apk文件到私有目录,这个路径:data/app/packageName/base.apk。其实无论下载到哪个目录,只要安装都会copy一份到私有目录。该目录下只有root权限的才能执行一些操作。即对热修复方案copyFile的解释。

      2.5.4.思路

    • 创建BaseDexClassLoader(DexClassLoader加载器的子类);

    • 加载修复好的classes2.dex(模拟服务器下载的修复包,直接放在手机中);
    • 将自有的和系统的dexElements进行合并,并设置自有的dexElements优先级;
    • 通过反射技术,赋值给系统的pathList;

三.代码实现

3.1.activity_main.xml

  1. <?xml version="1.0" encoding="utf-8"?>
  2. <android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
  3. xmlns:app="http://schemas.android.com/apk/res-auto"
  4. xmlns:tools="http://schemas.android.com/tools"
  5. android:layout_width="match_parent"
  6. android:layout_height="match_parent"
  7. tools:context=".MainActivity"
  8. tools:layout_editor_absoluteY="25dp">
  9. <!-- 对应activity中的showToast方法-->
  10. <Button
  11. android:onClick="showToast"
  12. android:layout_width="wrap_content"
  13. android:layout_height="wrap_content"
  14. android:layout_marginEnd="40dp"
  15. android:layout_marginRight="40dp"
  16. android:layout_marginTop="420dp"
  17. android:text="showToast"
  18. app:layout_constraintEnd_toStartOf="@+id/guideline2"
  19. app:layout_constraintTop_toTopOf="parent" />
  20. <!-- 对应activity中的shotFix方法-->
  21. <Button
  22. android:onClick="hotFix"
  23. android:layout_width="wrap_content"
  24. android:layout_height="wrap_content"
  25. android:layout_marginLeft="40dp"
  26. android:layout_marginStart="40dp"
  27. android:layout_marginTop="420dp"
  28. android:text="fix"
  29. app:layout_constraintStart_toStartOf="@+id/guideline2"
  30. app:layout_constraintTop_toTopOf="parent" />
  31. <!--不了解ConstraintLayout的童鞋只用关注上面的两个按钮就行 -->
  32. <android.support.constraint.Guideline
  33. android:id="@+id/guideline2"
  34. android:layout_width="wrap_content"
  35. android:layout_height="wrap_content"
  36. android:orientation="vertical"
  37. app:layout_constraintGuide_percent="0.5" />
  38. </android.support.constraint.ConstraintLayout>

3.2.MainActivity

  1. public class MainActivity extends AppCompatActivity {
  2. @Override
  3. protected void onCreate(Bundle savedInstanceState) {
  4. super.onCreate(savedInstanceState);
  5. setContentView(R.layout.activity_main);
  6. //模拟热修复实现,需要读取sd卡的文件,所以高于6.0的系统需要申请运行时权限
  7. //判断当前系统是否高于或等于6.0
  8. if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
  9. String[] perms = {Manifest.permission.WRITE_EXTERNAL_STORAGE};
  10. //当前系统大于等于6.0
  11. if (ContextCompat.checkSelfPermission(MainActivity.this,perms[0]) == PackageManager.PERMISSION_GRANTED) {
  12. //具有权限
  13. //具体调用代码
  14. } else {
  15. //不具有sd卡读写权限权限,需要进行权限申请
  16. requestPermissions(perms,200);
  17. }
  18. } else {
  19. //当前系统小于6.0,在AndroidManifest中配置权限即可使用
  20. }
  21. }
  22. public void showToast(View view) {
  23. int a = 10;
  24. int b = 0; //修复前 b = 0
  25. int c = a/b;
  26. Toast.makeText(context, c + " 厉害啦,我的锅",Toast.LENGTH_SHORT).show();
  27. }
  28. public void hotFix(View view) {
  29. //1.通过服务器下载dex文件,如:v1.0.0有一个热修复dex包
  30. File sourceFile = new File(Environment.getExternalStorageDirectory(), Constants.DEX_NAME);
  31. //目标路径,私有目录里面的临时文件odex
  32. File targetFile = new File(getDir(Constants.DEX_DIR, Context.MODE_PRIVATE).getAbsolutePath()
  33. + File.separator + Constants.DEX_NAME);
  34. //如果存在,比如之前修复过的classes2.dex。清理
  35. if(targetFile.exists()){
  36. targetFile.delete();
  37. Toast.makeText(this,"删除已存在的dex文件",Toast.LENGTH_SHORT).show();
  38. }
  39. //2.复制修复包dex文件到app私有目录
  40. try {
  41. FileUtils.copyFile(sourceFile,targetFile);
  42. Toast.makeText(this,"复制dex文件完成",Toast.LENGTH_SHORT).show();
  43. //开始修复
  44. FixDexUtils.loadFixedDex(this);
  45. } catch (IOException e) {
  46. e.printStackTrace();
  47. }
  48. }
  49. }

3.3.工具类-ArrayUtils

  1. import java.lang.reflect.Array;
  2. /**
  3. * created by Jack
  4. * email:yucrun@163.com
  5. * date: 2019/4/1
  6. * describe:反射工具类
  7. */
  8. public class ArrayUtils {
  9. /**
  10. * 合并数组
  11. * @param arrayLhs 前数组(插队数组)
  12. * @param arrayRhs 后数组(已有数组)
  13. * @return 处理后的新数组
  14. */
  15. public static Object combineArray(Object arrayLhs,Object arrayRhs){
  16. //获得一个数组的class对象,通过Array.newInstance()可以反射生成新的数组
  17. Class<?> loaclClass = arrayLhs.getClass().getComponentType();
  18. //前数组长度
  19. int i = Array.getLength(arrayLhs);
  20. //新数组长度 = 前数组长度 + 后数组长度
  21. int j = i + Array.getLength(arrayRhs);
  22. //生成数组对象
  23. Object result = Array.newInstance(loaclClass,j);
  24. //从0开始遍历,如果前数组有值,添加到新数组的第一个位置
  25. for(int k = 0; k < j;k++){
  26. if(k < i){
  27. //从0开始遍历,如果前数组有值,添加到新数组的第一个位置
  28. Array.set(result,k,Array.get(arrayLhs,k));
  29. }else {
  30. //添加完前数组,在添加后数组,合并完成
  31. Array.set(result,k,Array.get(arrayRhs,k - i));
  32. }
  33. }
  34. //添加完前数组,在添加后数组,合并完成
  35. return result;
  36. }
  37. }

3.4.工具类-Constants

  1. /**
  2. * created by Jack
  3. * email:yucrun@163.com
  4. * date:19-4-1
  5. * describe:常量类
  6. */
  7. public class Constants {
  8. public static final String DEX_NAME = "classes2.dex";
  9. public static final String DEX_DIR = "odex";
  10. public static final String DEX_SUFFIX = ".dex";
  11. }

3.5.工具类-FileUtils

  1. import java.io.BufferedInputStream;
  2. import java.io.BufferedOutputStream;
  3. import java.io.File;
  4. import java.io.FileInputStream;
  5. import java.io.FileOutputStream;
  6. import java.io.IOException;
  7. /**
  8. * created by Jack
  9. * email:yucrun@163.com
  10. * date:19-4-1
  11. * describe:文件工具类
  12. */
  13. public class FileUtils {
  14. /**
  15. *
  16. * @param sourceFile 源文件(来自sd卡)
  17. * @param targetFile 目标文件(私有)
  18. * @throws IOException IO异常
  19. */
  20. public static void copyFile(File sourceFile,File targetFile) throws IOException {
  21. //新建文件输入流并对它进行缓冲
  22. FileInputStream input = new FileInputStream(sourceFile);
  23. BufferedInputStream inBuff = new BufferedInputStream(input);
  24. //新建文件输出流并对它进行缓冲
  25. FileOutputStream outPut = new FileOutputStream(targetFile);
  26. BufferedOutputStream outBuff = new BufferedOutputStream(outPut);
  27. //缓冲数组
  28. byte[] b = new byte[1024 * 5];
  29. int len;
  30. while ((len = inBuff.read(b)) !=-1){
  31. outBuff.write(b,0,len);
  32. }
  33. //刷新此缓冲区的输出流
  34. outBuff.flush();
  35. //关闭流
  36. input.close();
  37. inBuff.close();
  38. outPut.close();
  39. outBuff.close();
  40. }
  41. }

3.6.工具类-ReflectUtils

  1. import java.lang.reflect.Field;
  2. /**
  3. * created by Jack
  4. * email:yucrun@163.com
  5. * date:19-4-1
  6. * describe:反射工具类
  7. */
  8. public class ReflectUtils {
  9. /**
  10. * 通过反射获取某对象,并设置私有可以访问
  11. * @param obj 该属性所属类的对象
  12. * @param clazz 该属性所属类
  13. * @param field 属性名
  14. * @return
  15. */
  16. private static Object getField(Object obj,Class<?> clazz,String field)
  17. throws NoSuchFieldException, IllegalAccessException {
  18. Field localField = clazz.getDeclaredField(field);
  19. localField.setAccessible(true);
  20. return localField.get(obj);
  21. }
  22. /**
  23. * 通过反射获取某对象,并设置私有可访问
  24. * @param obj 该属性所属类的对象
  25. * @param clazz 该属性所属类
  26. * @param value 值
  27. */
  28. public static void setField(Object obj,Class<?> clazz,Object value)
  29. throws NoSuchFieldException, IllegalAccessException {
  30. Field localField = clazz.getDeclaredField("dexElements");
  31. localField.setAccessible(true);
  32. localField.set(obj,value);
  33. }
  34. /**
  35. * 通过反射获取BaseDexClassLoader对象中的PathList对象
  36. * @param baseDexClassLoader
  37. * @return PathList对象
  38. */
  39. public static Object getPathList(Object baseDexClassLoader) throws
  40. ClassNotFoundException, NoSuchFieldException, IllegalAccessException {
  41. return getField(baseDexClassLoader,
  42. Class.forName("dalvik.system.BaseDexClassLoader"),
  43. "pathList");
  44. }
  45. /**
  46. *
  47. * 通过反射获取BaseDexClassLoader对象的PathList对象,再获取dexElements对象
  48. * @param paramObject PathList对象
  49. * @return dexElements对象
  50. */
  51. public static Object getDexElements(Object paramObject)
  52. throws NoSuchFieldException, IllegalAccessException {
  53. return getField(paramObject,paramObject.getClass(),"dexElements");
  54. }
  55. }

3.7.工具类-FixDexUtils

  1. import android.content.Context;
  2. import java.io.File;
  3. import java.util.HashSet;
  4. import dalvik.system.DexClassLoader;
  5. import dalvik.system.PathClassLoader;
  6. import jack.cn.hotfixlib.utils.ArrayUtils;
  7. import jack.cn.hotfixlib.utils.Constants;
  8. import jack.cn.hotfixlib.utils.ReflectUtils;
  9. /**
  10. * created by Jack
  11. * email:yucrun@163.com
  12. * date:19-4-1
  13. * describe:热修复管理类,严格意义上需要单例子。模仿热修复实现的思路。
  14. */
  15. public class FixDexUtils {
  16. //存放需要修复的dex集合,可能不止一个dex
  17. private static HashSet<File> loadedDex = new HashSet<>();
  18. static {
  19. //修复前,进行清理工作
  20. loadedDex.clear();
  21. }
  22. /**
  23. * 加载热修复的dex文件
  24. *
  25. * @param context 上下文
  26. */
  27. public static void loadFixedDex(Context context) {
  28. if (context == null)
  29. return;
  30. //Dex文件目录(私有目录中,存在之前已经复制过来的修复包)
  31. File fileDir = context.getDir(Constants.DEX_DIR, Context.MODE_PRIVATE);
  32. File[] listFiles = fileDir.listFiles();
  33. //遍历私有目录中所有的文件
  34. for (File file : listFiles) {
  35. //找到修复包,加入到集合,主包(classes.dex)不添加
  36. if (file.getName().endsWith(Constants.DEX_SUFFIX) && !"classes.dex".equals(file.getName())) {
  37. loadedDex.add(file);
  38. }
  39. }
  40. //模拟类加载器
  41. createDexCLassLoader(context, fileDir);
  42. }
  43. /**
  44. * 创建加载补丁的DexClassLoader(自有)
  45. *
  46. * @param context 上下文
  47. * @param fileDir Dex文件目录
  48. */
  49. private static void createDexCLassLoader(Context context, File fileDir) {
  50. //创建临时的解压目录(先解压到该目录,再加载java)
  51. String optimizedDir = fileDir.getAbsolutePath() + File.separator + "opt_dex";
  52. //不存在就创建
  53. File fopt = new File(optimizedDir);
  54. if (!fopt.exists()) {
  55. //创建多级目录
  56. fopt.mkdirs();
  57. }
  58. for (File dex : loadedDex) {
  59. //每遍历一个要修复的dex文件,就需要插桩一次
  60. DexClassLoader classLoader = new DexClassLoader(dex.getAbsolutePath(),
  61. optimizedDir, null, context.getClassLoader());
  62. hotFix(context, classLoader);
  63. }
  64. }
  65. /**
  66. * 热修复
  67. *
  68. * @param context
  69. * @param classLoader
  70. */
  71. private static void hotFix(Context context, DexClassLoader classLoader) {
  72. //获取系统的PathClassLoader类加载器
  73. PathClassLoader pathLoader = (PathClassLoader) context.getClassLoader();
  74. try {
  75. //获取自有的dexElements数组对象
  76. Object myDexElements = ReflectUtils.getDexElements(ReflectUtils.getPathList(classLoader));
  77. //获取系统的dexElements数组对象
  78. Object systemDexElements = ReflectUtils.getDexElements(ReflectUtils.getPathList(pathLoader));
  79. //合并成新的dexElements数组对象
  80. Object dexElements = ArrayUtils.combineArray(myDexElements, systemDexElements);
  81. //通过反射再去 获取 系统的 pathList对象
  82. Object systemPathList = ReflectUtils.getPathList(pathLoader);
  83. //重新赋值给系统的pathList属性 --- 修改了pathList的dexElements数组对象
  84. ReflectUtils.setField(systemPathList,systemPathList.getClass(),dexElements);
  85. } catch (Exception e) {
  86. e.printStackTrace();
  87. }
  88. }
  89. }

3.8.验证热修复效果

  1. 先查看3.2中的第30行,在修复前b=0,运行showToast方法会出现崩溃。先执行android -studio中顶部的Build中Build apk(s)生成apk,在app/build/outputs/apk/debug查看。生成后安装;
  2. 修改3.2中的第30行代码,将b = 0,改为b = 1,然后执行Build apk(s),将新生成的apk改为zip格式,然后打开,复制classes2.dex文件到手机内存卡中;

    1. ![zip格式查看.png](https://cdn.nlark.com/yuque/0/2019/png/249982/1554188969150-76521d48-ccad-4f5a-9a7e-68faf81fd50e.png#align=left&display=inline&height=571&name=zip%E6%A0%BC%E5%BC%8F%E6%9F%A5%E7%9C%8B.png&originHeight=571&originWidth=612&size=76232&status=done&width=612)
  3. 开始验证,打开安装好的应用,首选允许读写权限,点击showToast按钮,首先会闪退。再次进入点击hotFix方法,完成修复。下次再进入点击showToast就不会闪退,即完成修复。

四.写在最后

非常感谢,能看到这里,因为个人水平尚浅,不免有疏漏,希望自己不会误人子弟,上面代码有疑问或错误的地方,欢迎指出。个人email:yucrun@163.com。项目地址