阅读 1257

Android热修复技术选择和原理分析

背景

热修复就是通过下发补丁包,让已安装的客户端动态更新,用户不用重新安装APP,就能够修复软件缺陷。

热修复技术对比

1.公司角度

大致可以分为阿里系和腾讯系和其他,如下:

  1. 阿里系
    Dexposed 开源,实时修复
    AndFix 开源,实时生效
    HotFix 阿里百川,未开源,免费、实时修复
    Sophix 未开源,商业收费,实时生效/冷启动修复
    HotFix是AndFix的优化版本,Sophix是HotFix的优化版本。目前阿里系主推是Sophix。
  2. 腾讯系
    Qzone超级补丁 QQ空间,未开源,冷启动修复
    QFix 手Q团队,开源,冷启动修复
    Tinker 微信团队,开源,冷启动修复。提供分发管理,基础版免费
  3. 其他
    Robust 美团, 开源,实时修复
    Nuwa 大众点评,开源,冷启动修复
    Amigo 饿了么,开源,冷启动修复
    RocooFix 百度金融,开源,冷启动修复
    Aceso 美丽说蘑菇街,开源,实时修复
2.技术角度
1.代码修复的角度

2.代码修复,资源修复,so修复这三个角度

3.已开源的热修复框架数据对比
框架名称和github地址star数量最后一次更新版本
Dexposed4.3k5 years ago0.1.8
AndFix6.8k4 years ago0.5.0
QFix83 years ago
Nuwa2.9k5 years ago1.0.0
Tinker15.2k29 days ago1.9.14.7
Robust3.7k4 months ago0.4.99
Aceso7913 years ago0.0.3
Amigo1.3k3 years ago0.6.*
RicooFix1.6k4 years ago

可以看到,近期还在更新的有Tinker和Robust,其他的都是至少三年之前的更新。

如何选择热修复框架

三个方面进行考虑
1.项目需求

方法级别修复,资源修复,so库的修复

对平台兼容性要求和成功率要求

有需求对分发进行控制,对监控数据进行统计,补丁包进行管理

是否付费

2.学习,使用成本

学习成本

代码侵入性

调试维护成本

3.技术保障,稳定性

比如GitHub Star,大公司技术保障,专人维护

热度高,社区活跃

小结

从这三个方面考虑,最后筛选出三个比较优秀的热修复库,Sophix,Tinker,Robust

如果考虑付费,Sophix和Tinker付费版(云服务),我支持Sophix,性能消耗低,支持即时生效,对代码无侵入,免费阈值的支持更好。

如果不考虑付费,只需要支持方法级别的Bug修复,不支持资源以及so库,推荐使用Robust,否则使用Tinker免费版。

当然如果公司实力够牛逼,可以考虑自研,灵活性以及可控性最强。

代码、资源、so库修复

AndroidManifest出现Bug是无法修复的,因为它是由系统进行解析的,系统会直接获取安装包里唯一的AndroidMainfest.xml文件,在解析过程不会访问补丁包信息。

代码修复:任何的热修复方案,想要改变代码逻辑,都需要在补丁包里包含一个新逻辑的dex文件。

资源修复:有些资源,比如桌面图标,通知栏图标以及RemoteView之类的资源,是由系统直接解析安装包里的资源得到的,因此对于这类资源,任何热修复方案都无法进行资源替换和修复。

so库修复:so库的修复思路应该是最明确的。在Android系统中,所有的so库都是由System.load进行加载的,因此只要找到办法在加载的时候优先加载补丁包的so库,而不是加载原有安装包的so库,就能够进行完整的底层代码替换了。

代码修复

代码修复主要有三大主要方案,阿里系的底层替换和腾讯系的类加载方案以及美团的javaHook方案(Instant Run原理)。

  1. 底层替换方案限制颇多,但是时效性最好,加载轻快,立即见效。

    传统的底层替换方案(Dexposed,AndFix),依赖直接修改虚拟机方法实体的具体字段实现的。不同厂商/版本对ArtMethod结构体的结构修改带来的问题。每个Java方法在art中都对应着一个ArtMethod,ArtMethod记录了这个Java方法的所有信息,包括所属类,访问权限,代码执行地址等等。

    实现一种不修改底层具体结构的替换方式,解决兼容性问题,代码量大大减少。native层面替换,把ArtMethod方法作为整体进行替换。

  2. 类加载方案时效性差,需要重新类启动才能见效,但修复范围广,限制少。

    类加载方案的原理是在App重新启动后让Classloader去加载新的类。

    QQ空间方案会入侵打包流程,并且为hack添加一些无用的信息,不优雅。

    QFix方案需要获取底层虚拟机的函数,稳定性不够,无法新增public函数

    Tinker方案是完整的全量的dex文件加载,将补丁合成的方案做到了极致,从dex的方法和指令维度进行全量合成,对于dex内容的比较粒度过细,实现较为复杂,性能消耗比较严重,时空转换性价比不高。

    dex比较的最佳粒度,应该是类的维度,采用全量合成dex的技术,这个技术方案是从手机淘宝插件化框架Atlas汲取的。直接利用Android原有的类查找与合成机制,快速合成新的的全量dex文件。这样既不需要处理合成时的方法数超过原有方法数的情况,也不会对dex的结构进行破坏性重构。

    ​ 我们重新编排了包中dex文件的顺序。这样虚拟机在查找类的时候,会优先找到classes.dex中的类,然后才是classes2.dex,classes3.dex,也可以看作是dex文件级别的类插桩方案。这个方式十分巧妙,它对旧包与补丁包中的classes.dex顺序进行了打破和重组,最终使系统可以自然的识别到这个顺序,以实现类覆盖的目的,大大减少合成补丁的开销。

    Sophix使用了两者的结合,自动选择,小修改,在底层替换方案限制范围内的,直接采用底层替换热修复,可以做到及时生效。其他采用类加载替换方案。

  3. Robust的JavaHook方案的原理与Instant Run的代码插桩原理一致,优点是实时生效,不需要重新启动,高兼容性(Robust只是在正常的使用DexClassLoader),高稳定性,修复成功率高达99%。支持方法级别的修复,包括静态方法。支持增加方法和类。支持ProGuard的混淆,内联优化等操作。

    缺点是代码是侵入式的,会在原有的类中加入相关代码,so库和资源的替换暂时不支持。会增大apk的体积,平均一个函数会比原来增加17.47个字节,10万个函数会增加1.67M。

