[React Native] 加载、维护 bundle 的正确姿
前言:React Native 的其中一个卖点是程序可热更新,当前官方和非官方对这类实操的完整指导不多,所以在我们的项目实践中,我们做了一套自己的方案,iOS 侧已经上线运行,理论上和实践上没啥问题,这里梳理出来,一方面作为后续我们在 Android 的对齐基准,另一方面与大家共享思路方便探讨调优。
要做好 React Native 的热更新,主要需要处理好如下几个情况:
-
本地启动:为保证启动速度,不能全部依赖线上的 bundle,需保证还未下载到 bundle 的时候,能如常载入 bundle 并启动,所以初始化 RCTBridge 或 RCTRootView 时用的 bundleURL 得指向本地而非网络;
-
及时更新:为实现所用 bundle 能够及时更新,需要在合适时机拉取最新版的 bundle 存放到本地,细则如下:在 app 启动时,在 app 从后台切到前台后,以及在网络状态发生变化后,发起请求拉取最新的配置信息,根据配置信息确定是否需要下载 bundle 以及后续处理。
-
流量节约:为实现可控的流量节约,配置信息中包含了要使用的 bundle 信息如下:
- url:bundle 文件的存放地址;
- token:bundle 文件的标识字符串,每次将 bundle 文件成功保存到本地后,都同时在本地保存该值,以作下次拉取到配置时的比较依据,当配置中的 token 与本地的一致,那就无需做后续的下载和更多相关操作;
- urging:更新该 bundle 的紧急程度,可选值如下:
- 1:有 WIFI 就下载,下好后重启 app 时启用 // 不紧急的时候用这个
- 2:有 WIFI 就下载,下载好后,从后台切回前台的时候启用 // 免流量,界面刷新柔和,推荐这个
- 3:不管有没有 WIFI 都下载,下载好后,从后台切回前台的时候启用 // 耗点流量,界面刷新柔和,次推荐这个
- 4:不管有没有 WIFI 都下载,下载好后,立马启用 // 杀很大,一般不用这个
当读取到上述信息后,基于配置中的 token 与本地值比较是否一致确认是否结束流程,如果不一致则以配置中的 url 发起一个请求,得到 bundle 后,保存到本地,同时把配置中的 token 也保存到本地。
- 版本并存:为实现多版本同时并存,提供 A/B Test、灰度发布等能力,需要做到:
- 约定每次发布 bundle,都以新文件形式发布,新老文件并存于服务器端,客户端根据配置情况按需拉取、使用;
- 实现因应不同情况输出不同配置信息的能力,有两种做法:
a. 搭个动态 server,提供个接口,接受表达客户端情况的几个参数,根据这些参数的不同输出不同的配置信息,客户端读取配置信息时,都通过访问 server 上的这个接口来;
b. 写个 JavaScript 文件,在其中写个函数,接受表达客户端情况的几个参数,根据这些参数的不同输出不同的配置信息,把这个 JavaScript 文件作为静态资源部署到 server,客户端读取配置信息时,都通过访问 server 拉取这个 JavaScript 文件,然后将其中的内容作为 JavaScriptCore 的 code 执行一下,然后调用其中的函数来获取配置信息;
由于懒得搭动态 server,我们选择了 b 做法,关键代码如下;// versionControl.js, // 实际上这是个全局通用的资源版本控制配置文件, // react-native bundle 作为其中一种资源存于其中。 // 注意:这里的代码是要放到 JavaScriptCore 中直接执行的,所以高级的 ES6 语法不能用。 var latestReactNativeBundleMetas = { ios: { url: 'http://cdn.xxx.com/react-native/1.1.0/04291109.ios.bundle', token: 'a69cc86a12115f0b962ef4bd8c0a8241' }, android: { url: 'http://cdn.xxx.com/react-native/1.0.3c.android.bundle', token: '' } }; var versionControlGetters = { production: function(platform, appVer, innerId) { // 每次在测试环境测试通过后,请将上边的 latestReactNativeBundleMetas.ios 的值复制到这里。 var meta = { url: 'http://cdn.xxx.com/react-native/1.1.0/04291109.ios.bundle', token: 'a69cc86a12115f0b962ef4bd8c0a8241' }; return { "react-native": { meta: meta, urging: 1 } }; }, test: function(platform, appVer, innerId) { return { "react-native": { // 这里的值一般维持不变,使用 latestReactNativeBundleUrls.ios 的值即可。 meta: latestReactNativeBundleMetas[platform], urging: 3 } }; } } function getVersionControl(envType, platform, appVer, innerId) { return versionControlGetters[envType](platform, appVer, innerId); }
- (void)getVersionControl:(void(^)(NSDictionary *data))callback { if (callback) { NSString *url = @"http://cdn.xxx.com/config/versionControl.js"; AFHTTPRequestOperationManager *manager = [AFHTTPRequestOperationManager manager]; manager.responseSerializer = [AFHTTPResponseSerializer serializer]; [manager GET:url parameters:nil success:^(AFHTTPRequestOperation * _Nonnull operation, id _Nonnull responseObject) { NSString *code = [[NSString alloc] initWithData:responseObject encoding:NSUTF8StringEncoding]; JSContext *context = [JSContext new]; [context evaluateScript:code withSourceURL:[NSURL URLWithString:url]]; NSArray *args = @[[PlatFormUtil isNormalService] ? @"production" : @"test", @"ios", [PlatFormUtil AppVer], @(getCurrentInnerId())]; NSDictionary *data = [[context[@"getVersionControl"] callWithArguments:args] toDictionary]; callback([data objectForKey:@"react-native"]); } failure:^(AFHTTPRequestOperation * _Nonnull operation, NSError * _Nonnull error) { callback(nil); }]; } }
-
错误跟踪:为实现诸如错误上报版本跟踪、问题反馈版本跟踪等需求,需在代码中提供版本号和 Build 号信息,为此,提供一个 version 模块,考虑到 iOS、Android 并存,提供了一个公共的 version.base 模块,在 version.ios 和 version.android 中分别引用并扩展平台相关的信息;
// version.base.js 'use strict'; export default class Version { code = '1.1.0'; build = '04291109'; folderUrl = 'http://cdn.xxx.com/react-native/'; platformCode = 'unknown'; };
// version.ios.js 'use strict'; import Version from './version.base'; export default new Version({ platformCode: 'ios' });
// version.android.js // 预留,尚未启用 'use strict'; import Version from './version.base'; export default new Version({ platformCode: 'android' });
鉴于 version.ios 和 version.android 的代码是固定的,所以版本升级时,主要维护的是 version.base,
-
发布流程自动化;
一般来说,一个发布过程应该包括如下过程:
- 修改 version.base 内的代码,为 version 设置新的 code 和 build 信息;
- 通过 react-native bundle 把 bundle 生成出来,过程中注意命名,确保不与既有文件重名,输出新文件,发布之;
- 将上述生成的 bundle 复制一份,覆盖到 iOS、Android 项目的内嵌 bundle 文件所在位置;
- 然后根据新文件的路径,调整 controlVersion.js,发布之
这么个流程,人工搞是可以,不过未免过于琐碎繁琐、易于出错,所以建议搞脚本,把这流程自动化起来。这个话题的细节比较多,后边会单独撰文详述。