【Dev Club 分享】微信热补丁 Tinker 的实践演进之路

时间:2022-05-05
本文章向大家介绍【Dev Club 分享】微信热补丁 Tinker 的实践演进之路,主要内容包括1. 当前各种热补丁框架对比 & Tinker 的设计目标、2. 3. Tinker 的实践演进、3.Tinker 在实现中遇到的困难、结语、互动问答环节、基本概念、基础应用、原理机制和需要注意的事项等,并结合实例形式分析了其使用技巧,希望通过本文能帮助到大家理解应用这部分内容。

Dev Club 是一个交流移动开发技术,结交朋友,扩展人脉的社群,成员都是经过审核的移动开发工程师。每周都会举行嘉宾分享,话题讨论等活动。

本期,我们邀请了腾讯WXG Android开发工程师——张绍文,为大家分享《微信热补丁 Tinker 的实践演进之路》

分享内容简介:

Tinker 是微信官方的 Android 热补丁解决方案,它支持动态下发代码、So库以及资源,让应用能够在不需要重新安装的情况下实现更新。这里大致介绍 Tinker 的实现原理,当时遇到的各种坑以及对它各个方面性能的优化工作。

内容大体框架:

  1. 当前各种热补丁框架的比较以及 Tinker 的设计目标
  2. Tinker的实践演进
  3. Tinker在实现中遇到的困难

下面是本期分享内容整理


hello,大家好。我是张绍文,目前在微信主要负责 Android 的性能优化以及终端质量平台相关工作。

下面开始我们今天的分享。

1. 当前各种热补丁框架对比 & Tinker 的设计目标

热补丁技术是当前非常热门的 Android 开发技术,其中比较出名的方案有支付宝的 AndFix以及 QZone 的超级热补丁方案。

微信大约在2015年6月开始尝试应用,经过研究与尝试现有的各个方案,我们发现它们都有着自身的一些局限性。我们最终采用不同于它们的技术方案,自研微信热补丁开源框架 Tinker。

下面我们先来讲讲先有框架的一些局限性。

1.1 AndFix

Andfix 是阿里推出的开源框架,它在 github 的地址是:

https://github.com/alibaba/AndFix

它的技术原理如下图:它采用 native hook 的方式,这套方案直接使用 dalvik_replaceMethod 替换 class 中方法的实现。

它的缺点主要包括以下几个:

  1. 兼容性不佳;由于它采用 native 替换的方式,在 github Issue 中也有大量崩溃的反馈;
  2. 成功率不高;不支持修改 inline 方法,不支持修改方法参数超过8个或参数中带有 long, double 或者 float。跟一些使用 Andfix 的产品讨论过,它们的成功率不超过40%; 原因:只替换了 DexCache 中的 ArtMethod 结构体,对于 Art 中一些 compiledCode 是直接通过 bx 过去
  3. 开发不透明;由于它还不支持增加 filed,我们需要为了补丁而补丁,无法采用这个技术发布需求。

Andfix 的好处是可以立刻生效,但它可以支持的补丁场景非常有限,仅仅可以使用它来修复特定问题。

所以我们不考虑采用这个方案。

1.2 Qzone 超级补丁方案

现在我们讲讲 Qzone 超级补丁方案,在腾讯内部已开源。

这个方案使用 classloader 的方式,能实现更加友好的类替换。而且这与我们加载 Multidex 的做法相似,能基本保证稳定性与兼容性。

它主要的面临问题有两个:

  1. 为了解决 unexpected DEX problem 异常,而采用插桩的方式给所有类插入不会真正运行的代码,防止类打上 preverify 标志。

采用插桩导致所有类都非 preverify,导致上图中的 verify 与 optimize 操作会在加载类时触发。这会有一定的性能损耗,微信分别采用插桩与不插桩两种方式做过两种测试,一是连续加载700个50行左右的类,一是统计微信整个启动完成的耗时。

  1. 在 art 平台,若补丁中的类出现 Field、Method 或 Interface 变化,可能会导致出现内存地址错乱的问题。为了解决这个问题,我们最后补丁中的类要有以下规则: a. 修改跟新增的 class; b. 若 class 有 field,method 或 interface 数量变化,它们所有的子类; c. 若 class 有 field,method 或 interface 数量变化,它们以及它们所有子类的调用类。如果采用 ClassN 方式,即需要多个 dex 一起处理。

Qzone 的方案最为简单,而且开发透明,补丁的成功率也是非常高的。

