博客
关于我
强烈建议你试试无所不能的chatGPT,快点击我
Android热修复-Tinker简析
阅读量:4097 次
发布时间:2019-05-25

本文共 12054 字,大约阅读时间需要 40 分钟。

一、简介

日常工作工作中难免会遇到项目上线后出现bug问题,如果紧急发版往往由于渠道审核时间问题,导致bug修复不及时,影响用户体验。这时我们需要引入热修复,免去发版审核烦恼。

热更新优势:

让应用能够在无需重新安装的情况实现更新,帮助应用快速建立动态修复能力。

  1. 轻量而快速的升级,无需发版
  2. 远端调试,,可以将补丁推送给指定用户
  3. 可以通过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.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加载之前就使用该类,该类一次找不到,会被永远的打上找不到的标志)

之所以选择构造函数是因为他不增加方法数,一个类即使没有显式的构造函数,也会有一个隐式的默认构造函数。空间使用的是在字节码插入代码,而不是源代码插入,使用的是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。
在这里插入图片描述

针对不同平台Dalvik和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.X(1.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 loadTinkerJars(Application application, boolean tinkerLoadVerifyFlag, String directory, Intent intentResult, boolean isSystemOTA) {        ...        try {            SystemClassLoaderAdder.installDexes(application, classLoader, optimizeDir, legalFiles);        } catch (Throwable e) {            Log.e(TAG, "install dexes failed");//            e.printStackTrace();            intentResult.putExtra(ShareIntentUtil.INTENT_PATCH_EXCEPTION, e);            ShareIntentUtil.setIntentReturnCode(intentResult, 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 findClass(String name, List
suppressed) { for (Element element : dexElements) { //每个Element就是一个dex文件 DexFile dex = element.dexFile; if (dex != null) { Class clazz = dex.loadClassBinaryName(name, definingContext, suppressed); if (clazz != null) { return clazz; } } } if (dexElementsSuppressedExceptions != null) { suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions)); } return null; }

只要把有问题的类修复后,放到一个单独的dex,通过反射插入到dexElements数组的最前面,实现让虚拟机加载打完补丁的class。

参考:

九、DexDiff原理

Dex结构

在这里插入图片描述

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 {    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方法来判断方法里的代码是否经过修改。

@Override    public int compareTo(Code other) {        int res = CompareUtils.sCompare(registersSize, other.registersSize);        if (res != 0) {            return res;        }        res = CompareUtils.sCompare(insSize, other.insSize);        if (res != 0) {            return res;        }        res = CompareUtils.sCompare(outsSize, other.outsSize);        if (res != 0) {            return res;        }        res = CompareUtils.sCompare(debugInfoOffset, other.debugInfoOffset);        if (res != 0) {            return res;        }        res = CompareUtils.uArrCompare(instructions, other.instructions);        if (res != 0) {            return res;        }        res = CompareUtils.aArrCompare(tries, other.tries);        if (res != 0) {            return res;        }        return CompareUtils.aArrCompare(catchHandlers, 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");    }}

smali代码对比

在这里插入图片描述

从上面两段代码的对比中我们可以看到虽然我们没有改变 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 dodola14 hello dodola115 hello dodola216 hello dodola517 out18 println------new-----11 foo3 12 foo5 13 hello dodola1 14 hello dodola315 hello dodola_modify16 out17 println对比的old cursor 和 new cursor 指针的改变以及操作判定,判定过程如下old_11 new_11 cmp <0  delold_12 new_11 cmp >0  addold_12 new_12 cmp =0  noold_13 new_13 cmp <0  delold_14 new_13 cmp =0  noold_15 new_14 cmp <0  delold_16 new_14 cmp >0  addold_16 new_15 cmp <0  delold_17 new_15 cmp >0  addold_17 new_16 cmp =0  noold_18 new_17 cmp =0  nobreak;进入下一步过程可以确定的是删除的内容肯定是从 old 中的 index 进行删除的 添加的内容肯定是从 new 中的 index 中来的,按照这个逻辑我们可以整理如下内容。old_11 delnew_11 addold_13 delnew_14 addold_15 delnew_15 addold_16 del到这一步我们需要找出替换的内容,很明显替换的内容就是从 old 中 del 的并且在 new 中 add 的并且 index 相同的i tem,所以这就简单了old_11 replaceold_13 delnew_14 addold_15 replaceold_16 delok,到这一步我们就能判定出两个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[] transform(short[] encodedInstructions) throws DexException {        ShortArrayCodeOutput out = new ShortArrayCodeOutput(encodedInstructions.length);//因为每个指令的长度是u1 也就是0~255        InstructionPromoter ipmo = new InstructionPromoter();//地址转换,应对类似const-string 到const-string/jumbo的地址扩展情况        InstructionWriter iw = new InstructionWriter(out, ipmo);        InstructionReader ir = new InstructionReader(new ShortArrayCodeInput(encodedInstructions));        try {            // First visit, we collect mappings from original target address to promoted target address.            ir.accept(new InstructionTransformVisitor(ipmo));            // Then do the real transformation work.            ir.accept(new InstructionTransformVisitor(iw));        } catch (EOFException e) {            throw new DexException(e);        }        return out.getArray();    }

InstructionReader用来解析 Code 里了bytecode信息,提取索引等相关内容。

参考:

十、为什么添加了patch文件就能合成新的apk,别的文件行不行

patch中包含了YAPATCH.MF文件里面标注了基准包的数据,用于区分是否是针对baseApk的补丁文件,针对补丁文件进行校验后取出,合成新的dex文件Created-Time: 2019-04-08 17:58:00.883Created-By: YaFix(1.1)YaPatchType: 2VersionName: 1.1VersionCode: 2From: 1.1.2_0408-16-48-16To: 1.1.2_0408-17-58-01

在这里插入图片描述

十一、Tinker接入

1、

2、

关于接入直接按照文档上来就行了,写的很详细,上面提供了demo,就不贴代码了

小结:

这篇文章多半是参考以下文章写的,用于对Tinker的一个小结吧,方便以后查看

参考文章:

1、

2、
3、
4、

转载地址:http://lioii.baihongyu.com/

你可能感兴趣的文章
gawk程序基础
查看>>
JVM架构之JVM工作原理
查看>>
Java中的垃圾回收
查看>>
sed编辑器基础之替换命令(二)
查看>>
Java代码中如何交换两个对象
查看>>
Java中的随机数
查看>>
Java虚拟机工具之堆栈跟踪工具jstack定位死循环
查看>>
OpenCV在Microsoft Visual Studio 2010环境中的配置
查看>>
在VS(Visual Studio)中运行带有参数的控制台程序
查看>>
第N个偶斐波那契数
查看>>
字符数目相同的子字符串的数目
查看>>
Java虚拟机工具之堆栈跟踪工具jstack检测死锁
查看>>
Java虚拟机工具之堆栈跟踪工具jstack检测输入等待
查看>>
Java虚拟机工具之堆栈跟踪工具jstack检测对象wait方法
查看>>
Windows下Jconsole无法连接到进程
查看>>
设置tomcat启动参数
查看>>
启动Tomcat提示:指定的服务未安装
查看>>
构建一个n×n的unique矩阵
查看>>
JavaScript代码加Alert后代码有效,不加则无效。
查看>>
Intellj Idea 16添加Maven新建模块
查看>>