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

688 查看

前言: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 也保存到本地。

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

  6. 发布流程自动化;

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

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

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