但由于微信对于运行性能以及补丁大小都比较敏感,我们最终也没有采用这套方案。

1.3 Tinker 的设计目标

那么微信希望的是一套怎么样的热补丁框架呢,我们认为主要的目标有以下几个:

  1. 开发透明;开发者无需关心是否在补丁版本,他可以随意修改,不由框架限制;
  2. 性能无影响;补丁框架不能对应用带来性能损耗;
  3. 完整支持;支持代码,So 库以及资源的修复,可以发布功能。
  4. 补丁大小较小;补丁大小应该尽量的小,提高升级率。
  5. 稳定,兼容性好;保证微信的数亿用户的使用,尽量减少反射;

2. 3. Tinker 的实践演进

现在我们来讲讲微信热补丁框架 Tinker 的实现,目前在腾讯内部已开源。

它的名字来至 Dota 中的地精修补匠,我们希望发版本可以像它一样做到无限刷新。

Tinker 的方案来源 gradle 编译的 instant run 与 buck 编译的 exopackage。它们的思想都是全量替换新的 Dex。即我们完全使用了新的 Dex,那样既不出现 Art 地址错乱的问题,在 Dalvik 也无须插桩。

但是 instant run 针对的是编译期,它可以直接将最后生成的所有变化都直接拷到手机端。对于线上方案,这肯定是不可行的。所以当前核心问题是找到合适的,使补丁结果更小的差分算法。

微信首先 demo 中采用的是 bsdiff,它无关文件格式,但对于dex效果不是特别好,而且非常不稳定。当前微信对于 so,依然使用 bsdiff 算法。

然后我们想到 dexmerge 算法,把修改跟新增的类通过 dexmerge 方式与原来的 dex 合并,从而得到最终的完整 Dex。

经过实践,dexmerge 的核心问题有两个:

  1. 无法删除 class;导致在 Dalvik 平台会出现加载类重复的情况,这要求我们只能采用 miniloader 加载方案来避免;
  2. 合成时内存占用过大;dexmerge 库使用场景在 pc,它没有太多的考虑内存问题。它的峰值内存可以达到输入 dex 的大小的4倍-6倍。一个12M的 dex,峰值内存可能达到70多M。

最后我们决定基于 dex 的格式,自研出一种 Dexdiff 算法,它需要达到以下目标;

  1. diff 结果小;
  2. 合成过程占用内存小;
  3. 支持删除、新增、修改 dex 中的 class。

这里面主要的原理是深度利用原来 dex 中的信息,对于 dex 的每一个 section 做处理。这块在今天不再深入,感兴趣的同学可以交流。

内存方面 dexdiff 峰值内存是 dex 的两倍左右,达到预期的结果。

对于微信热补丁的更多信息,可以阅读我之前发的一篇文章。

微信Android热补丁实践演进之路

然后我们来看看 Tinker 的框架设计,它主要包括以下几部分:

  1. 补丁合成;这些都在单独的 patch 进程工作,这里包括 dex,so 还有资源,主要完成补丁包的合成以及升级;
  2. 补丁的加载;如果通过反射系统加载我们合成好的 dex,so 与资源;
  3. 监控回调;在合成与加载过程中,出现问题及时回调;
  4. 版本管理; Tinker 支持补丁升级,甚至是多个补丁不停的切换。这里我们需要保证所有进程版本的一致性;
  5. 安全校验;无论在补丁合成还是加载,我们都需要有必要的安全校验。

在微信中,我们为 Tinker 框架加入了100多个实时上报,监控着在每个过程可能出现的问题:

3.Tinker 在实现中遇到的困难

接下来我们来看看在开发 Tinker 过程中,遇到的一些问题:

1. 厂商 OTA;

对于 Art 平台,dex2oat 时间较长。特别是厂商 OTA 之后,所有动态加载的代码都需要重新执行 dex2oat。这是因为 boot image 已经改变,但是系统在升级时只会给 ClassN.dex 重新 OTA。

对于补丁 dex 会出现主进程同步执行 dex2oat,这个时间非常久,很有可能会出现 ANR,对于小米等一些产品的开发板更是如此。这也是我们现在努力在实现分平台合成的原因,即在 Art平台,只合成规则下需要的 class。只要不是全量替换,重新 dex2oat 的时间是可以接受的。

2. Android N 混合编译导致补丁机制失效