Sophix方案

热部署(及时生效)

在native层面替换,把ArtMethod方法作为整体进行替换,实时生效,但同时也会带来诸多限制:

访问权限的问题
  1. 方法调用时的权限检查
  2. 同名包下的权限问题(设置新类的ClassLoader为原来类就可以了,通过反射进行设置)
  3. 反射调用非静态方法产生的问题
及时生效带来的限制

以下两种情况是不适用的:

  1. 引起了原有的类中发生结构变化的修改(字段,方法增加减少),改成以冷启动支持
  2. 修复了的非静态方法会被反射调用,改成以冷启动方式支持
编译期和语言特性的影响

1. 内部类编译

  1. 静态内部类/非静态内部类的区别

  2. 内部类和外部类互相访问

    外/内部类为了访问内/外部类私有的域/方法,编译器会自动为内部类生成access$数字编号相关方法,JVM规范,好像是提供了一个静态方法

  3. 热部署解决方案

    一个外部类如果有内部类,把所有的method/field的私有访问权限改成protected或public或者默认访问权限。

    同时把内部类所有的method/field的私有访问权限改成protected或public或者默认访问权限。

2. 匿名内部类编译

  1. 匿名内部类编译命名规则(access$**)

  2. 热部署解决方案

    应该极力避免插入一个新的匿名内部类。当然如果匿名内部类是插入到外部类的末尾,那么是允许的。

3. 有趣的域编译

  1. 静态field,非静态field

  2. 静态field初始化,静态代码块(不支持<clinit>方法的热部署,只能冷启动生效)

    静态代码块和静态域初始化在clinit中的先后关系就是两者出现在源码中的先后关系

    以下三种情况都会尝试去加载一个类:

    1. 创建一个类的对象(new-instance指令)
    2. 调用类的静态方法(invoke-static指令)
    3. 获取类的静态域的值(sget指令)
  3. 非静态field初始化,非静态代码块

  4. 热部署解决方案

    clinit只能冷启动,init无影响

4. final static预编译规则

​ final static修饰的基本类型或String常量类型,没有被编译到clinit方法中

  1. final static域编译规则

    final static修饰的原始类型和String类型域(非引用类型),并不会被编译在clinit方法中,而是在初始化执行initSFields方法时得到了初始化赋值。

    final static修饰引用类型,初始化仍在clinit方法中

  2. final static域优化原理

    final static引用类型没有得到优化,只是基本数据类型和String类型域(非引用类型)

  3. 热部署解决方案

    修改 final static 基本类型或者 String 类型域(非引用类型域),由于在编译期间引用到基本类型的地方被立即数替换,引用到 String 类型(非引用类型)的地方被常量池索引 ID 替换,所以在热部署模式下,最终所有引用到该 final static 域的方法都会被替换。实际上此时仍然可以执行热部署万案。

    修改 final static 引用类型域,是不允许的,因为这个 field 的初始化会被编译到 clinit 万法中,所以此时没法走热部署 。

5. 有趣的方法编译

  1. 应用混淆方法编译

    项目如果应用了混淆方法编译,可能导致方法内联和裁剪,最后可能导致method的新增或减少。

  2. 方法内联

    以下几种可能导致方法被内联掉:

    • 方法没有被其他任何地方引用
    • 方法足够简单,比如一个方法的实现就只有一行代码
    • 方法只被一个地方引用

    可能导致只能走冷启动方案

  3. 方法裁剪

    可能导致只能走冷启动方案

    如果让该参数不被裁剪,不让编译器在优化的时候认为引用了一个无用的参数就好了,这里介绍一种最有效的方法:

    public static void test(Context context){
            if(Boolean.FALSE.booleanValue()){
          context.getApplicationContext();
        }
        Log.d("BaseBug","test")
    }
    复制代码

    这里不能使用基本类型,必须使用包装类Boolean,因为如果使用基本数据类型if语句可能也会被优化掉。

  4. 热部署解决方案

    混淆配置文件加上-dontoptimize项就不会去做方法内联和裁剪,proguard-android-optimize.txt 或者 proguard-android.txt,两者的区别就是后者应用了 -dontoptimize 这一项配置而前者没有应用。

    混淆库给热部署带来的影响主要在optimization阶段

    optimzation step: -dontoptimize(热补丁模式下)

    preverification step: -dontpreverify

    混淆库对反射的处理(proguard.jar),shrinking阶段,obfuscation阶段

6. switch case语句编译

​ packed-switch,sparse-switch指令

​ 可能导致资源替换不全

​ 热部署解决方案:反编译->资源ID->替换->回编译

7. 泛型编译

