文档章节

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

kymjs张涛
 kymjs张涛
发布于 2016/05/16 19:55
字数 2727
阅读 1935
收藏 47
点赞 2
评论 1

写在前面

本文原创,转载请以链接形式注明地址:http://kymjs.com/code/2016/05/08/01

一种动态加载最简单的实现方式,代码实现起来非常简单,重要的是这种思路和原理

《插件化从放弃到捡起》第一章,首先看一张图:
Android插件化
这张图是我所理解的 Android 插件化技术的三个技术点以及它们的应用场景。今天以 【Qzone 热修复方案为例】,跟大家讲一讲插件化中 热修复方案 的实现。

原理

ClassLoader

在 Java 中,要加载一个类需要用到ClassLoader
Android 中有三个 ClassLoader, 分别为URLClassLoaderPathClassLoaderDexClassLoader。其中

  • URLClassLoader 只能用于加载jar文件,但是由于 dalvik 不能直接识别jar,所以在 Android 中无法使用这个加载器。
  • 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
  • DexClassLoader 是最理想的加载器。它的构造函数包含四个参数,分别为:
    1. dexPath,指目标类所在的APK或jar文件的路径.类装载器将从该路径中寻找指定的目标类,该类必须是APK或jar的全路径.如果要包含多个路径,路径之间必须使用特定的分割符分隔,特定的分割符可以使用System.getProperty(“path.separtor”)获得.
    2. dexOutputDir,由于dex文件被包含在APK或者Jar文件中,因此在装载目标类之前需要先从APK或Jar文件中解压出dex文件,该参数就是制定解压出的dex 文件存放的路径.在Android系统中,一个应用程序一般对应一个Linux用户id,应用程序仅对属于自己的数据目录路径有写的权限,因此,该参数可以使用该程序的数据路径.
    3. libPath,指目标类中所使用的C/C++库存放的路径
    4. classload,是指该装载器的父装载器,一般为当前执行类的装载器

framework源码中的dalvik.system包下,找到DexClassLoader源码,并没有什么卵用,实际内容是在它的父类BaseDexClassLoader中,顺带一提,这个类最低在API14开始有用。包含了两个变量:

/** originally specified path (just used for {@code toString()}) */
private final String originalPath;
 
/** structured lists of path elements */
private final DexPathList pathList;

可以看到注释:pathList就是多dex的结构列表,查看其源码