这块花了一定的时间重新梳理了 Android N art 的代码,详细的分析可以查看之前我发的一篇文章。

Android N混合编译与对热补丁影响解析

3. Dex 反射成功但是不生效;

开始的时候,我们加载补丁 dex 采用的是 makedexElement 的方式。但是发现大约有几十万台机器,补丁加载成功了,但是使用的还是旧版本的代码。某些机器类似三星 s6 502系统,尽管反射 pathList 成功,查找顺序依然以 base.apk 优先。

这里采取的解决方法是类似 instant run,采用反射 parent classloader 的方式。这里不得不提,instant run 的 increaseClassLoader 实现非常精妙。

4. Xposed 等微信插件;

市面上有各种各样的微信插件,它们在微信启动前会提前加载微信中的类,这会导致两个问题:

a. 在 Dalvik 平台,直接出现 Class ref in pre-verified class resolved to unexpected implementation 的 crash;

b. 在 Art 平台,由于出现部分类使用了旧的代码,这可能导致补丁无效,或者地址错乱的问题。

它们根本的原因都是Xposed反射调用,提前导入了我们的某些类。

事实上,由于补丁使用不当或者其他问题,我们的确需要有一个安全模式。即在应用启动不起来或多次 crash 时,进入补丁清理或者升级的流程。

结语

也许有人觉得 Tinker 过于臃肿,过于复杂。这是因为热补丁并不是仅仅加载一个 dex 或 so 文件,事实上它要关心的细节有很多。进程的一致性,控制可修改类的范围,版本的管理,扩展性等等。

Tinker 的未来规划是真正的开源出去,大约下周会提交分享平台合成以及资源相关的所有代码。然后等公司的开源审计结束后将在 github 开源,欢迎大家接入 Tinker 内测,给我们更多的意见。

由于时间有限,今天的分享就到这里。对于 So,资源的合成方式,dexdiff 的技术细节,若大家感兴趣可以与我们交流。

互动问答环节

Q1:请教下 patch 进程和主进程是怎么通信的?

是通过 intent service 通信的,主进程一个接受补丁结果的 intent service,patch 进程是一个接受补丁请求的 intent service

Q2:“分平台合成”没听太明白,能再仔细说下么?

分平台合成就是在 Dalvik 平台,我们合成全量的 dex,这可以避免我们插桩的要求。 在 Art 平台,我们只合成上述三个条件下的类: a. 修改跟新增的 class; b. 若 class 有 field,method 或 interface 数量变化,它们所有的子类; c. 若 class 有 field,method 或 interface 数量变化,它们以及它们所有子类的调用类。如果采用 ClassN 方式,即需要多个 dex 一起处理。 这里的难点是同一份 diff 代码,可以做到不同的合成方式。

Q3:对于内部空间不足引起的 patch 失败现在有什么好的解决办法?

对于我们的方案,空间占用有可能比较大,我们解决的方法有两个:

  1. 在 patch 之前提前检查用户的剩余空间,如果用户剩余空间过少,即不尝试。
  2. 若本次失败,我们会有回调,然后我们会定期重试三次。

你也可以在这里采用提示用户清理空间。Tinker 框架是可以高度定制化的。

Q4:对于替换 classloader 失败后再用 MultiDex install 这种方案有什么考虑?

有的,对于替换失败的话,的确会回退到类似 Multidex install 方式的

Q5:目前微信对热补丁技术的应用场景一般集中在哪些方面呢?除了修复紧急的 BUG,还有哪些真实场景下用过这个技术吗?微信是如何评估是否需要通过打热补丁的方式来处理一些问题的呢?

正如我之前的一篇文章来说,在 Android 热补丁技术的应用比 iOS 更加容易。我们可以完全做到无感知的开发,推给用户等。这里面的应用场景有很多,用户调试,版本升级,发布需求,Abtest 等等。

Q6:想问下大神,对于替换 app 中使用的第三方 jar 包,有具体实践吗?

抱歉,这部分还没有实践。原理上是没问题的,如果第三方的 jar 包是集成到源码,那么编译新包的时候已经可以带上改变。如果第三方的 jar 包是动态加载的,也是没有问题的。我们通过 parent classloader 的方式,查找顺序也会在你们之前。

Q7:patchCoreSDK 怎么绕过 换 classloader 后跨 dex 加载类 accesserror 的问题?有对 patchcoreSDK 做强制访问隔离吗?