​ 泛型擦除,类型擦除与多态的冲突和解决,泛型类型转换

​ 热部署解决方案

​ 如果由 B extends A 变成了 B extends A<Number>,那么就可能会新增对应的桥接方法 ,此时新增了方法,只能走冷 部署,如果要避免,那就避免类似上面的那种修复。

​ 泛型的系统注解

8. Lambda表达式编译

​ Lambda表达式可能导致方法的新增或减少

  1. Lambda表达式的编译规则

    函数式接口具有两个主要特征:它是一个接口,这个接口具有唯一的抽象方法;我们将同时满足这两个特性的接口称为函数式接口。比如java.lang.Runnable和java.util.Comparator,函数式接口和匿名内部类的区别如下:

    • 关键字this,匿名类的this关键字指向匿名内部类,而Lambda表达式的this关键字指向包围Lambda表达式的类。
    • 编译方式,Java编译器将Lambda表达式编译成类的私有方法,使用Java7的invokeddynnamic字节码指令来动态绑定这个方法。Java编译器将匿名内部类编译成外部类$数字编号的新类。

    编译器间自动生成私有静态的lambda$main$**(*)方法,这个方法的实现其实就是Lambda表达式里面的逻辑。

    invokedynamic指令执行Lambda表达式

    比较与匿名内部类的区别,发现并没有在磁盘上生成外部类$数字编号的新类

    invokedynamic指令执行时实际上回去调用java/lang/invoke/LambdaMetafactory的metafactory静态方法。这个静态方法实际上会在运行时生成一个函数式接口的具体类。然后具体类会调用Test的私有静态lambda$main$**(*)方法。

    我们可以通过添加- Djdk.internal . lambda .dumpProxyClasses 这个虚拟机运行参数,那么运行时会将生成的新类的 .class 内窑输出到一个文件中。

Sun/Oracle Hotspot VM和Android虚拟机解释Lambda表达式的异同点

上面的方式是Sun/Oracle Hotspot VM解释.class文件中lambda表达式的方式,Android虚拟机首先通过javac把源代码编译成.class,然后再通过dx工具优化成适合移动设备的dex字节码文件。Android中如果要使用Java8的语言特性,需要使用新的Jack工具类来替换掉就的工具类编译。细节见书籍。

很明显可以看到.dex字节码文件和.class字节码文件对Lambda表达式处理的异同点:

共同点:编译期间都会为外部类合成一个static辅助方法,该方法内部逻辑实现Lambda表达式。

不同点:

  1. .class字节码通过invoke-dynamic指令执行Lambda表达式。而.dex字节码中执行Lambda表达式跟普通方法调用没有任何区别。

  2. .class字节码中运行时生成新类,.dex字节码中编译期间生成新类

  1. 热部署解决方案

    打补丁是通过反编译为smail然后跟新APK跟基线APK进行差异对比,得到最后的补丁包。

    新增一Lambda表达式,会导致外部类新增一个辅助方法,所以此时不支持走热部署方案。如果Lambda表达式中访问非静态field/method,就会持有外部类的引用。

    增加或减少一个Lambda表达式会导致类方法比较错乱,所以会导致热部署失败。

    修改一个Lambda表达式,可能导致新增field,所以此时也导致热部署失败。

9. 访问权限检查对热替换的影响

  1. 类加载阶段父类/实现接口访问权限检查

    一个类的加载过程,必须经历resolve,link,init三个阶段,父类或实现接口权限控制检查主要发生在link阶段。

  2. 类校验阶段访问权限检查

10. <clinit>方法

由于补丁热部署的特殊性,不允许类结构变更以及不允许变更<clinit>方法,所以补丁工具如果发现了以上几种限制情况,只能走冷启动。可能有时候在源码层上来看并没有新增或减少method和 field,但是实际上由于要满足 Java 各种语法特性的需求,所以编译器会在编译期间自动合成一些method 和 field时,最后就有可能触发了这几个限制情况。

小结

重点讲解了影响热替换热修复的一些重要的编译器问题。但是热修复还有个比较大的问题,由于是在运行期发生了变动,如果我们修改了某个方法的逻辑,就会导致它在修复前后的逻辑不一致,这就会引发一些诡异的错误。因此热替换方式的热修复只适用于修复一些简单的BUG,如果要做一些功能方面的更新,不建议采用。热部署修复方案的根本原理是基于native层方法的替换,所以当类结构变化时,如新增减少类method/field在热部署模式下会受到限制,修复了的非静态方法会被反射调用也会受到限制。

冷启动代码修复

现有的一些冷启动实现方案

TinkerQQ空间
原理提供dex差量包,整体替换dex的方案。差量的方式给出patch.dex,然后将patch.dex与应用的class.dex合并成一个完整的dex,完整的dex加载得到dexFile对象作为参数构建一个Element对象然后整体替换掉旧的dex Element数组。为了解决Dalvik下的unexpected dex problem异常而采用插桩的方式,单独放一个帮助类在独立的dex中让其他类调用,阻止了类被打上CLASS_ISPREVERIFIED标志从而规避问题的出现。最后加载补丁dex得到dexFile对象作为参数构建一个Element对象插入到dex Elements数组的最前面
优点自研dex差异算法,补丁包很小,dex merge成完整的dex,Dalvik不影响类加载性能,Art下也不存在必须包含父类/引用类的情况没有合成整包,产物比较小,比较灵活
缺点dex合并内存消耗在vm heap上,容易导致OOM,最后导致dex合成失败Dalvik下影响类加载性能,Art下类地址写死,导致必须包含父类/引用类,最后补丁包很大