/*package*/ 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";

    /** 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。

此时我们整理一下思路,DexClassLoader 包含有一个dex数组Element[] dexElements,其中每个dex文件是一个Element,当需要加载类的时候会遍历 dexElements,如果找到类则加载,如果找不到从下一个 dex 文件继续查找。

那么我们的实现就是把这个插件 dex 插入到 Elements 的最前面,这么做的好处是不仅可以动态的加载一个类,并且由于 DexClassLoader 会优先加载靠前的类,所以我们同时实现了宿主 apk 的热修复功能。

ODEX过程

上文就是整个热修复的原理了,就是向Classloader列表中插入一个dex。但是如果你这儿实现了,会发现一个问题,就是 ODEX 过程中引发的问题。
在讲这个蛋疼的过程之前,有几个问题是要搞懂的。
为什么 Android 不能识别 .class 文件,而只能识别 dex 文件。
因为 dex 是对 class 的优化,它对 class 做了极大的压缩,比如以下是一个 class 文件的结构(摘自邓凡平老师博客)

class文件结构

dex 将整个 Android 工程中所有的 class 压缩到一个(或几个) dex 文件中,合并了每个 class 的常量、class 版本信息等,例如每个 class 中都有一个相同的字符串,在 dex 中就只存一份就够了。所以,在Android 上,dalvik 虚拟机是无法识别一个普通 class 文件的,因为无法识别这个 class 文件的结构。
以下是一个 dex 文件的结构

dex文件结构

感兴趣的可以阅读《深入理解Android》这本书。

继续往下,其实 dalvik 虚拟机也并不是直接读取 dex 文件的,而是在一个 APK 安装的时候,会首先做一次优化,会生成一个 ODEX 文件,即 Optimized dex。 为什么还要优化,依旧是为了效率。
只不过,Class -> dex 是为了平台无关的优化;
而 dex -> odex 则是针对不同平台,不同手机的硬件配置做针对性的优化。
就是在这一过程中,虚拟机在启动优化的时候,会有一个选项就是 verify 选项,当 verify 选项被打开的时候,就会执行一次校验,校验的目的是为了判断,这个类是否有引用其他 dex 中的类,如果没有,那么这个类会被打上一个 CLASS_ISPREVERIFIED 的标志。一旦被打上这个标志,就无法再从其他 dex 中替换这个类了。而这个选项开启,则是由虚拟机控制的。

字节码操作

那么既然知道了原因,解决的办法自然也有了。你不是没有引用其他 dex 中的类就会被标记吗,那咱们就引用一个其他 dex 中的类。

ClassReader:该类用来解析编译过的class字节码文件。
ClassWriter:该类用来重新构建编译后的类,比如说修改类名、属性以及方法,甚至可以生成新的类的字节码文件。
ClassAdapter:该类也实现了ClassVisitor接口,它将对它的方法调用委托给另一个ClassVisitor对象。

/**
 * 当对象初始化的时候注入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);
            //如果方法名是<init>,每个类的构造函数函数名叫<init>
            if ("<init>".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("Lcom/hujiang/hotfix/Inject;"));
                        }
                        //执行opcode对应的其他操作
                        super.visitInsn(opcode);
                    }
                }
            }
            //责任链完成,返回
            return mv;
        }
    };
    //accept这个方法接受一个实现了 ClassVisitor接口的对象实例作为参数,然后依次调用 ClassVisitor接口的各个方法
    //用户无法控制各个方法调用顺序,但是可以提供不同的 Visitor(访问者) 来对字节码树进行不同的修改
    //在这里,调用这一步的目的是为了让上面的visitMethod方法被调用
    cr.accept(cv, 0);
    return cw.toByteArray();
}

代码实现

可以参考 nuwa 中的实现,首先是 dex 怎样去插入到Classloader列表中,其实就是一段反射:

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(), "dexElements", allDexElements);
}

首先分别获取到宿主应用和补丁的 dex 中的PathList.dexElements, 并把两个 dexElements 数组做拼接,将补丁数组放在前面,最后将拼接后生成的数组再赋值回Classloader

nuwa 更主要的是他的 groovy 脚本,完整代码:这里,由于代码很多,就只跟大家讲两个关键的点的实现以及目的,具体的内容可以直接查看源码。

//获得所有输入文件,即preDex的所有jar文件
Set<File> inputFiles = preDexTask.inputs.files.files
inputFiles.each { inputFile ->
    def path = inputFile.absolutePath
    //如果不是support包或者引入的依赖库,则开始生成代码修改部分的hotfix包
    if (HotFixProcessors.shouldProcessPreDexJar(path)) {
        HotFixProcessors.processJar(classHashFile, inputFile, patchDir, classHashMap, includePackage, excludeClass)
    }
}

其中HotFixProcessors.processJar()是脚本的第一个作用,就是找出哪些类是发生了改变,应该生成对应的补丁。
循环遍历工程中的全部类,声明忽略的直接跳过.对每个类计算hash,并写入到hashFile文件中.通过比较hashFile文件与原先host工程的hashFile(即这里的classHashMap参数),得到所有修改过的类生成这些类的class文件,以及所有修改过的class文件的集合jar文件。

Set<File> 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))

                if (HotFixMapUtils.notSame(classHashMap, path, hash)) {
                    HotFixFileUtils.copyBytesToFile(inputFile.bytes, HotFixFileUtils.touchFile(patchDir, path))
                }
            }
        }
    }
}

这一段是脚本的第二个作用,也就是上文字节码操作的目的,为了防止类被虚拟机打上CLASS_ISPREVERIFIED,所以需要执行字节码写入。其中HotFixProcessors.processClass()就是实际写入字节码的代码。

好像差个结尾

同样的方案,除了 nuwa 还有一个开源的实现,HotFix 两者是差不多的,所以看一个就可以了。

看到有很多朋友问,如果混淆后代码怎么办。在 Gradle 插件编译过程中,有一个proguardTask,看名字应该就知道他是负责 proguard 任务的,我们可以保存首次执行时的混淆规则(也就是线上出BUG的包),这个混淆规则保存在工程目录中的一个mapping文件,当我们需要执行热修复补丁生成的时候,将线上包的mapping规则拿出来应用到本次编译中,就可以生成混淆后的类跟线上混淆后的类相同的类名的补丁了。具体实现可以看 nuwa 项目的applymapping()方法。

 

 

© 著作权归作者所有

共有 人打赏支持
kymjs张涛

kymjs张涛

粉丝 502
博文 63
码字总数 78319
作品 4
普陀
Android工程师
加载中

评论(1)

12叔
12叔
刚在前个项目中用过 13
InstantRun从2.0到3.0,历史解毒

个人博客地址 http://dandanlove.com/ InstantRun从2.0到3.0,历史解毒 前言 已经出来3年了,为什么现在会想写这篇文章。从 发布就已经有文章做了详细的介绍,但主要分为两类:一类是讲其主要...

静默加载 ⋅ 05/18 ⋅ 0

Android高级之十三讲-HotFix、热加载和热更新

本文来自http://blog.csdn.net/liuxian13183/ ,引用必须注明出处! 组件化与插件化:前者对功能进行拆分后,独立开发,打成一个包发布;后者对功能拆分,使用主包+分包,可以分别独立发布。...

liuzxgeek ⋅ 2016/12/14 ⋅ 0

笔记 深入探索Android热修复技术原理

阿里电子书《深入探索Android热修复技术原理》整理的笔记 1.热修复技术介绍 代码修复两大主要方案 代码修复底层替换方案 代码修复类加载方案 资源修复 SO库修复:本质上是对native方法的修复和...

幻海流心 ⋅ 05/23 ⋅ 0

Android 【插件化】"偷梁换柱"的高手-VirtualApk源码解析

关于VirtualApk VirtualApk github : https://github.com/didi/VirtualAPK VirtualAPK wiki : https://github.com/didi/VirtualAPK/wiki 工程介绍 工程结构 CoreLibrary是VirtualApk(以下简称......

qq_17250009 ⋅ 04/12 ⋅ 0

HomeCenter 7.85,J2SE/JS+CSS 编写家庭云应用

HomeCenter 7.85 已发布,HomeCenter 是 J2SE/JavaScript+CSS编写跨平台家庭云应用,实时生成Android/iPhone所需的HTML5+JavaScript+CSS。 新功能/修复: 增加桌面版服务器一键热布署工程到同...

Java6 ⋅ 05/07 ⋅ 0

AlertDialog源码解析

前言 最近在研究设计模式中的建造者模式,而AlertDialog源码正是采用这种模式进行设计的,故将整个AlertDialog源码都给分析了一遍。 总体框架 在MainActivity中实例化一个AlertDialog对象: ...

zhang_pan ⋅ 04/11 ⋅ 0

react-native技术的优劣

前言 从2017年初开始到现在,使用React-Native做项目已经一年了。我们做的是一款IM软件,嵌入在一个手机游戏平台的工程内部。之所以要采用react-native(后文简称RN)框架重构它,是因为现在...

codeGoogle ⋅ 04/23 ⋅ 0

Android热修复Tinker接入实战

自2016年底Android Studio3.0版本退出以来,Android提出了InstantRun热修复方案,基于这种机制,各种热修复框架竞相涌现,国内的软件大厂纷纷开发了自己的热修复框架。对于热修复的更多介绍大...

code_xzh ⋅ 05/04 ⋅ 0

virjar/xposedhooktool

hook base工具 Android 破解的hook工具,集成一些帮助破解的常用功能,如自动网络抓包、网络堆栈爆破、文件日志、webview调试环境 入口在 com.virjar.xposedhooktool.hotload.XposedInit,但是...

virjar ⋅ 04/22 ⋅ 0

React Native在Android当中实践(五)——常见问题

React Native在Android当中实践(一)——背景介绍 React Native在Android当中实践(二)——搭建开发环境 React Native在Android当中实践(三)——集成到Android项目当中 React Native在A...

YangZC ⋅ 05/28 ⋅ 0

没有更多内容

加载失败,请刷新页面

加载更多

下一页

PHP语言系统ZBLOG或许无法重现月光博客的闪耀历史[图]

最近在写博客,希望通过自己努力打造一个优秀的教育类主题博客,名动江湖,但是问题来了,现在写博客还有前途吗?面对强大的自媒体站点围剿,还有信心和可能型吗? 至于程序部分,我选择了P...

原创小博客 ⋅ 18分钟前 ⋅ 0

IntelliJ IDEA 2018.1新特性

工欲善其事必先利其器,如果有一款IDE可以让你更高效地专注于开发以及源码阅读,为什么不试一试? 本文转载自:netty技术内幕 3月27日,jetbrains正式发布期待已久的IntelliJ IDEA 2018.1,再...

Romane ⋅ 44分钟前 ⋅ 0

浅谈设计模式之工厂模式

工厂模式(Factory Pattern)是 Java 中最常用的设计模式之一。这种类型的设计模式属于创建型模式,它提供了一种创建对象的最佳方式。 在工厂模式中,我们在创建对象时不会对客户端暴露创建逻...

佛系程序猿灬 ⋅ 今天 ⋅ 0

Dockerfile基础命令总结

FROM 指定使用的基础base image FROM scratch # 制作base image ,不使用任何基础imageFROM centos # 使用base imageFROM ubuntu:14.04 尽量使用官方的base image,为了安全 LABEL 描述作...

ExtreU ⋅ 昨天 ⋅ 0

存储,对比私有云和公有云的不同

导读 说起公共存储,很难不与后网络公司时代的选择性外包联系起来,但尽管如此,它还是具备着简单和固有的可用性。公共存储的名字听起来也缺乏专有性,很像是把东西直接堆放在那里而不会得到...

问题终结者 ⋅ 昨天 ⋅ 0

C++难点解析之const修饰符

C++难点解析之const修饰符 c++ 相比于其他编程语言,可能是最为难掌握,概念最为复杂的。结合自己平时的C++使用经验,这里将会列举出一些常见的难点并给出相应的解释。 const修饰符 const在c...

jackie8tao ⋅ 昨天 ⋅ 0

聊聊spring cloud netflix的HystrixCommands

序 本文主要研究一下spring cloud netflix的HystrixCommands。 maven <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-clo......

go4it ⋅ 昨天 ⋅ 0

Confluence 6 从其他备份中恢复数据

一般来说,Confluence 数据库可以从 Administration Console 或者 Confluence Setup Wizard 中进行恢复。 如果你在恢复压缩的 XML 备份的时候遇到了问题,你还是可以对整个站点进行恢复的,如...

honeymose ⋅ 昨天 ⋅ 0

myeclipse10 快速搭建spring boot开发环境(入门)

1.创建一个maven的web项目 注意上面标红的部分记得选上 2.创建的maven目录结构,有缺失的目录可以自己建立目录补充 补充后 这时候一个maven的web项目创建完成 3.配置pom.xml配置文件 <proje...

小海bug ⋅ 昨天 ⋅ 0

nginx.conf

=========================================================================== nginx.conf =========================================================================== user nobody; #......

A__17 ⋅ 昨天 ⋅ 0

没有更多内容

加载失败,请刷新页面

加载更多

下一页

返回顶部
顶部