大前端时代下的热修复平台建设

随着移动需求的增加、移动项目的拓展,如果移动端应用出现Bug不能及时得到修复,影响的不仅仅是用户体验,还会造成业务上的损失,因此,建立一套完整的热修复平台迫在眉睫。基于此,本文作者所在的搜狗商业应用研发团队构建了一套移动热修复服务中间件平台,本文从系统架构到主要流程对解决方案进行了详细的呈现,无论是iOS、Android、RN、Flutter都可以借助这一思想来开发自己的热修复平台。

写在前面

移动应用开发与服务端开发有很大不同,服务端应用如果出现问题,可以通过发布新版本修复,或立即回滚到上一个版本,用户能够立刻感知到这一变化;而移动端应用则不同,即使立刻发布新版本修复了问题,也无法保证所有人都能更新到这个版本,如果用户不升级移动端应用,问题依然得不到修复。

此外,众所周知,iOS发布App需要经过AppStore的审核,审核的周期几天甚至一周,虽然近来时间有所缩短,但如果想要快速发布新版本依然避免不了审核;而Android发布App虽然没有审核的过程,但国内多样化的应用市场,依然影响着新版本的发布。

因此,针对移动端应用的实时更新是非常必要的需求。随着移动需求的增加、移动项目的扩展,建立一套完整的热修复平台日益迫切。

热修复概念

2015年以来,移动开发领域对热修复技术的讨论和分享越来越多,同时也陆续出现了一些不同的解决方案。业内普遍共识是把不用重新发布新版本,不更新App自身安装包,在用户无感知的情况下,就可以对应用当前版本实现bug修复、部分功能修改的技术解决方案称为热修复HotFix。

对比常规的开发流程而言,热修复的开发流程显得更加灵活方便,优势很多:

  • 无需重新发版,实时高效修复bug;
  • 用户无感知修复,无需下载新的应用,代价小;
  • 修复成功率高,能把损失降到最低。

实现方式

目前的移动项目中既有使用iOS/Android原生技术进行开发,也有使用React Native/Flutter/Weex等跨平台和H5 Hybrid技术进行开发的。H5的特性使其不用关注热更新的问题,原生开发和跨平台开发虽然都能实现热更新/热修复,但技术实现手段不尽相同,因此热修复平台必须能够提供对上述不同技术方案的支持。热修复本质上是动态化、插件化技术的一种形式,可以理解为动态加载一个插件,在不同平台由于系统底层实现的不同,采用了不同的实现方式。

iOS系统

用于iOS原生应用热修复的第三方技术方案主要有JSPatch和waxPatch/LuaView等。主要技术原理是用脚本语言编写补丁patch,下发给客户端,在客户端本地通过Objective-C Runtime在运行时进行类名/方法名反射,替换相应的类和方法实现。

Android系统

普遍的修复原理都基于DEX分包方案,使用了多DEX加载的原理,大致的过程就是:把BUG方法修复以后,放到一个单独的DEX里,插入到dexElements数组的最前面,让虚拟机去加载修复完后的方法。在功能上已经支持类、资源的替换和新增,功能非常强大。

跨平台

目前比较主流的跨平台方案就是React Native、Weex和Flutter了,RN和Weex原理类似都是通过JavaScript语言反射成原生语言代码去执行,是使用JS脚本语言来编写的,也就是“即读即运行”。我们在“读”之前将之替换成新版本的脚本,运行时执行的便是新的逻辑了。脚本本质上和图片资源一样,都是可以进行热修复的,所以热修复的原理也比较简单,只要下发相应的JS补丁包就可以了。业内比较成熟的方案有微软的CodePush。

Flutter由于出现的比较晚,在Flutter 1.2.1中,Google提供了ResourceUpdater,用来做包的检查和下载解压,可以理解为官方支持的热修复。许多公司为了使用方便也研发了自己的热修复方案。

存在的问题