Tinker的dex merge操作是在Java层面进行的,所有对象的分配都是在java heap上完成的,可能发生申请的java heap超过vm heap规定的大小,进程发生OOM导致进程被杀死合成失败,如果在JNI层面进行C++ new/malloc申请的内存,分配在native heap,native heap的增长并不受vm heap大小的限制,只受限于RAM,如果RAM不足,也会导致进程被杀死导致闪退。在JNI层面进行dex merge,从而避免OOM提高合并成功率。

QQ空间的解决方案

问题来源

如果我们把要修复的QzoneActivityManager类打包成一个dex文件,插入到所有的dex文件的最前面,

1. ModuleManager在classes.dex中

2. QzoneActivityManager在patch.dex

ModuleManager引用了QzoneActivityManager,但是发现这两个类不在同一个dex文件中,于是问题就出现了

解决方案

dex在转换为odex(dexopt)的代码中的一段,在安装apk的时候,class.dex会被虚拟机(dexopt)优化成odex文件,然后才拿去执行

//DexPrepare.cpp
/*
     * First, try to verify it.
     */
    if (doVerify) {
        if (dvmVerifyClass(clazz)) {
            /*
             * Set the "is preverified" flag in the DexClassDef.  We
             * do it here, rather than in the ClassObject structure,
             * because the DexClassDef is part of the odex file.
             */
            assert((clazz->accessFlags & JAVA_FLAGS_MASK) ==
                pClassDef->accessFlags);
            ((DexClassDef*)pClassDef)->accessFlags |= CLASS_ISPREVERIFIED;
            verified = true;
        } else {
            // TODO: log when in verbose mode
            ALOGV("DexOpt: '%s' failed verification", classDescriptor);
        }
    }
复制代码

虚拟机在启动的时候,会有许多的启动参数,其中一项就是verify选项,当verify选项被打开的时候,上面doVerify变量为true,那么就会执行dvmVerifyClass进行类的校验,如果dvmVerifyClass校验类成功,那么这个类会被打上CLASS_ISPREVERIFIED的标志,那么具体的校验过程是什么样子的呢?

//DexVerify.cpp
bool dvmVerifyClass(ClassObject* clazz)
{
    int i;
    if (dvmIsClassVerified(clazz)) {
        ALOGD("Ignoring duplicate verify attempt on %s", clazz->descriptor);
        return true;
    }
    for (i = 0; i < clazz->directMethodCount; i++) {
        if (!verifyMethod(&clazz->directMethods[i])) {
            LOG_VFY("Verifier rejected class %s", clazz->descriptor);
            return false;
        }
    }
    for (i = 0; i < clazz->virtualMethodCount; i++) {
        if (!verifyMethod(&clazz->virtualMethods[i])) {
            LOG_VFY("Verifier rejected class %s", clazz->descriptor);
            return false;
        }
    }
    return true;
}
复制代码
  1. 验证clazz->dvmIsClassVerified方法,directMethods包含了以下方法:

    • static方法
    • private方法
    • 构造函数
  2. clazz->virtualMethods

    • 虚函数=override方法

概括一下就是如果以上方法中直接引用到的类(第一层级关系,不会递归进行搜索)和clazz都在同一个dex中的话,那么这个类就会被打上CLASS_ISPREVERIFIED

因为dex在优化的过程中会进行class的校验,给每一个class打上了一个CLASS_ISPREVERIFIED的标签,在调用的时候会根据该标签判断所在的class是否是同一个dex如果不是会抛出异常导致程序停止。所以我们需要防止类被打上CLASS_ISPREVERIFIED。

g)

最终的解决方案是在所有类的构造函数里面插入了一段代码:代码如下

if (ClassVerifier.PREVENT_VERIFY) {
System.out.println(AntilazyLoad.class);
}
复制代码

其中AntilazyLoad类会被打包成单独的hack.dex,这样当安装apk的时候,classes.dex内的类都会引用一个在不相同dex中的AntilazyLoad类,这样就防止了类被打上CLASS_ISPREVERIFIED的标志了,只要没被打上这个标志的类都可以进行打补丁操作。

然后在应用启动的时候加载进来.AntilazyLoad类所在的dex包必须被先加载进来,不然AntilazyLoad类会被标记为不存在,即使后续加载了hack.dex包,那么他也是不存在的,这样屏幕就会出现茫茫多的类AntilazyLoad找不到的log。

所以Application作为应用的入口不能插入这段代码。(因为载入hack.dex的代码是在Application中onCreate中执行的,如果在Application的构造函数里面插入了这段代码,那么就是在hack.dex加载之前就使用该类,该类一次找不到,会被永远的打上找不到的标志)

其中

class ClassVerifier{
        public static boolean PREVENT_VERIFY=false;//false防止代码被执行,提高性能
}
复制代码

之所以选择构造函数是因为他不增加方法数,一个类即使没有显式的构造函数,也会有一个隐式的默认构造函数。

空间使用的是在字节码插入代码,而不是源代码插入,使用的是javaassist库来进行字节码插入的。

虚拟机在安装期间为类打上CLASS_ISPREVERIFIED标志是为了提高性能的,我们强制防止类被打上标志会影响性能.(5.0以下)

但是插桩会给类加载效率带来比较严重的影响,熟悉Dalvik虚拟机开发的都知道,一个类的加载阶段通常有三个阶段:

  • dvmResolveClass

  • dvmLinkClass

  • dvmInitClass:在类解析完并尝试初始化类的时候执行,这个方法主要完成父类的初始化,当前类的初始化,静态变量的初始化赋值等操作。

