Android热修复原理探索与实践

最近一直在阅读微信Tinker的源码,为了加深理解和记忆,便自己动手实现了一下热修复,做了一个比较简单的demo,并写了这篇博客与大家分析,理解有误的地方还请大家指出~
阅读本文大概需要5分钟时间。

类加载器

说到热修复,那么肯定要先说一下Android中的类加载器,这里我们简单介绍一下。
在Android中,我们应用自己的Class是交由PathClassLoader来加载的,PathClassLoader的代码很简单,只有两个构造函数

public class PathClassLoader extends BaseDexClassLoader {
    public PathClassLoader(String dexPath, ClassLoader parent) {
        super(dexPath, null, null, parent);
    }
    public PathClassLoader(String dexPath, String librarySearchPath, ClassLoader parent) {
        super(dexPath, null, librarySearchPath, parent);
    }
}

我们看到他的父类其实是BaseDexClassLoader,那么奥妙一定是在这里面,我们看一下BaseDexClassLoader的源码

public class BaseDexClassLoader extends ClassLoader {
    private final DexPathList pathList;
    ...

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        List<Throwable> suppressedExceptions = new ArrayList<Throwable>();
        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;
    }

    ...
}

这里我们可以看到findClass查找Class的方法,BaseDexClassLoader重写了ClassLoaderfindClass方法,在方法内部调用了DexPathListfindClass方法,我们进去看看这个方法做了什么

final class DexPathList {
    /**
     * List of dex/resource (class path) elements.
     * Should be called pathElements, but the Facebook app uses reflection
     * to modify 'dexElements' (http://b/7726934).
     */
    private Element[] dexElements;
    ...  
    public Class<?> findClass(String name, List<Throwable> suppressed) {
        for (Element element : dexElements) {
            Class<?> clazz = element.findClass(name, definingContext, suppressed);
            if (clazz != null) {
                return clazz;
            }
        }

        if (dexElementsSuppressedExceptions != null) {
            suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions));
        }
        return null;
    }
    ...
}

可以看到,DexPathList中有一个Element数组的成员变量,根据注释可以知道这个数组是用于存放dex的路径节点的,通过它的findClass方法可以知道,通过遍历数组然后找到需要的类并返回,我们的切入点便是这里,将我们需要进行热修复的“补丁”插入到这个数组的前端,那么在寻找Class的时候便会先找到我们已经修复的类,从而实现热修复。

为了控制本文篇幅,这里就不对Android的类加载器进行太多讲解,对这一块还不是特别清楚的童鞋可以先看看鸿洋大神前阵子推送的jjlanbupt写的《Android插件化框架系列之类加载器》

动手实践

Demo

这里比较简单,就是点击SHOW按钮弹出个Toast,我们现在要做的就是通过热修复把Toast中的文本进行替换。SHOW按钮的点击事件如下

  @Override
    public void onClick(View v) {
        int viewId = v.getId();
        ...
        if (viewId == R.id.btn_show) {
            Toast.makeText(this, Test.test(), Toast.LENGTH_SHORT).show();
        }
    }

这里我们看一下Test类,也是比较简单

public class Test {
    public static String test() {
        return "hello world";
    }
}

ok,目标明确,我们要通过热修复更改test方法中返回的字符串,开始撸代码

public class Test {
    public static String test() {
        return "I am change!!!";
    }
}

首先,我们把字符串改了之后把它编译为class,再打包成jar,如果忘了如何把java编译后打包成jar的方法的同学,可以网上搜一下,这里就不再啰嗦了。
打包了jar后,别忘记要使用sdk中的dx工具将jar包转换成dx格式的jar包,工具目录在sdk目录下的**...\build-tools\ **中


在控制台使用该命令即可,现在我们已经完成了补丁包的制作,我们先来看一下执行热修复的代码

