上一篇文章简易总结了热修复实现的几大原理,并详细介绍了Android中的类加载机制及源码探索,Android的类加载机制涉及到ClassLoader、DexClassLoader 、PathClassLoader 、BaseDexClassLoader 、DexPathList、DexFile多个类之间方法互相调用,但是真正的核心实现类其实是DexPathList,它具体实现了findClass
方法,即所谓的“类加载”概念。
其实此方法中的实现逻辑给开发者暗示了热修复的实现思路,此篇文章将一探究竟,剖析出如何根据DexClassLoader类加载机制来实现类替换修复,通过分析思路具体编码实践整个过程。
此篇文章将学习:
(阅读此篇文章之前,要求理解前一篇文章的分析以作基础。此系列文章是环环相扣,不可缺一,链接如下:)
Android热修复(一):底层替换、类加载原理总结 及 DexClassLoader类加载机制源码探索
在经过上一篇对DexClassLoader相关源码分析后,可以发现类加载的主要逻辑处理都在DexPathList类中进行,类中有一个重要的成员变量——Element类型数组,和两个重要的方法——构造函数和findClass
:
findClass
方法中遍历Element数组,找到对应类名的dex文件(Element类型的dexFile方法可转化为DexFile类型),调用本身native方法获取字节码文件返回。以上我又再次强调归纳一遍DexPathList类的逻辑处理,这几乎代表着Android类加载机制的重点核心了,也与后续“热修复处理”有关。
在多次总结中可见DexPathList类的成员变量Element类型数组很关键,而且又是通过Element数组中的成员加载得到class字节码文件,因此可以说所有加载的类都在Element数组中!
见上图,此时客户端中的Test类(Test.class)有bug,而服务器已有修复好的Test.class,如若能够将已修复的Test.class代替客户端上的,是否就达到成功修复bug的功能?
如上图,再仔细查看DexPathList类中对Element数组的注释:dex或者资源(类路径)的集合,更应该命名为pathElement,但是Facebook app使用反射来修改 ‘dexElement’。以上谷歌注释相当于默认开发者可以这么玩:修改dexElement。
在获取谷歌助攻之后,实现热修复功能萌生的第一个想法是:反射获取到dexElement,直接修改其中有bug的类替换成已修复的类。
不过此方法进一步具体实现有些不太理想,因为dexElement是一个数组,需要一个一个遍历找到对应的再做替换,过程实现是比较麻烦的。再仔细观察DexPathList类的findClass
方法,发现内部循环的一个重点逻辑:在遍历dexElement数组时,若加载到的指定类class不为空时,直接return结束遍历,将class字节码返回!
综上所述,第二个想法由此萌生:根据findClass
方法里的内部逻辑,遍历查找到指定类加载,class不为空就直接返回,那我们可以直接将已修复的类放到有bug类的前面,一旦加载到已修复好的类,那后面的bug类就不会有任何影响。
第二个想法的思路很简单,相当于做了一个拦截。此时你可能还在纠结第一个想法,在dexElement数组中删去有bug的去替换成已修复的,这操作过程并没有表面那么容易,与第二个想法比起来,为何不采用更简单的呢?
实现热修复功能思路:直接将已修复的dex放到dexElement数组中有bug类的dex前面!
再结合Android系统思考详细操作:Android类加载加载的是dex文件,也就是解压APK后的classes.dex,而一个dex文件是包含了整个工程的class字节码文件。在发现原工程中有某个类或涉及到的几个类有错误,将这些已修复好的类打包到修复好的补丁包classes2.dex中,可以通过推送或其他方式由服务器传送到客户端中,那么接下来的任务就是将这两个dex合并,其注意目的也就是将修复的class放置到原来出bug的class(其实这里只需要将其放置到数组中第一个位置,就可以一劳永逸!)
如上图,这是github上Tinker技术的介绍图,其原理与上述实现思路是不是非常类似?实则两者实现原理相同,皆从DexClssLoader类加载原理入手,但是此篇文章并不详细介绍Tinker使用及原理(下一篇介绍),而是了解以上原理自行编写demo实现类替换修复。
DexPathList类源码
(以下过程采用Genymotion模拟器操作,AS的java目录如下)
(1)测试UI界面
如下图,测试界面十分简单,一个activity中2个button,点击后的处理逻辑如下:
10/0
这样一个错误运算,点击之后程序必然出错闪退,以此来模拟应用闪退的情况(实际编码中不会出现此疏漏,在此仅为模拟)。注意:此过程中的服务器传送给客户端已修复的dex文件(实际可通过推送或其他方式),此步骤笔者并未详细实现,而自行简化直接将已修复好的dex文件拖入Genymotion模拟器中,重在突出热修复逻辑处理过程。
(2)TEST按钮方法实现
如上所述,点击TEST按钮后将进行10/0
这个错误运算,即调用DexFixTest类的testFix
方法,程序肯定会出错闪退,后续将修复此bug类,并生成已修复的dex用于热修复。
DexFixTest类仅用于测试,代码如下:
public class DexFixTest {
public void testFix(Context context){
int dividend = 10;
//bug:除数不可为0
int divisor = 0;
Toast.makeText(context, "shit:"+dividend/divisor, Toast.LENGTH_SHORT).show();
}
}
(3)FIX按钮方法实现
此按钮点击会调用fixBug()
方法:
注意:此处可能有人会疑惑为何需要移置dex文件位置?因为此热修复方案采用的原理是DexClssLoader类加载,DexClassLoader有一个限制就是加载指定的dex文件必须要在应用程序的目录下面!因此先要移动dex文件到指定位置,再实行热修复。
获取规定应用程序下的目录路径
Context.java
public File getDir(String name, int mode)
此API返回/data/data/youPackageName/app_name(”app_” 是返回时系统自己加上的)下的指定名称的文件夹File对象,如果该文件夹不存在则用指定名称创建一个新的文件夹。用此API在内部存储中的对应app下创建name文件夹,用于存放移置后的已修复dex文件,后续DexClassLoader会加载此处dex来进行热修复。
获取dex文件下载到的路径
Environment.getExternalStorageDirectory().getAbsolutePath()
此API返回sdcard路径。在测试过程中是直接将已修复的dex文件拖入模拟器/真机中,通过此API获取到下载好的dex文件,准备移置。
fixBug()
方法完整代码如下:
/*
* 把下载完成的已修复文件classes2.dex(SD卡)移置到 应用目录filePath
* */
private void fixBug() {
String dexName = "classes2.dex";
File fileDir = getDir(HotfixConstants.DEX_DIR, Context.MODE_PRIVATE);
//⭐️⭐️⭐️⭐️⭐️DexClassLoader指定的应用程序目录
String filePath = fileDir.getPath()+File.separator+dexName;
File dexFile = new File(filePath);
if(dexFile.exists()){
dexFile.delete();
}
//移置dex文件位置(从sd卡中移置到应用目录)
InputStream is = null;
FileOutputStream os = null;
// ⭐️⭐️⭐️⭐️⭐️sd卡路径应为"/storage/emulated/0",此处直接将文件拖入Genymotion,因此路径还需加上"/Download"
String sdPath = Environment.getExternalStorageDirectory().getAbsolutePath()+"/Download";
try {
is = new FileInputStream(sdPath+File.separator+dexName);
os = new FileOutputStream(filePath);
int len = 0;
byte[] buffer = new byte[1024];
while((len = is.read(buffer)) != -1){
os.write(buffer, 0, len);
}
//测试是否成功写入
File fileTest = new File(filePath);
if(fileTest.exists()){
Toast.makeText(this, "dex重写成功", Toast.LENGTH_SHORT).show();
}
//获取到已修复的dex文件,进行热修复操作
DexFixUtils.loadFixedDex(this);
}catch (Exception e){
e.printStackTrace();
}
}
最后还是要强调一下sd卡路径和应用程序目录路径,笔者在编写代码时对此处有些混淆,因此在第一大点中增加了Android内外存的相关介绍。分别用Genymotion模拟器(Google Nexus 5 API 23)和真机(华为 API 23)测试的结果如下:
/data/user/0/com.lemon.hotfix/app_odex
,odex是自定义的文件夹名,此文件夹用于存放移置后的已修复dex文件。/storage/emulated/0
/storage/sdcard0
下图举例模拟器测试时debug到的路径:
DexFixUtils类的主要作用是:
/*
* @author lemonGuo
* 处理热修复主要逻辑
* */
public class DexFixUtils {
private static HashSet loadedDex = new HashSet();
static{
loadedDex.clear();
}
......
}
(1)loadFixedDex 加载dex方法
/*
* 遍历所有的dex文件存储到成员变量loadedDex中,用于后续合并
* */
public static void loadFixedDex(Context context) {
if(context == null){
return ;
}
File fileDir = context.getDir(HotfixConstants.DEX_DIR, Context.MODE_PRIVATE);
File[] listFiles = fileDir.listFiles();
for (File file:listFiles){
if(file.getName().startsWith("classes") && file.getName().endsWith("dex")){
loadedDex.add(file);
}
}
//合并之前到dex
doDexInject(context,fileDir);
}
(2)doDexInject 合并替换dex方法
目前的条件是已经获取到dex集合,即原本的classes.dex和已修复好的classes.dex2,而接下来的任务就是合并这两个dex文件,再回顾以下两个加载器作用:
根据以上加载器的各自作用,再结合第一点中的思路讲解,实现dex文件合并稍稍有了头绪,整体逻辑可分为3个步骤:
context.getClassLoader()
获取的便是加载应用的PathClassLoader;上面3个步骤,重点在于获取到PathClassLoader、DexClassLoader加载器后,第三步如何详细实现第三步,即重写dexElements数组?
首先滤清思路既然要重写dexElements数组,首先要获得加载程序PathClassLoader所加载的dexElements数组,和加载已修复dex文件DexClassLoader所加载的dexElements数组,获得这两组数组之后,再进行重写。条件是已获得PathClassLoader、DexClassLoader两个加载器,接下来任务是如何获取其对应的dexElements数组?
查看以下源码,可见我们已经洞悉dexElements数组最终藏身之处,可是在AS中是无法直接查阅BaseDexClassLoader及相关源码,这将意味着首先需要通过反射获取到BaseDexClassLoader类,再反射获取类中的DexPathList类,即可获取到dexElements数组。
//源码
BaseDexClassLoader{
DexPathList pathList;
}
DexPathList{
Element[] dexElements;
}
注意此过程还没有结束,获取到两个数组后,首先要进行合并,即将已修复的dex放到dexElement数组中有bug类的dex前面,然后将合并后的数组去替换成程序加载器PathClassLoader所加载的数组,因为DexClassLoader只是用来加载程序之外的已修复dex文件,最终加载程序应用的还是PathClassLoader。如此一来即可大功告成!
以上doDexInject
方法逻辑完整实现源码如下:
/**
* 通过PathClassLoader、DexClassLoader合并dex文件,实现类替换修复
* @param context 上下文环境
* @param filesDir dex所在的文件目录
*/
private static void doDexInject(Context context, File filesDir) {
//dex文件需要被写入的目录
String optimizeDir = filesDir.getAbsolutePath()+File.separator+"opt_dex";
File fileOpt = new File(optimizeDir);
if(!fileOpt.exists()){
fileOpt.mkdirs();
}
//1.获得加载应用程序dex的PathClassLoader
PathClassLoader pathClassLoader = (PathClassLoader) context.getClassLoader();
for (File dex : loadedDex) {
//2.获得加载指定路径下dex的DexClassLoader
DexClassLoader dexClassLoader = new DexClassLoader(
dex.getAbsolutePath(),
fileOpt.getAbsolutePath(),
null,
pathClassLoader);
//3.合并dex
try {
Object dexObj = getPathList(dexClassLoader);
Object pathObj = getPathList(pathClassLoader);
Object fixDexElements = getDexElements(dexObj);
Object pathDexElements = getDexElements(pathObj);
//合并两个数组
Object newDexElements = combineArray(fixDexElements,pathDexElements);
//重新赋值给PathClassLoader 中的exElements数组
Object pathList = getPathList(pathClassLoader);
setField(pathList,pathList.getClass(),"dexElements",newDexElements);
} catch (Exception e) {
e.printStackTrace();
}
}
}
(3)反射相关方法
此部分有关反射getField
的代码较为常规,另笔者多做了一层封装,通过getPathList
、getDexElements
调用getField
反射方法获取指定类,这样整体代码风格看起来更优雅。
注意:此处两个反射获取BaseDexClassLoader类和dexElements数组时提供的参数字符串不同,获取类需要以“包+类名”,例如dalvik.system.BaseDexClassLoader
;获取成员变量只需“变量名”即可,例如dexElements
private static Object getPathList(Object baseDexClassLoader) throws Exception {
return getField(baseDexClassLoader,Class.forName("dalvik.system.BaseDexClassLoader"),"pathList");
}
private static Object getDexElements(Object obj) throws Exception {
return getField(obj,obj.getClass(),"dexElements");
}
/**
* 通过反射获得对应类
* @param obj Object类对象
* @param cl class对象
* @param field 获得类的字符串名称
* @return
*/
private static Object getField(Object obj, Class> cl, String field) throws NoSuchFieldException, IllegalAccessException {
Field localField = cl.getDeclaredField(field);
localField.setAccessible(true);
return localField.get(obj);
}
/**
* 通过反射修改值
* @param obj 待修改值
* @param cl class对象
* @param field 待修改值的字符串名称
* @param value 修改值
*/
private static void setField(Object obj,Class> cl, String field, Object value) throws Exception {
Field localField = cl.getDeclaredField(field);
localField.setAccessible(true);
localField.set(obj,value);
}
(4)数组合并方法
此方法的参数就是DexClassLoader、PathClassLoader所加载的这两个dexElements数组,主要目的就是将已修复的dex放到dexElement数组中有bug类的dex前面。
正如第一大点所说,没必要去找到那个bug类所在的数组位置,而去刚刚好将已修复的放置它前面,有一个最简单的方法:DexClassLoader所加载的dexElements数组是已修复过的,而PathClassLoader所加载的dexElements数组是存有bug的,因此直接创建一个新组,长度是两者之和,先赋值已修复过的数组中的值,然后再去赋值存在bug的数组中的值,如此一来根据findClass
方法的return机制,遍历找到指定类后直接return,数组后面的值不再考虑,即可完美简化解决数组合并问题。
方法逻辑图如下:
该方法具体实现代码如下:
/**
* 两个数组合并
* @param arrayLhs
* @param arrayRhs
* @return
*/
private static Object combineArray(Object arrayLhs, Object arrayRhs) {
Class> localClass = arrayLhs.getClass().getComponentType();
int i = Array.getLength(arrayLhs);
int j = i + Array.getLength(arrayRhs);
Object result = Array.newInstance(localClass, j);
for (int k = 0; k < j; ++k) {
if (k < i) {
Array.set(result, k, Array.get(arrayLhs, k));
} else {
Array.set(result, k, Array.get(arrayRhs, k - i));
}
}
return result;
}
首先程序运行后直接点击TEST按钮,客户端处理10/0
运算,必然出错,出现程序闪退,效果GIF如下:
笔者本想通过AS来获取这个已修复的dex文件,可是错误类只有DexFixTest这一个类,因此只需要对其编译成class文件,再到dex文件即可(此处暂时不考虑混淆),可是AS每次编译是将整个项目工程一起编译,那生成的dex文件是包含所有类的,如此一来还需什么替换,直接用此dex不就行了?
这样并非达到热修复本意,已修复dex文件只需要包含对错误类的修复,因此笔者采用手动通过命令来生成。过程如下:
10/0
为10/1
,点击编译,在/Users/lemon/Desktop/gym/android_project/apps_gym/HotfixDemo/app/build/intermediates/classes/debug/com/lemon/hotfix/test/DexFixTest.class
可以找到该类的class文件。DexFixTest.class
文件后,在桌面随意建一个文件夹放置该文件,注意包名需对应文件夹名!(图片如下),打开终端输入dx --dex --output=/Users/lemon/Desktop/test/classes2.dex /Users/lemon/Desktop/test/
,即可生成对应的dex文件。(此处对于dx命令不作过多讲解,详细可看笔者写的另一篇博文,链接如下:)
Android Dex VS Class:实例图解剖析两种格式文件结构、优劣
最终演示效果图如下,首先点击TEST按钮,程序运行10/0
出错闪退。再次进入程序点击FIX按钮,进行类替换修复,将DexFixTest类中的运算替换为10/1
,屏幕吐司显示dex文件已被重写,再点击TEST按钮计算,此时计算的是10/1
,吐司显示1,表示类已经被替换,热修复成功。
注意:在编写代码中一定要关闭AS 的Instant Run功能,它虽然可以加快构建和部署流程的速度,但其本身有太多限制。笔者掉入坑中好久,一直调试有问题,最后才发现是这厮在作怪!
以上是根据第一篇解析了DexClassLoader类加载相关源码后,探索到可实现热修复的思路,即修改dexElement,让findClass
方法预先加载已修复的dex文件而忽略后续的bug文件,并凭借此思路最终实现类替换修复功能。
整体实现思路并不复杂,不过其中涉及到一些底层知识,例如Android内外存、反射、dex文件相关知识等等,笔者掉了不少坑,继续努力。这两篇主要学习DexClassLoader类加载相关原理并自行实现了一个小demo,对此理解已经不少,下一篇将学习记录腾讯Tinker的使用及原理,共勉~
(源码正在整理中,后续放出)
若有错误,虚心指教~