查看原文
其他

论热修复实战心得—很值得学习的热修复详解|强烈推荐

2017-11-16 林洽锐 终端研发部

 【公众号回复“1024”,送你一个特别推送】

博客地址:

http://www.jianshu.com/p/cb1f0702d59f

声明原创|本文为林洽锐授权发布,未经允许请勿转载

正文


先上一张效果图吧:



一、简述

热修复无疑是这2年较火的新技术,是作为安卓工程师必学的技能之一。在热修复出现之前,一个已经上线的app中如果出现了bug,即使是一个非常小的bug,不及时更新的话有可能存在风险,若要及时更新就得将app重新打包发布到应用市场后,让用户再一次下载,这样就大大降低了用户体验,当热修复出现之后,这样的问题就不再是问题了。

目前较火的热修复方案大致分为两派,分别是:

  1. 阿里系:DeXposed、andfix:从底层二进制入手(c语言)。

  2. 腾讯系:tinker:从java加载机制入手。

本篇的主题并非讲述上面两种方案的使用,而是基于java加载机制,来研究热修复的原理与实现。(类似tinker,当然tinker没这么简单)

二、Android中如何动态修复bug

关于bug的概念自己百度百科吧,我认为的bug一般有2种(可能不太准确):

  • 代码功能不符合项目预期,即代码逻辑有问题。

  • 程序代码不够健壮导致App运行时崩溃。

这两种情况一般是一个或多个class出现了问题,在一个理想的状态下,我们只需将修复好的这些个class更新到用户手机上的app中就可以修复这些bug了。但说着简单,要怎么才能动态更新这些class呢?其实,不管是哪种热修复方案,肯定是如下几个步骤:

  1. 下发补丁(内含修复好的class)到用户手机,即让app从服务器上下载(网络传输)

  2. app通过**"某种方式"**,使补丁中的class被app调用(本地更新)

这里的**"某种方式"**,对本篇而言,就是使用Android的类加载器,通过类加载器加载这些修复好的class,覆盖对应有问题的class,理论上就能修复bug了。所以,下面就先来了解和分析Android中的类加载器吧。

三、Android中的类加载器

Android跟java有很大的渊源,基于jvm的java应用是通过ClassLoader来加载应用中的class的,但我们知道Android对jvm优化过,使用的是dalvik,且class文件会被打包进一个dex文件中,底层虚拟机有所不同,那么它们的类加载器当然也是会有所区别,在Android中,要加载dex文件中的class文件就需要用到 PathClassLoader 或DexClassLoader 这两个Android专用的类加载器。

1、源码查看

一般的源码在Android Studio中可以查到,但 PathClassLoader 和 DexClassLoader的源码是属于系统级源码,所以无法在Android Studio中直接查看。不过,有两种方式可以在外部进行查看:第一种是通过下载Android镜像源码的方式进行查看,但一般镜像源码体积较大,不好下载,而且就只是为了看3、4个文件的源码动不动就下载3、4个g的源码,确实不太明智,所以我们一般采用第二种方式:到androidxref.com这个网站上直接查看,下面会列出之后要分析的几个类的源码地址,供看客们方便浏览。

以下是Android 5.0中的部分源码:

  • PathClassLoader.java

  • DexClassLoader.java

  • BaseDexClassLoader.java

  • DexPathList.java

2、PathClassLoader与DexClassLoader的区别

1)使用场景

  • PathClassLoader:只能加载已经安装到Android系统中的apk文件(/data/app目录),是Android默认使用的类加载器。

  • DexClassLoader:可以加载任意目录下的dex/jar/apk/zip文件,比PathClassLoader更灵活,是实现热修复的重点。

2)代码差异

因为PathClassLoader与DexClassLoader的源码都很简单,我就直接将它们的全部源码复制过来了:

// PathClassLoader
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);
   }
}

// DexClassLoader
public class DexClassLoader extends BaseDexClassLoader {
   public DexClassLoader(String dexPath, String optimizedDirectory,
           String librarySearchPath, ClassLoader parent) {
       super(dexPath, new File(optimizedDirectory), librarySearchPath, parent);
   }
}

