手动实现Android热修复

本文详细解析了Android应用的类加载机制,特别是DexClassLoader和PathClassLoader的工作原理,以及如何利用这些机制实现热修复,即在不发布新版本的情况下修复应用程序中的bug。作者还提供了一个简单的热修复Demo,展示了如何在Splash界面隐秘地加载修复好的dex文件以替换原有存在bug的dex。
摘要由CSDN通过智能技术生成

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 戳上面的蓝字关注我们哦!

作者:MinuitZhttps://www.jianshu.com/p/9e67e3eb129b由作者授权并原创首发

周一发布了新版本,当天晚上用户就为app未测试到的bug发飙了,恩,很快就找到了问题所在,一个容易疏忽的空指针。虽然只是一个小小的bug但是不修复是很影响用户体验的啊,如果要重新修复上线,波及范围太广了,所有用户又要重新下载。我们可以让这个bug“偷偷”的修复

1.类加载的过程

类加载由ClassLoader的实现类完成。玩过反编译的都知道,我们在解压了apk之后,最终会需要dex格式的文件来搞事,这个dex由class文件打包而成。那么安卓中,要加载dex文件中的class文件,需要用到DexClassLoader或者PathClassLoader。

我们可以直接在AS中点开,但是却无法正常查看,因为这些是系统级的源码。我们可以选择下载源码,或者直接在http://androidxref.com/中找一找。

1.1先来看看类加载器

PathClassLoader 可以加载Android系统中的dex文件DexClassLoader 可以加载任意目录的dex/zip/apk/jar文件 , 但是要指定optimizedDirectory.这两个类加载器都继承BaseDexClassLoader, 并且在构造函数中, DexClassLoader多传入了一个optimizedDirectory, 这一点先暂记一下

看一下BaseDexClassLoader的构造方法:

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;    }}

最后

最后这里放上我这段时间复习的资料,这个资料也是偶然一位朋友分享给我的,里面包含了腾讯、字节跳动、阿里、百度2019-2021面试真题解析,并且把每个技术点整理成了视频和PDF(知识脉络 + 诸多细节)。

还有 高级架构技术进阶脑图、高级进阶架构资料 帮助大家学习提升进阶,也可以分享给身边好友一起学习。

一起互勉~

网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。

需要这份系统化学习资料的朋友,可以戳这里获取

一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!

一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!

  • 26
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值