[React Native] 加载、维护 bundle 的正确姿势

字数 1784阅读 8651

前言:React Native 的其中一个卖点是程序可热更新,当前官方和非官方对这类实操的完整指导不多,所以在我们的项目实践中,我们做了一套自己的方案,iOS 侧已经上线运行,理论上和实践上没啥问题,这里梳理出来,一方面作为后续我们在 Android 的对齐基准,另一方面与大家共享思路方便探讨调优。

要做好 React Native 的热更新,主要需要处理好如下几个情况:

  1. 本地启动:为保证启动速度,不能全部依赖线上的 bundle,需保证还未下载到 bundle 的时候,能如常载入 bundle 并启动,所以初始化 RCTBridge 或 RCTRootView 时用的 bundleURL 得指向本地而非网络;

  2. 及时更新:为实现所用 bundle 能够及时更新,需要在合适时机拉取最新版的 bundle 存放到本地,细则如下:在 app 启动时,在 app 从后台切到前台后,以及在网络状态发生变化后,发起请求拉取最新的配置信息,根据配置信息确定是否需要下载 bundle 以及后续处理。

  3. 流量节约:为实现可控的流量节约,配置信息中包含了要使用的 bundle 信息如下:

  • url:bundle 文件的存放地址;
  • token:bundle 文件的标识字符串,每次将 bundle 文件成功保存到本地后,都同时在本地保存该值,以作下次拉取到配置时的比较依据,当配置中的 token 与本地的一致,那就无需做后续的下载和更多相关操作;
  • urging:更新该 bundle 的紧急程度,可选值如下:
    • 1:有 WIFI 就下载,下好后重启 app 时启用 // 不紧急的时候用这个
    • 2:有 WIFI 就下载,下载好后,从后台切回前台的时候启用 // 免流量,界面刷新柔和,推荐这个
    • 3:不管有没有 WIFI 都下载,下载好后,从后台切回前台的时候启用 // 耗点流量,界面刷新柔和,次推荐这个
    • 4:不管有没有 WIFI 都下载,下载好后,立马启用 // 杀很大,一般不用这个

当读取到上述信息后,基于配置中的 token 与本地值比较是否一致确认是否结束流程,如果不一致则以配置中的 url 发起一个请求,得到 bundle 后,保存到本地,同时把配置中的 token 也保存到本地。

  1. 版本并存:为实现多版本同时并存,提供 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);
                  }];
         }
    }
    
  1. 错误跟踪:为实现诸如错误上报版本跟踪、问题反馈版本跟踪等需求,需在代码中提供版本号和 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,

  2. 发布流程自动化;

一般来说,一个发布过程应该包括如下过程:

  • 修改 version.base 内的代码,为 version 设置新的 code 和 build 信息;
  • 通过 react-native bundle 把 bundle 生成出来,过程中注意命名,确保不与既有文件重名,输出新文件,发布之;
  • 将上述生成的 bundle 复制一份,覆盖到 iOS、Android 项目的内嵌 bundle 文件所在位置;
  • 然后根据新文件的路径,调整 controlVersion.js,发布之

这么个流程,人工搞是可以,不过未免过于琐碎繁琐、易于出错,所以建议搞脚本,把这流程自动化起来。这个话题的细节比较多,后边会单独撰文详述。

推荐阅读更多精彩内容