Android热修复学习笔记(二):热替换修复

1.初识热替换修复

 目前android热修方案有热替换修复和冷启动代码修复两个方向。相比之下,热替换方案适用性更为严苛,但是由于其无须重启,实时修复的功能,依然受到了很多人的关注。热替换修复的基本原理是在类已经被加载的情况下,在其Native层用补丁包中的新方法替换掉旧方法。
  在Android虚拟机中,每一个java方法都对应着一个ArtMethod,ArtMethod记录了这个java方法的所有信息,包括所需类,访问权限、代码执行地址。这样问题就简单了,如果我们能获取这个ArtMethod的指针,就可以对所包含的成员进行修改。
  理论看起来很简单,但是还是要有几个问题需要解决的。
  (1)为什么替换了ArtMethod就可以实现热替换修复?
 答:因为在ArtMethod底层类中,有两个指针作为方法的执行入口。分别为entry_point_from_interpreter和entry_point_from_compiled_code_。这两个入口是对等的,依据虚拟机执行JIT解释模式,还是AOT机器码模式,来确定使用哪个入口。值得一提的是目前在Dalvik中使用的是JIT,而在Android 5.0版本之后,ART全面代替了Dalvik,并使用了AOT模式,相比之下AOT由于预先将dex code编译为了机器码,会比JIT启动更加快速,但是这也会导致更多的内部存储空间和更长的apk安装时间。
  (2)是否可以只修改ArtMethod的方法入口,将其指向补丁包中的ArtMethod呢?
  答:不可以。ArtMethod中还有其他的的一些参数会被使用,例如方法的定义和声明等。单纯替换方法入口,而不替换旧的ArtMethod其他参数会导致错误。
  (3)热替换能否新增新的ArtMethod吗?
 答:不可以。除非新增的ArtMethod是在dex的最后面。虚拟机使用机器码的偏移来找到需要的ArtMethod位置。如果在dex中间有新的方法的插入,会导致其他的ArtMethod顺序后移,原有的对于ArtMethod的索引无效,最终导致代码运行错误。所以热替换修复无法支持新增新的方法。

2.热替换修复中的权限和编译问题

权限问题

 替换ArtMethod是一个总体思路,但是实际上在操作中还是会遇到各种各样的难题。思考这么一个问题:我们只是替换了ArtMethod的内容,但是替换方法的所属类,和原有方法的所属类是不同的类,被替换的方法有权限访问这个类的其他private方法吗?
 答案是可以的。可以发现在dex码中构造函数中调用同一个类中的私有方法时,是不做任何权限检查的。换而言之,在此时将方法偷梁换柱也不会有什么错误。但是既然机器码执行时没有权限检查。因此,我们推断在dex生成时,会有一定的权限检查。
 假设我们想要将A类中的方法test,替换成补丁包中的B类中的方法test,需要满足A类和B类是同一个classLoader加载进去的。不然就会报错。它的校验时机是在类被调用时。解决方法很简单,可以通过反射机制进行实现。(classLoader是类的一个参数,通过反射将其替换掉就可以了)
 在来看一个问题:如果我们想要反射调用被热替换的非静态方法,这时候会抛出异常。

PatchTest  pt = PatchTest();
Method method = PatchTest.class.getDeclaredMehod("test");
method.invoke(pt);

原因需要查看底层代码。反射最终调用的native方法,invokeMethod方法。invokeMethod方法会校验作用的对象是不是ArtMethod所属类型的一个实例才能通过验证。那么原因就显而易见了:热替换本质是在虚拟机为方法分配好对象后,将原方法地址指针改为补丁包中的方法指针。在底层,那个方法的所属类是补丁包中的类,即PatchTest.class.getDeclaredMehod(“test”)返回的method属于补丁包中的类,在进行反射时,会进行校验传入对象是不是所要对象的实例,因为对象实例虽然被替换,但是对象类型我们没有替换,因此校验会无法通过。
这个问题目前热替换没有解决方案,只能通过冷启动来修复

编译问题

 假如热替换要在类的层级上进行改动,比如添加类,或者修改类的属性,那么和方法层的修改又会有所不同。
 先来复习一下基础知识。

