一.简介
1.1.定义
热修复是一种插件化的技术,当线上应用出现BUG时,无需发布新包让用户更新,而是通过补丁包让用户更新。具有及时修复BUG,用户无需下载新的应用从而达到无感知修复。
1.2.优势
- 无需重新发布新版本,省时省力;
- 用户无感知修复,也无需下载最新应用,代价小;
-
1.3.热修复能完成哪些修复
代码修复
- 资源修复
-
1.4.热修复框架及分类
Nativehook
- Dexpose、Andfix、阿里百川Hotfix
- Java
- Qzone超级补丁、QFix、Robust、Nuwa、RocooFix、Aceso、Amigo、Tinker
混合
-
1.5.原理
本文介绍的热修复是一种插桩、排队的原理实现(Java层)。
- 把一个项目解压,往往有一个分包的情况。问题引出:为什么会分包?往往一线互联网企业,项目分出来的包有classes.dex,classes1.dex,classes2.dex,classes3.dex等等,都会在Elements数组里面。我们现在要做的事情就是自己做一个patch.dex插入到数组里面。加入到第一位。
- 插桩的原理是:Hook了ClasLoader.pathList.dexElements[],因为ClassLoader的findClass是通过遍历dexElements[]中的dex来寻找类(找到了优先加载,找不到抛出异常)。
1.6.小知识
-
热修复/插件化/增量更新
- 热修复:体现的是修复线上的bug;
- 插件化:一般用于功能的拓展;
- 增量更新:往往是一个差分的补丁;
- jar轻量的好处:降低耦合;
- build.gradle更改一个版本号?可能会出现api断层。
- javac将java编译class,dx.bat打包dex;
二.热修复实现
2.1.实现过程
- 请求后台的接口,根据版本判断是否需要下载修复包,将服务器下载修复好的dex包,替换原有bug的java类;
演示使用:将有bug的apk和没有bug的apk的差分包放入手机中。注意:因为模拟手机加载,加载修复包在6.0以上的系统需要一个运行时权限。
2.2.实现原理
思路:将修复好的classed2.dex下载到手机,并替换有bug的dex文件;
注意事项:
类加载器:ClassLoader
-
2.5.源码相关及思路整理
2.5.1.Android源码链接,在线阅读
2.5.2.分析
Java虚拟机 —— JVM 是加载类的class文件的,而Android虚拟机——Dalvik/ART VM 是加载类的dex文件,而他们加载类的时候都需要ClassLoader,ClassLoader有一个子类BaseDexClassLoader,BaseDexClassLoader下有一个数组——DexPathList,是用来存放dex文件,当BaseDexClassLoader通过调用findClass方法时,实际上就是遍历数组,找到相应的dex文件,找到,则直接将它return。而热修复的解决方法就是将新的dex添加到该集合中,并且是在旧的dex的前面,所以就会优先被取出来并且return返回。
查看ClassLoader的子类BaseDexClassLoader:
public class BaseDexClassLoader extends ClassLoader {
//pathList
private final DexPathList pathList;
//...省略代码
public BaseDexClassLoader(String dexPath, File optimizedDirectory,
String libraryPath, ClassLoader parent) {
super(parent);
//pathList被赋值,分析一:DexPathList
this.pathList = new DexPathList(this, dexPath, libraryPath, optimizedDirectory);
}
//...省略代码
}
查看BaseDexClassLoader中的DexPathList对象,即分析一:
//分析一
final class DexPathList {
private static final String DEX_SUFFIX = ".dex";
//...省略代码
/**
* List of dex/resource (class path) elements.
* Should be called pathElements, but the Facebook app uses reflection
* to modify 'dexElements' (http://b/7726934).
*/
private final Element[] dexElements;
//...省略代码
public DexPathList(ClassLoader definingContext, String dexPath,
String libraryPath, File optimizedDirectory) {
//...省略代码
//分析二:makePathElements
this.dexElements = makePathElements(splitDexPath(dexPath), optimizedDirectory,
suppressedExceptions);
//...省略代码
}
/**
* Makes an array of dex/resource path elements, one per element of
* the given array.
*/
//todo 分析二makePathElements
private static Element[] makePathElements(List<File> files, File optimizedDirectory,
List<IOException> suppressedExceptions) {
List<Element> elements = new ArrayList<>();
/*
* Open all files and load the (direct or contained) dex files
* up front.
*/
//遍历传递进来的文件集合
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()) {
// We support directories for looking up resources and native libraries.
// Looking up resources in directories is useful for running libcore tests.
elements.add(new Element(file, true, null, null));
} else if (file.isFile()) {
//如果文件的文件名是以 .dex结尾的,执行loadDexFile方法
if (name.endsWith(DEX_SUFFIX)) {
// Raw dex file (not inside a zip/jar).
try {
dex = loadDexFile(file, optimizedDirectory);
} catch (IOException ex) {
System.logE("Unable to load dex file: " + file, ex);
}
} else {
zip = file;
try {
dex = loadDexFile(file, optimizedDirectory);
} catch (IOException suppressed) {
/*
* IOException might get thrown "legitimately" by the DexFile constructor if
* the zip file turns out to be resource-only (that is, no classes.dex file
* in it).
* Let dex == null and hang on to the exception to add to the tea-leaves for
* when findClass returns null.
*/
suppressedExceptions.add(suppressed);
}
}
} else {
System.logW("ClassLoader referenced unknown path: " + file);
}
if ((zip != null) || (dex != null)) {
//执行loadDexFile方法后,将dex文件加入到elements中
elements.add(new Element(dir, false, zip, dex));
}
}
//将elements返回
return elements.toArray(new Element[elements.size()]);
}
}
2.5.3.源码涉及的对象和数组
BaseDexClassLoader:ClassLoader的子类
pathList:BaseDexClassLoader中的对象
下载的apk一般放在sdcard/download目录(不安全),那么app安装成功,会不会同时复制一份apk文件到私有目录,这个路径:data/app/packageName/base.apk。其实无论下载到哪个目录,只要安装都会copy一份到私有目录。该目录下只有root权限的才能执行一些操作。即对热修复方案copyFile的解释。
2.5.4.思路
创建BaseDexClassLoader(DexClassLoader加载器的子类);
- 加载修复好的classes2.dex(模拟服务器下载的修复包,直接放在手机中);
- 将自有的和系统的dexElements进行合并,并设置自有的dexElements优先级;
- 通过反射技术,赋值给系统的pathList;
三.代码实现
3.1.activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity"
tools:layout_editor_absoluteY="25dp">
<!-- 对应activity中的showToast方法-->
<Button
android:onClick="showToast"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="40dp"
android:layout_marginRight="40dp"
android:layout_marginTop="420dp"
android:text="showToast"
app:layout_constraintEnd_toStartOf="@+id/guideline2"
app:layout_constraintTop_toTopOf="parent" />
<!-- 对应activity中的shotFix方法-->
<Button
android:onClick="hotFix"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginLeft="40dp"
android:layout_marginStart="40dp"
android:layout_marginTop="420dp"
android:text="fix"
app:layout_constraintStart_toStartOf="@+id/guideline2"
app:layout_constraintTop_toTopOf="parent" />
<!--不了解ConstraintLayout的童鞋只用关注上面的两个按钮就行 -->
<android.support.constraint.Guideline
android:id="@+id/guideline2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
app:layout_constraintGuide_percent="0.5" />
</android.support.constraint.ConstraintLayout>
3.2.MainActivity
public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
//模拟热修复实现,需要读取sd卡的文件,所以高于6.0的系统需要申请运行时权限
//判断当前系统是否高于或等于6.0
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
String[] perms = {Manifest.permission.WRITE_EXTERNAL_STORAGE};
//当前系统大于等于6.0
if (ContextCompat.checkSelfPermission(MainActivity.this,perms[0]) == PackageManager.PERMISSION_GRANTED) {
//具有权限
//具体调用代码
} else {
//不具有sd卡读写权限权限,需要进行权限申请
requestPermissions(perms,200);
}
} else {
//当前系统小于6.0,在AndroidManifest中配置权限即可使用
}
}
public void showToast(View view) {
int a = 10;
int b = 0; //修复前 b = 0
int c = a/b;
Toast.makeText(context, c + " 厉害啦,我的锅",Toast.LENGTH_SHORT).show();
}
public void hotFix(View view) {
//1.通过服务器下载dex文件,如:v1.0.0有一个热修复dex包
File sourceFile = new File(Environment.getExternalStorageDirectory(), Constants.DEX_NAME);
//目标路径,私有目录里面的临时文件odex
File targetFile = new File(getDir(Constants.DEX_DIR, Context.MODE_PRIVATE).getAbsolutePath()
+ File.separator + Constants.DEX_NAME);
//如果存在,比如之前修复过的classes2.dex。清理
if(targetFile.exists()){
targetFile.delete();
Toast.makeText(this,"删除已存在的dex文件",Toast.LENGTH_SHORT).show();
}
//2.复制修复包dex文件到app私有目录
try {
FileUtils.copyFile(sourceFile,targetFile);
Toast.makeText(this,"复制dex文件完成",Toast.LENGTH_SHORT).show();
//开始修复
FixDexUtils.loadFixedDex(this);
} catch (IOException e) {
e.printStackTrace();
}
}
}
3.3.工具类-ArrayUtils
import java.lang.reflect.Array;
/**
* created by Jack
* email:yucrun@163.com
* date: 2019/4/1
* describe:反射工具类
*/
public class ArrayUtils {
/**
* 合并数组
* @param arrayLhs 前数组(插队数组)
* @param arrayRhs 后数组(已有数组)
* @return 处理后的新数组
*/
public static Object combineArray(Object arrayLhs,Object arrayRhs){
//获得一个数组的class对象,通过Array.newInstance()可以反射生成新的数组
Class<?> loaclClass = arrayLhs.getClass().getComponentType();
//前数组长度
int i = Array.getLength(arrayLhs);
//新数组长度 = 前数组长度 + 后数组长度
int j = i + Array.getLength(arrayRhs);
//生成数组对象
Object result = Array.newInstance(loaclClass,j);
//从0开始遍历,如果前数组有值,添加到新数组的第一个位置
for(int k = 0; k < j;k++){
if(k < i){
//从0开始遍历,如果前数组有值,添加到新数组的第一个位置
Array.set(result,k,Array.get(arrayLhs,k));
}else {
//添加完前数组,在添加后数组,合并完成
Array.set(result,k,Array.get(arrayRhs,k - i));
}
}
//添加完前数组,在添加后数组,合并完成
return result;
}
}
3.4.工具类-Constants
/**
* created by Jack
* email:yucrun@163.com
* date:19-4-1
* describe:常量类
*/
public class Constants {
public static final String DEX_NAME = "classes2.dex";
public static final String DEX_DIR = "odex";
public static final String DEX_SUFFIX = ".dex";
}
3.5.工具类-FileUtils
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
/**
* created by Jack
* email:yucrun@163.com
* date:19-4-1
* describe:文件工具类
*/
public class FileUtils {
/**
*
* @param sourceFile 源文件(来自sd卡)
* @param targetFile 目标文件(私有)
* @throws IOException IO异常
*/
public static void copyFile(File sourceFile,File targetFile) throws IOException {
//新建文件输入流并对它进行缓冲
FileInputStream input = new FileInputStream(sourceFile);
BufferedInputStream inBuff = new BufferedInputStream(input);
//新建文件输出流并对它进行缓冲
FileOutputStream outPut = new FileOutputStream(targetFile);
BufferedOutputStream outBuff = new BufferedOutputStream(outPut);
//缓冲数组
byte[] b = new byte[1024 * 5];
int len;
while ((len = inBuff.read(b)) !=-1){
outBuff.write(b,0,len);
}
//刷新此缓冲区的输出流
outBuff.flush();
//关闭流
input.close();
inBuff.close();
outPut.close();
outBuff.close();
}
}
3.6.工具类-ReflectUtils
import java.lang.reflect.Field;
/**
* created by Jack
* email:yucrun@163.com
* date:19-4-1
* describe:反射工具类
*/
public class ReflectUtils {
/**
* 通过反射获取某对象,并设置私有可以访问
* @param obj 该属性所属类的对象
* @param clazz 该属性所属类
* @param field 属性名
* @return
*/
private static Object getField(Object obj,Class<?> clazz,String field)
throws NoSuchFieldException, IllegalAccessException {
Field localField = clazz.getDeclaredField(field);
localField.setAccessible(true);
return localField.get(obj);
}
/**
* 通过反射获取某对象,并设置私有可访问
* @param obj 该属性所属类的对象
* @param clazz 该属性所属类
* @param value 值
*/
public static void setField(Object obj,Class<?> clazz,Object value)
throws NoSuchFieldException, IllegalAccessException {
Field localField = clazz.getDeclaredField("dexElements");
localField.setAccessible(true);
localField.set(obj,value);
}
/**
* 通过反射获取BaseDexClassLoader对象中的PathList对象
* @param baseDexClassLoader
* @return PathList对象
*/
public static Object getPathList(Object baseDexClassLoader) throws
ClassNotFoundException, NoSuchFieldException, IllegalAccessException {
return getField(baseDexClassLoader,
Class.forName("dalvik.system.BaseDexClassLoader"),
"pathList");
}
/**
*
* 通过反射获取BaseDexClassLoader对象的PathList对象,再获取dexElements对象
* @param paramObject PathList对象
* @return dexElements对象
*/
public static Object getDexElements(Object paramObject)
throws NoSuchFieldException, IllegalAccessException {
return getField(paramObject,paramObject.getClass(),"dexElements");
}
}
3.7.工具类-FixDexUtils
import android.content.Context;
import java.io.File;
import java.util.HashSet;
import dalvik.system.DexClassLoader;
import dalvik.system.PathClassLoader;
import jack.cn.hotfixlib.utils.ArrayUtils;
import jack.cn.hotfixlib.utils.Constants;
import jack.cn.hotfixlib.utils.ReflectUtils;
/**
* created by Jack
* email:yucrun@163.com
* date:19-4-1
* describe:热修复管理类,严格意义上需要单例子。模仿热修复实现的思路。
*/
public class FixDexUtils {
//存放需要修复的dex集合,可能不止一个dex
private static HashSet<File> loadedDex = new HashSet<>();
static {
//修复前,进行清理工作
loadedDex.clear();
}
/**
* 加载热修复的dex文件
*
* @param context 上下文
*/
public static void loadFixedDex(Context context) {
if (context == null)
return;
//Dex文件目录(私有目录中,存在之前已经复制过来的修复包)
File fileDir = context.getDir(Constants.DEX_DIR, Context.MODE_PRIVATE);
File[] listFiles = fileDir.listFiles();
//遍历私有目录中所有的文件
for (File file : listFiles) {
//找到修复包,加入到集合,主包(classes.dex)不添加
if (file.getName().endsWith(Constants.DEX_SUFFIX) && !"classes.dex".equals(file.getName())) {
loadedDex.add(file);
}
}
//模拟类加载器
createDexCLassLoader(context, fileDir);
}
/**
* 创建加载补丁的DexClassLoader(自有)
*
* @param context 上下文
* @param fileDir Dex文件目录
*/
private static void createDexCLassLoader(Context context, File fileDir) {
//创建临时的解压目录(先解压到该目录,再加载java)
String optimizedDir = fileDir.getAbsolutePath() + File.separator + "opt_dex";
//不存在就创建
File fopt = new File(optimizedDir);
if (!fopt.exists()) {
//创建多级目录
fopt.mkdirs();
}
for (File dex : loadedDex) {
//每遍历一个要修复的dex文件,就需要插桩一次
DexClassLoader classLoader = new DexClassLoader(dex.getAbsolutePath(),
optimizedDir, null, context.getClassLoader());
hotFix(context, classLoader);
}
}
/**
* 热修复
*
* @param context
* @param classLoader
*/
private static void hotFix(Context context, DexClassLoader classLoader) {
//获取系统的PathClassLoader类加载器
PathClassLoader pathLoader = (PathClassLoader) context.getClassLoader();
try {
//获取自有的dexElements数组对象
Object myDexElements = ReflectUtils.getDexElements(ReflectUtils.getPathList(classLoader));
//获取系统的dexElements数组对象
Object systemDexElements = ReflectUtils.getDexElements(ReflectUtils.getPathList(pathLoader));
//合并成新的dexElements数组对象
Object dexElements = ArrayUtils.combineArray(myDexElements, systemDexElements);
//通过反射再去 获取 系统的 pathList对象
Object systemPathList = ReflectUtils.getPathList(pathLoader);
//重新赋值给系统的pathList属性 --- 修改了pathList的dexElements数组对象
ReflectUtils.setField(systemPathList,systemPathList.getClass(),dexElements);
} catch (Exception e) {
e.printStackTrace();
}
}
}
3.8.验证热修复效果
- 先查看3.2中的第30行,在修复前b=0,运行showToast方法会出现崩溃。先执行android -studio中顶部的Build中Build apk(s)生成apk,在app/build/outputs/apk/debug查看。生成后安装;
修改3.2中的第30行代码,将b = 0,改为b = 1,然后执行Build apk(s),将新生成的apk改为zip格式,然后打开,复制classes2.dex文件到手机内存卡中;
![zip格式查看.png](https://cdn.nlark.com/yuque/0/2019/png/249982/1554188969150-76521d48-ccad-4f5a-9a7e-68faf81fd50e.png#align=left&display=inline&height=571&name=zip%E6%A0%BC%E5%BC%8F%E6%9F%A5%E7%9C%8B.png&originHeight=571&originWidth=612&size=76232&status=done&width=612)
开始验证,打开安装好的应用,首选允许读写权限,点击showToast按钮,首先会闪退。再次进入点击hotFix方法,完成修复。下次再进入点击showToast就不会闪退,即完成修复。
四.写在最后
非常感谢,能看到这里,因为个人水平尚浅,不免有疏漏,希望自己不会误人子弟,上面代码有疑问或错误的地方,欢迎指出。个人email:yucrun@163.com。项目地址。