热修复之方案总结
扩展:
了解JVM中的类加载机制及双亲委托模式;
Android的ClassLoader与Java中的不同之处:因着前者加载的是dex文件,并非是class字节码文件,再去学习dex相关概念知识,
三. Android类加载机制源码探究
注意:此大点只是重点研究Android类加载机制源码,涉及到的热修复的原理后篇文章讲解!
1. JVM类加载之双亲委派模式
(详细分析请阅读笔者的另一篇文章:JVM高级特性与实践(九):类加载器 与 双亲委派模式(自定义类加载器源码探究ClassLoader))
(1)介绍
“双亲委派模式”是JVM中的一个重要知识点,它是类加载器的重要特征,类加载器分类如下:
启动类加载器:负责将指定类库加载到虚拟机内存中。无法被 Java 程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给引导类加载器,直接使用null代替即可。
拓展类加载器:负责将指定类库加载到内存中。开发者可以直接使用标准扩展类加载器
自定义类加载器:负责用户类路径(ClassPath)上所指定的类库。开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。
上图展示的类加载器之间的层次关系,称为类加载器的双亲委派模型(Parents Delegation Model)。
该模型要求除了顶层的启动类加载器外,其余的类加载器都应该有自己的父类加载器,而这种父子关系一般通过组合(Composition)关系来实现,而不是通过继承(Inheritance)。
(2)工作过程
如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载,而是把这个请求委派给父类加载器,每一个层次的加载器都是如此,依次递归,
因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成此加载请求(它搜索范围中没有找到所需类)时,子加载器才会尝试自己加载。
(3)模式优点
双亲委派模式 特点及作用:
类加载的共享功能: 一些framework层的类被顶层classLoader加载过后会缓存在内存中,避免重复加载。
类加载的隔离功能:不同继承实现的classLoader加载的类肯定不会是同一个类,一些系统层级类java.lang.String 会在初始化时被加载,可避免用户写代码访问核心类库可见的成员变量。 例如java.lang.String就是在系统启动之前就已经加载好,用户可自定义一个String类提前加载与之替换,这会带来严重的安全问题。
上述就引发出一个问题:如何的两个类才算是相同的类呢?两个类的包名、类名相同即可?并非如此!还需加上一个条件:同一个ClassLoader加载,以上三个条件成立,这两个类才能被称为相同类。
例如类java.lang.Object,它存在在rt.jar中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此Object类在程序的各种类加载器环境中都是同一个类。
相反,如果没有双亲委派模型而是由各个类加载器自行加载的话,用户编写了一个java.lang.Object的类,并放在程序的ClassPath中,那系统中将会出现多个不同的Object类,程序将混乱。因此,如果开发者尝试编写一个与rt.jar类库中已有类重名的Java类,将会发现可以正常编译,但是永远无法被加载运行。
2. Android类加载介绍
Android中的ClassLoader类加载机制主要用来加载dex文件,系统提供了PathClassLoader、DexClassLoader两个API可供选择。ClassLoader种类如下
BootClassLoader
BaseDexClassLoader:父类
PathClassLoader:只能加载已安装到Android系统的APK文件;
DexClassLoader:支持加载外部的APK、Jar或dex文件;(所有的插件化方案都是使用它来加载插件APK中的.class文件,也是动态加载的核心依据!)
如上, 发现Android的ClassLoade和Java的大体上是一一对应的,只不过内部实现有些变化。
思考一个问题,一个App正常运行最少需要哪些ClassLoade?
答案揭晓:最少需要BootClassLoader和PathClassLoader。首先BootClassLoader是无可或缺的,因为它需要加载framework层的一些class文件,而PathClassLoader用来加载已安装到系统上的文件。因此一个应用运行至少需要以上两个ClassLoade,下面通过一个简单demo来证实以上猜想。
public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
ClassLoader classLoader = getClassLoader();
if(classLoader != null){
Log.e("lemonnnnnn", "classLoader: " + classLoader.toString());
while (classLoader.getParent() != null){
classLoader = classLoader.getParent();
Log.e("lemonnnnnn", "classLoader: " + classLoader.toString());
}
}
}
}
上述测试代码逻辑也很简单,获取并输出加载当前应用的类加载器,然后 再判断其父加载器并输出(双亲委派模式)。查看控制台显示可知输出了PathClassLoader、BootClassLoader,因此证实了以上猜想。
3. Android类加载源码过程解析
此处的ClassLoader是java.lang包下的,因此与那篇讲解Java类加载机制中讲解的逻辑大同小异,最多只是版本上的差别,最大的区别则在于继承此类并实现的一些类,也就是Android的dalvik.system包下的BaseDexClassLoader、PathClassLoader、DexClassLoader,见下图:
如上图,在AS编辑器中点进详情无法阅读dalvik.system包下类源码,接下来在网页中提供源码作以分析。
4 重点总结
以上就是对Android的ClassLoader加载机制源码部分的剖析,其实整个过程并不复杂,只是有些逻辑上的嵌套,
涉及到ClassLoader、DexClassLoader 、PathClassLoader 、BaseDexClassLoader 、DexPathList, DexFile多个类之间方法互相调用,真正有难度的是最后native方法中的C层处理(此处不深究,有兴趣可自行研究C层)。
(笔者强烈建议认真阅读下面时序图,也许上述一系列的源码分析让你有些云里雾里,但笔者在画完时序图后,骤然理解,颇有“拨开云雾见天日 守得云开见月明”之感!画图实在有助于理解)
结合以上Android类加载时序图,再次回顾一下ClassLoader源码的解读研究过程:
首先类的加载是在ClassLoader类的loadClass 方法中进行,此方法会判断此类是否被自己或双亲加载过(这也是著名的“双亲委派模式”);
若加载过则无需重复load,直接返回类实例;
否则调用findClass方法寻找获取这个类,可是findClass方法在ClassLoader类中是一个空实现,真正实现是在BaseDexClassLoader类中;
而BaseDexClassLoader类也未具体实现,调用的实则是DexPathList类中的findClass方法;
DexPathList类中 findClass方法最终又调用DexFile中的defineClassNative ,DexFile的一个native方法来完成主要类加载逻辑。
以上是类加载过程涉及到的几个类中方法互相调用最终实现“类加载”的过程,
以下是重点方法中实现的逻辑总结:
首先在DexPathList类的构造方法中:将所有的dex文件(File类型)转换成DexFile类型,并且将其转化为Element数组,便于findClass方法逻辑处理,
然后在findClass 方法中遍历Element数组(Element类型中存储着DexFile类型),获取Element中的DexFile,
调用DexFile的内部方法loadClassBinaryName,在dex文件中查找获取拼接成class字节码文件返回(loadClassBinaryName是一个 native方法)。
而这整个过程,一系列方法、类之间调用的核心逻辑是:通过指定加载dex路径中,遍历文件找到dex文件,然后在存储了整个工程class文件数据中的dex文件中,查找搜索并拼接 class字节码文件返回。
(1)DexClassLoader源码分析
package dalvik.system;
import java.io.File;
public class DexClassLoader extends BaseDexClassLoader {
public DexClassLoader(String dexPath, String optimizedDirectory,
String librarySearchPath, ClassLoader parent) {
super(dexPath, new File(optimizedDirectory), librarySearchPath, parent);
}
}
以上源码可以看到DexClassLoader类中只有一个构造方法,4个参数含义分别是:
dexPath:指定要加载dex文件的路径;
optimizedDirectory:指定dex文件需要被写入的目录,一般是应用程序内部路径(不可以为null);
librarySearchPath:包含native库的目录列表(可能为null);
parent:父类加载器;
DexClassLoader类注释: 用来加载包含dex的jar包或apk中的类,也可以执行于尚未安装到应用中的代码,因此它才是动态加载的核心!
(2)PathClassLoader源码分析
package dalvik.system;
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,PathClassLoader只是一个简单的类加载器实现,运行于本地文件系统中的文件和目录,但不尝试从网络加载类。
Android用此类加载器PathClassLoader来加载一些系统级别类和已存在于应用中的类。
查看源码可知PathClassLoader有两个构造方法,其参数相较于DexClassLoader,少了一个指定dex文件需要被写入的内部目录optimizedDirectory,因此PathClassLoader只能加载已安装到应用的dex文件。
(3)BaseDexClassLoader源码分析
以上DexClassLoader、PathClassLoader两个类源码没有具体实现,最大的区别在于后者只能加载已安装于应用的dex文件,而详情部分还是要参数它们的父类——BaseDexClassLoader
上图是 BaseDexClassLoader类重点源码部分,类中只有一个成员变量DexPathList,继续查看其构造方法,其中创建了DexPathList对象,传入了四个参数,分别是:
DexClassLoader:父类加载器本身;
dexPath: 需要加载的dex文件路径;
librarySearchPath: 包含native库的目录列表(可能为null);
optimizedDirectory: dex文件需要被写入的内部目录(可能为null);
BaseDexClassLoader 构造方法中的这些参数是其子类传过来的,只是对于在其构造方法中只做了一件事——创建DexPathList对象,有些不解。继续查看重点方法findClass(String name),重点部分笔者用红框圈出来了,通过成员变量dexList的findClass 加载获取的类返回,若类为null则报错,此处意味着真正执行加载类的重点部分并非是BaseDexClassLoader,它也只是一个中介,真相在于DexPathList类,继续延伸查看此类。
(4)DexPathList源码分析——背后的Boss
首先查看它的一些重要成员变量:
DEX_SUFFIX:字符串类型,值是”.dex”;
definingContext: ClassLoader类型,加载器,也就是BaseDexClassLoader 构造方法中创建DexPathList时传入的加载器;
dexElements: Element[]类型,Element是一个内部类。此类作用就是指定dex/resource/native 库路径,其内部重要成员DexFile的dexFile,这是dex文件在Dalvik安卓虚拟机中的具体实现,稍后讲解;后续成员变量类型类似,只是代表不同数据,不再赘述……
接下来查看其构造方法:
查看其构造方法,就是用来接收参数并对成员变量赋值。由此可知参数definingContext(即ClassLoader)、dexPath一定不可为null,否则直接报异常,optimizedDirectory被写入内部的目录可能为null(即使用默认系统目录),
然而重点在于笔者圈起来的第二个红框, 调用内部makeElements方法, 获取Element数组 赋值给 成员变量dexElements。
深入查看,如何通过上述几个参数获得Element数组,此方法有几个重载,最终调用的方法如下:
private static Element[] makeElements(List<File> files, File optimizedDirectory, List<IOException> suppressedExceptions,
boolean ignoreDexFiles, ClassLoader loader) {
Element[] elements = new Element[files.size()];
int elementsPos = 0;
//循环遍历所有File并加载dex
for (File file : files) {
File zip = null;
File dir = new File("");
DexFile dex = null;
String path = file.getPath();
String name = file.getName();
if (path.contains(zipSeparator)) {
String split[] = path.split(zipSeparator, 2);
zip = new File(split[0]);
dir = new File(split[1]);
} else if (file.isDirectory()) {
//若果该file是文件夹格式,则继续递归
elements[elementsPos++] = new Element(file, true, null, null);
} else if (file.isFile()) {
if (!ignoreDexFiles && name.endsWith(DEX_SUFFIX)) {
//若该file是文件且是以dex后缀结尾,说明正是需要加载的文件,调用loadDexFile去创建一个dex(DexFile类型)
try {
dex = loadDexFile(file, optimizedDirectory, loader, elements);
} catch (IOException suppressed) {
System.logE("Unable to load dex file: " + file, suppressed);
suppressedExceptions.add(suppressed);
}
} else {
zip = file;
// 若该file是压缩文件,调用loadDexFile去创建一个dex(DexFile类型)
if (!ignoreDexFiles) {
try {
dex = loadDexFile(file, optimizedDirectory, loader, elements);
} catch (IOException suppressed) {
suppressedExceptions.add(suppressed);
}
}
}
} else {
System.logW("ClassLoader referenced unknown path: " + file);
}
if ((zip != null) || (dex != null)) {
elements[elementsPos++] = new Element(dir, false, zip, dex);
}
}
if (elementsPos != elements.length) {
elements = Arrays.copyOf(elements, elementsPos);
}
return elements;
}
如上DexPathList类的重点方法makeElements源码,方法中的参数经过上述源码讲解后也能够知名见意了,
只有一个需要特别说明:files其实是对dexPath的一个转化,获得了该路径内的所有文件。笔者已在源码中加以注释,
主要逻辑就是 循环遍历files(由dexPath转化的),文件中可能包含文件夹或压缩文件,分别判断,找到后缀为dex文件,调用loadDexFile加载生成DexFile文件(⭐️注释处),最后将生成的dex文件和路径等信息传入Element构造方法来创建对象,返回Element数组。
此方法makeElements逻辑并不复杂,需要格外注意一下内部临时变量dex,它是DexFile类型,代表着dex文件。
在makeElements方法中判断file是文件格式或者zip压缩格式时,都会调用此方法来创建DexFile对象,具体有何不同呢?进一步查看loadDexFile方法源码,查看其内部细节:
/*
*实例化DexFile类:查看loadDexFile源码,其主要作用就是创建DexFile对象返回,
*/
private static DexFile loadDexFile(File file, File optimizedDirectory, ClassLoader loader,
Element[] elements)
throws IOException {
if (optimizedDirectory == null) {
return new DexFile(file, loader, elements);
} else {
String optimizedPath = optimizedPathFor(file, optimizedDirectory);
return DexFile.loadDex(file.getPath(), optimizedPath, 0, loader, elements);
}
}
首先判断写入目录optimizedDirectory是否为null,如果为null表明file确实是一个dex文件,直接创建DexFile,否则会先将其解压获取真正DexFile文件。
DexPathList类的makeElements方法核心作用就是:
将指定加载路径dexPath的所有文件遍历获取dex文件,并转换成DexFile类型存储到Element数组中。
(Element数组的作用是为了后续DexPathList类的findClass方法铺垫)
下面就来解析最万众瞩目的重点,DexPathList类的findClass方法:
作用就是遍历之前makeElements 方法中存储好的Element数组,将Element类型转换为DexFile类型,调用DexFile的内部方法loadClassBinaryName在dex文件中查找获取拼接成class字节码文件返回。
public Class findClass(String name, List<Throwable> suppressed) {
for (Element element : dexElements) {
DexFile dex = element.dexFile;
if (dex != null) {
//通过dex来加载类返回字节码⭐️
Class clazz = dex.loadClassBinaryName(name, definingContext, suppressed);
if (clazz != null) {
return clazz;
}
}
}
if (dexElementsSuppressedExceptions != null) {
suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions));
}
return null;
}
小结
Android中类加载器的PathClassLoader和DexClassloader所调用的findClass方法其实并非是自己实现的,它们内部只实现了本身的构造方法,因此调用的是其父类BaseDexClassLoader中实现的方法,可是最后追述到的真正实现者是DexPathList类!由它来具体实现了findClass方法,
而此方法内部具体是调用了 DexFile的核心内部方法loadClassBinaryName实现重要功能:在dex文件中查找获取拼接成class字节码文件返回。
DexPathList源码
(5)DexFile源码分析——Boss的心腹
下面具体查看DexFile的核心内部方法 loadClassBinaryName实现:
public Class loadClassBinaryName(String name, ClassLoader loader, List<Throwable> suppressed) {
return defineClass(name, loader, mCookie, this, suppressed);
}
private static Class defineClass(String name, ClassLoader loader, Object cookie,
DexFile dexFile, List<Throwable> suppressed) {
Class result = null;
try {
result = defineClassNative(name, loader, cookie, dexFile);
} catch (NoClassDefFoundError e) {
if (suppressed != null) {
suppressed.add(e);
}
} catch (ClassNotFoundException e) {
if (suppressed != null) {
suppressed.add(e);
}
}
return result;
}
以上源码可以发现代码的一个设计准则:loadClassBinaryName方法类型是public的,可供外部调用,但其内部只有调用defineClass方法这一行代码,
而defineClass方法类型是private的,仅供内部调用,因此它只是借助loadClassBinaryName 方法做了一层封装,保持了私有性!
继续查看defineClass方法源码,逻辑也十分简单,除了异常捕获之外,核心代码只有一行:defineClassNative(name, loader, cookie, dexFile),通过它完成类的查找,查看详情:
最后的结果很明显,这是一个native方法,我们无法再向下剖析。
若是对dex文件格式颇有了解或者阅读过笔者写过的分析dex格式文章,可知一个dex文件中存储了整个工程中所有的class文件,其文件数据存储在dex文件中的“数据区”。因此, 也可以推理出defineClassNativenative方法是通过C/C++ 在dex文件中查找获取拼接成class字节码文件返回。
DexFile源码地址
5. 动态加载难点:
以上就是ClassLoader中的一个loadClass和findClass的过程,了解之后,接下来介绍Android动态加载的难点:
在了解以上源码解析后,发现Android的动态加载,不过是使用DexClassLoader指定需要加载的APK路径,思路很简单呀?
其实在实际使用中并不尽然,由于Android系统的特点和第三方原因带来了以下限制:
许多组件类需要注册才能使用。例如Android系统中的Activity、Service等需要在Manifest中注册才可以工作,这意味着即使在工程中加载了一个新的组件,若没有注册也将无法工作。
资源的动态加载复杂度高。 Android开发的一大特点就是使用资源,将资源通过ID注册好再来使用,因此资源的注册这一步不可或缺,之后才可以通过ID向Resource实例中获取对应资源。这意味着动态加载时运行的新类中, 若涉及到资源的加载,由于新类资源没有注册的原因,程序会抛出异常。
Android各版本的差异可能存在加载资源、注册的方式不同的隐患问题,对适配造成影响。
以上总结的问题存在一个共性: 即Android程序运行需要一个上下文环境,它可以为Android中的组件提供使用的功能,例如主题、资源、组件查询等等。
因此现在面临的问题,就是如何为动态加载到程序中的类或者 资源提供这样一个Android上下文环境呢?这也是许多第三发动态加载库Tinker、AndFix核心解决问题关键,学习它们的实现原理着实必要,后续涉及。
建议阅读完此篇文章后,阅读笔者上一篇特地为了热修复系列去学习的Android dex格式解析:Android Dex VS Class:实例图解剖析两种格式文件结构、优劣,这样会对Android类加载机制的了解更加深入。
此系列对笔者而言又是一个“大头”,刚开始实在理解无能,研究原理、探索源码是一个痛苦而又艰难的过程,通过相关书籍、视频、博客、官方源码等渠道慢慢悟道。想要把一个知识点分析透彻实属不易,个中牵涉的部分太多,只能尽自己目前的知识存储量去理解并研究,共勉。
(例如此篇的Android类加载机制,首先毫无疑问你需要 了解JVM中的类加载机制 及双亲委托模式,之后你会发现Android的ClassLoader与Java中的不同之处,因着前者加载的是dex文件,并非是class字节码文件,再去学习dex相关概念知识,再从源码角度慢慢深入探索总结其原理 )
下一篇博文的内容是解析如何使用类加载实现热修复,以及用类加载方案自行实现demo完成热修复技术。
二. 典型的热修复方案?
目前热修复有两大主流方案,优劣如下:
底层替换方案(阿里系):从底层C的二进制来解决问题,这样做限制颇多,但时效性最好,加载轻快,立即见效;
类加载方案(腾讯系): 从Java加载机制来解决问题,这样做时效性差,需要重新冷启动才能见效,但修复范围广,限制少;
(1)底层替换方案
底层替换方案原理是直接在已加载类中替换掉原有方法,即在原来类基础上进行修改,因此无法实现增减原有类方法或字段,这样会破坏原有类的结构。
不仅如此,一旦补丁类中的方法数量有增减,会直接导致此类以及整个Dex的方法熟变化,从而访问方法时无法正常索引到正确方法。若字段发生了增减,和方法数变化情况相同,而且所有字段索引都会变化。更严重的后果是,若程序运行中间某个类突然增加字段,那么对于原先已经产生的类实例,它还是原来的结构,而新方法使用到这些“过期”实例对象时,访问新增字段就会产生不可预期的结构!
以上是底层替换方案的固有限制,既然决定从底层出发,那么必定就要承担它本身带来的问题。
不仅如此,它的稳定性不好,传统的底层替换方式如Dexposed、Andfix及其他安全界的Hook方案都是直接依赖修改虚拟机方法实体的具体字段,例如修改Dalvik方法的jni函数指针、修改类或方法的访问权限等。这里埋藏着一个隐患,由于Android是开源的,各个手机厂商都可以对代码进行改造,而Andfix里ArtMethod结构题进行了修改,就和原先开源代码结构不同,导致在修改过了的设备上,通用性的替换机制会出问题,这就是不稳定的根源。
而hotfix技术框架针对以上的问题做了完善,它实现的是一种无视底层具体结构的替换方式,不仅解决了兼容性问题,并且忽略了底层ArtMethod结构的差异,从而对所有Android版本都兼容,大量减少代码量。即使以后都Android版本不断修改ArtMethod的成员,只要保证ArtMethod数组仍是线性结构排列,就可适应以后Android新版本,也无须针对新的系统版本进行适配。
(2)类加载方案
类加载方案的原理是在app重新启动后让Classloader加载新的类。因为当app运行到一半时,所需发生变更的类已经被加载过,
而在Android上无法对一个类进行卸载操作,若不重启,原来的类还存储于虚拟机中,新类无法被加载。因此只有在下次重启时,在业务逻辑运行之前抢先加载补丁中的新类,这样后续访问此类时,才会Resolve为新类,从而达到热修复的目的。
说到腾讯系三大类加载方案的实现原理,QQ空间方案会侵入打包流程,可能为了hack而添加一些无用信息;
而QFix的方案需要获取底层虚拟机的函数,不够稳定,最大的问题时无法增加public函数。微信的Tinker方案是完整的全量dex加载,将补丁做到了极致,其合成方案是从dex方法和指令维度进行全量合成,整个过程是自己研发的。
在上一部分技术比较中也体现出了微信的Tinker方案的综合优势,但是结合上一段所说它采用的dex全量合成,可以很大地节省空间,但由于对dex内容的比较粒度过细,实现较复杂,对于性能会有所损耗。实际上dex占据APK的比例是很小的,资源文件才是占据APK的主要部分,因此Tinker用空间换取性能的转换并非理想。
此种方案虽然尤其限制,但也有提升空间:dex比较多最佳粒度,在于类的维度,它既不像方法和指令维度那样细微,也不像bsbiff那样粗糙,因此在类的维度上是可以达到时间和空间平衡的最佳效果。
(3)两者结合方案
上述分析可见底层替换方案和类加载方案都有各自的优缺点,阿里的Sophix技术结合了两张方案,可灵活地根据实际情况切换。
在补丁生成阶段,补丁工具会根据实际代码变动情况进行自动选择,针对一些在底层替换方案限制范围内的小修改,就直接采用底层替换方案,便于修复即时生效;而对于代码修复超出底层替换限制的,采用类加载方案,虽然及时性不太好,但可达到热修复的目的。
不仅如此,Sophix在运行时阶段,还会判断所运行机型是否支持热修复,防止部分机型底层虚拟机构造不支持情况,可以执行类加载方案,从而达到最好的兼容性。
4. 资源修复和so库修复
Google官方Instant Run方案资源修复原理:
说起Android热修复浪潮的主因,不得不提Instant Run的实现,市面上大多数资源热修复方案基本参考了Instant Run的实现。简要而言,Instant Run中的资源热修复分为两步:
首先构造一个新的AssetManager,并通过反射调用addAssetPath 方法,把这个完整的新资源包加入到AssetManager中,这样就获得了一个含有所有新资源的AssetManager。找到所有之前引用到原有AssetManager的地方,通过反射将引用处替换成AssetManager。
阿里实现资源修复原理:
阿里对于“资源修复”这一块没有直接采用Instant Run技术,而是构造一个package id为0x66的资源包,该包只包含修改了的资源项,然后直接在原有AssetManager中调用addAssetPath 方法添加此包即可。由于补丁包的package id 为0x66,不与目前已经加载的0x7f冲突,因此直接加入到已有的AssetManager中就可以使用了。补丁包中的资源只包含原有包里没有的新增资源,以及原有内容发生改变的资源,并且采用的替换方式是直接在原有的AssetManager对象上进行析构和重构,这样所有原先对AssetManager对象的引用是没有改变的,因此无需像Instant Run那样繁琐修改引用了。
两者比较:
总之阿里的资源修复方案相较于Google官方研制的Instant Run方案,优势如下:
不需要修改AssetManager的引用处,替换更快更完全。(对比Instant Run以及所有copycat的实现)
不必下发完整包,补丁包中只包含有变动的资源。(对比Instant Run以及所有Amigo等方式的实现)
不需要在运行时合成完整包,不占用运行时计算和内存资源。(对比Tinker的实现)
so库修复:
so库的修复本质上是对native方法对修复和替换。
阿里采用的是类似类修复反射注入方式,即把补丁so库的路径插入到nativeLibraryDirectories数组的最前面,就能够达到加载so库时的是补丁库,并非原来so库的目录,从而达到修复的目的。Sophix是在启动期间反射注入patch中的so库,对开发者依然透明,而其他方案则需要手动替换系统的System.load来实现替换目的。
(以上阿里叙述部分及相关图片来自于《深入探索Android热修复技术原理》一书,书中可能在评价其他产品稍稍带有主观意识,但在技术原理比较部分很客观了,特别是在对比官方、其他第三方库实现功能原理,阿里在书中给出了自己的实现思路,从不同的角度剖析问题、讲解透彻,给了笔者醍醐灌顶的感觉。)
ps. 尽量在上述内容去掉了“优雅”二字,书中特别喜欢形容自我“优雅” :)
一.热修复之类加载机制总结
一. Android类加载介绍?
Android中的ClassLoader类加载机制主要用来加载dex文件,系统提供了PathClassLoader、DexClassLoader两个API可供选择。
ClassLoader种类如下:
BootClassLoader,BaseDexClassLoader:父类
PathClassLoader:只能加载已安装到Android系统的APK文件;
DexClassLoader:支持加载外部的APK、Jar或dex文件 (所有的插件化方案都是使用它来加载插件APK中的.class文件,也是动态加载的核心依据!)
发现Android的ClassLoade和Java的大体上是一一对应的,只不过内部实现有些变化。
思考一个问题,一个App正常运行最少需要哪些ClassLoader ?
答案揭晓:最少需要BootClassLoader和PathClassLoader。
首先BootClassLoader是无可或缺的,因为它需要加载framework层的一些class文件,而PathClassLoader用来加载已安装到系统上的文件。
1.基于ClassLoad的热修复实现
原理: 主要是基于classLoader的热修复。
在android中有两个常用ClassLoader,两个有共同的父类BaseDexClassLoader(父类是ClassLoader),
PathClassLoader加载已安装apk中class,DexClassLoader加载未安装apk或者aar中class。
其PathDexLoader用来加载系统类和应用类;
DexClassLoader用来加载一些jar、apk、dex文件,其实jar和apk文件实际上加载的都是dex文件。
在BaseDexClassLoader->DexPathList类-> Element[] dexElements( 存储着apk或者aar中所有dex的集合)。
class加载类是从头遍历这个集合找到class就返回不会再往下找,这样我们就可以把修改好的dex查在数组的前边,让类加载器选择我们修改好的class(不知道算不算是一个bug)。
热修复的原理:
知道Java虚拟机 —— JVM是加载类的class文件的,而Android虚拟机——Dalvik/ART VM是加载类的dex文件,
而他们加载类的时候都需要ClassLoader,ClassLoader有一个子类BaseDexClassLoader,而BaseDexClassLoader下有一个
数组——DexPathList,是用来存放dex文件,当BaseDexClassLoader通过调用findClass方法时,实际上就是遍历数组,
找到相应的dex文件,找到,则直接将它return。而热修复的解决方法就是将新的dex添加到该集合中,并且是在旧的dex的前面,
所以就会优先被取出来并且return返回。
热修复原理:
ClassLoader会遍历一个由dex文件组成的数组,然后加载其中的dex文件,
我们会把正确的dex(修复过的类所在的dex)文件插入数组的前面, 当加载器加载到好的类文件时候就不会加载有bug的类了,就实现了热修复
修复的步骤:
可以看出是通过获取到当前应用的Classloader,即为BaseDexClassloader
通过反射,获取到他的DexPathList属性对象pathList
通过反射,调用pathList的dexElements方法把patch.dex转化为Element[]
两个Element[]进行合并,把patch.dex放到最前面去
加载Element[],达到修复目的。
2. 几个概念介绍:
BaseDexClassLoader源码分析:
以上DexClassLoader、PathClassLoader两个类源码没有具体实现,最大的区别在于后者只能加载已安装于应用的dex文件,而详情部分还是要参数它们的父类——BaseDexClassLoader。
BaseDexClassLoader类重点源码部分,类中只有一个成员变量DexPathList,继续查看其构造方法,其中创建了DexPathList对象,传入了四个参数,分别是:
DexClassLoader:父类加载器本身;
dexPath: 需要加载的dex文件路径;
librarySearchPath: 包含native库的目录列表(可能为null);
optimizedDirectory: dex文件需要被写入的内部目录(可能为null);
BaseDexClassLoader 构造方法中的这些参数是其子类传过来的,
只是对于在其构造方法中只做了一件事——创建DexPathList对象,有些不解。
继续查看重点方法findClass(String name),重点部分笔者用红框圈出来了,通过成员变量dexList的findClass 加载获取的类返回,若类为null则报错,此处意味着真正执行加载类的重点部分并非是BaseDexClassLoader,它也只是一个中介,真相在于DexPathList类,继续延伸查看此类。
DexPathList类介绍:
成员变量dexElements: Element[]类型,Element是一个内部类。此类作用就是指定dex/resource/native 库路径,其内部重要成员DexFile的dexFile,这是dex文件在Dalvik安卓虚拟机中的具体实现。后续成员变量类型类似 不再赘述……
DexPathList类的makeElements()核心作用就是:
将指定加载路径dexPath的所有文件遍历获取dex文件,并转换成DexFile类型存储到Element数组中。(Element数组的作用是为了后续DexPathList类的findClass方法铺垫)。
DexPathList的findClass():查找dex,
作用就是遍历之前makeElements方法中存储好的Element数组,将Element类型转换为DexFile类型,调用DexFile的内部方法loadClassBinaryName(),在dex文件中查找获取拼接成class字节码文件返回。
Element: 内部封装了DexFile,DexFile用于加载dex文件,因此每个dex文件对应一个Element。
DexFile类: 主要作用就是创建DexFile对象返回,
调用DexFile的内部方法loadClassBinaryName()--> defineClassNative(name, loader, cookie, dexFile),
因此, 也可以推理出defineClassNativenative方法是通过C/C++ 在dex文件中查找获取拼接成class字节码文件返回。
dex: dex文件格式,一个dex文件中存储了整个工程中所有的class文件,其文件数据存储在dex文件中的“数据区”。
===================
--通过反射操作得到PathClassLoader的DexPatchList, 反射调用patchlist的makeDexElements()方法,
把本地的dex文件直接替换到Element[]数组中去,达到修复的目的。
多个Element组成了有序的Element数组dexElements。当要查找类时,会在注释1处遍历Element数组dexElements(相当于遍历dex文件数组),注释2处调用Element的findClass方法,其方法内部会调用DexFile的loadClassBinaryName方法查找类。如果在Element中(dex文件)找到了该类就返回,如果没有找到就接着在下一个Element中进行查找。
根据上面的查找流程,我们将有bug的类Key.class进行修改,再将Key.class打包成包含dex的补丁包Patch.jar,放在Element数组dexElements的第一个元素,这样会首先找到Patch.dex中的Key.class去替换之前存在bug的Key.class,排在数组后面的dex文件中的存在bug的Key.class根据ClassLoader的双亲委托模式就不会被加载,这就是类加载方案。
2.结合以上Android类加载时序图,再次回顾一下ClassLoader源码的解读研究过程?
首先类的加载是在ClassLoader类的loadClass 方法中进行,此方法会判断此类是否被自己或双亲加载过(这也是著名的“双亲委派模式”);
若加载过则无需重复load,直接返回类实例;
否则调用findClass方法寻找获取这个类,可是findClass方法在ClassLoader类中是一个空实现,真正实现是在BaseDexClassLoader类中;
而BaseDexClassLoader类也未具体实现,调用的实则是DexPathList类中的findClass方法;
DexPathList类中 findClass方法最终又调用DexFile中的defineClassNative ,DexFile的一个native方法来完成主要类加载逻辑。
以上是类加载过程涉及到的几个类中方法互相调用最终实现“类加载”的过程,
以下是重点方法中实现的逻辑总结:
首先在DexPathList类的构造方法中:将所有的dex文件(File类型)转换成DexFile类型,并且将其转化为Element数组,便于findClass方法逻辑处理,
然后在findClass 方法中遍历Element数组(Element类型中存储着DexFile类型),获取Element中的DexFile,
调用DexFile的内部方法loadClassBinaryName,在dex文件中查找获取拼接成class字节码文件返回(loadClassBinaryName是一个 native方法)。
而这整个过程,一系列方法、类之间调用的核心逻辑是:通过指定加载dex路径中,遍历文件找到dex文件,然后在存储了整个工程class文件数据中的dex文件中,查找搜索并拼接 class字节码文件返回。
----
问题3:类加载修复方案对比?
QQ空间的超级补丁和Nuwa是按照上面说得,将补丁包放在Element数组的第一个元素得到优先加载。
微信Tinker:
微信Tinker将新旧apk做了diff,得到patch.dex,然后将patch.dex与手机中apk的classes.dex做合并,生成新的classes.dex,然后在运行时通过反射将classes.dex放在Element数组的第一个元素。
微信针对QQ空间超级补丁技术的不足提出了一个提供DEX差量包,整体替换DEX的方案。
主要的原理是与QQ空间超级补丁技术基本相同,
区别在于不再将patch.dex增加到elements数组中,而是差量的方式给出patch.dex,然后将patch.dex与应用的classes.dex合并,然后整体替换掉旧的class.dex文件,以达到修复的目的。
--通过反射 操作得到PathClassLoader的DexPatchList, 反射调用patchlist的makeDexElements()方法,
把本地的dex文件直接替换到Element[]数组中去,达到修复的目的。
对于如何进行patch.dex与classes.dex的合并操作,这里微信开启了一个新的进程,开启新进程的服务TinkerPatchService 进行合并。
如果Key.Class文件中存在异常,将该Class文件修复后,将其打入Patch.dex的补丁包
(1) 方案一:
通过反射获取到PathClassLoader中的DexPathList,然后再拿到 DexPathList中的Element数组,将Patch.dex放在Element数组dexElements的第一个元素,最后将数组进行合并后并重新设置回去。在进行类加载的时候,由于ClassLoader的双亲委托机制,该类只被加载一次,也就是说Patch.dex中的Key.Class会被加载。
(2)方案二:
提供dex差量包patch.dex,将patch.dex与应用的classes.dex合并成一个完整的dex,完整dex加载后得到dexFile对象,作为参数构建一个Element对象,然后整体替换掉旧的dex-Elements数组。(Tinker)
问题2:Dex插桩原理:
ClassLoader 是通过调用 findClass方法,在pathList对象中的 dexElements[] 中遍历dex文件寻找相关的类。由于靠前的dex会优先被系统调用,所以就有了插桩的概念。将修复好的 dex 插入到 dexElements[] 的最前方,这样系统就会调用修复好的插入类而不是靠后的 bug 类。
上图中,patch.dex 是插入的 dex ,classes2.dex 是原有的 bug dex。ClassLoader 在遍历时优先获取了 patch.dex 中的 D.class ,所以 classes2.dex 中的 D.class 就不会被调用,这样就完成了对 D.class 的替换,修复了bug。
本文简单介绍了代码修复的技术原理,下篇文章将从系统源码入手,结合我自己封装的代码修复开源框架Fettler,详细解读代码修复的每一个过程。
问题1:类加载方案需要重启App后让ClassLoader重新加载新的类,为什么需要重启呢?
这是因为类是无法被卸载的,因此要想重新加载新的类就需要重启App,因此采用类加载方案的热修复框架是不能即时生效的。