Android 热修复,没你想的那么难 - 张涛

FreyaZKN 9年前
   <p> </p>    <h3>写在前面</h3>    <p>一种动态加载最简单的实现方式,代码实现起来非常简单,重要的是这种思路和原理</p>    <p>《插件化从放弃到捡起》第一章,首先看一张图:</p>    <p><img src="https://simg.open-open.com/show/2d903bc08893134f053f0694e6c22da2.png"></p>    <p>这张图是我所理解的 Android 插件化技术的三个技术点以及它们的应用场景。今天以 <a href="http://www.open-open.com/lib/view/open1462746397044.html">【Qzone 热修复方案为例】</a> ,跟大家讲一讲插件化中 热修复方案 的实现。</p>    <h2>原理</h2>    <h3>ClassLoader</h3>    <p>在 Java 中,要加载一个类需要用到 ClassLoader 。</p>    <p>Android 中有三个 ClassLoader, 分别为 URLClassLoader 、 PathClassLoader 、 DexClassLoader 。其中</p>    <ul>     <li>URLClassLoader 只能用于加载jar文件,但是由于 dalvik 不能直接识别jar,所以在 Android 中无法使用这个加载器。</li>     <li>PathClassLoader 它只能加载已经安装的apk。因为 PathClassLoader 只会去读取 /data/dalvik-cache 目录下的 dex 文件。例如我们安装一个包名为 com.hujiang.xxx 的 apk,那么当 apk 安装过程中,就会在 /data/dalvik-cache 目录下生产一个名为 data@app@com.hujiang.xxx-1.apk@classes.dex 的 ODEX 文件。在使用 PathClassLoader 加载 apk 时,它就会去这个文件夹中找相应的 ODEX 文件,如果 apk 没有安装,自然会报 ClassNotFoundException 。</li>     <li>DexClassLoader 是最理想的加载器。它的构造函数包含四个参数,分别为:      <ol>       <li>dexPath,指目标类所在的APK或jar文件的路径.类装载器将从该路径中寻找指定的目标类,该类必须是APK或jar的全路径.如果要包含多个路径,路径之间必须使用特定的分割符分隔,特定的分割符可以使用System.getProperty(“path.separtor”)获得.</li>       <li>dexOutputDir,由于dex文件被包含在APK或者Jar文件中,因此在装载目标类之前需要先从APK或Jar文件中解压出dex文件,该参数就是制定解压出的dex 文件存放的路径.在Android系统中,一个应用程序一般对应一个Linux用户id,应用程序仅对属于自己的数据目录路径有写的权限,因此,该参数可以使用该程序的数据路径.</li>       <li>libPath,指目标类中所使用的C/C++库存放的路径</li>       <li>classload,是指该装载器的父装载器,一般为当前执行类的装载器</li>      </ol> </li>    </ul>    <p>从 <a href="/misc/goto?guid=4959672693006172385" rel="nofollow,noindex">framework源码</a> 中的 dalvik.system 包下,找到 DexClassLoader 源码,并没有什么卵用,实际内容是在它的父类 BaseDexClassLoader 中,顺带一提,这个类最低在API14开始有用。包含了两个变量:</p>    <p>```java /** originally specified path (just used for {@code toString()}) */ private final String originalPath;</p>    <p>/** structured lists of path elements */ private final DexPathList pathList; ```</p>    <p>可以看到注释:pathList就是多dex的结构列表,查看 <a href="/misc/goto?guid=4959672693105479562" rel="nofollow,noindex">其源码</a></p>    <p>```java / <em>package</em> / final class DexPathList { private static final String DEX_SUFFIX = “.dex”; private static final String JAR_SUFFIX = “.jar”; private static final String ZIP_SUFFIX = “.zip”; private static final String APK_SUFFIX = “.apk”;</p>    <pre>  <code class="language-java">/** class definition context */  private final ClassLoader definingContext;    /** list of dex/resource (class path) elements */  private final Element[] dexElements;    /** list of native library directory elements */  private final File[] nativeLibraryDirectories; ``` 可以看到 ```dexElements``` 注释,dexElements 就是一个dex列表,那么我们就可以把每个 Element 当成是一个 dex。    </code></pre>    <p>此时我们整理一下思路,DexClassLoader 包含有一个dex数组 Element[] dexElements ,其中每个dex文件是一个Element,当需要加载类的时候会遍历 dexElements,如果找到类则加载,如果找不到从下一个 dex 文件继续查找。</p>    <p>那么我们的实现就是把这个插件 dex 插入到 Elements 的最前面,这么做的好处是不仅可以动态的加载一个类,并且由于 DexClassLoader 会优先加载靠前的类,所以我们同时实现了宿主 apk 的热修复功能。</p>    <h3>ODEX过程</h3>    <p>上文就是整个热修复的原理了,就是向 Classloader 列表中插入一个dex。但是如果你这儿实现了,会发现一个问题,就是 ODEX 过程中引发的问题。</p>    <p>在讲这个蛋疼的过程之前,有几个问题是要搞懂的。</p>    <p>为什么 Android 不能识别 .class 文件,而只能识别 dex 文件。</p>    <p>因为 dex 是对 class 的优化,它对 class 做了极大的压缩,比如以下是一个 class 文件的结构(摘自邓凡平老师博客)</p>    <p><img src="https://simg.open-open.com/show/12ee39024fe7e6efb5ca17b25a491e5c.png"></p>    <p>dex 将整个 Android 工程中所有的 class 压缩到一个(或几个) dex 文件中,合并了每个 class 的常量、class 版本信息等,例如每个 class 中都有一个相同的字符串,在 dex 中就只存一份就够了。所以,在Android 上,dalvik 虚拟机是无法识别一个普通 class 文件的,因为无法识别这个 class 文件的结构。以下是一个 dex 文件的结构</p>    <p><img src="https://simg.open-open.com/show/d152e91cc83e79cdd38c087fd84c9cf5.png"></p>    <p>感兴趣的可以阅读《深入理解Android》这本书。</p>    <p>继续往下,其实 dalvik 虚拟机也并不是直接读取 dex 文件的,而是在一个 APK 安装的时候,会首先做一次优化,会生成一个 ODEX 文件,即 Optimized dex。 为什么还要优化,依旧是为了效率。</p>    <p>只不过,Class -> dex 是为了平台无关的优化;</p>    <p>而 dex -> odex 则是针对不同平台,不同手机的硬件配置做针对性的优化。</p>    <p>就是在这一过程中,虚拟机在启动优化的时候,会有一个选项就是 verify 选项,当 verify 选项被打开的时候,就会执行一次校验,校验的目的是为了判断,这个类是否有引用其他 dex 中的类,如果没有,那么这个类会被打上一个 CLASS_ISPREVERIFIED 的标志。一旦被打上这个标志,就无法再从其他 dex 中替换这个类了。而这个选项开启,则是由虚拟机控制的。</p>    <h3>字节码操作</h3>    <p>那么既然知道了原因,解决的办法自然也有了。你不是没有引用其他 dex 中的类就会被标记吗,那咱们就引用一个其他 dex 中的类。</p>    <p>ClassReader:该类用来解析编译过的class字节码文件。</p>    <p>ClassWriter:该类用来重新构建编译后的类,比如说修改类名、属性以及方法,甚至可以生成新的类的字节码文件。</p>    <p>ClassAdapter:该类也实现了ClassVisitor接口,它将对它的方法调用委托给另一个ClassVisitor对象。</p>    <p>java /** * 当对象初始化的时候注入Inject类 * * @Note https://www.ibm.com/developerworks/cn/java/j-lo-asm30/ * @param inputStream 需要注入的Class的文件输入流 * @return 返回注入以后的Class文件二进制数组 */ private static byte[] referHackWhenInit(InputStream inputStream) { //该类用来解析编译过的class字节码文件。 ClassReader cr = new ClassReader(inputStream); //该类用来重新构建编译后的类,比如说修改类名、属性以及方法,甚至可以生成新的类的字节码文件 ClassWriter cw = new ClassWriter(cr, 0); //类的访问者,可以用来创建对一个Class的改动操作 ClassVisitor cv = new ClassVisitor(Opcodes.ASM4, cw) { @Override public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) { MethodVisitor mv = super.visitMethod(access, name, desc, signature, exceptions); //如果方法名是&lt;init&gt;,每个类的构造函数函数名叫&lt;init&gt; if (&quot;&lt;init&gt;&quot;.equals(name)) { //在原本的visitMethod操作中添加自己定义的操作 mv = new MethodVisitor(Opcodes.ASM4, mv) { @Override void visitInsn(int opcode) { //Opcodes可以看做为关键字 if (opcode == Opcodes.RETURN) { //visitLdcInsn() 将一个值写入到栈中,可以是一个Class类名/method方法名/desc方法描述 //这里相当于插入了一条语句:Class a = Inject.class; super.visitLdcInsn(Type.getType(&quot;Lcom/hujiang/hotfix/Inject;&quot;)); } //执行opcode对应的其他操作 super.visitInsn(opcode); } } } //责任链完成,返回 return mv; } }; //accept这个方法接受一个实现了 ClassVisitor接口的对象实例作为参数,然后依次调用 ClassVisitor接口的各个方法 //用户无法控制各个方法调用顺序,但是可以提供不同的 Visitor(访问者) 来对字节码树进行不同的修改 //在这里,调用这一步的目的是为了让上面的visitMethod方法被调用 cr.accept(cv, 0); return cw.toByteArray(); }</p>    <h2>代码实现</h2>    <p>可以参考 <a href="/misc/goto?guid=4958971966097492509" rel="nofollow,noindex">nuwa</a> 中的实现,首先是 dex 怎样去插入到 Classloader 列表中,其实就是一段反射:</p>    <p>java public static void injectDexAtFirst(String dexPath, String defaultDexOptPath) throws NoSuchFieldException, IllegalAccessException, ClassNotFoundException { DexClassLoader dexClassLoader = new DexClassLoader(dexPath, defaultDexOptPath, dexPath, getPathClassLoader()); Object baseDexElements = getDexElements(getPathList(getPathClassLoader())); Object newDexElements = getDexElements(getPathList(dexClassLoader)); Object allDexElements = combineArray(newDexElements, baseDexElements); Object pathList = getPathList(getPathClassLoader()); ReflectionUtils.setField(pathList, pathList.getClass(), &quot;dexElements&quot;, allDexElements); }</p>    <p>首先分别获取到宿主应用和补丁的 dex 中的 PathList.dexElements , 并把两个 dexElements 数组做拼接,将补丁数组放在前面,最后将拼接后生成的数组再赋值回 Classloader</p>    <p>nuwa 更主要的是他的 groovy 脚本,完整代码: <a href="/misc/goto?guid=4959672693235263406" rel="nofollow,noindex">这里</a> ,由于代码很多,就只跟大家讲两个关键的点的实现以及目的,具体的内容可以直接查看源码。</p>    <p>groovy //获得所有输入文件,即preDex的所有jar文件 Set&lt;File&gt; inputFiles = preDexTask.inputs.files.files inputFiles.each { inputFile -&gt; def path = inputFile.absolutePath //如果不是support包或者引入的依赖库,则开始生成代码修改部分的hotfix包 if (HotFixProcessors.shouldProcessPreDexJar(path)) { HotFixProcessors.processJar(classHashFile, inputFile, patchDir, classHashMap, includePackage, excludeClass) } }</p>    <p>其中 HotFixProcessors.processJar() 是脚本的第一个作用,就是找出哪些类是发生了改变,应该生成对应的补丁。</p>    <p>循环遍历工程中的全部类,声明忽略的直接跳过.对每个类计算hash,并写入到hashFile文件中.通过比较hashFile文件与原先host工程的hashFile(即这里的classHashMap参数),得到所有修改过的类生成这些类的class文件,以及所有修改过的class文件的集合jar文件。</p>    <p>```groovy Set inputFiles = dexTask.inputs.files.files inputFiles.each { inputFile -> def path = inputFile.absolutePath if (path.endsWith(".class") && !path.contains("/R\$") && !path.endsWith("/R.class") && !path.endsWith("/BuildConfig.class")) { if (HotFixSetUtils.isIncluded(path, includePackage)) { if (!HotFixSetUtils.isExcluded(path, excludeClass)) { def bytes = HotFixProcessors.processClass(inputFile) path = path.split("${dirName}/")[1] def hash = DigestUtils.shaHex(bytes) classHashFile.append(HotFixMapUtils.format(path, hash))</p>    <pre>  <code class="language-java">            if (HotFixMapUtils.notSame(classHashMap, path, hash)) {                  HotFixFileUtils.copyBytesToFile(inputFile.bytes, HotFixFileUtils.touchFile(patchDir, path))              }          }      }  } } ```   这一段是脚本的第二个作用,也就是上文字节码操作的目的,为了防止类被虚拟机打上```CLASS_ISPREVERIFIED```,所以需要执行字节码写入。其中```HotFixProcessors.processClass()```就是实际写入字节码的代码。    </code></pre>    <h2>好像差个结尾</h2>    <p>同样的方案,除了 nuwa 还有一个开源的实现, <a href="/misc/goto?guid=4959655848560686200" rel="nofollow,noindex">HotFix</a> 两者是差不多的,所以看一个就可以了。</p>    <p>如果觉得我的文章对您有用,请随意打赏。您的支持将鼓励我继续创作!</p>    <p>来自: http://www.kymjs.com/code/2016/05/08/01</p>