(1) 内部类编译

 内部类在编译期会被编译为跟外部类一样的顶级类。
 静态内部类和非静态内部类的区别在于非静态内部类持有外部类的引用,静态内部类不持有外部类的引用。所以在Android性能优化中建议Handle的实现尽量使用静态内部类,防止外部Activity类不能被回收导致可能的oom。
 我们都知道,内部类可以访问到外部类的私有值,而外部类可以访问到内部类的私有值,既然两者在编译后都是顶级类,这是怎么做到的呢。这是因为,在编译期间,编译期会自动帮助内部类生成获取私有值的方法,以提供给外部类使用,对于外部类也是如此。
 显然,外部类或者内部类私有值的增加会导致新方法的出现,而会造成方法增加的情况不适合artMethod替换方案。所以补丁包不能增加内部类私有值的使用(这会造成新增获取私有值的方法生成),如果一定要使用内部类私有值,那么外部类和内部类所有私有值都要改成公有。

(2) 匿名内部类

 匿名内部类就是没有名字的内部类,名称格式一般是外部类$数字编号,数字编号一般是依据该匿名内部类在外部类中出现的先后关系,依次累加命名的。
 在原有类中新增匿名内部类会造成新方法的增加,这是无解的。

(3) 静态Field,静态Field编译

 首先我们要区分clinit和init这两个方法。clinit方法包含着任何静态field初始化和静态代码块,相对于clinit方法,还有一个init方法,init方法则包含了非静态filed和非静态代码块。构造函数会被Android自动编译为init方法。其中代码快和参数的赋值会按照出现的先后顺序进行。热部署方案不支持clinit方法的修复。这个方法是在类被加载时调用,而热部署是在类加载之后。由于不支持clinit方法的热部署,所以任何静态field初始化和静态代码块的变更都无法被编译到clinit方法中,导致最后热部署的失败,只能冷启动生效。而非静态filed和非静态代码块的变更被编译到init函数中,热部署视为一个普通方法的变更,此时对于热部署是没有影响的。
 类加载进行类初始化的时候,会去调用clinit方法,一个类仅加载一次。以下三种情况都会尝试去加载一个类:

  • 创建一个类的对象(new-instance指令)
  • 调用类的静态方法(invoke-static指令)
  • 获取类的静态域的值(sget命令)
(4) final static域编译

 static 和final static修饰filed的区别:
 1.final static修饰的原始类型和string类型域(非引用类型),并不会被编译在clinit方法中,而是在类初始化执行initSfileds方法时得到了初始化赋值。
 2.final static修饰的引用类型,初始化仍然在clinit方法中。
 如果一个field是常量,那么推荐尽量使用static final作为修饰符,这句话是不太对的。因为得到优化的仅仅是final static原始类型和String类型域(非引用类型),如果是引用类型,那么是得不到优化的。
 原因:final 修饰的基本类型,在获取时是用过const/4指令获取的,String类型则只是多了一步通过获取立即数,在常量区中索引的步骤,也非常快。而非final类型,获取时用的是s-get指令,相比于const/4要慢,此外会进行各种域是否解析的校验。应用对象无论是不是final最后走的都是s-get-oject指令,所以无优化。
 修改final static基本类型或者String类型域(非引用类型域),由于在编译期间引用到基本类型的地方被立即数替换,引用到String类型域(非引用类型域)的地方被常量池索引id替换,所以在热部署模式下,最终所有引用到该final static域的方法都会被替换。因此可以使用热部署。但是final static引用类型域的初始化是在clinit方法中的,这个方法我们是没办法进行变动的,因此不能走热部署。

(5)代码编译优化对热修复带来的挑战

 首先说明一下,什么是代码优化。这个主要是两点。方法内联和方法裁剪。
 可能导致内联的原因有:
 1.方法没有被其他地方引用,毫无疑问,该方法会被内联掉。
 2.方法足够简单,比如一个方法的实现只有一行代码,该方法会被内联掉。那么任何调用该方法的地方都会被该方法的实现替换掉。
 3.方法只被一个地方调用,这个地方会被方法的实现内联掉。
 方法裁剪:
 如果方法形参没有被用到,在编译时参数就会被优化。
 热部署不允许method添加和减少,但是项目应用了混淆方法编译,那么也会导致方法的内联和裁剪,那么最后也可能导致method的新增和减少。
 解决方案:为混淆配置文件加上-dontoptimize项就不会去做方法的裁剪和内联。
 编译优化主要有两步:optimization step:会优化代码,非入口点的类和方法可以被设置为private,static或者是final,无用的参数可能被移除。preverification step:针对.class文件的预校验。前者会影响热部署,后者由于android运行的.dex文件,.class文件在编译期间就会编译为.dex文件,在art中,AOT更是预先将dex code编译为了机器码,所以preverification step也是没有必要的,所以都要避免。