由于系统的差异性,不同平台有着不同的解决方案,它们的原理各有不同,适用场景各异,到底采用哪种方案,是开发者比较头疼的问题。如针对iOS平台的JSPatch、滴滴DynamicCocoa、阿里聚划算LuaView等,Android平台的QQ空间补丁方案、阿里AndFix和Sophix以及微信Tinker等等,当然也可根据技术实现原理自研。

不管使用哪种热修复技术,我们都需要后台服务的支持,不然就无法实现补丁的分发。由于不同技术团队选用的技术方案也不一样,导致存在以下几个问题:

  • 针对不同客户端平台需要单独开发不同的补丁上传下载后台;
  • 如果更换热修复技术方案,后台也需要做调整;
  • 客户端接入逻辑复杂。客户端接入不同的第三方SDK,需要进行代码适配;
  • 缺乏数据监控和统计。修复是否成功无法得到相应的数据反馈;
  • 如果管理多个App,需手动管理版本、渠道等多种复杂工作,增加出错隐患。

解决方案

虽然客户端由于平台的差异性选择的热修复技术不同,但服务端相对而言整体流程和处理策略是统一的,因此,热修复需要一个后台服务来上传、管理和分发修复脚本;同时,也要提供针对不同客户端的SDK,封装向平台请求脚本、传输解密、版本管理等功能。基于以上几点考虑,我们构建了一套移动热修复服务中间件平台,主要功能包括:

  • 对iOS、Android原生技术及React Native技术开发的移动应用提供热修复服务;
  • 提供不同平台的客户端SDK;
  • 提供分发平台,方便复补丁的上传和维护;
  • 有补丁的版本控制功能,可以进行更新、回滚操作等;
  • 支持补丁文件全量下发和按条件下发;
  • 补丁分发时进行加密传输,保证安全性;
  • 支持数据统计。

系统架构

热修复服务平台

提供应用管理、应用版本和补丁的管理、修复补丁的上传和分发、补丁异常时的回滚、传输过程中的加密传输、自定义加密的RSA密钥、按条件下发等功能。

客户端SDK

提供了向后端请求补丁、补丁的版本管理、传输后的解密等功能。

主要流程

热修复涉及的主要模块是热修复服务平台和客户端SDK,核心流程如下图所示:

通常情况的热修复流程如下:

  1. 首先需要在热修复平台创建应用,设置版本号等信息。一般该操作和应用发布、消息推送等功能一起在移动开发平台统一管理;
  2. 添加一个补丁脚本,并选择所属应用名、系统和版本,设置下发条件规则等信息;
  3. App启动同时SDK也启动,并发送查询请求给服务端,请求携带App标识、App版本号等参数;
  4. 服务端根据收到的请求和条件下发规则判断下发哪个修复脚本。如果该App在该版本中存在修复脚本,则返回当前生效的脚本信息和地址;
  5. 客户端SDK根据接口返回数据判断进行下载、回滚或不进行任何操作;如果未返回任何信息,说明不存在修复脚本需要下发,此时客户端SDK不进行任何操作;
  6. 客户端SDK对下载后的补丁脚本进行RSA校验,并执行脚本;
  7. 补丁脚本执行成功或者失败等log信息上传至后台,以供数据分析使用。

应用管理

热修复是针对App的某个版本进行修复,因此发补丁前必须创建相应的应用和版本。应用创建后会分配一个随机的appKey,客户端SDK与服务端交互时必须携带appKey,用于标识应用。

补丁管理

补丁列表

补丁列表显示历史上发布的补丁信息,可以根据应用名称和版本号进行查询。