可以看到如果类没被打上CLASS ISPREVERIFIED/CLASS ISOPTIMIZED 的标志,那么类的校验和优化都在类的初始化阶段进行。那么类的校验和优化都将在类的初始化阶段进行。正常情况下类的校验和优化都仅在 APK 第一次安装执行 dexopt 操作的时候进行 , 类的校验任务实际上是很重的,因为会对类的所有方法中的所有指令都进行校验,单个类加载时类校验耗时并不多,但是如果是在同一时间点加载大量类的情况下,这种耗时就会被放大 。

性能影响

插桩导致所有的类都非preverify,从而导致校验和优化操作都会在类加载时触发,平均每个类的校验和优化的耗时并不长,但是应用刚启动的时候一般会同时加载大量的类,容易导致用户白屏。

QFix的解决方案

​ 与QQ空间采用的native hook方式不同,不会去hook某个系统方法,而是从navie层直接调用,有如下注意点:

  • dvmResolveClass的第三个参数fromUnverifiedConstant必须为true。

  • 在APK多dex的情况下,dvmResolveClass第一个参数referrer类必须跟需要打包的类在同一个dex中,但是它们两个类不需要存在任何引用关系,任何一个在同一个dex中的类作为referrer都可以。

  • referrer类必须提前加载。

但是QFix的方案有它独特的缺陷,由于是在dexopt后绕过的,dexopt会改变原有的很多逻辑,许多odex层面的优化会固定字段和方法的访问偏移,这会导致比较严重的bug。比如可能导致方法调用错乱。

但是上面两种方案都是侵入应用打包,我们的需求是冷启动模式是热部署模式的补充方案,所以这两种方案使用的应该是同一套补丁,我们的需求是无侵入打包又能做热部署模式的补充解决方案

如何解决Dalvik虚拟机下类的pre-verify问题,如果一个类中直接引用到的所有非系统类都和该类在同一个dex中的话,那么这个类就会被打上CLASS_ISPAREVERIFIED标志,具体判定代码可见虚拟机中的verifyAndOptimizeClass函数。

腾讯三大热修复方案如何解决该问题

  • QQ空间的处理方式是在每个类中插入一个来自其他dex的hack.class,由此让所有类都无法满足pre-verified条件。

    缺点:入侵打包流程,添加臃肿代码,不优雅,无法新增public函数。

  • Tinker的方式是合成全量的dex文件,这样所有类都在全量dex中解决,从而消除类重复而带来的冲突。

    粒度过细,实现起来较为复杂,性能消耗比较严重,时空代价转换的性价比不高(dex占apk比例不高)

  • QFix的方式是获取虚拟机中的某些底层函数,提前解析所有补丁类。以此绕过Pre-verify检查。

    需要获取底层虚拟机函数,不够稳定可靠,无法新增public函数。

Robust解决方案(实时生效)

在打基础包时插桩,在每一个方法前插入一段ChangeQuickRedirect静态变量的逻辑,插入过程对业务开发完全透明。加载补丁时,从补丁包中读取要替换的类以及具体替换的方法实现,新建ClassLoader加载补丁dex。当changeQuickRedirect不为null的时候,可能会执行到accessDispatch从而替换掉之前老的逻辑,达到fix的目的。

public static ChangeQuickRedirect u;
protected void onCreate(Bundle bundle) {
        //为每个方法自动插入修复逻辑代码,如果ChangeQuickRedirect为空则不执行
        if (u != null) {
            if (PatchProxy.isSupport(new Object[]{bundle}, this, u, false, 78)) {
                PatchProxy.accessDispatchVoid(new Object[]{bundle}, this, u, false, 78);
                return;
            }
        }
        super.onCreate(bundle);
        ...
    }
复制代码

Robust的核心修复源码如下:

public class PatchExecutor extends Thread {
    @Override
    public void run() {
        ...
        applyPatchList(patches);
        ...
    }
    /**
     * 应用补丁列表
     */
    protected void applyPatchList(List<Patch> patches) {
        ...
        for (Patch p : patches) {
            ...
            currentPatchResult = patch(context, p);
            ...
            }
    }
     /**
     * 核心修复源码
     */
    protected boolean patch(Context context, Patch patch) {
        ...
        //新建ClassLoader
        DexClassLoader classLoader = new DexClassLoader(patch.getTempPath(), context.getCacheDir().getAbsolutePath(),
                null, PatchExecutor.class.getClassLoader());
        patch.delete(patch.getTempPath());
        ...
        try {
            patchsInfoClass = classLoader.loadClass(patch.getPatchesInfoImplClassFullName());
            patchesInfo = (PatchesInfo) patchsInfoClass.newInstance();
            } catch (Throwable t) {
             ...
        }
        ...
        //通过遍历其中的类信息进而反射修改其中 ChangeQuickRedirect 对象的值
        for (PatchedClassInfo patchedClassInfo : patchedClasses) {
            ...
            try {
                oldClass = classLoader.loadClass(patchedClassName.trim());
                Field[] fields = oldClass.getDeclaredFields();
                for (Field field : fields) {
                    if (TextUtils.equals(field.getType().getCanonicalName(), ChangeQuickRedirect.class.getCanonicalName()) && TextUtils.equals(field.getDeclaringClass().getCanonicalName(), oldClass.getCanonicalName())) {
                        changeQuickRedirectField = field;
                        break;
                    }
                }
                ...
                try {
                    patchClass = classLoader.loadClass(patchClassName);
                    Object patchObject = patchClass.newInstance();
                    changeQuickRedirectField.setAccessible(true);
                    changeQuickRedirectField.set(null, patchObject);
                    } catch (Throwable t) {
                    ...
                }
            } catch (Throwable t) {
                 ...
            }
        }
        return true;
    }
}
复制代码

