我将热修复原理落地实践MyHotFix
1.热修复技术介绍
1.1 什么是热修复
为了修复刚发版时出现的紧急bug,无需重新发版!
1.2 技术积淀
手淘基于Xposed进行改进,产生针对Android Dalvik虚拟机的Java Method Hook技术的Dexposed。
支付宝提出AndFix方案,可以做到在Dalvik和Art全平台兼容的即时修复
阿里百川结合手淘实际使用AndFix的经验,解耦后推出HotFix方案
2017手淘联合阿里云推出Sophix热修复方案
其余著名热修复方案有:
QQ空间超级补丁、微信Tinker
饿了么Amigo
美团Robust
360RePlugin
滴滴出行VirtualAPK
uwa
2.代码热修复技术
2.1底层热替换原理
AndFix:由补丁类的classLoader加载补丁类,在native层针对不同Android架构中的不同的ArtMethod结构调用对应的replaceMethod方法按照定义好的ArtMethod结构一一替换方法的所有信息如所属类、访问权限、代码内存地址等。
稳定性较差,会受到国内ROM厂商对ArtMethod结构更改的影响,所以这正是AndFix不支持很多机型的原因。
Sophix:由补丁类的classLoader加载补丁类,在native层直接memcpy(smeth,dmth,sizeof(ArtMethod))替换整个artMethod的结构。初始化类时会为这个类分配空间,AllocArtMethodArray会紧挨着的new出来放入art中的方法数组中。通过计算辅助类的前后两个方法的起始地址就可以计算出artMethod结构的大小了。
注:补丁类初始化时,也会分配自己的artMethod空间,拿这个修复过的新ArtMethod去替换旧ArtMethod的内容,不用管ArtMethod的结构。稳定性大大提高!
猜测:由于补丁类加载是从dex中加载,故替换后的ArtMethod的方法入口首先应该是dexCode解释执行,同步被优化成oat机器码,下次执行时就执行oat机器码入口了。
- 访问权限问题
1.方法调用时权限检查
得益于在安装时优化成oat文件时,已经校验过了。所以对同类的方法进行调用时,不会再进行权限检查。
2.同包名下权限问题
同包名下调用热修复之后的方法,会再次权限检查,在native中的IsInSamePackage方法中判断两个类的classLoader是否相同,否则IllegalAcessError,因为补丁类是由补丁classLoader加载的,所以解决方法时,反射修改加载之后的补丁类的classLoader字段为旧classLoader。 - 反射调用非静态方法产生的问题(依赖冷启动解决)
反射调用非静态方法时,会调用底层的InvokeMethod,会调用VerifyObjectIsClass来判断调用方法的对象是否是方法所属类的实例,然而由于热替换的方法类还是执行补丁类,所以校验失败。 - 即时生效带来的限制
两种情况不适用,仅支持修复方法,其他情况补丁小,修复快。
1.引起了原有类中结构变化的修改
2.修复的非静态方法被反射调用
2.2你所不知道的Java
内部类编译
- 静态内部类/非静态内部类区别
内部类会被编译器生成同外部类一样的顶级类。只不过非静态内部类会持有外部类的引用。这也是Android性能优化建议Handler使用静态内部类,防止外部类Activity不能被回收导致造成OOM。 - 内部类和外部类互相访问
内部类和外部类互相访问private方法和字段时,会自动在对应类为对方生成public的access&**方法。 - 热部署解决方案
外部类如果有内部类把所有的field/method的private访问权限改成proteced或者public
内部类将所有的field/method的private访问权限改成proteced或者public
匿名内部类编译
- 匿名内部类命名规则
外部类&numble
number即编译器根据匿名内部类出现在外部类中的顺序,依次累加。 - 热部署解决方案
新增/减少匿名内部类对热部署是无解的,因为补丁修复工具拿到的是class文件,无法区别DexFileDemo&1和DexFileDemo&2,会导致类的顺序乱套。如果匿名内部类插入到末尾是允许。
有趣的域编译
- 静态field,非静态field编译
热部署不支持field/method增加和删除和<clinit>方法的修改
静态field的初始化和静态代码块会被编译在编译器合成的方法<clinit>中
非静态字段的初始化会被编译在编译器生成的<init>无参构造函数中 - 静态field,静态代码块
<clinit>方法会在类加载阶段的类初始化时调用,<clinit>中静态field和静态代码块的出现顺序就是二者在源码中出现的顺序。因为类已经加载过了,所以就算修复了<clinit>方法也不会生效了。
dvmResolveClass->dvmLinkClass->dvmInitClass,然后执行clinit方法
以下情况会去加载一个类
1.new 一个类的对象时new instance
2.调用类的静态方法(invoke static)
3.获取类的静态域的值(sget) - 非静态field,非静态代码块
类的构造函数会被编译器翻译成<init>方法,会先进行非静态field和非静态代码块的初始化。它们出现的顺序也是和在源码中出现的顺序一样。
执行new instance指令时,如果类没有加载过,就尝试加载类。然后对对象内存分配,再然后执行invoke direct指令调用类的init构造函数进行初始化 - 热部署解决方案
不支持对静态字段和静态代码块的修改,会导致热部署失败,只能冷启动生效。支持非静态字段和非静态代码块修改,热部署只是将init构造函数作为普通的方法变更。
final static 域编译
- final static 域编译规则
final static引用类型初始化仍在<clinit>中
final static基本类型和String类型,类加载初始化dvminitClass在执行clinit方法之前,先执行initSFields,这个方法为static域赋予默认值。引用类型默认NULL,final static修饰的基本类型和String类型会在这里初始化赋值。 - final static 域优化原理
final static基本类型执行const/4指令,操作数在dex中的位置(encoded_array_item)就是在opcode后一个字节。
final static String类型执行const-string指令,本质同上只不过拿到的是字符串常量在dex文件结构中字符串常量区的索引id。dex文件有一块区域存储所有的字符串常量会被完整的加载到虚拟机内存中-字符串常量区。
final static引用类型执行sget指令,首先调用dvmDexGetResolveField看这个域是否之前解析过,没有的话调用dvmDexResolveField尝试解析域,如果这个静态域所在的类没有解析过,尝试调用dvmResolveClass,拿到这个sField,然后通过dvmDexGetResolveField(sField)获取这个静态值。 - 热部署解决方案
final static基本类型/string类型最终引用的类型会被热部署替换掉。
final static引用类型因为会被翻译到clinit方法中,热部署失败。
有趣的方法编译
- 应用混淆方法编译
如果项目应用了混淆,会导致方法内联和裁剪最终导致Method新增/减少 - 方法内联
以下几种情况下回发生方法内联(该方法会被删除)
1.方法没有被任何地方引用
2.方法仅在一处被引用,调用方法的地方会被方法的实现替换掉
3.方法太简单,仅仅一行语句。调用的语句也会被其实现所替换掉
如果补丁修复的方法突然调用了原先只有一处被调用的方法,那么原先被内联掉的方法会新增出来,导致热修复失败。 - 方法裁剪(参数会被删除)
方法的参数没有被引用过,该方法会被裁剪,然后再进行混淆。如果补丁方法再次调用这个参数就会导致新增方法,那么只能走冷启动方案。
可以采用走包装类型Boolean判断,简单调用该参数,保持对该参数的引用就不会被裁剪了 - 热部署解决方案
只要混淆配置文件中-dontoptmize就不会做方法内联和裁剪了。所以不建议混淆时优化代码。
而且因为Android执行的是优化后的dex文件,所以混淆中预校验在class文件中的优势就不存在了。
switch case语句编译
- switch case语句编译规则
编译器会根据switch case的值是否连续分别生成不同的指令,packed-switch和sparse-switch指令。如果packed有值不连续就用pswitch_0补齐 return-void。 - 热部署解决方案
在sophix进行资源补丁包时,需要对引用的资源进行替换,如果swith case语句恰好被编译成packed-switch指令则可能会漏掉。解决方法是修改打补丁包时的smail反编译流程,碰到packed-switch指令强转为sparse-switch指令,:pswitch_N等标签指令也需要被替换成:sswitch_N指令,然后做资源Id替换,编程smail为dex
泛型编译
- 为什么需要泛型
Java泛型完全有编译器实现,由编译器执行类型检查和类型推断,生成非泛型字节码,称之为擦除。
没有泛型之前想要实现类泛型,利用所有类的父类时Object进行强转,这完全依赖程序员的自主性,很容易出现ClassCastException。泛型的出现解决了类型检查和类型推断的问题。 - 泛型类型擦除
Java字节码中不包含泛型类型信息,想要区别类型定义可以限定泛型类型 <T extends Number> - 类型擦除与多态的冲突和解决
父类是泛型类有setNumber(T value),子类想override setNumber(Number value)。然而实际父类的方法实际是setNumber(Object value),子类想重写却变成了重载,这就出现了类型擦除和多态之间的冲突。然而编译器自动帮我们合成了Bridge方法实现了重载,在子类中生成了相同签名bridge方法,内部实际调用子类的重写方法。 - 泛型类型转换
编译器如果发现变量声明加上了泛型信息,编译器自动加上了check-cast的强制转换,因为编译器会为泛型做类型检查,所以自动的强制转换不会出现ClassCastException。 - 热部署解决方案
如果父类补丁变成了增加了泛型则会增加Bridge方法,造成热部署失败。
将方法从void get(B t) 变成<B extends Number> void get(B t)方法逻辑不会发生变化,但是方法的签名会发生变化,这种情况热修复没有意义,需要避免这种情况的发生。
Lambda表达式编译
- Lambda表达式编译规则
Lamda表达式具有函数式编程的特点,是Java中最接近闭包的概念。函数式接口:一个接口具有唯一一个抽象方法
Java中的Runable和Comparator都是典型的函数式接口
Lamada表达式和匿名内部类的区别:
1.this关键字指包围Lamada表达式的类而不是指向匿名内部类自己
2.编译方式,Java编译器将Lamda表达式编译成类的私有方法,使用了Java7的invokedynamic动态绑定这个私有方法。而匿名内部类则是生成外部类&number的新类
编译器都会在类下生成lamda$main$**{ * }私有静态方法,这个方法实现了lamda表达式的逻辑,引用的变量都会变成方法的参数。
在HostSpot VM下解释class文件的lamda表达式:
invokeDynamic指令调用java/lang/invoke/LamdaMetafactory的metafactory这个静态方法。这个方法会在运行时生成实现函数式接口的具体类,这个具体类会调用那个静态私有方法。
在Android虚拟机下解释dex文件中的lamda表达式:则是在优化成dex文件的时候就生成了这个具体类。 - 热部署解决方案
新增lamada表达式会导致外部类新增一个辅助方法。修改的lamda表达式逻辑引用了外部变量,会导致辅助类持有了外部对象,会新增这个外部对象的变量。也是会导致热修复失败。
访问权限检查对热替换的影响
- 类加载阶段父类/实现接口访问检查
一个类的加载阶段包括resolve->link->init三个阶段,父类/实现接口 权限检查在link阶段,dvmLinkClass中依次对父类/实现接口进行dvmCheckClassAcess权限检查,如果父类/实现接口是非public然后进行检查当前类和它是否是相同的ClassLoader。热修复补丁类是新classLoader加载的,所在会报父类不允许访问的错误。 - 类校验阶段访问权限检查
如果访问public类和方法在类加载阶段会通过,但是在运行时会爆出crash异常。
补丁在单个dex文件中,加载dex肯定要进行dexopt,再dexopt过程中会dvmVerifyClass校验dex每个类。在校验过程中会检查补丁类所引用类的访问权限(提前dvmResolveClass被调用类)。还会校验调用方法的访问权限,public修饰直接返回。protected的话,先检查当前类和被调用方法所属类是否是父子类关系,不是的话会调用dvmIsSmaePackage,这里会判断是否是相同的classLoader。...
<clinit>方法
该方法无法被热修复,只能走冷启动。
2.3冷启动类加载原理
类启动方案实现概述
QQ空间超级补丁采用的插桩方式,入侵打包流程,单独放一个帮助类在独立的dex中让其他类调用,阻止类在dexopt时被打伤CLASS_ISPREVERIFIED标记。加载补丁dex得到dexFile对象作为参数构建一个Element对象插入到dexElement数组最前面。
Tinker提供差量包,整体替换dex的方案。将patch.dex与应用的class.dex合并生成一个完整的dex,加载完整的dex得到dexFile对象为参数构建一个Element对象替换dexElements数组。
官方multiDex
没有补丁查询更新,下载补丁待下次启动时生效。
插桩实现前因后果
插桩方案是在dalvik和art下通用的冷启动方案。
将dex加载到本地内存时,如果不存在odex文件,首先会进行dexopt。dexopt会调用verify/optimize操作。Apk第一次安装时,会对dex执行dexopt,如果只存在一个dex文件则dvmVerifyClass(class)会打上CLASS_IS_PREVERIFIED标记。然后执行dvmOptimizeClass(class)打上CLASS_IS_OPTIMIZED标记。
dvmVerifyClass:防止类被篡改校验类的合法性。我们主要关心会校验类的所有方法直接引用类和当前类是否属于同一个dex
-
dvmOptimizeClass:类优化,将部分指令优化成虚拟机内部指令,如invoke-=>invoke--quick。quick指令会直接从vtable中获取方法地址,vtable是记录了类的所有方法。加快执行速度。
补丁类存在独立的dex中,类A访问补丁类的方法。尝试解析dvmResolveClass补丁类时,会判断referrer类打上标记然后校验referrer类和当前类是否是同一个dex,抛出dvmIllegeThrowAccessException。 为了解决问题,通常的做法是入侵打包流程,利用class字节码修改技术在每个类的构造函数中引用独立dex中的帮助类。防止Apk安装时被打上CLASS_IS_PREVERFIED标记,因此解决了这个异常问题。 类在运行时初始化时没打上标记还会在执行一次verifyClass/optimizeClass。dvmInitClass完成父类初始化,当前类初始化以及static字段初始化赋值。dvmVerifyClass非常重,对类方法的所有指令进行校验,虽然单个类影响不大,但应用加载大量类时,会导致非常耗性能。
插桩导致类加载性能影响
在应用启动场景下会加载大量的类,启动时容易出现白屏。
避免插桩的QFix方案
在native层提前调用dvmResolveClass,是的在dvmResolve中调用dvmDexGetResolve不为null,也避免了校验一致性的问题。
这个方案要求传递的在多dex情况下,referrer类必须跟patch类是同一个dex。fromUnverifiedConstant必须为true。referrer必须提前加载。
这方案还要一些问题,在dexopt之后绕过,但是dexopt会改变很多原先的逻辑,许多odex层面的优化会写死字段和访问方法的偏移。这会造成很严重的BUG。
Art下冷启动实现
除了通用的插桩方案,针对Art下的冷启动方案实现如下。
为了使热部署和冷启动共用一个补丁,热部署模式下的补丁能够降级直接走冷启动,所以不需要dex merge。而Thinker为了解决Art下odex地址偏移写死的问题,合并成一个全新完整的dex得到dexElement整体替换旧的dexElements。
- Dalvik-dexload:在加载原dex文件和jar压缩文件中只会把classes.dex文件加载到内存,然后会生成odex文件。
- Art-dexload:打开压缩文件加载多个dex文件,首先加载的是primaryDex(classes.dex),后续加载其他dex文件。方法调用链:DexFile_openDexFileNative->openDexFileFromOat(从oat文件中加载)->LoadDexFiles(从压缩文件中加载)
补丁类的dex只需要改成classes.dex,其他原dex文件依次更名为classes(2,3...).dex,一起打包一个压缩文件。先加载classes.dex的补丁类就不会再加载其他dex中的补丁类了。
不得不说的其他点
DexFile.loadDex会将一个dex加载到内存,在加载到内存之前,如果dex不存在对应odex,在Dalvik下会执行dexopt,Art下会执行dexoat。
如果dex足够大,会导致dexopt/dexoat很耗时。针对Art启动耗时的方案(感觉这里有误,art下优化文件应该是oat而不是odex,loadDex方案赞同),sophix方案在art下dexopt处理的是patch.dex和原理的dex的压缩包,所以dexoat非常耗时。所以如果优化后的odex没有生成就不能够在应用启动的时候loadDex,只能开子线程loadDex。将loadDex看做事务,一旦打断就删除odex,生成odex之后,如果应用重启发现生成了odex文件就loadDex,然后就反射替换dexElements数组。如果不存在就重启另外一个子线程loadDex,重启之后再生效。
还会对补丁进行签名校验和对odex进行md5校验,不对就重新生成,防止文件被篡改。
完整方案的考虑
在补丁加载类之前的类是无法被修复的(Application类)
在Dalvik采用阿里自己研发的全量dex方案
在art下仅仅是将差量补丁包作为primaryDex(classes.dex)加载
2.4多态对冷启动加载的影响
重新认识多态
多态用的是动态绑定技术,在运行期判断引用对象的实际类型,根据实际类型调用方法。动态指非静态方法和非私有方法即public/proteced/default,字段没有多态。
那么Android中是如何实现多态呢?
Android对动态绑定技术做了优化,在类初始化时一次性动态绑定好,然后在运行时调用该类的virtual方法直接读取方法引用,调用就可以了。
具体实现是:
初始化时
1.为类创建一个vtable表,vtable实际是记录类的所有virtual方法的方法指针。
2.确定vtable数组的最大大小为父类vtable的大小+子类virutal方法数
3.初始化vtable内容,将父类的vtable复制到子类的vtable中
4.遍历子类的vitual方法,判断方法的原型是否和vtable中一样,相同则重写该方法实际引用
5.如果方法原型不同则将子类的virtual方法添加到vtable中的末尾
调用时
1.类方法invoke virtual指令在Android dex第一次加载时执行dexopt的dvmOptimizeClass中会被优化成invoke-virtual-quick虚拟机内部指令
2.确定当前变量的实际类型,quick指令会直接从类的vtable中获取方法指针进行执行,加f快执行速度
sget/invoke static指令简述
从当前变量的引用类型找,而不是实际类型。找不到就递归从父类中查找
冷启动方案限制
QFix方法在Davik下,dex执行dexopt的dvmOptimize时将invoke-virtual指令优化成了invoke-virtual-quick指令。指令后面跟的立即数就是方法在vtable中索引值(这里有个问题,我们从上面知道类在初始化时才会确定vtable中各子类的索引值。而dexopt不没有初始化类,猜测是提前使用了动态绑定的分析方法确定了vtable的索引值)。
patch类新增类virtual方法,如果patch类是父类,则该父类加载之后就会使得子类中继承父类vtable中索引值混乱了。比如子类原本重写了父类的A方法,而修复之后的父类的B方法在A方法之前,实际就是A方法重写了新增的B方法,在想要调用子类对象的中父类新增的B方法时却调用被覆盖的A方法。
终极方案
QFix方法不可行,而Tinker将多个dex合并成完整的dex方案可以解决这个问题。
google开源了dexmerge方法,将补丁dex和原dex合并成一个完整的dex,但是多个dex合并会有65536的问题,而且还会非常消耗内存,内存不足会导致合并失败。2.5会说明完美方案。
2.5 Dalvik下完整DEX方案的新探索
冷启动类加载修复
QQ空间
- dex插入方案,将补丁dex插入到classLoader索引路径最前面
- 通过插桩方式绕过Dalvik虚拟机下类的pre-verify问题
QFix
- 同是dex插入方案
- 通过在Jni层提前resolve所有补丁类绕过pre-verify检查
Tinker:
- 全量合成新dex,消除重复class重复带来的冲突
- 所有类加载都在一个dex完成,没有pre-verify问题
一种新的全量dex方案
在基线包里去除掉补丁包里的class后,然后将其他的dex都load进来。基线包可以找到补丁中新类,补丁包中新类也可以找到基线包中不变的类。这样基线包中不变的class仍然可以按照旧的逻辑odex,最大程度保证了dexopt的效果。
在dex文件中类的入口在DexHeader中表现为class_defs。遍历pHeader->classDefsOff偏移值获取DexClassDef,如果发现这个类名存在补丁中就从pClassDefs数组中移除,重新排列,修改classDefsSize。这样修改不去移除类的定义等信息,虽然会残留类等信息,但是会提高dex的处理速度。
对于Application的处理
Sophix在Applicatoin类加载补丁之后,清除Applicatoin类的pre_verified标记,使得跨dex加载类不会报异常。不侵入编译流程,不进行反射,在运行期自动做好。
Tinker要求开发者声明TinkerApplication,将真正的Application作为参数传入TinkerApplication,应用启动时启动Tinker自己的热修复逻辑,在声明周期回调时通过反射执行原来的Application逻辑。
Amigo在编译过程中通过gradle插件将Application替换成它的Application,修复完成之后调用旧Application的attach(context),最后调用旧Application中的oncarete()调用原来的逻辑。
清除pre_verified逻辑:在jni层清除标记 clazzObj->accessFlags=~CLASS_PREVERIFIED.
dvmOptResolveClass问题与对策
清除标记遇到问题。多dex应用时,如果在Application类没有被打上pre_verified标记,那么虚拟机在初始化类时会扫描其所有用到的类进行dvmOptResolveClass操作,这个方法对类进行初始化加载的是原dex的类,那么这些类就会被打上pre_verified标记。补丁加载之后,只对Application类进行了清除标记操作。那些打上标记的类调用补丁类的话还是会出现pre_verified问题。
绕过dvmOptResovleClass操作,仅仅让Application类被打上标记的解决方案
- 让Application用到的非系统类和Application在同一个dex中,保证打上pre_verified标记,避免进入dvmOptResolveClass操作。(Android官方的multi-dex就是将Application用到的类都打包到主dex中)
- 让Application类除了热修复代码之外,其他代码剥离开放到一个单独的类。Application通过反射调用其他类,是的Application彻底与其他类独立开,保证被打上pre_verified标记。
3资源热修复技术
3.1普遍的实现方式
InstantRun实现分两步
- 构造新的AssetManager,反射调用addAssetPath添加新的完整的资源包路径到AssetManager
- 找到之前所有引用到原有的AssetManager的地方,通过反射将引用处替换为新的AssetManager
一个Android进程只有一个AssetManager, AssetManager->mResources->mResTable,从资源包路径解析出resource.arsc(记录了所有资源id分配情况和资源中所有字符串,以二进制信息存储),将其相关信息存储到mResTable.mPackageGroups(解析过的所有资源包的集合)
3.2资源文件的格式
resource.arsc由一个个ResChunk拼接起来,ResChunk头部都是一个ResChunk_header结构 (指示了大小和类型)
每个Android资源信息都是一个32为的编号0xPPTTEEEE,PP为packageId,TT为typeid,EEEE为entryid.
- packageid对应ResTable_package结构体的id
- typeid对应ResTable_typeSpec结构体的id,typeid对应的具体类型需要到package chrunk里的String Pool解析到。
- 每个entry表示一个资源项,资源项是按照排列的先后顺序被自动标记编号。
3.3运行时资源的解析
应用运行第一行代码时,系统已经构建好AssetManager,包含系统包资源id为0x01,安装包资源0x7f,aapt打包应用资源packageid都是为0x7f.
如果在这个原assetManager基础上添加补丁包资源。
Android KK下。addAssetPath 只将补丁包路径添加到mAssetPath中,而在此之前资源包已经被解析过了,所以补丁资源包不会生效。
在Android L上。因为packageid相同,会添加同一个packageGroup下。补丁资源会添加到Type资源集合后面。获取某个type资源的时候会从前往后遍历,新补丁资源则仍不会生效。
所以InstantRun方案,需要一个全新的AssetManager,添加一个完整的资源包,替换掉原来的AssetManager.
3.4另辟蹊径的资源修复方案
采用差量资源包,补丁足够小,不入侵打包流程。
构建差量的0x66的补丁资源包,只包含改变之后的资源项。然后直接在原有的AssetManager中addAssetPath这个资源包。并修正补丁包中引用的资源id,旧资源id发生变化的id。
3.5更优雅的替换AssetManager
在Android L以后的版本直接在原来的AssetManager添加资源包就行了。
在Android kk下,调用native层销毁AssetManager资源,将java层的AssetManager指向native层的AssetManager设置为空,native层的AssetManager的析构函数会释放之前加载的所有资源。java层的AssetManager成为空壳之后就可以重新初始化了。初始化之后我们再AddAssetPath就会生效了。
sophix的资源修复,需要代码修复支持,也只在新代码中生效。
4.SO库热修复技术
4.1SO库加载原理
so库加载句柄,从so库中加载方法映射到内存中的hash表中,此时方法代码应该被加载到内存中。
4.2SO库热部署实时生效可行性分析
动态注册:解析映射native方法,第一次走原so包,第二次走补丁包
静态注册:加载补丁包之后,解注册静态的jni native方法,重新映射
4.3SO库冷部署重启生效实现方案
art:在解析nativeLibaryDirectorys中插在最前面 补丁包
dalvik:重命名补丁包,同样方法