业务快节奏运转下的ReactNative无感升级

2021年9月10日 238点热度 0人点赞 0条评论

一、背景


满帮集团移动团队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分为两种:

  1. breaking change
  2. 非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获取VALUEMBBridge.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
4.针对Android的hbc产物生成额外的map并上传ftp。
5.3、数据准备

这里主要是埋点,用来和 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平台建设。


37060业务快节奏运转下的ReactNative无感升级

这个人很懒,什么都没留下

文章评论