其实我感觉Robust的方案虽然有侵入性,增加代码体积等的缺点,但是感觉这种方式是兼容率最好的方案。也不会受Android版本的更新影响。

Sophix的解决方案

新的全量dex方案(Dalvik)

在原先基线包里的dex中,去掉补丁包dex中也有的类,这样基线包dex里就只包含不变的类了。而这些不变的类在要用拿到补丁中的新类会自动找到补丁dex,补丁包中的新类在需要用到不变的类时也会找到基线包dex的类。基线包里面不使用补丁类的类仍旧可以按原来的逻辑做odex,最大程度的保证了dexopt的效果。对dex的结构也不用进行破坏性重构。

所以问题就变成了如何在基线包dex中去掉补丁包中包含的所有类,dalvik中dex的文件结构可以查看这里

解决方法

我们要做的并不是把某个类的所有信息都从你dex移除,因为这么做,可能会导致dex的各个部分都发送变化,从而需要调整大量的offset,这么就变得费时费力了。我们需要做的仅仅是使得解析这个dex的时候找不到这个类的定义就可以了。因此只需要移除定义的入口,对于类的具体内容不进行删除,这样可以最大限度的减少offset的修改。

  • Dalvik下采用自行研发的全量dex方案

  • 在Art下本质上虚拟机已经支持多dex的加载,我们要做的仅仅是把补丁dex作为dex(classes.dex)加载而已。

Art模式下虚拟机默认支持多dex压缩文件的加载。Art下面最终冷启动解决方案:我们只要把补丁dex命名为classes.dex。原APK中dex依次命名为class(2,3,4…).dex就可以了,然后一起打包进一个压缩文件。在通过DexFile.loadDex得到DexFile对象,最后用该DexFile整体替换旧的dexElements就可以了。

不得不说的其他点

DexFile.loadDex尝试把一个dex文件解析并加载到native内存中,在加载到内存之前,如果dex不存在对应的odex,那么Dalvik下会执行dexopt,Art下会执行dexoat,最后得到的都是一个优化后的odex。实际上最后虚拟机执行的是这个odex而不是dex。如果dex足够大,dexopt/dexoat的操作是很耗时的,在Dalvik下面的影响比较小,因为loadDex的仅仅是补丁包。但是在Art下影响非常大。因为loadDex是补丁dex和Apk中原dex合并成一个完整补丁压缩包,所以dexoat操作非常耗时,所以优化后的所以如果优化后的 odex 文件没生成或者不完整,那么 loadDex 便不能在应用启动的时候进行,因为会 阻塞 loadDex 线程, 一般是主线程 。 所以为了解决这个问题,我们]把 loadDex 当作一个事务来看,如果中途被打断,那么就删除odex 文件,重启的时候如果发现存在odex 文件,loadDex 完之后,反射注入 /替换dexElernents 数组,实现打包。如 果不存在odex文件,那么重启另一个子线程loadDex,重启之后再生效。

另外一方面,为了补丁包的安全性,虽然对补丁包进行签名校验,这个时候能够防止整个补丁包被篡改,但是实际上因为虚拟机执行的是 odex 而不是 dex,还需要对odex文件进行 MD5 完整性校验,如果匹配,则直接加载,如果不匹配,则重新生成odex文件,防止odex文件被篡改 。

注意:

  • 补丁dex必须命名为classes.dex
  • 用loadDex得到的DexFile完整替换掉dexElements数组而不是插入。

对于Application的处理

现在已经实现了完成的dex合成,所有完整的dex替换方案都会遇到,对Application的处理问题。由于Application是整个App的入口,因此在进入到替换的完整的dex之前,一定会通过Appliation代码,而Application必然是加载在原来的dex里面的。只有在补丁加载后使用的类,会在新的完整dex里面找到。如果替换掉Application,也会遇到这个问题。

因此,加载补丁后,如果Application类使用其他新dex里的类,由于在不同dex里,如果Application被打上了pre-verified标志,就会抛出异常。

Sophix在JNI层清除掉pre-verified(CLASS_ISPREVERIFIED)的标志,这样在dvmResolveClass中找到新的dex里的类后,由于CLASS_VERIFIED标志被清空,就不会判断所在dex是否相同,从而成功的避免抛出异常。这样对Application既没有侵入编译过程,也不需要进行反射替换,所有兼容操作都在运行期间自动做好,极其顺滑。

在处理标志的过程会遇到dvmOptResolveClass的问题,有两种处理方式,第一种方式是Android官方的multi-dex机制会自动将Application用到的类都自动打包到住dex中,所以只要把热修复初始化放在attachBaseContext的最前面,一般都没有问题。

入口类与初始化时机

Application.attachBaseContext → ContentProvide.oncreate → Application.onCreate → Activity.onCreate

attachBaseContext是Application中最早被执行的代码,但是需要注意的是,在attachBaseContext里面有很多限制,此时的App申请权限还没有被授予完成,所以会遇到无法访问网络之类的问题。因此在attachBaseContext里面可以初始化,但不可以进行网络请求下载新补丁。如果要使热修复类之前使用其他类最少,最好放在attachBaseContext()中。

防不胜防的细节错误

