手动实现 Android 热修复,给 2021 的移动开发一些建议
public?class?BaseDexClassLoader?extends?ClassLoader?{????private?final?DexPathList?pathList;????public?BaseDexClassLoader(String?dexPath,?File?optimizedDirectory,?String?libraryPath,?ClassLoader?parent){????????super(parent);????????this.pathList?=?new?DexPathList(this,?dexPath,?libraryPath,?optimizedDirectory);????}????...}
构造方法中初始化了 pathList, 传入三个参数 , 分别为 dexPath:目标文件路径(一般是 dex 文件,也可以是 jar/apk/zip 文件)所在目录。热修复时用来指定新的 dexoptimizedDirectory:dex 文件的输出目录(因为在加载 jar/apk/zip 等压缩格式的程序文件时会解压出其中的 dex 文件,该目录就是专门用于存放这些被解压出来的 dex 文件的)。libraryPath:加载程序文件时需要用到的库路径。parent:父加载器
1.2 加载类的过程
在 BaseDexClassLoader 中 , 紧接着构造函数的是一个叫 findClass 的方法 , 这个方法用来加载 dex 文件中对应的 class 文件.
@Overrideprotected?Class<?>?findClass(String?name)?throws?ClassNotFoundException?{????List<Throwable>?suppressedExceptions?=?new?ArrayList<Throwable>();????//从pathList中找到相应类名的class文件????Class?c?=?pathList.findClass(name,?suppressedExceptions);????//判空,?抛出异常????if?(c?==?null)?{????????ClassNotFoundException?cnfe?=?new?ClassNotFoundException("Didn't?find?class?\""?+?name?+?"\"?on?path:?"?+?pathList);????????for?(Throwable?t?:?suppressedExceptions)?{????????????cnfe.addSuppressed(t);????????}????????throw?cnfe;????}????return?c;}
大体上不难理解, 拿到初始化完成的 pathList 之后 , 根据类名找出相应的 class 字节码文件, 如果没有异常直接返回 class.接下来我们继续跟进 pathList
1.3 DexPathList
DexPathList 源码在这里好了, 点开源码不要慌 , 我们目前只需要知道两个东西:
构造函数. 我们在 BaseDexClassLoader 中实例化 DexPathList 需要用到 findClass 方法, 在 BaseDexClassLoader 的 findClass 中, 本质调用了 DexpathList 的 fndClass 方法.其他的方法姑且不用关心.1->构造函数
public?DexPathList(ClassLoader?definingContext,?String?dexPath,????????String?libraryPath,?File?optimizedDirectory)?{?????this.definingContext?=?definingContext;?????ArrayList<IOException>?suppressedExceptions?=?new?ArrayList<IOException>();?????//?save?dexPath?for?BaseDexClassLoader?????this.dexElements?=?makePathElements(splitDexPath(dexPath),?optimizedDirectory,?????????????????????????????????????????suppressedExceptions);?????this.nativeLibraryDirectories?=?splitPaths(libraryPath,?false);?????this.systemNativeLibraryDirectories?=?????????????splitPaths(System.getProperty("java.library.path"),?true);?????List<File>?allNativeLibraryDirectories?=?new?ArrayList<>(nativeLibraryDirectories);?????allNativeLibraryDirectories.addAll(systemNativeLibraryDirectories);?????this.nativeLibraryPathElements?=?makePathElements(allNativeLibraryDirectories,?null,???????????????????????????????????????????????????????suppressedExceptions);????}
首先 , 将传入的 classLoader 保存起来 , 接下来使用 makePathElements 方法 ,来初始化 Element 数组 .
那接下来无疑是分析 makeDexElements()方法了,因为这部分代码比较长,引用一下大神的分析:
private?static?Element[]?makeDexElements(ArrayList<File>?files,?File?optimizedDirectory,?ArrayList<IOException>?suppressedExceptions)?{????//?1.创建Element集合????ArrayList<Element>?elements?=?new?ArrayList<Element>();????//?2.遍历所有dex文件(也可能是jar、apk或zip文件)????for?(File?file?:?files)?{????????ZipFile?zip?=?null;????????DexFile?dex?=?null;????????String?name?=?file.getName();????????...????????//?如果是dex文件????????if?(name.endsWith(DEX_SUFFIX))?{????????????dex?=?loadDexFile(file,?optimizedDirectory);????????//?如果是apk、jar、zip文件(这部分在不同的Android版本中,处理方式有细微差别)????????}?else?{????????????zip?=?file;????????????dex?=?loadDexFile(file,?optimizedDirectory);????????}????????...????????//?3.将dex文件或压缩文件包装成Element对象,并添加到Element集合中????????if?((zip?!=?null)?||?(dex?!=?null))?{????????????elements.add(new?Element(file,?false,?zip,?dex));????????}????}????//?4.将Element集合转成Element数组返回????return?elements.toArray(new?Element[elements.size()]);}
总体来说,DexPathList 的构造函数是将一个个的
目标(可能是 dex、apk、jar、zip , 这些类型在一开始时就定义好了)封装成一个个 Element 对象,最后添加到 Element 集合中。
其实,Android 的类加载器(不管是 PathClassLoader,还是 DexClassLoader),它们最后只认 dex 文件,而 loadDexFile()是加载 dex 文件的核心方法,可以从 jar、apk、zip 中提取出 dex,但这里先不分析了,因为第 1 个目标已经完成,等到后面再来分析吧。
2->findClass 方法
public?Class?findClass(String?name,?List<Throwable>?suppressed)?{????for?(Element?element?:?dexElements)?{????????DexFile?dex?=?element.dexFile;????????if?(dex?!=?null)?{????????????Class?clazz?=?dex.loadClassBinaryName(name,?definingContext,?suppressed);????????????if?(clazz?!=?null)?{????????????????return?clazz;????????????}????????}????}????if?(dexElementsSuppressedExceptions?!=?null)?{????????suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions));????}????return?null;}
在 DexPathList 的构造函数中已经初始化了 dexElements,所以这个方法就很好理解了,只是对 Element 数组进行遍历,一旦找到类名与 name 相同的类时,就直接返回这个 class,找不到则返回 null。
为什么是调用 DexFile 的 loadClassBinaryName()方法来加载 class?这是因为一个 Element 对象对应一个 dex 文件,而一个 dex 文件则包含多个 class。也就是说 Element 数组中存放的是一个个的 dex 文件,而不是 class 文件!!!这可以从 Element 这个类的源码和 dex 文件的内部结构看出。
2.热修复的实现方法
加载 class 会使用 BaseDexClassLoader,在加载时,会遍历文件下的 element,并从 element 中获取 dex 文件方案 ,class 文件在 dex 里面 , 找到 dex 的方法是遍历数组 , 那么热修复的原理, 就是将改好 bug 的 dex 文件放进集合的头部, 这样遍历时会首先遍历修复好的 dex 并找到修复好的类 . 这样 , 我们就能在没有发布新版本的情况下 , 修改现有的 bug。虽然我们无法改变现有的 dex 文件,但是遍历的顺序是从前往后的,在旧 dex 中的目标 class 是没有机会上场的。
3.手撸一个热修复 Demo
在了解了大致的热修复过程之后,我们要准备好以下几个东西:
带有 bug 的 apk,并且可以获取到 dex 文件来修复已修复 bug 的 dex 文件因为修复工作是需要隐秘的进行的 , 毕竟有 bug 也不是什么光彩的事儿 , 所以我吧 dex 的插入操作放在 Splash 界面中. 在 Splash 时先检测有没有 dex 文件, 如果有则进行插入 , 否则直接进入 MainActivity.1->写一个有 bug 的程序哇, 是不是第一次见到这么爽的需求~首先在 MainActivty 中写一个 bug 出来:
public?class?BugTest?{????public?void?getBug(Context?context)?{????????//模拟一个bug????????int?i?=?10;????????int?a?=?0;????????Toast.makeText(context,?"Hello,Minuit:"?+?i?/?a,?Toast.LENGTH_SHORT).show();????}}public?class?MainActivity?extends?AppCompatActivity?{????Button?btnFix;????@Override????protected?void?onCreate(Bundle?savedInstanceState)?{????????super.onCreate(savedInstanceState);????????setContentView(R.layout.activity_main);????????init();????????new?BugTest().getBug(MainActivity.this);????}????private?void?init()?{????????btnFix?=?findViewById(R.id.btn_fix);????}}
运行这段代码必然会报错的 , 但是我们要首先吧这段代码装到手机上 , 方便之后的修复.
接下来编写 SplashActivity 以及工具类 . 大家可以根据具体逻辑修改
/***@author?Minuit*@time?2018/6/25?0025?15:50*/public?class?FixDexUtil?{????private?static?final?String?DEX_SUFFIX?=?".dex";????private?static?final?String?APK_SUFFIX?=?".apk";????private?static?final?String?JAR_SUFFIX?=?".jar";????private?static?final?String?ZIP_SUFFIX?=?".zip";????public?static?final?String?DEX_DIR?=?"odex";????private?static?final?String?OPTIMIZE_DEX_DIR?=?"optimize_dex";????private?static?HashSet<File>?loadedDex?=?new?HashSet<>();????static?{????????loadedDex.clear();????}????/**?????*?加载补丁,使用默认目录:data/data/包名/files/odex?????*?????*?@param?context?????*/????public?static?void?loadFixedDex(Context?context)?{????????loadFixedDex(context,?null);????}????/**?????*?加载补丁?????*?????*?@param?context???????上下文?????*?@param?patchFilesDir?补丁所在目录?????*/????public?static?void?loadFixedDex(Context?context,?File?patchFilesDir)?{????????//?dex合并之前的dex????????doDexInject(context,?loadedDex);????}????/**????*@author?Minuit????*@time?2018/6/25?0025?15:51????*@desc?验证是否需要热修复????*/????public?static?boolean?isGoingToFix(@NonNull?Context?context)?{????????boolean?canFix?=?false;????????File?externalStorageDirectory?=?Environment.getExternalStorageDirectory();????????//?遍历所有的修复dex?,?因为可能是多个dex修复包????????File?fileDir?=?externalStorageDirectory?!=?null??????????????????externalStorageDirectory?:????????????????new?File(context.getFilesDir(),?DEX_DIR);//?data/data/包名/files/odex(这个可以任意位置)????????File[]?listFiles?=?fileDir.listFiles();????????for?(File?file?:?listFiles)?{????????????if?(file.getName().startsWith("classes")?&&????????????????????(file.getName().endsWith(DEX_SUFFIX)????????????????????????????||?file.getName().endsWith(APK_SUFFIX)????????????????????????????||?file.getName().endsWith(JAR_SUFFIX)????????????????????????????||?file.getName().endsWith(ZIP_SUFFIX)))?{????????????????loadedDex.add(file);//?存入集合????????????????//有目标dex文件,?需要修复????????????????canFix?=?true;????????????}????????}????????return?canFix;????}????private?static?void?doDexInject(Context?appContext,?HashSet<File>?loadedDex)?{????????String?optimizeDir?=?appContext.getFilesDir().getAbsolutePath()?+????????????????File.separator?+?OPTIMIZE_DEX_DIR;????????//?data/data/包名/files/optimize_dex(这个必须是自己程序下的目录)????????File?fopt?=?new?File(optimizeDir);????????if?(!fopt.exists())?{????????????fopt.mkdirs();????????}????????try?{????????????//?1.加载应用程序dex的Loader????????????PathClassLoader?pathLoader?=?(PathClassLoader)?appContext.getClassLoader();????????????for?(File?dex?:?loadedDex)?{????????????????//?2.加载指定的修复的dex文件的Loader????????????????DexClassLoader?dexLoader?=?new?DexClassLoader(????????????????????????dex.getAbsolutePath(),//?修复好的dex(补丁)所在目录????????????????????????fopt.getAbsolutePath(),//?存放dex的解压目录(用于jar、zip、apk格式的补丁)????????????????????????null,//?加载dex时需要的库????????????????????????pathLoader//?父类加载器????????????????);????????????????//?3.开始合并?????????????????//?合并的目标是Element[],重新赋值它的值即可????????????????/**?????????????????*?BaseDexClassLoader中有?变量:?DexPathList?pathList?????????????????*?DexPathList中有?变量?Element[]?dexElements?????????????????*?依次反射即可?????????????????*/????????????????//3.1?准备好pathList的引用????????????????Object?dexPathList?=?getPathList(dexLoader);????????????????Object?pathPathList?=?getPathList(pathLoader);????????????????//3.2?从pathList中反射出element集合?????????????????Object?leftDexElements?=?getDexElements(dexPathList);????????????????Object?rightDexElements?=?getDexElements(pathPathList);????????????????//3.3?合并两个dex数组????????????????Object?dexElements?=?combineArray(leftDexElements,?rightDexElements);????????????????//?重写给PathList里面的Element[]?dexElements;赋值????????????????Object?pathList?=?getPathList(pathLoader);//?一定要重新获取,不要用pathPathList,会报错????????????????setField(pathList,?pathList.getClass(),?"dexElements",?dexElements);????????????}????????????Toast.makeText(appContext,?"修复完成",?Toast.LENGTH_SHORT).show();????????}?catch?(Exception?e)?{????????????e.printStackTrace();????????}????}????/**?????*?反射给对象中的属性重新赋值?????*/????private?static?void?setField(Object?obj,?Class<?>?cl,?String?field,?Object?value)?throws?NoSuchFieldException,?IllegalAccessException?{????????Field?declaredField?=?cl.getDeclaredField(field);????????declaredField.setAccessible(true);????????declaredField.set(obj,?value);????}????/**?????*?反射得到对象中的属性值?????*/????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);????}????/**?????*?反射得到类加载器中的pathList对象?????*/????private?static?Object?getPathList(Object?baseDexClassLoader)?throws?ClassNotFoundException,?NoSuchFieldException,?IllegalAccessException?{????????return?getField(baseDexClassLoader,?Class.forName("dalvik.system.BaseDexClassLoader"),?"pathList");????}????/**?????*?反射得到pathList中的dexElements?????*/????private?static?Object?getDexElements(Object?pathList)?throws?NoSuchFieldException,?IllegalAccessException?{????????return?getField(pathList,?pathList.getClass(),?"dexElements");????}????/**?????*?数组合并?????*/????private?static?Object?combineArray(Object?arrayLhs,?Object?arrayRhs)?{????????Class<?>?clazz?=?arrayLhs.getClass().getComponentType();????????int?i?=?Array.getLength(arrayLhs);//?得到左数组长度(补丁数组)????????int?j?=?Array.getLength(arrayRhs);//?得到原dex数组长度????????int?k?=?i?+?j;//?得到总数组长度(补丁数组+原dex数组)????????Object?result?=?Array.newInstance(clazz,?k);//?创建一个类型为clazz,长度为k的新数组????????System.arraycopy(arrayLhs,?0,?result,?0,?i);????????System.arraycopy(arrayRhs,?0,?result,?i,?j);????????return?result;????}}
接下来 , 我们在 Splash 中进行检测以及修复工作
if?(FixDexUtil.isGoingToFix(activity))?{????????????FixDexUtil.loadFixedDex(activity,?Environment.getExternalStorageDirectory());????????}????????new?Thread(new?Runnable()?{????????????@Override????????????public?void?run()?{????????????????try?{????????????????????Thread.sleep(2000);????????????????????startActivity(new?Intent(activity,MainActivity.class));????????????????????finish();????????????????}?catch?(InterruptedException?e)?{????????????????????e.printStackTrace();????????????????}????????????}????????}).start();
接下来 , 在 As 中一定一定要把 instance run 取消勾选,因为 instance run 用到的原理也是热修复的原理,也就是在重新运行 app 时不会完整的安装,只会安装你修改过的代码。
![](https://static001.infoq.cn/static/write/img/img-copy-disabled.4f2g7h.png)
编译运行:
![](https://static001.infoq.cn/static/write/img/img-copy-disabled.4f2g7h.png)
恩 , 接下来我们要修复 bug,并且将修复好的包放进 sd 卡里面,这样在 Splash 开始时就会自动遍历到 dex。
评论