一、简介
日常工作工作中难免会遇到项目上线后出现bug问题,如果紧急发版往往由于渠道审核时间问题,导致bug修复不及时,影响用户体验。这时我们需要引入热修复,免去发版审核烦恼。
热更新优势:
让应用能够在无需重新安装的情况实现更新,帮助应用快速建立动态修复能力。
- 轻量而快速的升级,无需发版
- 远端调试,,可以将补丁推送给指定用户
- 可以通过patch使用户安装两个不同的版本,埋点进行数据统计
局限性
1、补丁只能针对单一客户端版本,随着版本差异变大补丁体积也会增大;
2、补丁不能支持所有的修改
3、补丁无论对代码还是资源的更新成功率都无法达到100%。
适用场景
1、热补丁技术也可以理解为一个动态修改代码与资源的通道,它适合于修改量较少的情况。以微信的多次发布为例,补丁大小均在300K以内,它相对于传统的发布有着很大的优势。
2、补丁技术非常适合使用在灰度阶段,利用热补丁技术,我们可以快速对同一批用户验证修复效果,这大大缩短了我们的发布流程。
3、热补丁技术可以降低开发成本,缩短开发周期,实现轻量而快速的升级
二、市场上常见热修复方案对比
支持的替换内容比较
支持的版本比较:
比较Dexposed不支持Art模式(5.0+),且写补丁有点困难,需要反射写混淆后的代码,粒度太细,要替换的方法多的话,工作量会比较大。
AndFix支持2.3-6.0,但是不清楚是否有一些机型的坑在里面,毕竟jni层不像java曾一样标准,从实现来说,方法类似Dexposed,都是通过jni来替换方法,但是实现上更简洁直接,应用patch不需要重启。但由于从实现上直接跳过了类初始化,设置为初始化完毕,所以像是静态函数、静态成员、构造函数都会出现问题,复杂点的类Class.forname很可能直接就会挂掉。
ClassLoader方案支持2.3-6.0,会对启动速度略微有影响,只能在下一次应用启动时生效,在空间中已经有了较长时间的线上应用,如果可以接受在下次启动才应用补丁,是很好的选择。总的来说,在兼容性稳定性上,ClassLoader方案很可靠,如果需要应用不重启就能修复,而且方法足够简单,可以使用AndFix,而Dexposed由于还不能支持art,所以只能暂时放弃,希望开发者们可以改进使它能支持art模式,毕竟xposed的种种能力还是很吸引人的(比如hook别人app的方法拿到解密后的数据,嘿嘿),还有比如无痕埋点啊线上追踪问题之类的,随时可以下掉
方案分析比较
一. AndFix
AndFix采用native hook的方式,这套方案直接使用dalvik_replaceMethod替换class中方法的实现。由于它并没有整体替换class, 而field在class中的相对地址在class加载时已确定,所以AndFix无法支持新增或者删除filed的情况通过替换init与clinit只可以修改field的数值)
也正因如此,Andfix可以支持的补丁场景相对有限,仅仅可以使用它来修复特定问题。结合之前的发布流程,我们更希望补丁对开发者是不感知的,即他不需要清楚这个修改是对补丁版本还是正式发布版本事实上我们也是使用git分支管理+cherry-pick方式)。另一方面,使用native替换将会面临比较复杂的兼容性问题。
二. QZone
一个ClassLoader可以包含多个dex文件,每个dex文件是一个Element,多个dex文件排列成一个有序的数组dexElements,当找类的时候,会按顺序遍历dex文件,然后从当前遍历的dex文件中找类,如果找类则返回,如果找不到从下一个dex文件继续查找。理论上,如果在不同的dex中有相同的类存在,那么会优先选择排在前面的dex文件的类,如下图:
把有问题的类打包到一个dex(patch.dex)中去,然后把这个dex插入到Elements的最前面,如下图:
所以为了实现补丁方案,所以必须从这些方法中入手,防止类被打上CLASS_ISPREVERIFIED标志。最终空间的方案是往所有类的构造函数里面插入了一段代码,代码如下:
if ClassVerifier.PREVENT_VERIFY) {
System.out.printlnAntilazyLoad.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加载之前就使用该类,该类一次找不到,会被永远的打上找不到的标志)
之所以选择构造函数是因为他不增加方法数,一个类即使没有显式的构造函数,也会有一个隐式的默认构造函数。空间使用的是在字节码插入代码,而不是源代码插入,使用的是javaassist库来进行字节码插入的。
隐患:虚拟机在安装期间为类打上CLASS_ISPREVERIFIED标志是为了提高性能的,我们强制防止类被打上标志是否会影响性能?这里我们会做一下更加详细的性能测试.但是在大项目中拆分dex的问题已经比较严重,很多类都没有被打上这个标志。
如何打包补丁包:
1. 空间在正式版本发布的时候,会生成一份缓存文件,里面记录了所有class文件的md5,还有一份mapping混淆文件。
2. 在后续的版本中使用-applymapping选项,应用正式版本的mapping文件,然后计算编译完成后的class文件的md5和正式版本进行比较,把不相同的class文件打包成补丁包。
备注:该方案现在也应用到我们的编译过程当中,编译不需要重新打包dex,只需要把修改过的类的class文件打包成patch dex,然后放到sdcard下,那么就会让改变的代码生效。
Dalvik; 在dexopt过程,若class verify通过会写入pre-verify标志,在经过optimize之后再写入odex文件。这里的optimize主要包括inline以及quick指令优化等
总的来说,Qzone方案好处在于开发透明,简单,这一套方案目前的应用成功率也是最高的,但在补丁包大小与性能损耗上有一定的局限性。特别是无论我们是否真正应用补丁,都会因为插桩导致对程序运行时的性能产生影响。微信对于性能要求较高,所以我们也没有采用这套方案。
三. 微信热补丁方案
结合InstantRun和buck的exopackage全量替换新的Dex,既不出现Art地址错乱的问题,在Dalvik也无须插桩。
将新旧两个Dex的差异放到补丁包中,最简单我们可以采用BsDiff算法。
采用DexDiff算法减小补丁包大小
简单来说,在编译时通过新旧两个Dex生成差异path.dex。在运行时,将差异patch.dex重新跟原始安装包的旧Dex还原为新的Dex。这个过程可能比较耗费时间与内存,所以我们是单独放在一个后台进程:patch中。为了补丁包尽量的小,微信自研了DexDiff算法,它深度利用Dex的格式来减少差异的大小。它的粒度是Dex格式的每一项,可以充分利用原本Dex的信息,而BsDiff的粒度是文件,AndFix/QZone的粒度为class。
差分包生成方案对比
AndroidN差分包生成方案
分平台合成的想法,即在Dalvik平台合成全量Dex,在Art平台合成需要的小Dex。
1、Dalvik全量合成,解决了插桩带来的性能损耗;
2、Art平台合成small dex,解决了全量合成方案占用Rom体积大, OTA升级以及Android N的问题;
3、大部分情况下Art.info仅仅1-20K, 解决由于补丁包可能过大的问题;
缺点:
它带来的问题有两个:占用Rom体积;这边大约是你修改Dex数量的1.5倍dexopt与dex压缩成jar)的大小。一个额外的合成过程;虽然我们单独放在一个进程上处理,但是合成时间的长短与内存消耗也会影响最终的成功率。
相比其他方案,AndFix的最大优点在于立即生效。事实上,AndFix的实现与Instant Run的热插拔有点类似,但是由于使用场景的限制,微信在最初期已排除使用这一方案
综合来看Tinker的热修复方案功能比较全,而且tinker在github上面开源,更加方便后期自己扩展
三、支持的系统版本、支持修复的参数
Tinker热补丁方案不仅支持类、So以及资源的替换,它还是2.X-8.X1.9.0以上支持8.X)的全平台支持
支持修改:
1、方法添加与修改
2、清单文件Manifest修改
3、activity新增
4、application修改
四、连续发布两个补丁修复是否支持
支持,两个补丁基于同一个baseApk生成差分包,只需再上传一个新的patch即可,上传新的补丁后,会自动下发新版本,停止下发旧版本补丁
五、差分包生成方式
DexDiff方案
六、为什么能够生效
采用Dex全量替换方式,将合成的dex文件通过反射插入到dexElements中,并放置在数组第一个索引位置,下次进行类加载的时候classLoader加载新生成的dex文件
七、为什么需要重启app生效
1、运行时通过反射将合并后的dex文件放置在加载的dexElements数组的前面
2、只有app重新启动的时候才会classLoader才会遍历Elements数组中dex文件,加载dex中的类文件
3、当一个apk在安装的时候,apk中的classes.dex会被虚拟机dexopt)优化成odex文件,然后才会拿去执行。
loadTinkerJars
public static boolean loadTinkerJarsApplication application, boolean tinkerLoadVerifyFlag, String directory, Intent intentResult, boolean isSystemOTA) {...try {SystemClassLoaderAdder.installDexesapplication, classLoader, optimizeDir, legalFiles);} catch Throwable e) {Log.eTAG, "install dexes failed");
// e.printStackTrace);intentResult.putExtraShareIntentUtil.INTENT_PATCH_EXCEPTION, e);ShareIntentUtil.setIntentReturnCodeintentResult, ShareConstants.ERROR_LOAD_PATCH_VERSION_DEX_LOAD_EXCEPTION);return false;}return true;}
八、ClassLoader加载Dex原理
运行时通过反射将合并后的dex文件放置在加载的dexElements数组的前面
ClassLoader
multidex方案的实现,其实就是把多个dex放进app的classloader之中,从而使得所有dex的类都能被找到。而实际上findClass的过程中,如果出现了重复的类,参照下面的类加载的实现,是会使用第一个找到的类的。
public Class findClassString name, List<Throwable> suppressed) { for Element element : dexElements) { //每个Element就是一个dex文件 DexFile dex = element.dexFile; if dex != null) { Class clazz = dex.loadClassBinaryNamename, definingContext, suppressed); if clazz != null) { return clazz; } } } if dexElementsSuppressedExceptions != null) { suppressed.addAllArrays.asListdexElementsSuppressedExceptions)); } return null;}
只要把有问题的类修复后,放到一个单独的dex,通过反射插入到dexElements数组的最前面,实现让虚拟机加载打完补丁的class。
参考:文章
九、DexDiff原理
Tinker针对上面的Data Section部分每一项内容都做了相应的diff逻辑
CodeSectionDiffAlgorithm算法过程
Code Section
下面的图指出了在method结构里通过code_off字段引用到指定的code_item段
上面图中指出的code_item段的内容在Tinker里面通过com.tencent.tinker.android.dex.Code类对应
public final class Code extends Item<Code> {public int registersSize;//本段代码使用到的寄存器数目public int insSize;//method传入参数的数目public int outsSize;//本段代码调用其它method 时需要的参数个数public int debugInfoOffset;//指向调试信息的偏移public short[] instructions;//表示具体的字节码public Try[] tries;//try_item 数组public CatchHandler[] catchHandlers;}
然后Tinker在做diff的时候通过compareTo方法来判断方法里的代码是否经过修改。
@Overridepublic int compareToCode other) {int res = CompareUtils.sCompareregistersSize, other.registersSize);if res != 0) {return res;}res = CompareUtils.sCompareinsSize, other.insSize);if res != 0) {return res;}res = CompareUtils.sCompareoutsSize, other.outsSize);if res != 0) {return res;}res = CompareUtils.sComparedebugInfoOffset, other.debugInfoOffset);if res != 0) {return res;}res = CompareUtils.uArrCompareinstructions, other.instructions);if res != 0) {return res;}res = CompareUtils.aArrComparetries, other.tries);if res != 0) {return res;}return CompareUtils.aArrComparecatchHandlers, other.catchHandlers);}
对比案例:
old版本
public class Foo { public void foo){System.out.println"hello dodola5");}
}
new 版本
public class Foo { public String foo1 = "hello dodola";public String foo5 = "hello dodola1";public String foo2 = "hello dodola2";public String foo3 = "hello dodola3";public String foo4 = "hello dodola4";public void foo){System.out.println"hello dodola5");}
}
两个版本字节码对比
public class Foo { public String foo1 = "hello dodola";public String foo5 = "hello dodola1";public String foo2 = "hello dodola2";public String foo3 = "hello dodola3";public String foo4 = "hello dodola4";public void foo){System.out.println"hello dodola5");}
}
从上面两段代码的对比中我们可以看到虽然我们没有改变 hello dodola5 这个字符串的内容,但是这个字符串由于我们新增的字符串导致其string_id产生变化,也就是上述代码中出现的string@000a和string@0014的不同,并且由于字段的增加导致读取的field位置也是不同 sget-object指的是根据 字段ID 读取静态对象引用字段到 vx,这说明java.io.PrintStream java.lang.System.out 所在的fieldid变了。
按照直接取出两个Code做对比的方法,在类似这种情况下虽然没有对其方法做修改,也是会被判定为different的,所以我们需要一个过程,将这样内容没有变化,id出现变化的情况,将新dex里的ID映射回旧dex的ID上面。这是一方面的考虑。
Tinker 做新旧 ID的映射示例
old version
public class Foo { public String foo1="hello dodola";public String foo5="hello dodola1";public String foo2="hello dodola2";public void foo){System.out.println"hello dodola5");}
}
new version
public class Foo { public String foo1="hello dodola_modify";public String foo5="hello dodola1";public String foo3="hello dodola3";public void foo){System.out.println"hello dodola1");}
}
我们用上面修改的内容看一下 Tinker 里所用的diff算法的逻辑
算法过程
算法的过程比较简单,描述一下就是:首先我们需要将新旧内容排序,这需要针对排序的数组进行操作新旧两个指针,在内容一样的时候 old、new 指针同时加1,在 old 内容小于 new 内容注:这里所说的内容比较是单纯的内容比较比如’A’<‘a’)的时候 old 指针加1 标记当前 old 项为删除在 old 内容大于 new 内容 new 指针加1, 标记当前 new 项为新增下面我列出了算法执行的简单过程
二路归并算法------old-----
11 foo2
12 foo5
13 hello dodola
14 hello dodola1
15 hello dodola2
16 hello dodola5
17 out
18 println
------new-----
11 foo3
12 foo5
13 hello dodola1
14 hello dodola3
15 hello dodola_modify
16 out
17 println
对比的old cursor 和 new cursor 指针的改变以及操作判定,判定过程如下
old_11 new_11 cmp <0 del
old_12 new_11 cmp >0 add
old_12 new_12 cmp =0 no
old_13 new_13 cmp <0 del
old_14 new_13 cmp =0 no
old_15 new_14 cmp <0 del
old_16 new_14 cmp >0 add
old_16 new_15 cmp <0 del
old_17 new_15 cmp >0 add
old_17 new_16 cmp =0 no
old_18 new_17 cmp =0 no
break;
进入下一步过程
可以确定的是删除的内容肯定是从 old 中的 index 进行删除的 添加的内容肯定是从 new 中的 index 中来的,按照这个逻辑我们可以整理如下内容。
old_11 del
new_11 add
old_13 del
new_14 add
old_15 del
new_15 add
old_16 del
到这一步我们需要找出替换的内容,很明显替换的内容就是从 old 中 del 的并且在 new 中 add 的并且 index 相同的i tem,所以这就简单了
old_11 replace
old_13 del
new_14 add
old_15 replace
old_16 del
ok,到这一步我们就能判定出两个dex的变化了。很机智的算法
Dalvik bytecode
Dalvik虚拟机是基于寄存器的,在java字节转换为dalvik字节码的过程中,方法调用栈的尺寸就已经确定,其中明确指出了方法使用寄存器的个数
一段Dalvik字节码由一系列Dalvik指令组成,指令语法由指令的位描述与指令格式标识来决定。位描述约定如下:每16位的字采用空格分隔开来。每个字母表示4位,每个字母按顺序从高字节开始,排列到低字节。每4位之间可能使有竖线“|”来表示不同的内容。顺序采用A~Z的单个大写字母作为一个4位的操作码,op表示一个8位的操作码。“Ø”来表示这字段所有位为0值。
以指令格式A|G|op BBBB F|E|D|C为例:
指令中间有两个空格,每个分开的部分大小为16位,所以这条指令由三个16位的字组成。第一个16位是A|G|op,高8位由A与G组成,低字节由操作码op组成。第二个16位由BBBB组成,它表示一个16位的偏移值。第三个16位分别由F,E,D,C共四个4位组成,在这里它们表示寄存器参数。
在实际存储时,是以小端方式,而在描述时,则以大端方式。
单独使用位标识还无法确定一条指令,必须通过指令格式标识来指定指令的格式编码。它的约定如下
指令格式标识大多由三个字符组成,前两个是数字,最后一个是字母。
第一个数字是表示指令有多少个16位的字组成。
第二个数字是表示指令最多使用寄存器的个数。特殊标记“r”标识使用一定范围内的寄存器。
第三个字母为类型码,表示指令用到的额外数据的类型。取值见下表。
还有一种特殊的情况是末尾可能会多出另一个字母,如果是字母 s 表示指令采用静态链接,如果是字母 i 表示指令应该被内联处理
以指令格式标识 22x 为例
第一个数字2表示指令有两个16位字组成,第二个数字2表示指令使用到2个寄存器,第三个字母x表示没有使用到额外的数据
Insruction Transformer
我们拿到的Code是不能直接进行对比的,所以Tinker写了一个InstructionTransformer来对字节码进行一个转换操作,来解决上述的问题
public short[] transformshort[] encodedInstructions) throws DexException {ShortArrayCodeOutput out = new ShortArrayCodeOutputencodedInstructions.length);//因为每个指令的长度是u1 也就是0~255InstructionPromoter ipmo = new InstructionPromoter);//地址转换,应对类似const-string 到const-string/jumbo的地址扩展情况InstructionWriter iw = new InstructionWriterout, ipmo);InstructionReader ir = new InstructionReadernew ShortArrayCodeInputencodedInstructions));try {// First visit, we collect mappings from original target address to promoted target address.ir.acceptnew InstructionTransformVisitoripmo));// Then do the real transformation work.ir.acceptnew InstructionTransformVisitoriw));} catch EOFException e) {throw new DexExceptione);}return out.getArray);}
InstructionReader用来解析 Code 里了bytecode信息,提取索引等相关内容。
参考:文章
十、为什么添加了patch文件就能合成新的apk,别的文件行不行
patch中包含了YAPATCH.MF文件里面标注了基准包的数据,用于区分是否是针对baseApk的补丁文件,针对补丁文件进行校验后取出,合成新的dex文件Created-Time: 2019-04-08 17:58:00.883
Created-By: YaFix1.1)
YaPatchType: 2
VersionName: 1.1
VersionCode: 2
From: 1.1.2_0408-16-48-16
To: 1.1.2_0408-17-58-01
十一、Tinker接入
1、通过Bugly平台接入
Bugly热更新使用详情
2、Tinker官方接入文档
关于接入直接按照文档上来就行了,写的很详细,上面提供了demo,就不贴代码了
小结:
这篇文章多半是参考以下文章写的,用于对Tinker的一个小结吧,方便以后查看
参考文章:
1、微信Android热补丁实践演进之路
2、Android N对热补丁影响解析
3、Tinker DexDiff算法解析
4、微信Tinker的一切都在这里,包括源码(一)