private static void patch(Context base) {
        try {
            //获取PathClassLoader加载器
            ClassLoader classLoader = MainActivity.class.getClassLoader();
            //反射获得BaseDexClassLoader中的pathList成员变量
            Field dexPathListField = classLoader.getClass().getSuperclass().getDeclaredField("pathList");
            //设为可访问
            dexPathListField.setAccessible(true);
            //获得PathClassLoader中的pathList对象
            Object pathList = dexPathListField.get(classLoader);
            //反射获得DexPathList中的dexElements成员变量
            Field dexElementsField = pathList.getClass().getDeclaredField("dexElements");
            //设为可访问
            dexElementsField.setAccessible(true);
            //获得pathList中的dexElements数组对象
            Object dexElements[] = (Object[]) dexElementsField.get(pathList);
            //反射获得pathList中的makeDexElements方法
            Method method= pathList.getClass().getDeclaredMethod("makeDexElements", ArrayList.class, File.class,
                    ArrayList.class);
            //设为可访问
            method.setAccessible(true);
            List<File> files = new ArrayList<>();
            ArrayList<IOException> suppressedExceptions = new ArrayList<IOException>();
            //获得我们的patch补丁包
            File patchFile = new File(copyAssetsFile("patch.jar", base));
            files.add(patchFile);
            //指定解压目录
            File optimizedDirectory = new File(base.getFilesDir().getAbsolutePath() + File.separator + "patch");
            if (!optimizedDirectory.exists()) {
                optimizedDirectory.mkdirs();
            }
            //执行makeDexElements方法,解析我们的补丁包获得dexElements数组
            Object dexElementsResult[] = (Object[]) method.invoke(pathList, files, optimizedDirectory, suppressedExceptions);
            //创建一个新的数组
            Object finalResult[] = (Object[]) Array.newInstance(dexElements.getClass().getComponentType(), dexElements.length + dexElementsResult.length);
            //先把我们的补丁包的dexElements数组放入刚创建的数组
            System.arraycopy(dexElementsResult, 0, finalResult, 0, dexElementsResult.length);
            //再把原来的dexElements数组放入
            System.arraycopy(dexElements, 0, finalResult, dexElementsResult.length, dexElements.length);
            //将新的数组设置回去
            dexElementsField.set(pathList, finalResult);
            for (Object o : finalResult) {
                Log.d(MainActivity.class.getSimpleName(), o.toString());
            }
        } catch (NoSuchFieldException e) {
            e.printStackTrace();
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        } catch (NoSuchMethodException e) {
            e.printStackTrace();
        } catch (InvocationTargetException e) {
            e.printStackTrace();
        }
    }

这里主要就是使用反射获取到dexElements数组,然后将我们自己的补丁包插入。因为我这里偷了个懒把补丁包直接放在assets目录下,copyAssetsFile方法是将assets目录下的文件复制到我们的app目录下,因为PathClassLoader只能读取app目录下的文件,代码也很简单,这里也贴一下

private static String copyAssetsFile(String assetsFileName,Context context) {
        String src = context.getFilesDir().getAbsolutePath() + File.separator + assetsFileName;
        InputStream inputStream = null;
        OutputStream outputStream = null;
        try {
            inputStream = context.getAssets().open(assetsFileName);
            outputStream = new BufferedOutputStream(new FileOutputStream(src));
            byte[] temp = new byte[1024];
            int len;
            while ((len = (inputStream.read(temp))) != -1) {
                outputStream.write(temp, 0, len);
            }
        } catch (FileNotFoundException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            try {
                if (inputStream != null) {
                    inputStream.close();
                }
                if (outputStream != null) {
                    outputStream.flush();
                    outputStream.close();
                }
            } catch (IOException e) {
                e.printStackTrace();
            }

        }
        return src;
    }

到这里我们代码就搞定了,但是有两个地方还是需要注意一下:

  1. 如果是在Activity中进行热修复,并且开启了instant run,那么会导致看不到热修复的效果,因为Android Studio的instant run会将ClassLoader替换成DelegateClassLoader,这里建议大家打包成apk然后提取出来安装运行就可以。
  2. 为了避免掉进CLASS_ISPREVERIFIED的坑中,这里我们使用Art的机型来运行(Android 5.0以上),具体原因大家可以参考安卓App热补丁动态修复技术介绍,微信Tinker由于采用了是全量替换dex的方法,所以也可以说是从另一个角度解决了问题。

最后我们看一下运行效果,重启app,先点击patch按钮,再点击show按钮

可以看到,patch成功。

总结

最近看Tinker的源码,确实对各种异常情况处理的很到位,收获颇丰。各位有兴趣的同学也可以去阅读一番,自己动手实践实践,确实能加深自己的理解。若本文中哪里错误的地方大家也可以在评论中指出,谢谢大家~

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 213,752评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,100评论 3 387
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 159,244评论 0 349
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,099评论 1 286
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,210评论 6 385
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,307评论 1 292
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,346评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,133评论 0 269
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,546评论 1 306
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,849评论 2 328
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,019评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,702评论 4 337
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,331评论 3 319
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,030评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,260评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,871评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,898评论 2 351

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,918评论 25 707
  • 一、简述 热修复无疑是这2年较火的新技术,是作为安卓工程师必学的技能之一。在热修复出现之前,一个已经上线的app中...
    GitLqr阅读 22,349评论 32 153
  • 从去年下半年开始,热修复技术在 Android 技术社区热了一阵子,这种不用发布新版本就可以修复线上 bug 的技...
    小小亭长阅读 6,328评论 6 19
  • 前言 好几个月之前关于AndroidApp热补丁修复火了一把,源于QQ空间团队的一篇文章安卓App热补丁动态修复技...
    lgzaaron阅读 743评论 1 3
  • 最近才意识到自己的人生其实糟透了,以前也知道它并不是那么的好,但却一直在努力去改变,但现在发觉其实还在原地打转。回...
    N皮脸阅读 798评论 0 0