通过比对,可以得出2个结论:

  • PathClassLoader与DexClassLoader都继承于BaseDexClassLoader。

  • PathClassLoader与DexClassLoader在构造函数中都调用了父类的构造函数,但DexClassLoader多传了一个optimizedDirectory。

3、BaseDexClassLoader

通过观察PathClassLoader与DexClassLoader的源码我们就可以确定,真正有意义的处理逻辑肯定在BaseDexClassLoader中,所以下面着重分析BaseDexClassLoader源码。

1)构造函数

先来看看BaseDexClassLoader的构造函数都做了什么:

public class BaseDexClassLoader extends ClassLoader {
   ...
   public BaseDexClassLoader(String dexPath, File optimizedDirectory, String libraryPath, ClassLoader parent){
       super(parent);
       this.pathList = new DexPathList(this, dexPath, libraryPath, optimizedDirectory);
   }
   ...
}
  • dexPath:要加载的程序文件(一般是dex文件,也可以是jar/apk/zip文件)所在目录。

  • optimizedDirectory:dex文件的输出目录(因为在加载jar/apk/zip等压缩格式的程序文件时会解压出其中的dex文件,该目录就是专门用于存放这些被解压出来的dex文件的)。

  • libraryPath:加载程序文件时需要用到的库路径。

  • parent:父加载器

tip:上面说到的"程序文件"这个概念是我自己定义的,因为从一个完整App的角度来说,程序文件指定的就是apk包中的classes.dex文件;但从热修复的角度来看,程序文件指的是补丁。

因为PathClassLoader只会加载已安装包中的dex文件,而DexClassLoader不仅仅可以加载dex文件,还可以加载jar、apk、zip文件中的dex,我们知道jar、apk、zip其实就是一些压缩格式,要拿到压缩包里面的dex文件就需要解压,所以,DexClassLoader在调用父类构造函数时会指定一个解压的目录。

不过,从Android 8.0开始,BaseDexClassLoader的构造函数逻辑发生了变化,optimizedDirectory过时,不再生效,详情可查看Android 8.0的BaseDexClassLoader.java源码

2)获取class

类加载器肯定会提供有一个方法来供外界找到它所加载到的class,该方法就是findClass(),不过在PathClassLoader和DexClassLoader源码中都没有重写父类的findClass()方法,但它们的父类BaseDexClassLoader就有重写findClass(),所以来看看BaseDexClassLoader的findClass()方法都做了哪些操作,代码如下:

private final DexPathList pathList;

@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
   List<Throwable> suppressedExceptions = new ArrayList<Throwable>();
   // 实质是通过pathList的对象findClass()方法来获取class
   Class c = pathList.findClass(name, suppressedExceptions);
   if (c == null) {
       ClassNotFoundException cnfe = new ClassNotFoundException("Didn't find class \"" + name + "\" on path: " + pathList);
       for (Throwable t : suppressedExceptions) {
           cnfe.addSuppressed(t);
       }
       throw cnfe;
   }
   return c;
}

可以看到,BaseDexClassLoader的findClass()方法实际上是通过DexPathList对象(pathList)的findClass()方法来获取class的,而这个DexPathList对象恰好在之前的BaseDexClassLoader构造函数中就已经被创建好了。所以,下面就来看看DexPathList类中都做了什么。

4、DexPathList

在分析一个代码量较多的源码之前,我们要明确要从这段源码中要知道些什么?这样才不会在“码海”中迷失方向,我自己就定了2个小目标,分别是:

  • DexPathList的构造函数做了什么事?

  • DexPathList的findClass()方法是怎么获取class的?

为什么是这2个目标?因为在BaseDexClassLoader的源码中主要就用到了DexPathList的构造函数和findClass()方法。


其实,Android的类加载器(不管是PathClassLoader,还是DexClassLoader),它们最后只认dex文件,而loadDexFile()是加载dex文件的核心方法,可以从jar、apk、zip中提取出dex,但这里先不分析了,因为第1个目标已经完成,等到后面再来分析吧。

2)findClass()

再来看DexPathList的findClass()方法:

public Class findClass(String name, List<Throwable> suppressed) {
   for (Element element : dexElements) {
       // 遍历出一个dex文件
       DexFile dex = element.dexFile;

       if (dex != null) {
           // 在dex文件中查找类名与name相同的类
           Class clazz = dex.loadClassBinaryName(name, definingContext, suppressed);
           if (clazz != null) {
               return clazz;
           }
       }
   }
   if (dexElementsSuppressedExceptions != null) {
       suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions));
   }
   return null;
}

结合DexPathList的构造函数,其实DexPathList的findClass()方法很简单,就只是对Element数组进行遍历,一旦找到类名与name相同的类时,就直接返回这个class,找不到则返回null。

为什么是调用DexFile的loadClassBinaryName()方法来加载class?这是因为一个Element对象对应一个dex文件,而一个dex文件则包含多个class。也就是说Element数组中存放的是一个个的dex文件,而不是class文件!!!这可以从Element这个类的源码和dex文件的内部结构看出。

四、热修复的实现原理

终于进入主题了,经过对PathClassLoader、DexClassLoader、BaseDexClassLoader、DexPathList的分析,我们知道,安卓的类加载器在加载一个类时会先从自身DexPathList对象中的Element数组中获取(Element[] dexElements)到对应的类,之后再加载。采用的是数组遍历的方式,不过注意,遍历出来的是一个个的dex文件。

在for循环中,首先遍历出来的是dex文件,然后再是从dex文件中获取class,所以 43 34834 43 15288 0 0 2896 0 0:00:12 0:00:05 0:00:07 3017,我们只要让修复好的class打包成一个dex文件,放于Element数组的第一个元素,这样就能保证获取到的class是最新修复好的class了(当然,有bug的class也是存在的,不过是放在了Element数组的最后一个元素中,所以没有机会被拿到而已)。

五、热修复的简单实现

通过前面的一堆理论之后,是时候实践一把了。

1、得到dex格式补丁

1)修复好有问题的java文件

这一步根据bug的实际情况修改代码即可。

2)将java文件编译成class文件

在修复bug之后,可以使用Android Studio的Rebuild Project功能将代码进行编译,然后从build目录下找到对应的class文件。

将修复好的class文件复制到其他地方,例如桌面上的dex文件夹中。需要注意的是,在复制这个class文件时,需要把它所在的完整包目录一起复制。假设上图中修复好的class文件是SimpleHotFixBugTest.class,则到时复制出来的目录结构是:

3)将class文件打包成dex文件

a. dx指令程序

要将class文件打包成dex文件,就需要用到dx指令,这个dx指令类似于java指令。我们知道,java的指令有javac、jar等等,之所以可以使用这类指令,是因为我们有安装过jdk,jdk为我们提供了java指令,相同的,dx指令也需要有程序来提供,它就在Android SDK的build-tools目录下各个Android版本目录之中

b. dx指令的使用

dx指令的使用跟java指令的使用条件一样,有2种选择:

  • 配置环境变量(添加到classpath),然后命令行窗口(终端)可以在任意位置使用。

  • 不配环境变量,直接在build-tools/安卓版本 目录下使用命令行窗口(终端)使用。

第一种方式参考java环境变量配置即可,这里我选用第二种方式。下面我们需要用到的命令是:

dx --dex --output=dex文件完整路径 (空格) 要打包的完整class文件所在目录,如:

dx --dex --output=C:\Users\Administrator\Desktop\dex\classes2.dex C:\Users\Administrator\Desktop\dex

具体操作看下图:

在文件夹目录的空白处,按住shift+鼠标右击,可出现“在此处打开命令行窗口”。

2、加载dex格式补丁

根据原理,可以做一个简单的工具类:

....