错误示范:

  1. Crash的注册早于Sophix的初始化是不可以的,Sophix的初始化不可以包装在其他类中,否则会导致提前引入类。
  2. BuildConfig类是Android编译期间动态生成的,也属于非系统类,如果在这里使用就会有提前引入的问题。建议使用PackageManager来获取版本号。
  3. Sophix的回调类load中使用的自定义的Logger,在回调状态的时候可能热修复还未初始化完毕,需要替换成系统Log类。
  4. LocalStroageUtil直接在声明处赋值了它的实例,这个赋值其实是隐式发生在对象的构造函数中的,这个时候甚至是早于attachBaseContext的,因此也是不行的,需要在初始化之后才能赋值。
  5. MultiDex.install(this)放在热修复之后,可能会导致后面的热修复框架初始化的时候找不到其他不在主dex中的热修复框架内部类,因此需要放在热修复初始化之前。
  6. 不要遗漏super.attachBaseContext(base)

入口类带来的修复限制

如果获取某个类的某个方法,是根据这个方法在类里面的方法索引来取得的,这个索引是这个万法在类里面的序号,那么 , 如果在这个类里面新增或者减少万法, 就会导致这个类中的方法索引与原有的不一致 。 而在 Application 入口类中,仍然是处于安装包的 oat 文件里,是原有的索引,所以如果用这个索引去类中获取方法,将可能不再是原来的万法,从而引发崩溃。 同理,对于类里面的字段, 也存在索引,因此也有类似的问题 。

不过,触发这个问题需要使得 Application 中使用的类的方法索引发生变化。 如果对这些使用的类,不增加或者减少方法数或者字段的话 , 就没关系 。 即使发生了增减的情况,如果在 Application 里面直接使用到的这些方法或者字段索引没有受到影响,那也是没问题的 。 例如插入的方法正好是在所用方法之后,那就影响不到这个方法的索引。 保险起见,还是需要注意这类情况,并尽量避免。

开发者使用这种万式进行初始化的时候 , 只需要复制这个 SophixStubApplica­tion 类到自己的项目中,然后把 AndroidManifest 里面的 Application 指定为它,再设置 SophixEntry 为 SampleApplication 就可以了 。

使用SophixApplication,Sophix在运行的时候,会先执行初始化逻辑,当初始化完成后, 通过反射得到SophixStubApplication 的静态内部类 RealApplicationStub,最终通过它的类注解SophixEntry得到真正的 Application 即 SampleApplication。然后调用 Sample­Application 的生命周期函数 attachBaseContext、 onCreate 等 ,再进行替换 。 后续,所有使用 Application 的地方都能够找到这个换回来的 SampleApplication。

其他方案

Tinker的方案是在AndroidManifest.xml什么中就要求开发者将自己的Application直接替换成TinkerApplcation。而对于真正的Application,要在初始化TinkerApplcation时作为参数传入,在生命周期回调时通过反射的方式调用实际的Applicationn的相关逻辑。这么做确实很好地将入口Application和用户代码隔开,不过需要改造原有的Application,如果对Application有更多扩展,接入成本也是比较高的。Tinker中的sample如下:

@SuppressWarnings("unused")
@DefaultLifeCycle(application = "tinker.sample.android.app.SampleApplication",
                  flags = ShareConstants.TINKER_ENABLE_ALL,
                  loadVerifyFlag = false)
public class SampleApplicationLike extends DefaultApplicationLike {
    private static final String TAG = "Tinker.SampleApplicationLike";

    public SampleApplicationLike(Application application, int tinkerFlags, boolean tinkerLoadVerifyFlag,
                                 long applicationStartElapsedTime, long applicationStartMillisTime, Intent tinkerResultIntent) {
        super(application, tinkerFlags, tinkerLoadVerifyFlag, applicationStartElapsedTime, applicationStartMillisTime, tinkerResultIntent);
    }

    /**
     * install multiDex before install tinker
     * so we don't need to put the tinker lib classes in the main dex
     *
     * @param base
     */
    @TargetApi(Build.VERSION_CODES.ICE_CREAM_SANDWICH)
    @Override
    public void onBaseContextAttached(Context base) {
        super.onBaseContextAttached(base);
        //you must install multiDex whatever tinker is installed!
        MultiDex.install(base);

        SampleApplicationContext.application = getApplication();
        SampleApplicationContext.context = getApplication();
        TinkerManager.setTinkerApplicationLike(this);

        TinkerManager.initFastCrashProtect();
        //should set before tinker is installed
        TinkerManager.setUpgradeRetryEnable(true);

        //optional set logIml, or you can use default debug log
        TinkerInstaller.setLogIml(new MyLogImp());

        //installTinker after load multiDex
        //or you can put com.tencent.tinker.** to main dex
        TinkerManager.installTinker(this);
        Tinker tinker = Tinker.with(getApplication());
    }

    @TargetApi(Build.VERSION_CODES.ICE_CREAM_SANDWICH)
    public void registerActivityLifecycleCallbacks(Application.ActivityLifecycleCallbacks callback) {
        getApplication().registerActivityLifecycleCallbacks(callback);
    }

}
复制代码

Amigo的方案是在编译过程中,用Amigo自定义的gradle插件将App的Application替换成Amigo自己的一个Application,并将原来的Application的name保存起来,该修复的问题都修复完成后再调用之前保存的Application的attch(context),然后将它回调到loadApk中,最后调用它的onCreate(),执行原有的Application中的逻辑,这种方式是在编译期帮用户做替换,这种对系统做方式替换本身也是有一定风险的。

多态对冷启动类加载的影响