相关说明如下:

  • 添加新补丁:添加补丁的入口;
  • 应用名称:显示应用名称,来自于创建应用时的输入;
  • 平台:显示应用所属平台,即iOS/Android/React Native;
  • 应用版本号:应用的版本号,来自于添加应用版本时的输入;
  • 补丁版本号:补丁的版本号,该应用在该版本内的顺序递增;
  • 补丁大小:补丁文件的大小;
  • 补丁描述:来自于添加补丁时的输入;
  • 补丁状态:补丁的生效/失效状态,同一应用同一版本内,只有一个生效状态的补丁;
  • 下发状态:补丁的下发状态,有全量下发和条件下发两种;
  • 更新事件:该补丁的最近一次操作事件;
  • 显示内容:显示补丁的内容,只能显示iOS补丁的内容;
  • 全量下发:将补丁的下发状态改为全量下发;
  • 条件下发:将补丁的下发状态改为条件下发。

添加补丁

在发布一个修复补丁时,要将其上传至分发平台,并选择所属应用名和版本。上传后,分发平台存储补丁并存储补丁的相关信息后,由于一个版本内可能存在多个修复补丁,因此新上传的补丁会标记为生效,其他的历史补丁标记为失效状态。

存储

服务端由多台机器构成,需要使用统一的文件存储,不能使用本地的文件系统。

方案一:使用DFS

将补丁文件存储到DFS中,在MySQL中记录应用、版本,以及DFS唯一标识的关系,并提供下载接口,用于SDK请求。

方案二:使用CDN

将补丁文件上传至CDN,客户端SDK下载时直接访问CDN的链接。CDN支持高并发,且访问速度有保障。

考虑到CDN有代码暴露的风险,倾向于选择使用DFS。

补丁下发

一个App可能发布了多个版本,一个版本内又可能发布过多个修复补丁。在向SDK下发补丁时,应该下发哪个补丁呢?

下发时遵循如下约定:首先,补丁是基于某个App版本的,App不能跨版本请求补丁;其次,App一个版本内的多个补丁,只下发最新生效的一个。这是由于iOS和Android的热修复原理决定的。

iOS的热修复是通过运行时用补丁中的JavaScript代码动态替换Objective-C代码实现的,这就造成无法用补丁将App的版本整体升级。因此,在发布下一个版本时必须把上一个版本的补丁中的JS代码在新版本中用OC再实现一遍。例如,当App处于版本A时某个方法foo出现了问题,在补丁A1中用JS对foo方法进行了修复,在发布下一个App的版本B时,B中foo方法必须用OC重写一遍。Android的热修复是基于基准包,用修复后的新包与基准包diff后生成的补丁,客户端再加载补丁实现修复,这也要求在发布下一个App版本的时候,必须含有补丁中的内容。

此外,如果在一个版本内下发多个补丁的话,比如版本A中,发现了一个bug,发布了一个修复补丁A1;之后,发现了另一个bug,再发布一个补丁A2。如果A1和A2的内容彼此无关,那么就要求客户端SDK要加载多个补丁文件,当补丁之间存在依赖关系时,更需要控制加载的顺序,这无疑增加了复杂度,而且无法回滚到特定版本;对于Android而言,由于加载的补丁是基于基准包diff后的包,也做不到加载多个补丁。因此,针对同一版本内有多个补丁的情况,只下发最新的一个生效补丁。

不同App版本不能跨版本下发补丁

同一个App版本内多个补丁时只下发最后生效的版本

回滚

如果所发布的补丁存在问题,这会造成客户端APP本身出现异常,甚至应用闪退、完全不可用。针对这种情况,有两种方案,第一是再发布一个新的补丁,补丁中包括修正了的正确代码。另一种情况是,有可能错误难以定位或修正时间太长,根本来不及发布新补丁,那么必须及时将错误补丁回滚。

按照上一节中的下发策略,服务端只会下发当前生效的补丁,因此服务端在回滚的时候只需要简单地将目标补丁标记为生效即可。

传输安全

由于下发的补丁会改变客户端应用的行为,如果被人攻击替换代码,会造成很大危害,因此必须考虑传输过程中的安全性。

针对这一问题,设计了如下3个解决方案:

方案一:对称加密

若要让补丁在传输的过程中不会轻易被中间人截获替换,很容易想到的方式就是对补丁进行加密,可以用 zip 的加密压缩,也可以用 AES 等加密算法。

