一、背景
满帮集团移动团队2018年初开始尝试ReactNative,经过近三年的发展,目前已经承载了大部分的核心业务场景,涉及16+的业务模块、200+页面,日均PV数据在千万级。核心业务使用ReactNative开发后,我们脱离了APP发版的限制,统一使用动态发版。相比于APP发版,动态发版频率提高了很多,一周最低两版,有时一周甚至会发5个版本。
2018年上线ReactNative时,用的是当时比较新的0.51版本。在后续的版本中,Facebook官方引入了诸多新的特性,比如Hooks、Hermes引擎等等。我们继续使用0.51版本,这些新特性都无法使用,而且社区中很多基于更新版本ReactNative的第三方库业务也无法使用。因此,在使用0.51版本3年之后,我们决定升级到目前较新的0.62版本。
二、0.62主要改进
2.1 性能提升
相比于0.51版本,0.62最大的改进是,android上使用了Hermes作为JS执行引擎,在启动速度、内存占用、JS运行效率上都有非常大的提升。
2.2 稳定性提升
从0.51版本到0.62版本,修复了大量功能性和稳定性bug,比如Native 部分的SDK的健壮性得到了很大的加强,例如Android中 ReactHostView,在 show() 和 hide() 的安全性都进行了加强。又如ViewManager 部分, 不合法时直接进行了异常处理。
2.3 社区生态
ReactNative生态主要分为两部分:
2.3.1、React 本身语言特性。
0.51 使用的是 React 16.0, 0.6x 使用的是 16.11.+, 中间新增了很多令人兴奋的新特性,例如 16.2.0 的 Context 、 16.8.0 的 Hooks , 这些无疑是开发的利器!
2.3.2、围绕ReactNative、React 开发的 第三方库
社区的第三方库往往两年一个周期提高 React 的依赖版本,例如比较有名到导航库 :
React-Navigation , 并且增加很多实用的新特性, 例如 ReactNative 内部路由栈开始支持页面间的激活、回到后台等特性。这在我们日常开发中非常实用。
2.4 性能摸底
以上是Facebook官方给出的改进内容,其中性能上有较大的优化,为了对此次提升的性能有更加量化的认知,我们做了一次全面的性能测试。
2.5 Android端性能
0.6x开始,Android端引入了Hermes引擎,带来了很大的性能提升。相比于JSC,Hermes最大的改进是支持直接运行JS代码预编译的产物,因此冷启动性能上有很大提升,同时内存占用也有一定下降,但是包体积增大了一些。
为了了解清楚性能提升数据,我们在Android端对JSC和Hermes进行了性能对比测试。
测试设备VIVO X21 RAM:6G。
2.5.1 冷启动耗时数据
从下图可以看出,Hermes+HBC的冷启动耗时相较于JSC+JS下降了50%以上。因此我们决定使用Hermes+HBC的方案。
2.5.2 包体大小数据:
从下图可以看出,HBC二进制包的压缩比明显不如Jsbundle, 体积几乎是后者的两倍, 但是这一点可以通过后续的拆包, 端上转化HBC 等手段规避。
2.5.3 代码指令处理速度
面对大量运算以及解析的时候, JSC 性能衰退特别严重,而Hermes则相对平稳, Hermes 和 JSC 耗时比基本在 1/6 左右, 出色的处理速度对帧率、 动画流畅度都有很大的提升!
2.5.4 内存占用
测试设备VIVO X21 RAM:6G
从下图测算出来的数据可以得出两条结论:
1、ReactNative 0.62 内存表现明显优于 ReactNative 0.51,这得益于Hermes的加载机制,不会把整个文件一次性load进内存解析。
2、ReactNative 0.62 的内存抖动较平缓, 这得益于 Hermes 执行的产物是二进制, 而非JS代码,不需要二次转码。
整体操作流程,涉及 4 个 ReactInstanceManager 和 5个页面, 节省了 56 M 内存空间,收益确实可观。
2.6 iOS端性能
从0.51升级到 0.62后,IOS端的JS引擎依然只有JSC。但是在Jsbundle之外,支持了RAM格式, 采用RAM 以及 inline方案,冷启动速度和内存都可以得到很大的改善。但是考虑到我们后续要做基座拆分,因此没有使用RAM格式,IOS端依然使用JSC+Jsbundle的方案。因此,IOS端在内存、冷启动、指令执行速度上并没有太大提升。不过最近一个 ReactNative版本 0.64 ,官方在IOS上也开始支持 Hemers。
从性能数据上看,Android端性能有非常大的提升。升级后也能使用hooks等React的最新特性,提升开发效率,因此我们决定升级到0.62版本。
三、如何执行一次无感知升级
3.1 挑战和风险
3.1.1 多部门合作协作
如前所述,ReactNative承载了满帮大部分的核心业务场景,涉及16+业务模块,200+页面,50+开发人员。满帮集团的业务在高速发展期,各种业务运营活动的开展都以天为单位计时。业务多、人员多、迭代节奏快、稳定性要求高。需要统筹多个测试、开发团队以及发版团队等多条线的工作。
3.1.2 SDK升级与高频率发版并行
为了满足快节奏的业务迭代,我们每周最低发布最低两次动态版本(最高一周能发布5次)。我们要求技术改造不能影响业务迭代(包括APP版本迭代和动态版本迭代),任何业务需求不能因为技术改造而延期。因此我们需要 0.51 发版工作和 0.62 升级工作同步进行, 而且要互不干扰。
3.1.3 降低升级成本
快节奏高频率的发版下,SDK升级不能给业务需求的开发、测试带来过多的负担,需要把对业务开发、测试的影响尽可能的降低。本次升级作为跨越3年的大版本升级,涉及到非常多的 Release Note,我们需要尽力从底层兼容这些差异性,从而尽可能得降低开发人员修改面以及降级测试人员的回归力度,近而降低各方面的成本。
3.1.4 保证线上稳定
满帮集团核心的两款APP日均UV在500万级别,对APP体验的要求又非常严苛,异常率上升万分之一,都会导致客户投诉率提升,稳定性保障是升级方案的重中之重。但是,无论我们方案做得多完美,谁也不能保证不会有意外状况发生。因此我们需要在第一时间感知到线上异常,降低影响并及时修复。
本次ReactNative SDK升级,就像是给一辆以120码高速行驶的重型卡车更换轮胎,稍有不慎就会翻车。
3.2 升级方案原则
3.2.1 低风险
主要主要包括两点:
1.业务风险低:不影响业务需求的迭代。
2.稳定性风险低:不影响线上的稳定性,异常率要控制在极低的水平。
3.2.1.1 发布方案设计
为了满足上面两个条件,我们决定采用分批次、灰度的方式发布上线。
分批就是把线上用户分成多个批次,一个批次上线完成后再进行其他批次的。满帮有四款APP:运满满司机端、货车帮司机端、运满满货主端、货车帮货主端,我们结合业务特性分析后,采用的方案是两个司机端第一批上,两个货主端第二批上。
灰度放量现在业内用的非常普遍了,这里不再解释含义,下文中会详细说明灰度方案的细节。
3.2.1.2 告警和回退方案设计
真正做到低风险,我们还需要把线上问题扼杀在摇篮, 我们需要告警机制。升级前的满帮ReactNative已经有告警机制,因此我们只需要把 0.62 拆分出一个统计维度单独计算, 因为前期灰度的量少,如果和原先的告警机制复用就很难触发告警条件。
遇到线上顽疾短时间内无法解决的我们还需要有降级方案,能够短时间内把线上的 0.62 切换到 0.51, 待问题解决后再切回 0.62.。
3.2.2 低成本:
这里的低成本是指对业务开发、测试的影响尽可能的降低。降低代码修改量、修改难度,从而降低开发投入的人力成本;降低影响范围,从而缩小测试回归范围、降低回归力度,从而节省测试投入的人力成本。
3.2.2.1 一套代码
为了降低风险,我们采用的是多批次灰度放量的形式发布上线,整个上线周期会持续很长一段时间,在上线期间,各个业务模块都在不断的迭代开发新的需求。也就是说,存量的业务代码和新需求的业务代码,都要兼容两个版本的SDK。要兼容两个版本的SDK,最简单的方案是维护两套代码,分别适配两个版本的SDK,但是这样需要写两遍代码,对业务开发来说是非常沉重的负担。为了避免这种负担,我们提出了一套代码适配两个版本SDK的方案。
3.2.2.2 开发环境切换
一套代码适配两个版本SDK,代码当然要放在一个分支上。在开发业务需求时需要分别在两个版本SDK环境上运行代码,我们提供了环境切换脚本,可以使用一行命令切换到不同的ReactNative环境上。比如:司机端在线上已经进行灰度放量了,货主端还没有开始放量,对于需要同时在司机&货主两端运行的代码,开发人员可以通过脚本切换到不同的环境进行开发, 如下图:
3.2.2.3 代码修改扫描
为了进一步降低开发人员的适配成本,我们开发了专门的脚本工具,可以扫描出所有需要修改的地方,并给出具体的修改方法。
采用如上的方案,我们做到了升级风险完全可控(通过多批次灰度升级控制稳定性),又把开发人员的适配成本将到了最低(通过一套代码适配两个版本的SDK和脚本扫描修改内容)。
四、技术准备
4.1 API changes梳理
升级之前,需要先梳理两个版本SDK之间的API差异,对0.51到0.62的所有修改有全面的认识。API change分为两种:
-
breaking change -
非breaking change
我们的方法是暴力阅读了0.51~0.62所有版本的Release Note,整理出了所有breaking changes,并给每个breaking change制定专门的适配方案。例如AsyncStorage,51版本的AsyncStorage用法是xxx,0.62版本的用法是yyy,所以0.51版本的代码和0.62版本的代码互相不兼容。我们的适配方案是统一采用我们自己封装的Bridge[MBBridge.app.storage]
//npm install --save @react-native-community/asyncstorage
不建议使用
// import AsyncStorage from '@react-native-async-storage/async-storage';
// 建议修改为Bridge形式
// 根据KEY获取VALUE
MBBridge.app.storage.getItem({ key: BootPageModalKey.KEY_IS_SHOW_BOOTPAGEMODAL }).then(res => {
if (this.isGuidanceSwitch(res?.data?.text)) {
retuReactNative null
}
})
// 存储<KEY,VALUE>
MBBridge.app.storage.setItem({ key: Constant.StorageKey.Common.RefeReactNativeame, text: commonStore.refeReactNativeame })
4.2 代码适配方案
公司业务迭代节奏是一周三版甚至更多, 且主要使用的是ReactNative技术栈, 因此如果在如此快节奏的开发节奏下还要同步两套代码(0.51 && 0.62) , 那成本就太高了。因此我们觉得采用一套代码能够同时适配 0.51 和 0.62的方案:对于所有不兼容的API,封装一层适配层,屏蔽底层差异。如下图所示:
例如导航库的适配思路如下:
修改前:
这就是我们的业务层
import { StackNavigator } from "native-navigation"
const RootStack = StackNavigator(...)
export default class xxxx extends Component<any, any> {
render() {
retuReactNative (
<RootStack screenProps={this.props} />
)
}
}
修改后:
ReactNative-lib-protocal 就是我们的协议层
import { createStackNavigatorCompat, createAppContainerCompat } from "@ymm/ReactNative-lib-protocal"
const RootStack = createStackNavigatorCompat(...)
export default class StickerPageRouter extends Component<any, any> {
render() {
const App = createAppContainerCompat(RootStack)
retuReactNative (
<App screenProps={this.props} />
)
}
}
下面是0.62 的协议实现层:
import { NavigationActions } from 'react-navigation';
export default class StackActionsCompat {
static reset(resetAction: any){
retuReactNative NavigationActions.reset(resetAction)
}
static push(pushAction: any) {
retuReactNative NavigationActions.push(pushAction)
}
static pop(popAction: any) {
retuReactNative NavigationActions.pop(popAction)
}
static popToPop() {
retuReactNative NavigationActions.popToTop()
}
}
这样业务开发同学就可以实现一套代码跑在两个ReactNative版本上, 节省维护两套代码的成本。
4.3 脚本工具
这里的工具包括三个:
1、 API检查工具(支持本地 && CI/CD);
2、代码工程环境切换工具;
3、运行环境检查工具。
4.3.1 API检查工具
API检查工具是为了检查那些在 0.51 环境能运行而在 0.62 上不再兼容的API,为了解决这个问题, 我们针对两个版本众多变动点抽象出API检测的规则。检查工具使用 Python 脚本编写,开发同学既可以在本地检查(直接运行python脚本或者运行npm命令) 也可以在Jekins打包时启用该检查, 检查效果如下:
正在检查:
检查异常:
检查通过:
4.3.2 环境切换工具
工程环境切换工具则是为了方便开发同学能够方便得切换 0.51 和 0.62 的协议实现层 和 配置文件(package.json、metro.config.js 等) ,这块可以用 Shell 或者 Python 实现。
这个工具保证了业务开发同学能够在一个分支上进行开发,而不用把关注点放在 0.51 和 0.62 的APi差异和配置差异上。
4.3.3 环境检查工具
运行环境检查工具则是为在测试环境检查 ReactNative SDK 环境和 Bundle 产物不匹配的情况,例如 0.51 原生SDK加载了 0.62 的 Bundle/HBC, 或者 0.62 原生SDK加载了 0.51 的Bundle 包,从而避免不必要的麻烦以及沟通成本:
五、发布方案
下图是我们升级计划的一个简图, 整个流程以角色为纬度,划分为四条主线: 开发人员、测试人员、APP版本、 动态版本。每一条主线对应的时间轴在关键时间点都有详细的Action。例如:对于业务开发人员(第一条线)来说, 需要在 2020-12-18 日把适配的业务代码合入 dynamic-1231 主发布分支,随后 0.51、0.62 公用一套代码直至整个升级流程结束。
5.1、分批升级
如前所述,我们采用的是分批升级的方案,司机端APP第一批上线,货主端APP第二批上线。
Android端为了这次升级,对 ReactNative 的环境进行了插件化。为了尽可能的控制风险,第一批次Android司机端上线是通过插件动态发布的形式进行的:0.62版本的SDK和HBC的产物同时通过动态升级的方式下发到端上。动态发布的方式可以非常灵活的控制灰度节奏:为了确保稳定性,我们可以把灰度时间拉的足够长。而且,我们的动态升级平台支持线上实时回滚。
我们基于稳定性的角度出发,决定Android端通过动态升级的方式上线。但是保证稳定性的同时,也不能影响业务需求上线,0.62版本的SDK和HBC产物在线上灰度发布的同时,也会基于0.51版本的SDK和jsbundle产物同步做业务需求的发布。也就是说:0.51 和 0.62 环境需要在较长时间的在线上并行存在。
整个灰度过程中比较重要的一点是线上环境的功能同步问题:0.51 和 0.62 的产物会分别以各自的节奏发布到线上,不能相互干扰,但是同时又都必须包含所有的业务需求。
例如:0.51 每 2 天一个版本, 而 0.62 灰度周期是 10 天, 因此需要保证用户无论使用的是 0.62 还是 0.51 都需要包含最新的功能, 我们的策略如下:
如上图所示,0.51 和 0.62 的发布是两条平行的线, 0.62 的版本号在设计上要大于 0.51的版本号(保证了0.62 的产物永远不会被 0.51 的产物覆盖),每一次 0.51 业务包的发布都会同步发布一个 0.62 的业务包,因此可以保证以下两点:
1、线上用户使用的功能始终是最新的;
2、0.62产物始终按照自己的节奏灰度, 不会被0.51的产物覆盖。
待0.62灰度全量之后, 线上不会再发布 0.51 的业务包,线上升级切换完成!
5.2、CI/CD
由于0.51 和 0.62 的业务 Bundle 需要较长时间的在线上并行存在, 以及两个版本的环境、产物也会存在不兼容的情况。因此除了有测试阶段的环境检测手段之外, 还需要在CI/CD阶段插入我们的一系列校验流程:
1.环境切换;
2.把检查兼容API的python脚本集成到构建过程中;
3.根据产物类型来生成版本号规则:
-
0.51版本号规则 :5.91.xxx.yy -
0.62版本号规则:5.91.1xxx.yyyy
这里主要是埋点,用来和 0.51 版本的数据区分开,我们期望线上 0.62 版本 产生的数据能准确得反应升级的真实情况(访问占比、稳定性), 同时我们也为 0.62 的数据单独配置了告警策略:
指标/平台 |
Android |
IOS |
容器访问 |
.scenario("enterPage") .params("is62", true) |
scenario:@"enterPage" extraDic:@{@"is62":@(true),} |
异常统计 |
.scenario("jserror") .params("is62", true) |
scenario:@"jserror" extraDic:@{@"is62":@(true),} |
引擎创建耗时 |
.scenario("engine_cost") .params("is62", true) |
scenario:@"engine_cost" extraDic:@{@"is62":@(true),} |
冷启动耗时 |
.scenario("cold_lunch") .params("is62", true) |
scenario:@"cold_lunch" extraDic:@{@"is62":@(true),} |
项目上线以后,我们所要做的就是及时跟进线上数据, 验证前期实验室数据, 以及关注监控数据, 及时调整方案。
6.1 每日报表输出
在62升级包灰度期间,每日会有报表输出, 包括每个模块的DAU、PV、JS异常用户数、JS异常率、SDK异常用户数以及SDK异常率等, 开发和测试同学可以从整体了解线上运行的状况。我们也提前制定了预案,异常率达到一定阀值时就停止灰度。
6.2 性能数据输出
以Android端的性能数据为例,最终我们从线上采集到的性能数据如下,和我们线下测量的数据基本吻合:
指标 |
业务 |
包大小 |
冷启动时长 |
热启动时长 |
内存占用 |
|
升级前 |
发货页 |
5.2M |
2115ms |
48ms |
30M |
|
审核 |
2.4M |
1155ms |
41ms |
25M |
||
... |
... |
... |
... |
... |
||
升级后 |
发货页 |
7.1M |
748ms |
49ms |
21M |
|
审核 |
3.8M |
552ms |
43ms |
16M |
||
... |
... |
... |
... |
... |
||
提升 |
45% |
约64% |
无变化 |
约30% |
1、包大小
Android端采用的是Hermes+HBC的方案,打包输出的产物从字符串格式的.jsbundle变为二进制包 .hbc, 包体积因此增长了 45% 以上。这是以空间换时间的一个优化(JIT 变为 AOT)。
2、冷启动
采用Hermes+HBC的方案后,指令运行速度大大提升,冷启动时长时减少了约64% ,启动速度提升了近三倍,基本符合我们之前的测试和预期。
3、热启动
我们做了引擎复用机制,引擎创建一次后,会驻留内存,因此第二次启动就是热启动了。热启动的过程相较于冷启动,没有JS代码加载、初始化执行等耗时操作。因此、热启动时长几乎没有改善,这也基本符合我们之前的测试和预期。
4、内存占用
Hermes引擎执行HBC,省掉了JS代码解释的过程,因此冷启动单页面运行时内存减少了 30%以上。
6.3 后续批次
第一批次的升级工作基本告一段落, 在这中间会沉淀不少最佳实践:上线计划、容错方案、测试计划、性能分析 等,第二批次货主端的升级工作仅需要在这基础上做微调整, 上线风险和整体计划会流畅很多。
司机端第一批次对稳定性做了基本的验证,我们可以确定风险整体可控。因此,货主端第二批次上线时,直接采用了跟随APP发版的方式上线。
IOS端没有插件化机制,因此两个批次都是采用跟随APP发版的方式上线,使用AppStore默认的7天灰度。
至此, 整个升级工作告一段落。从一开始技术预研、性能摸底、方案指定、升级测试再到最后的线上验证 ,每一个环节都必不可少,整个流程对我们跨端团队的启发不仅在技术上,同时也在沟通和任务管理上,我们是技术人员、管理人员、风控人员、产品人员。在最后,通过线上数据的分析,我们也会不断调整我们的升级策略和细节参数, 同时也是验证实验室猜想的过程。整个过程并不能保证一帆风顺,但是只要每一个步骤都有相对靠谱的Plan B,以及团队成员的紧密合作, 升级工作并不会像想象中那般艰难。
作者简介
王超,现为满帮大前端团队架构师,目前负责满帮ReactNative平台建设。
文章评论