是的,Tinker 框架分为两部分,核心加载代码,成为 loader 类,这里大概有十几个类,他们是不允许修改的。其他大部分 Tinker 的类也是可以通过补丁修改的,这里 Tinker 框架已经做了处理,即在新合成的 Dex,我们已经删除了 loader 相关的类,从而彻底避免了这个问题。

Q8:patch 成功后怎么及时重启其他进程?

为了保证各个进程的唯一性,我们有一个版本管理文件用于记录当前补丁的版本。它分为 old 与 new 两个字段。同时做了约定,只有 patch 进程可以修改 new 字段,只有主进程可以修改 old 字段,其他所有进程启动时都只会加载 old 字段的补丁版本。然后主要主进程可以发起版本升级,即把 new 字段赋值给 old 字段,这个时候主进程要杀掉其他所有的进程,以保证统一性。 而及时重启其他进程的问题,主要是在我刚才讲的 result service。在结果回调中,我们如果发现补丁已经成功了,我们可以设置主进程在后台或者锁屏时自杀,以达到最快的应用。

Q9:完全使用新的资源包是怎么理解?旧的资源包会被替换删除吗?

旧的资源包是安装的 apk,我们是不会删掉的。我们只是反射系统的一些接口,把它替换成新的资源包

Q10:超级补丁方案,有没有想过不采用插桩的方式,而是去 hook 检验的方法,就能缓解性能的问题?

事实上,有些人实现 hook preverify 标志来避免插桩。但是看过底层代码,就知道是不可行的。我们要知道系统检查那个标志位的真正原因,即使 hook 了 preverify 标志,在真正运行过程中,由于 quck 指令以及 vtable 的优化,依然运行时会出问题。这个问题告诉我们,做事情需要知其然也要知其所以然。

Q11:合成新的资源和 so 是怎么加载的?

so 可以通过反射 classloader 的 lib path,但是我们并不建议这么多,一来是兼容性问题,二来在某些机器上,多 abi 的判断并不准确。我们更希望通过封装代码来支持。对于资源,我们处理是跟 dex 差不多,启动时即反射调用。

Q12:是否有动态下发第三方的 jar 包,如何调用第三方 jar 包的方法。反射?

Tinker 框架只会合成输入 pattern 下的 dex,而且在启动的时候把他们加载。如果调用的问题,使用者自己决定的。

Q13:差量下发更新,合成的时候是否会有性能问题?是否支持(图片)资源的差量下发?

合成的话,我们对于内存、GC 以及耗时都有大量的优化。即使是微信这样体量的 app,从外部监控来看,大部分用户都能在60秒以内完成。

Q14:需要在补丁合成加载之后才进入程序(交由用户操作)吗?

合成与加载是分开两个过程,我们的原则是除非合成已经彻底完成,不然其他进程是不会去加载的。即补丁不会去影相其他进程的加载性能

Q15:代码完全开源吗?

对的,所有代码都会开源,从编译到各个模块。

Q16:xposed 框架的那些插件,是通过反射调用替换值?那一般有啥方式保证安全性?保证 app 数据的安全性?

它们只要是反射调用微信的某些类,达到某些功能的篡改。事实上,如果在 root 下,单纯的保护是比较难的。

Q17:为什么要在补丁成功的时候加结果回调是为了启动程序么,但是和您刚才说的为了实时上报?

回调结果是为了给使用者一个回调,在这个回调里面它可以做各种各样的工作。例如我弹出升级完成的 dialog。我设置锁屏或者程序进入后台后自杀,这可以加快补丁的应用

Q18:既然能加载 so 和资源,Tinker 能用于插件化吗?

Tinker 当前没有做四大组件的代理,但是 Tinker 未来绝对是具备这个能力的

Q19:merge 失败后的补救机制是怎样的?可以回退么?

merge 失败,我们会收到回调,这个时候我们不会加载的。在默认实现里面,我们会删除这些临时文件。

Q20:这套框架目前是多少个人在维护呢?

Tinker当前有3个人在开发维护

Q21:请问资源是编译到 arsc 中还是反射加载二进制流?

你的问题我不太明白,资源我们采用的是全量替换,即完全使用新的资源包

Q22:在加入 Tinker 之后,对各平台的加固适配如何?微信是否有加固?

微信没有使用加固,但是加固应该是不影响的,只需要把接口改一下就可以了。


本文系腾讯Bugly独家内容,转载请在文章开头显眼处注明作者和出处“腾讯Bugly(http://bugly.qq.com)”