Android热修复方案的兼容策略CLASS_ISPREVERIFIED问题

前言

上文Android热修复主流方案盘点 中,提到了4种比较出名的热修复方案,

  • 腾讯Qzone超级补丁的multidex方案,
  • 腾讯Tinker的dexdiff方案,
  • 阿里andFix纯native方法指针重定向方案(已废弃,因为有了新的替代方案 sophix ),
  • 美团的robustinstantRun方案。

然而,在android多版本的兼容上,这些热修复方案多多少少存在一些问题。
本文思路来源为两篇官方技术博文:
安卓App热补丁动态修复技术介绍
Android N混合编译与对热补丁影响解析
可惜大佬发文一般人看不懂,所以我重新解读一下,更通俗易懂地展示这两个坑的解决方案.

正文大纲

  • CLASS_ISPREVERIFIED兼容问题
  • Android N混合编译兼容问题

正文

CLASS_ISPREVERIFIED 兼容问题

Demo地址:https://github.com/18598925736/HotUpdateDemo/tree/4.4_crash_solution

问题描述

一句话描述问题:

在apk安装的时候,Dalvik虚拟机如果发现 一个类A它所引用的其他类,和它自己都处于同一个dex文件内部,那么类A就会被打上一个 CLASS_ISPREVERIFIED 标记,从而提高性能。那么按照这个思路,如果类A引用了一个有bug的类Util,然后我们用multidex热修复方案给他推了一个patch.dex,然后重启修复,这个类已经被打上了标记,但是重启app之后,它所引用的类Util 此时和它又不处于一个dex内(新的Util类在patch.dex内)。此时,起了一个冲突,既打上了标记,又发现不处于一个dex内的引用类,程序就会报错。

CLASS_ISPREVERIFIED 分4个单词 class , is , pre verified , 是否 被预先 校验

此问题只会出现在Dalvik虚拟机之下(4.4 sdk19 以下默认使用dalvik,5.0 sdk 21 以后便默认使用art虚拟机),art不会有类似问题。所以可以认为此问题只出现在5.0以下(不含5.0)的机器上.

问题演示

我使用的是上一篇文章的 Android Muitldex热更新修复方案原理demo
下载之后,直接运行在SDK 19 android4.4的模拟器上。
这是一个已经加入了补丁包fix.dex的demo工程。
当你直接运行,会发现程序崩溃,报错如下:

image.png
大概意思就是 有一个类的引用预先校验了,但是没有找到预想中的实现。这就是由于 被打上了CLASS_ISPREVERIFIED标记之后又执行了补丁修复,造成冲突。

解决方案

既然问题的根源在于 引用Util的A类被打上了 CLASS_ISPREVIRIFIED标记,那么有没有办法让这些类不被打上标记呢?

思考:

:如何防止我们源代码中所有的类被打上CLASS_ISPPREVERIFIED标记?
答:
理论上,一个android工程中所有的java类(除了Application之外)都有可能需要热修复。如果让这些类都去引用一个另一个dex文件之下的class,就能防止在dex解析的时候被打CLASS_ISPPREVERIFIED标记。
但是这样有一个弊端,就是 CLASS_ISPPREVERIFIED带来的性能提升将会消失。但是既然出现bug,要解决,总要付出一点代价。代价且容后再说。

行动

  1. 创建一个hack module,其中创建一个空白java类 AntilazyLoad。编译它,得到 AntilazyLoad.class 然后用dx命令,将它打包成hack.dex


具体的命令为:dx --dex --output=hack.dex ./com/zhou/hack/Antilazyload.class

