Dev Club是一个交流移动开发技术,结交朋友,扩展人脉的社群,成员都是经过审核的移动开发工程师。每周都会举行嘉宾分享,话题讨论等活动。
本期,我们邀请了腾讯WXG Android开发工程师—— 张绍文 ,为大家分享 《微信热补丁 Tinker 的实践演进之路》 。
Tinker 是微信官方的 Android 热补丁解决方案,它支持动态下发代码、So库以及资源,让应用能够在不需要重新安装的情况下实现更新。这里大致介绍 Tinker 的实现原理,当时遇到的各种坑以及对它各个方面性能的优化工作。
当前各种热补丁框架的比较以及 Tinker 的设计目标
Tinker的实践演进
Tinker在实现中遇到的困难
hello,大家好。我是张绍文,目前在微信主要负责 Android 的性能优化以及终端质量平台相关工作。
下面开始我们今天的分享。
热补丁技术是当前非常热门的 Android 开发技术,其中比较出名的方案有 支付宝的 AndFix 以及 QZone 的超级热补丁 方案。
微信大约在2015年6月开始尝试应用,经过研究与尝试现有的各个方案,我们发现它们都有着自身的一些 局限性 。我们最终采用不同于它们的技术方案, 自研微信热补丁开源框架 Tinker。
下面我们先来讲讲先有框架的一些局限性。
Andfix 是阿里推出的开源框架,它在 github 的地址是:
https://github.com/alibaba/AndFix
它的技术原理如下图:它采用 native hook 的方式,这套方案直接使用 dalvik_replaceMethod
替换 class 中方法的实现。
兼容性不佳;由于它采用 native 替换的方式,在 github Issue 中也有大量崩溃的反馈;
成功率不高;不支持修改 inline 方法,不支持修改方法参数超过8个或参数中带有 long, double 或者 float。跟一些使用 Andfix 的产品讨论过,它们的成功率不超过40%;
原因:只替换了 DexCache 中的 ArtMethod 结构体,对于 Art 中一些 compiledCode 是直接通过 bx 过去
开发不透明;由于它还不支持增加 filed,我们需要为了补丁而补丁,无法采用这个技术发布需求。
Andfix 的好处是可以立刻生效,但它可以支持的补丁场景非常有限,仅仅可以使用它来修复特定问题。
所以我们不考虑采用这个方案。
现在我们讲讲 Qzone 超级补丁方案,在腾讯内部已开源。
这个方案使用 classloader 的方式,能实现更加友好的类替换。而且这与我们加载 Multidex 的做法相似,能基本保证稳定性与兼容性。
为了解决 unexpected DEX problem
异常,而采用插桩的方式给所有类插入不会真正运行的代码,防止类打上 preverify 标志。
采用插桩导致所有类都非 preverify,导致上图中的 verify 与 optimize 操作会在加载类时触发。这会有一定的性能损耗,微信分别采用插桩与不插桩两种方式做过两种测试,一是连续加载700个50行左右的类,一是统计微信整个启动完成的耗时。
在 art 平台,若补丁中的类出现 Field、Method 或 Interface 变化,可能会导致出现内存地址错乱的问题。为了解决这个问题,我们最后补丁中的类要有以下规则:
a.修改跟新增的 class;
b.若 class 有 field,method 或 interface 数量变化,它们所有的子类;
c.若 class 有 field,method 或 interface 数量变化,它们以及它们所有子类的调用类。如果采用 ClassN 方式,即需要多个 dex 一起处理。
Qzone 的方案最为简单,而且开发透明,补丁的成功率也是非常高的。
但由于微信对于 运行性能 以及 补丁大小 都比较敏感,我们最终也没有采用这套方案。
那么微信希望的是一套怎么样的热补丁框架呢,我们认为主要的目标有以下几个:
开发透明;开发者无需关心是否在补丁版本,他可以随意修改,不由框架限制;
性能无影响;补丁框架不能对应用带来性能损耗;
完整支持;支持代码,So 库以及资源的修复,可以发布功能。
补丁大小较小;补丁大小应该尽量的小,提高升级率。
稳定,兼容性好;保证微信的数亿用户的使用,尽量减少反射;
现在我们来讲讲微信热补丁框架 Tinker 的实现,目前在腾讯内部已开源。
它的名字来至 Dota 中的地精修补匠,我们希望发版本可以像它一样做到无限刷新。
Tinker 的方案来源 gradle 编译的 instant run 与 buck 编译的 exopackage。它们的思想都是全量替换新的 Dex。即我们完全使用了新的 Dex,那样既不出现 Art 地址错乱的问题,在 Dalvik 也无须插桩。
但是 instant run 针对的是编译期,它可以直接将最后生成的所有变化都直接拷到手机端。对于线上方案,这肯定是不可行的。所以当前核心问题是找到合适的,使补丁结果更小的差分算法。
微信首先 demo 中采用的是 bsdiff,它无关文件格式,但对于dex效果不是特别好,而且非常不稳定。当前微信对于 so,依然使用 bsdiff 算法。
然后我们想到 dexmerge 算法,把修改跟新增的类通过 dexmerge 方式与原来的 dex 合并,从而得到最终的完整 Dex。
无法删除 class;导致在 Dalvik 平台会出现加载类重复的情况,这要求我们只能采用 miniloader 加载方案来避免;
合成时内存占用过大;dexmerge 库使用场景在 pc,它没有太多的考虑内存问题。它的峰值内存可以达到输入 dex 的大小的4倍-6倍。一个12M的 dex,峰值内存可能达到70多M。
最后我们决定基于 dex 的格式,自研出一种 Dexdiff 算法,它需要达到以下目标;
diff 结果小;
合成过程占用内存小;
支持删除、新增、修改 dex 中的 class。
这里面主要的原理是深度利用原来 dex 中的信息,对于 dex 的每一个 section 做处理。这块在今天不再深入,感兴趣的同学可以交流。
内存方面 dexdiff 峰值内存是 dex 的两倍左右,达到预期的结果。
对于微信热补丁的更多信息,可以阅读我之前发的一篇文章。
微信Android热补丁实践演进之路
然后我们来看看 Tinker 的框架设计,它主要包括以下几部分:
补丁合成;这些都在单独的 patch 进程工作,这里包括 dex,so 还有资源,主要完成补丁包的合成以及升级;
补丁的加载;如果通过反射系统加载我们合成好的 dex,so 与资源;
监控回调;在合成与加载过程中,出现问题及时回调;
版本管理;Tinker 支持补丁升级,甚至是多个补丁不停的切换。这里我们需要保证所有进程版本的一致性;
安全校验;无论在补丁合成还是加载,我们都需要有必要的安全校验。
在微信中,我们为 Tinker 框架加入了100多个实时上报,监控着在每个过程可能出现的问题:
接下来我们来看看在开发 Tinker 过程中,遇到的一些问题:
对于 Art 平台,dex2oat 时间较长。特别是厂商 OTA 之后,所有动态加载的代码都需要重新执行 dex2oat。这是因为 boot image 已经改变,但是系统在升级时只会给 ClassN.dex 重新 OTA。
对于补丁 dex 会出现主进程同步执行 dex2oat,这个时间非常久,很有可能会出现 ANR,对于小米等一些产品的开发板更是如此。这也是我们现在努力在实现分平台合成的原因,即在 Art平台,只合成规则下需要的 class。只要不是全量替换,重新 dex2oat 的时间是可以接受的。
这块花了一定的时间重新梳理了 Android N art 的代码,详细的分析可以查看之前我发的一篇文章。
Android N混合编译与对热补丁影响解析
开始的时候,我们加载补丁 dex 采用的是 makedexElement 的方式。但是发现大约有几十万台机器,补丁加载成功了,但是使用的还是旧版本的代码。某些机器类似三星 s6 502系统,尽管反射 pathList 成功,查找顺序依然以 base.apk 优先。
这里采取的解决方法是类似 instant run,采用反射 parent classloader 的方式。这里不得不提,instant run 的 increaseClassLoader 实现非常精妙。
市面上有各种各样的微信插件,它们在微信启动前会提前加载微信中的类,这会导致两个问题:
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 失败现在有什么好的解决办法?
对于我们的方案,空间占用有可能比较大,我们解决的方法有两个:
在 patch 之前提前检查用户的剩余空间,如果用户剩余空间过少,即不尝试。
若本次失败,我们会有回调,然后我们会定期重试三次。
你也可以在这里采用提示用户清理空间。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)”