多态一般是指非静态非私有方法的多态,field和静态方法不具有多态性。

首先new B()的执行会尝试加载类B,方法调用链dvmResolveClass->dvmLinkClass->createVtable,此时会为类B创建一个vtable,其实在虚拟机中加载每个类都会为这个类生成一张vtable表,vtable表就是当前类的所有virtual方法的一个数组,当前类和所有继承父类的public/protected/default方法就是virtual方法,因为public/protected/default方法是可以被继承的。private/static方法不属于这个范畴,因为不能被继承。

子类vtable的大小等于子类virtual方法数+父类vtable的大小。

  • 整体复制父类vtable到子类的vtable
  • 遍历子类的virtual方法集合,如果方法原型一致,说明是重写父类方法,那么在相同索引位置处,子类重写方法覆盖掉vtable中父类的方法;
  • 若方法原型不一致,那么把该烦恼规范添加到vtable的末尾。

field/static方法为什么不具有多态性,简单来讲,是从当前变量的引用类型而不是实际类型中查找,如果查不到,再去父类中递归查找。所以field和static方法不具备多态性。

冷启动方案限制

方法调用错乱问题:

dex文件第一次加载的时候,会进行dexopt,dexopt有verify和optimize两个过程:

  • dvmVerifyClass:类校验,简单来说,类校验的目的就是为了防止类被篡改而校验类的合法性。此时会对类的每个方法进行校验,这里我们只需要知道如果类的所有方法中直接引用到的类(第一层级关系,不会进行递归搜索)和当前类都在同一个dex中的话,dvmVerifyClass就返回true。

  • dvmOptimizeClass:类优化,简单来说,这个过程会把部分指令优化成虚拟机的内部指令,比如方法调用指令invoke-virtual-quick,quick指令会从类的vtable表中直接获取,vtable简单来说就是类的所有方法的一张大表(包括继承自父类的方法)。因此提升了方法的执行速率。

    Invoke-virtual-quick效率明显比invoke-virtual更高,直接从实际类型的vtable中获取调用方法的指针,而省略了dvmResolveMethod从变量的引用类型获取该方法在vtable索引ID的步骤,所以更高效。(例子:new B()的执行会尝试加载类B,方法调用链dvmResolveClass->dvmLinkClass->createVtable)

资源修复

Instant Run中的资源热修复分为两步

  1. 构造一个新的AssetManager,通过反射调用addAssetPath函数,然后把完整的新资源包加载到AssetManager中。这样就得到了一个含有所有新资源的AssetManager。
  2. 找到所有之前引用到原有的AssetManager的地方,通过反射,把引用处替换为新的AssetManager

没有直接参考Instant Run的技术,而是另辟蹊径构造一个package id为0x66的资源包,这个包里面只需要包含需要改变的资源项,然后直接在原有AssetManger中通过addAssetPath函数添加这个包就可以了。由于补丁包的package id为0x66,不与目前的已经加载的地址为0x7f的包冲突,因此直接加载到已有的AssetManager中就可以直接使用了。

补丁包里面的只有新增的资源和需要替换的资源。并且,我们采用了更加优雅的替换方式,直接在原有的AssetManager对象上进行解析和重构,这样所有原有对AssetManager对象的引用是没有发生改变的,不需要想Instant Run那样进行繁琐的修改了。

Instant Run团队和Android Framework不是一个团队,他们对系统源码了解也不是很深,所以做的也并不是很好,我们只要仔细阅读源码,也能搞出更好的方案。

Sophix的修改方案

  • 不修改AssetManger的引用处,替换资源更快更完整(对比Instant Run以及所有CopyCat的实现)
  • 不必下发完整包,补丁包中只包含变动的资源(对比Instant Run,Amigo等方式的实现)
  • 不需要在运行时合成包,不占用运行时计算和内存资源(对比Tinker实现)

so库修复

so库的修复本质是对native方法的修复和替换

采用类似类修复反射反射注入方式。把补丁so库的路径插入到nativeLibraryDirectories数组的最前面,就能够达到加载so库的时候是补丁so库的目录,从而达到修复bug的目的。不像某些方案需要手动替换系统的System.load来实现替换功能。

区别于所谓的黑科技(类似保活),实际上是改善了Android的生态环境。

Android与iOS热修复的不同

  1. 谷歌和苹果在中国的地位不同,控制能力不同
  2. Android和iOS的开放性不同

热修复的必要性

热修复不是简单的客户端SDK,它还包含了安全机制和服务端的控制逻辑,整条链路也不是短时间可以快速完成的。

专业的事是交给专业的人去做。开发者应该把更多的时间精力放到自己的核心业务之中。

总结

想要深入了解热修复,需要了解类加载机制,Instant Run,multidex以及java底层实现细节,JNI,AAPT和虚拟机的知识,需要庞大的知识贮备才能进行深入理解,当然Android Framwork的实现细节是非常重要的。熟悉热修复的原理有助于我们提供自己的编程水平,提升自己解决问题的能力,最后热修复不是简单的客户端SDK,它还包含了安全机制和服务端的控制逻辑,整条链路也不是短时间可以快速完成的。还是这句话,专业的事是交给专业的人去做。开发者应该把更多的时间精力放到自己的核心业务之中。

参考文章

《深入探索Android热修复技术原理》

Android热修复升级探索

阿里推出业界首个非侵入式热修复方案Sophix,颠覆移动端传统发版更新流程!

安卓App热补丁修复技术介绍-QQ空间技术团队

热修复系列调研收费对比

github.com/baidao/tink…