dx命令的位置为下图所示,注意加入到系统环境变量path

  1. 使用gradle插桩的方式,干涉gradle打包流程,在生成javac命令之后,在dx命令之前,在所有我们编写的所有class里面的构造函数内部,加上 AntilazyLoad 的直接使用(反射引用是不行的)。
    这一句话的信息量有点大,分步解释:
  • gradle插桩
    类比为 用 gson,fastjson这类第三方框架来修改json文件。我们也可以利用 特定的手段来自由修改class文件。这类技术框架有ASM,AspectJ, Javassist等。 由于我们androidStudiogradle来构建项目,所以,还需要我们自定义gradle插件,来在合适的时机 使用ASM 这种技术框架来在class文件中修改字节码内容。
  • javac命令之后dx命令之前
    gradle执行项目构建,是通过一个一个的task来进行。比如 将java文件用javac命令编译为 class,任务名字叫做::app:compileDebugJavaWithJavac
    我们进行插桩的时机,便是上图中javac之后,dx之前。
    另外,任何一个Task,都有input元素和output元素,以及可以设置doFirst闭包,表示执行任务之前先执行一段逻辑,设置doLast,表示执行任务执行之后再执行一段逻辑。

Demo完全解读

上面的解决方案,只是大略提及方案思路,真实去执行方案的时候会涉及到非常多的小细节,我认为有必要将细节中比较重要的部分逐一分步详解。

项目结构

hack module

image.png

这个Module的作用,仅仅是生成一个普通的java类的class文件,然后用class 通过dx命令生成hack.dex(名字随意,只不过约定俗称用的hack)文件而已。没有别的。得到 hack.dex之后,它的使命就完成了。生成dex的方法上文已详述。

buildSrc module

image.png

这个Module只是一个普通的javaModule,但是,它是androidStudio中比较特殊的一个名字,当你在空白项目中创建一个buildSrc目录之后,执行同步,as就会为你自动生成如图所示的module结构。因为,这个名字是gradle插件特有的。
image.png

HotfixPlugin.java 作为gradle插件的核心类,其关键代码如下:

project.afterEvaluate(new Action<Project>() {
            @Override
            public void execute(Project project) {
                //找到额外属性
                final HotfixExt hotfixExt = project.getExtensions().findByType(HotfixExt.class);
                // 找到系统属性
                AppExtension appExtension = project.getExtensions().findByType(AppExtension.class);
                DomainObjectSet<ApplicationVariant> applicationVariants = appExtension.getApplicationVariants();
                for (ApplicationVariant var : applicationVariants) {
                    final String variantName = var.getName();//debug  release 因为任务的名字是release/debug有关,我们要找到确切的切入点,就必须拿到这个值
                    final String myTaskName = "transformClassesWithDexBuilderFor" + firstCharUpperCase(variantName);
                    final Task task = project.getTasks().findByName(myTaskName);
                    task.doFirst(new Action<Task>() {
                        @Override
                        public void execute(Task task) {
                            System.out.println("\n\n\n=================task.doFirst=================\n\n\n");
                            Set<File> files = task.getInputs().getFiles().getFiles();
                            for (File file : files) {
                                String filePath = file.getAbsolutePath();
                                if (filePath.endsWith(".jar")) {
                                    processJar(file);
                                } else if (filePath.endsWith(".class")) {
                                    processClass(variantName, file); //对于class的处理完毕
                                }
                            }
                            System.out.println("\n\n\n=================task.doFirst   end=================\n\n\n");
                        }
                    });
                }
                System.out.println("=================end=================");
            }
        });
    }
  • AppExtension appExtension = project.getExtensions().findByType(AppExtension.class);

AppExtension 是gradle在编译项目的时候读取来自android app的配置.

两张图看明白:
appModule的build.gradle

appModule的build.gradle 中我们写的这些配置,在AppExtension中可以一一中找到get方法。

  • DomainObjectSet<ApplicationVariant> applicationVariants = appExtension.getApplicationVariants();

这个所谓的 ApplicationVariant(翻译:app变体) 是android打包的中一个很常见的概念,它就是 debug/release . 由于我们去进行debug 或者 release打包的时候,几乎所有的gradle命令会附带上 debug/release .


所以,下一步我们对指定的task进行修改,要拿到这个值。

  • 我们的思路是 在java变成class之后在class变成 dex之前,将class进行ASM插桩。所以,我们要找的 gradle task 是 : transformClassesWithDexBuilderForRelease 或者 transformClassesWithDexBuilderForDebug 给它重写doFirst。
    也可以 找到 gradle taskcompileReleaseJavaWithJavac 或者 compileDebugJavaWithJavac. 给它重写 doLast。效果相同。