private static void doDexInject(Context appContext, HashSet<File> loadedDex) {        String optimizeDir = appContext.getFilesDir().getAbsolutePath() + File.separator + OPTIMIZE_DEX_DIR;// data/data/包名/files/optimize_dex(这个必须是自己程序下的目录)
       File fopt = new File(optimizeDir);        if (!fopt.exists()) {
           fopt.mkdirs();
       }        try {            // 1.加载应用程序的dex
           PathClassLoader pathLoader = (PathClassLoader) appContext.getClassLoader();            for (File dex : loadedDex) {                // 2.加载指定的修复的dex文件
               DexClassLoader dexLoader = new DexClassLoader(
                       dex.getAbsolutePath(),// 修复好的dex(补丁)所在目录
                       fopt.getAbsolutePath(),// 存放dex的解压目录(用于jar、zip、apk格式的补丁)
                       null,// 加载dex时需要的库
                       pathLoader// 父类加载器
               );                // 3.合并
               Object dexPathList = getPathList(dexLoader);                Object pathPathList = getPathList(pathLoader);                Object leftDexElements = getDexElements(dexPathList);                Object rightDexElements = getDexElements(pathPathList);                // 合并完成
               Object dexElements = combineArray(leftDexElements, rightDexElements);                // 重写给PathList里面的Element[] dexElements;赋值
               Object pathList = getPathList(pathLoader);// 一定要重新获取,不要用pathPathList,会报错
               setField(pathList, pathList.getClass(), "dexElements", dexElements);
           }
       } catch (Exception e) {
           e.printStackTrace();
       }
   } 
 } 

代码虽然较长,但注释写得很清楚,请仔细看,这里要说两点:

1)Class ref in pre-verified class resolved to unexpected implementation

// 合并完成Object dexElements = combineArray(leftDexElements, rightDexElements);// 重写给PathList里面的Element[] dexElements;赋值Object pathList = getPathList(pathLoader);// 一定要重新获取,不要用pathPathList,会报错setField(pathList, pathList.getClass(), "dexElements", dexElements);

在合并守Element数组后,一定要再重新获取一遍App中的原有的pathList,不要复用前面的pathPathList,会报错(Class ref in pre-verified class resolved to unexpected implementation)。

2)dexPath与optimizedDirectory的目录问题

DexClassLoader dexLoader = new DexClassLoader(
       dex.getAbsolutePath(),// 修复好的dex(补丁)所在目录
       fopt.getAbsolutePath(),// 存放dex的解压目录(用于jar、zip、apk格式的补丁)
       null,// 加载dex时需要的库
       pathLoader// 父类加载器

上面的代码是创建一个DexClassLoader对象,其中第1个和第2个参数有个细节需要注意:

  • 参数1是dexPath,指的是补丁所有目录,可以是多个目录(用冒号拼接),而且可以是任意目录,比如说SD卡。

  • 参数2是optimizedDirectory,就是存放从压缩包时解压出来的dex文件的目录,但不能是任意目录,它必须是程序所属的目录才行,比如:data/data/包名/xxx。

如果你把optimizedDirectory指定成SD卡目录,则会报如下错误:

java.lang.IllegalArgumentException: Optimized data directory /storage/emulated/0/opt_dex is not owned by the current user. Shared storage cannot protect your application from code injection attacks.

意思是说SD卡目录不属于当前用户。此外,这里再校正之前的一个小问题,optimizedDirectory不仅仅存放从压缩包出来的dex文件,如果补丁文件就是一个dex文件,那么它也会将这个补丁文件复制到optimizedDirectory目录下。

3、加载jar、apk、zip格式补丁(详见原文地址)

六、测试

这部分其实本不想写的,因为比较简单,但想了想不写又觉得不完整,那接下来就来测试一波吧。

演示

1、bug

不多说,看操作。


妥妥的ArithmeticException。

Caused by: java.lang.ArithmeticException: divide by zero

2、动态修复bug

首先,我将补丁文件classes2.dex放到手机的SD目录下。

然后先点击修复按钮,再点计算按钮。

大功告成,压缩格式的补丁跟dex格式的补丁一样,直接丢掉SD卡目录下就行了,但一定要注意,压缩格式的补丁中的文件一定是classes.dex!!!

最后贴下Demo地址

https://github.com/GitLqr/HotFixDemo


阅读更多

 一招教你读懂JVM和Dalvik之间的区别

一招教你打造一个滑动置顶的视觉特效

NDK项目实战—高仿360手机助手之卸载监听

最新2017(Android)面试题级答案(精选版)

相信自己,没有做不到的,只有想不到的

在这里获得的不仅仅是技术!

加入小密圈


日   更   精   彩

微  信  号:codeGoogler

终端研发部


如果你觉得此文对您有所帮助,可以转发给身边的朋友,一起做一个乐于分享的小猿猿


            

这里学到不仅仅是技术

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存