Android热修复-Tinker简析

一、简介

日常工作工作中难免会遇到项目上线后出现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<Throwable> 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<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方法来判断方法里的代码是否经过修改。

 @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 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[] 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.883
Created-By: YaFix(1.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的一切都在这里,包括源码(一)

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值