优点:实现非常简单。

缺点:是安全性低,容易被破解。因为密钥是要保存在客户端的,只要客户端被人拿去反编译,把密码字段找出来,就完成破解了。

方案二:HTTPS

第二个方案是直接使用 HTTPS 传输。

优点:安全性高,只要使用正确,证书在服务端未泄露,就不会被破解。

缺点:部署麻烦,需要服务器支持 HTTPS,门槛较高。另外客户端需要做好 HTTPS 的证书验证(有些使用者可能会漏掉这个验证,导致安全性大降)。如果服务器本来就支持 HTTPS,使用这种方案也是一种不错的选择。

方案三:RSA校验

这种方式属于数字签名,用了跟 HTTPS 一样的非对称加密,只是简化了,把非对称加密只用于校验文件,而不解决传输过程中数据内容泄露的问题,而我们的目的只是防止传输过程中数据被篡改,对于数据内容泄露并不是太在意。整个校验过程如下:

  • 服务端计算出补丁文件的 MD5 值,作为这个文件的数字签名;
  • 服务端通过私钥加密第 1 步算出的 MD5 值,得到一个加密后的 MD5 值;
  • 把补丁文件和加密后的 MD5 值一起下发给客户端;
  • 客户端拿到加密后的 MD5 值,通过保存在客户端的公钥解密;
  • 客户端计算补丁文件的 MD5 值;
  • 对比第 4/5 步的两个 MD5 值(分别是客户端和服务端计算出来的 MD5 值),若相等则通过校验。

只要通过校验,就能确保补丁在传输的过程中没有被篡改,因为第三方若要篡改补丁文件,必须计算出新的补丁文件 MD5 并用私钥加密,客户端公钥才能解密出这个 MD5 值,而在服务端未泄露的情况下第三方是拿不到私钥的。

优点:非对称加密能够有效解决传输中被篡改的问题。

缺点:数据内容可能会泄露,其实在传输过程中不泄露,保存在本地同样会泄露,若对此在意,可以对补丁文件再加一层简单的对称加密。

自定义RSA密钥

分发平台使用一套默认的公钥/私钥进行补丁传输过程中的加密/解密,如果对安全性要求更高,可以在上传补丁时设置自定义的RSA私钥。

条件下发

很多时候在发布一个补丁时,需要在小范围内进行验证,比如特定某个iOS版本或者特定某个用户;在验证通过后再进行全网用户的下发。这时可以用到条件下发。

分发平台在发布补丁时可以选择使用条件下发,除上传补丁外,还可以填写条件语句,只有满足条件的设备才会执行修复补丁。条件语句由key/value/运算符组成。条件语句的规则与代码中的条件表达式一致,支持“==、!=、>、<、>=、<=、&&、||”等运算符。如:

iOS>9.0或者userId == 200758 && role == 1

当补丁的下发状态处于条件下发,且条件语句与SDK上报参数中的条件一致时,才会将补丁发送给该SDK。

计算条件表达式时,如果通过字符串解析和替换的处理等方式,开发繁琐且实现时不够优雅。可以使用EL表达式引擎解决这一问题。常见的表达式引擎有Apache Commons中的JEXL(Java Expression Language)、fast-fel等,甚至Java 1.6后自带的JavaScript脚本引擎也可以完成这个工作。在综合考量性能和易用性后选择了JEXL表达式引擎(测试样例见附录1)。JEXL除了支持基本的算术表达式外,也支持在表达式中访问对象的属性、访问数组和集合、调用Java方法等特性,对于表达式的使用有很强的扩展性。

下面是一个JEXL的例子:

数据统计

提供分日、分App、分版本的补丁分发数据统计功能。

SDK设计

iOS/Android和各种跨平台方案只需实现接口的查询和patch包的下载即可,再根据所采用的热修复库实现对应平台的热修复功能。

查询接口

下载接口

以iOS为例:

客户端SDK启动时会发请求询问服务端,根据服务端返回数据进行相应处理。客户端SDK会保存下载到的修复脚本,避免重复下载造成的流量损失。具体流程如下:

  1. App启动同时SDK也启动,并发送查询请求给服务端,请求携带App标识、App版本号等参数;
  2. 服务端根据收到的请求判断下发哪个修复脚本。如果该App在该版本中存在修复脚本,则返回当前生效的脚本信息和地址。客户端SDK根据接口返回数据判断进行下载、回滚或不进行任何操作;
  3. 如果未返回任何信息,说明不存在修复脚本需要下发,此时客户端SDK不进行任何操作;
  4. 如果返回的脚本信息中,脚本的版本号等于本地生效脚本的版本,说明客户端保存的脚本已是最新的,SDK直接执行本地保存的脚本;
  5. 如果返回的脚本信息中,脚本的版本号不等于本地生效脚本的版本,说明服务端有新的修复脚本发布,或发生了回滚操作,客户端SDK判断本地是否存在该版本的脚本,存在时直接执行本地脚本;不存在时发起下载请求获取脚本,并在本地缓存,然后执行。

争议

2017年3月,众多iOS开发者收到苹果警告邮件,称其App违规使用动态方法,责令限时整改。这封邮件引起了开发者的恐慌,最后发现问题集中出现在两个热更新工具Rollout和JSPatch上。由于JSPatch在iOS业内的高覆盖率,这个事件影响几乎波及到了国内所有在AppStore上线的App。

这次警告事件无疑是对iOS平台Native动态化是一次严重打击,其影响甚至可能波及到Android平台,毕竟Google也是禁止加载远程代码的,并且执行更为严格,只是管不到中国的Android开发而已。

在安卓平台,虽然谷歌没有能力像苹果一样干涉国内的开发,但插件化技术从另一方面遭遇了困境。这一困境就是安卓新版本以及国内各种魔改ROM对于底层的改动。安卓插件化技术依赖部分底层方法以及私有API,而这些在新版本里是很有可能改动的,一旦修改了,插件化就会失效甚至出错。国内各大手机厂商的系统也喜欢对底层进行修改,它们的修改甚至都不会公开告知,因此兼容问题是插件化技术遇到的最大挑战。

2018年发布的Android 9.0,甚至要求开发者不得使用私有API,少了这些API,安卓开发被重新关回笼子里,还能玩的黑科技大大减少,无意之中竟然取得了和苹果警告类似的效果。

在苹果「热修复门」事件之后,iOS动态化的工具都转入地下发展,关于这方面的研究和分享也急剧减少,甚至连整个iOS技术的分享也变少了。虽然最初通过代码混淆等可以骗过苹果审核,但是很快也被禁止了,滴滴声称的DynamicCocoa也迟迟没有开源,QQ甚至开发了一个自己的中间语言OCScript,还开发了一个自己的虚拟机OCSVM去执行它。

虽然使用企业证书发布的App不受应用市场的监管影响,但是整个行业对于热修复技术的研究和讨论也越来越少了,大家不得不寻找新的技术突破点,这种氛围也间接促进了跨平台技术的推广。尤其是Flutter发布之后,相较于以前的RN等跨平台技术拥有了更流畅的用户体验,许多大厂也开始积极使用。相信在不久的将来移动开发会形成原生开发与跨平台开发并驾齐驱的态势。

结束语

本文介绍了一种基于第三方或自研的热修复客户端技术,但又不强依赖特定服务的通用热修复中间件管理平台,可以实现安全、稳定、可靠的热修复补丁上传、分发、版本管理等功能,并提供完善的数据统计。在实际应用中,可以结合团队自身技术栈打造成更加通用的热修复管理平台。

  • 发表于:
  • 本文为 InfoQ 中文站特供稿件
  • 首发地址https://www.infoq.cn/article/JTj1B492BXrVXtXZAjSk
  • 如有侵权,请联系 yunjia_community@tencent.com 删除。

扫码关注云+社区

领取腾讯云代金券