所以:
image.png
  • 开始重写doFirst,所有task都有input输入和output输出。我们这里获取它的输入getInputs(). 然后进行文件遍历。发现,既有jar文件也有class文件。jar文件是class的压缩包。要进行插装,必须分别处理。class文件直接插桩。jar文件解压缩之后插桩。
image.png
  • class文件的插桩。

注:有些class,不需要热修复,也就不需要插桩,比如android support包,或者androidx兼容包。比如MyApplication类。

private void processClass(String variantName, File file) {
        String path = file.getAbsolutePath();//拿到完整路径,如下:
        // D:\studydemo\hotfix\HotUpdateDemo\app\build\intermediates\classes\debug\com\example\administrator\myapplication\MainActivity.class
        // 这么一大串,包括三个部分,以debug为分界。
        // D:\studydemo\hotfix\HotUpdateDemo\app\build\intermediates\classes\ 是目录
        // debug\ 是编译变体名
        // com\example\administrator\myapplication\MainActivity.class 类完整路径
        //将他进行分割
        String className = path.split(variantName)[1].substring(1);
//        System.out.println("className:" + className);//拿到完整类名 com\example\administrator\myapplication\MainActivity.class
        // 由于有些class我们不用执行插桩,包括Application,也包括 androidx和support包
        if (isAndroidClz(className) || isApplicationClz(className)) {
            return;
        }
        // 能走到这里的,都是需要插桩的,那么,在这个任务执行时,我需要:
        // 使用文件流
        try {
            FileInputStream fis = new FileInputStream(path);
            byte[] byteCode = referHackWhenInit(fis);
            fis.close();

            FileOutputStream fos = new FileOutputStream(path);
            fos.write(byteCode);
            fos.close();

            //成功给class加了一行代码
            System.out.println("className:" + className + "植入hack成功");
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
  • jar文件的插桩:
private void processJar(File file) {
        try {
            // 先预备一个备份文件
            File bakJar = new File(file.getParent(), file.getName() + ".bak");
            JarOutputStream jos = new JarOutputStream(new FileOutputStream(bakJar));
            JarFile jarFile = new JarFile(file);
            Enumeration<JarEntry> entries = jarFile.entries(); // 准备遍历
            while (entries.hasMoreElements()) {
                JarEntry jarEntry = entries.nextElement(); // 迭代器遍历

                jos.putNextEntry(new JarEntry(jarEntry.getName()));
                InputStream is = jarFile.getInputStream(jarEntry);

                String className = jarEntry.getName();
                if (className.endsWith(".class") && !isApplicationClz(className)
                        && !isAndroidClz(className)) {
                    byte[] byteCode = referHackWhenInit(is);
                    jos.write(byteCode);
                } else {
                    //输出到临时文件
                    jos.write(IOUtils.toByteArray(is));
                }
                jos.closeEntry();
            }
            jos.close();
            jarFile.close();
            file.delete();
            bakJar.renameTo(file);
            //成功给class加了一行代码
            System.out.println("jarName:" + file.getAbsolutePath() + "植入hack成功");
        } catch (Exception e) {

        }
    }
  • ASM插桩写法:

以下代码,将这样一句代码插入到了构造函数中Class var10000 = Antilazyload.class;

插桩之后,java类的构造函数如图所示:
image.png
.

app module

相比于之前的app module,差别并不大,如图:

差别1: 多出来的hack.dex是用dx命令前面生成的。必须放到assets中。
差别2:之前没考虑要插入多个dex的情况,所以,hook参数是File,现在要插入fix.dex和hack.dex,参数改为 List<File> .

经本人多次验证。
Demo地址:https://github.com/18598925736/HotUpdateDemo/tree/4.4_crash_solution
可在4.4版本模拟器上 正常进行multidex热修复.

结语

4.4的multidex CLASS_ISPREVERIFIED热修复的问题已经解决. 然而到了7.0 又出了一个混合编译的问题。下一篇详解.

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