一.简介
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 {//pathListprivate final DexPathList pathList;//...省略代码public BaseDexClassLoader(String dexPath, File optimizedDirectory,String libraryPath, ClassLoader parent) {super(parent);//pathList被赋值,分析一:DexPathListthis.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) {//...省略代码//分析二:makePathElementsthis.dexElements = makePathElements(splitDexPath(dexPath), optimizedDirectory,suppressedExceptions);//...省略代码}/*** Makes an array of dex/resource path elements, one per element of* the given array.*///todo 分析二makePathElementsprivate 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方法--><Buttonandroid: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方法--><Buttonandroid: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.Guidelineandroid: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 {@Overrideprotected void onCreate(Bundle savedInstanceState) {super.onCreate(savedInstanceState);setContentView(R.layout.activity_main);//模拟热修复实现,需要读取sd卡的文件,所以高于6.0的系统需要申请运行时权限//判断当前系统是否高于或等于6.0if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {String[] perms = {Manifest.permission.WRITE_EXTERNAL_STORAGE};//当前系统大于等于6.0if (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 = 0int 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);//目标路径,私有目录里面的临时文件odexFile 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) throwsClassNotFoundException, 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集合,可能不止一个dexprivate 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文件到手机内存卡中;

开始验证,打开安装好的应用,首选允许读写权限,点击showToast按钮,首先会闪退。再次进入点击hotFix方法,完成修复。下次再进入点击showToast就不会闪退,即完成修复。
四.写在最后
非常感谢,能看到这里,因为个人水平尚浅,不免有疏漏,希望自己不会误人子弟,上面代码有疑问或错误的地方,欢迎指出。个人email:yucrun@163.com。项目地址。