(6)switch case底层实现对热替换的影响
private void init() {
        int a = 1;
        switch (a) {
            case 1:
                return;
            case 3:
                return;
            case 5:
                return;
            default:
                return;

        }
    }

 这段代码的.class文件为:

 private void init() {
        byte var1 = 1;
        switch(var1) {
        case 1:
            return;
        case 2:
        case 4:
        default:
            return;
        case 3:
            return;
        case 5:
        }
    }

可以看出1到5中不连续的case都被补上了。
在看一段并不连续的swtich代码

   private void init() {
        char a = 'a';
        switch (a) {
            case 'a':
                return;
            case 'd':
                return;
            case 'z':
                return;
            default:
                return;

        }
    }

它的.class文件则是:

  private void init() {
        byte var1 = 97;
        switch(var1) {
        case 97:
            return;
        case 100:
            return;
        case 122:
            return;
        default:
        }
    }

可以看到并不会补上对应的case。
如果将编译后的文件转为汇编码可以看到在连续的case值的情况下,编译器会使用packed-switch指令(比如1,2,3,5,7),对于空缺的部分,系统会进行补全。如果不是连续的case,使用的是sparese-switch指令。那么就不会出现这种情况。所以如果是packed-switch指令,热部署会无法解决资源id不足的情况。
解决方案:反编译dex,将packed-switch替换成sparese-switch

(7)热修复如何处理泛型

 泛型,在编译器中实现,由编译器执行类型检查和类型推断,然后生成普通的非泛型字节码。使用泛型的时候加上的类型参数,编译器在编译的时候会去掉,这个过程就称为类型擦除。在编译后,泛型最终都会变成T。set(Object t)最后就是set(Object t)。虚拟机是通过参数类型和返回类型共同确定一个方法的。如果在编译期间,编译器发现如果有一个变量的声明加上了泛型的话,编译器会自动进行强制类型转换。所以在使用泛型时,最好加上类型。
 如下所示:泛型中:子类中真正重写基类方法的是编译器自动合成的侨接方法,而子类定义的get和set上面的@Override注释只不过是假象,侨接方法的内部实现是去调用自己重写的print方法。所以,虚拟机巧妙的使用了侨接方法,来解决类型擦除和多态的冲突。

class A<T> {
    private T t;

    public T get(T t) {
        return t;
    }
}

class B extends A<Number>{
    private Number n;

    @Override
    public Number get(T t) {
        return super.get(t);
    }
}

 简单来说,B类中, T get() 和 Number get() 这两个方法是同时存在的,在通过泛型,调用A类中的get()方法时,其实是通过侨联的方式调用到了B类中的get()方法。泛型可能会增加侨接方法,这种情况,热部署是没法进行完成的。

(8)热修复如何处理lambda

 先明确一下什么是函数式接口。
 函数式接口(lambda):1.是一个接口 2.具有唯一的抽象方法。
 函数式接口和匿名内部类之间的区别:
 1.匿名内部类this指向匿名类,而函数式接口的this指向包围函数式接口的类
 2.java编译器将函数式接口编译为类的私用方法,而并使用invokedynamic字节码进行调用,而匿名内部类其实是一个和外部类平行的新类。
 lambda表达式会在外面生成一个新的方法,所以是不支持热部署方案的。

(9)访问权限对热替换的影响

 一个类的加载过程,必须经历resolve,link,init三个阶段。父类或实现接口权限控制检查主要发生在link阶段。在link阶段,如果发现加载两个类的classloader不是同一个,父类或者实现接口不是public,并且补丁类中存在非public类的访问或是非public方法或域的调用,那么就会调用失败,在加载过程中不会报错,但是在执行过程中却会崩溃。

总结

 热部署方案的限制还是主要有两点:一点是不能有新的method增加,第二点是修复了的非静态方法不能被反射调用。除此之外,热部署方案是相当优秀的。它不能用于对新的功能的添加,相比之下,冷启动修复的方法适用性会更加的广阔。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值