招商证券react-native热更新优化实践

Posted 前端之巅

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了招商证券react-native热更新优化实践相关的知识,希望对你有一定的参考价值。

作者|招商证券信息技术中心架构组
React Native(简称 RN)是 Facebook 于 2015 年 4 月开源的跨平台移动应用开发框架。RN 使用 javascript 语言、类似于 html 的 JSX,以及 CSS 来开发移动应用,因此熟悉 Web 前端开发的技术人员只需很少的学习就可以进入移动应用开发领域。本文将为大家介绍招商证券在 react-native jsbundle 解析、业务拆包、拆分包在 android 下接入、在 ios 端接入,以及静态资源压缩与热更新方案等几个方面的实践,适用于熟悉 react-native 加载原理、打包流程的开发人员。
1. 背景介绍
公司部分业务 APP 以 react-native 作为技术栈,当前有一个完整的 APP,不同的团队负责不同的业务开发,工作成果以子工程的形式加入到现有 APP 中。随着业务开发的迭代加快以及更多新的业务线的加入,子工程体积增加,进而主工程的体积也跟着增加,同时热更新以全量包的形式进行,也大大增加了用户更新时的流量消耗,因此业务拆包势在必行。拆包的目标如下:
  1. 减小业务打包生成的 jsbundle 体积,进而减小接入的 APP 的体积以及子工程热更新时下载体积,提升用户体验;
  2. 实现基础 jsbundle 和业务 jsbundle 的分离,多个业务共用一个基础包,使业务 jsbundle 更纯粹,提高开发效率;
  3. 优化子工程的 jsbundle 加载,实现基础包预加载,减小加载业务 bundle 时的白屏时间。

本系列文章将以 react-native 0.55 为具体例子,从以下几个方面讲述如何拆包以及如何对基础包和业务包进行异步加载:
  1. 0.52~0.55 版本如何改造官方 metro 打包工具,使 react-native bundle 命令支持基础包和业务包的拆分;
  2. 在原 react-native 应用的 APP(之后称之为主工程)中,如何加入另一个 react-native 应用的子工程;
  3. 主工程原生层如何预加载基础包,在进入对应的子工程viewController/Activity的时候加载业务包。

注: 因为 Facebook 对 react-native 0.56 以上的 metro 拆包已经趋于完善并开放了createModuleIdFactory方法的接口,如果你的 react-native 版本在 0.56 以上,那么第一点你只需关注相关方法实现即可,第 2、3 点是基于 APP 原生的思路及实现,理论上在任意版本的 react-native 都适用。

本文章适合以下人群阅读:熟悉 react-native 加载原理、打包流程,同时希望了解 0.50 以上 RN 异步加载 jsbundle 过程,同时了解原生开发的开发人员。

不适用以下人群:react-native 初学者,不了解 APP 原生开发或 objective-c/Java 语言的开发人员。

2. react-native jsbundle 解析

在进行如何进行业务拆包之前,我们先对 react-native 的 jsbundle 做一个简单的认识。

通过 react-native bundle 命令可对当前 RN 项目进行打包,以 iOS 为例,具体命令例子如下:
react-native bundle --platform ios --dev false --entry-file index.js --bundle-output __test__/example/index.ios.jsbundle --assets-dest __test__/example/
在对应目录下即生成对应的 index.ios.jsbundle和相应的 assets 文件夹, index.ios.jsbundle内容大致如下:
var
__DEV__ = false,
__BUNDLE_START_TIME__ = this.nativePerformanceNow ? nativePerformanceNow() : Date.now(),
process = this.process || {};
process.env=process.env || {};
process.env.NODE_ENV = "production";
!(function(r) {
    "use strict";

    r.__r = o,

    r.__d = function(r,i,n) {
        if(null != e[i])
            return;
        e[i] = {
            dependencyMap:n,factory:r,hasError:!1,importedAll:t,importedDefault:t,isInitialized:!1,publicModule:{exports:{}}
        }
    },

    r.__c = n;

   ....

})();
__d(function(r,o,t,i,n){t.exports=r.ErrorUtils},18,[]);
...
...
__d(function(c,e,t,a,n){var r=e(n[0]);r(r.S,'Object',{create:e(n[1])})},506,[431,460]);
require(46);
require(11);
可以看到 jsbundle 大致包含了 4 部分:
  1. var 声明的全局变量,对当前运行环境的定义和 Process 进程环境相关信息;
  2. (function() { })() 闭包中定义的代码块,其中定义了对 define(d) 、require(r)、clear(__c) 的支持,以及 module(react-native 及第三方 dependences 依赖的 module)的加载逻辑;
  3. __d 定义的代码块,包括 RN 框架源码 js 部分、自定义 js 代码部分、图片资源信息,最后以 require 方式引入使用;
  4. require 定义的代码块,找到 __d 定义的代码块并执行,其中require中的数字即为 __d定义行中最后出现的那个数字。

可以看到,如果每个业务都单独打一个完整包,那么每个包都会包含第一部分和第二部分,因此我们的目标也就是把第一部分和第二部分提取出来,放到基础包common.jsbundle中,之后业务包共用此基础包。

3. 业务拆包
业务拆包有两个方法:
  1. 使用 Google 的 diff-match-patch 工具计算业务 patch,在集成到主工程并需要加载业务页面时,再合成一个完整的 jsbundle 进行加载;
  2. 扩展 react-native 官方打包工具 metro,将打包后的业务代码提取出来,下面将对这两种方法进行介绍。

3.1 diff-match-patch 拆包
Google 给 diff-match-patch提供了多语言版本,包括 Java、Objective-c、Python 及 JavaScript 等,因此很容易用在跨平台的 react-native 应用中。首先我们在没有业务代码的 RN 框架进行打包,得到 common.ios.jsbundle。接下来再添加业务代码,如在一个业务页面添加代码 <Text>Hello World</Text>,打包,得到业务包 business.ios.bundle。接下来我们写一段脚本,将我们添加的这段代码通过 diff-match-patch 提取出来,以补丁 (patch) 的形式作为业务拆包:
// split.js
const DiffMatchPatch = require('diff-match-patch');
const fs = require('fs');

const dmp = new DiffMatchPatch();

fs.readFile(`${process.cwd()}/common.ios.jsbundle`, 'utf8', (e, data) => {
  let commonData = data;
  fs.readFile(`${process.cwd()}/business.ios.jsbundle`, 'utf8', (e, data) => {
    let businessData = data;
    const patch = dmp.patch_make(commonData, businessData); // 生成补丁
    const patchText = dmp.patch_toText(patch); // 生成补丁文本
     fs.writeFileSync(`${process.cwd()}/diff.ios.patch`, patchText);
  });
});
使用 node 执行此脚本,得到 diff.ios.patch,内容如下:
@@ -761599,32 +761599,83 @@
 ent(s.Text,null,
+%22Hello World%22),o.default.createElement(s.Text,null,
 this.state.param

可以看到,diff-match-patch生成的 patch 记录了业务包和公共包的差异部分,差异部分以特殊的符号记录了对应位置、字符等信息。

业务包体积方面,原business.ios.jsbundle大小为 1.2MB,经过diff-match-patch处理得到的diff-mch_apply仅为 121B。在子应用整合到主应用时,业务只需提供一个.patch文件,主工程再根据需要使用diff-match-patch的 patch_apply 方法将diff.ios.jsbundlecommon.ios.js重新合成一个完整的business.ios.jsbundle即可。

到了这里,通过 diff-match-patch拆业务包得到 xxx.patch的方案看起来很完美,但是我们使用拆包方案时没有采取这个方案,因为 diff-match-patch在实际的业务包处理过程中存在以下大坑:
  1. diff-match-patch得到的 patch 是记录业务包和基础包的差异,并且是没有经过压缩的文本记录。除了记录单纯的业务代码差异外,还加入了额外的补丁信息。实际业务开发过程中,差异部分是很多的,因此一个原 1000 行的业务 jsbundle 经过 diff-match-patch处理,得到的 diff.patch文件能达到 2000 多行!原业务包大小为 3.6MB,补丁包大小居然达到 7.2MB!这大大违背了我们拆包的初衷。
  2. diff-match-patchpatch_apply方法在 Android 中存在严重的效率问题,性能一般的机型,光是合并刚才上面 Hello world 的例子拆出来的仅为 121B 的diff.patch,就花了 3200ms 左右!如果补丁文件再大一些,合并效率会更低,因此完整的业务拆包diff-match-patch并不适用。

3.2 metro 拆包
metro 是 react-native 的官方打包工具及 dev server 工具,在执行 react-native bundle 或在 RN 项目下执行 npm start 时,实际是调用 metro-builder 来进行加载打包任务的,类似于 Web 开发常用的 Webpack。针对拆包工作,0.56 之后版本的 RN 依赖的 metro 已经趋于完善,开放了 --config <path / to / config> 参数来配置提供自定义文件,通过这个配置选项,我们就可以通过两个关键方法配置来修改打包配置,这两个方法分别是: createModuleIdFactory、 processModuleFilter
  • createModuleIdFactory负责固定 module 的 ID。在上文的 jsbundle 解析中, __d中定义的各个 module 后都有一个数字表示,并在最后的 require 方法中进行调用(如 require(41)),这其中的数字就是 createModuleIdFactory方法生成的。如果添加了 module,那么 ID 会重新生成。如果要做一个基础包,那么公共 module 的 ID 必须是固定的,因此 0.56+ 版本的 RN 可以通过此方法的接口来将 module 的 ID 固定,0.52~0.55 的 RN 依赖的 metro 也用到了这个方法,只是没暴露出来,因此可以通过修改源码的方式来实现 0.56+ 版本相同的效果。
  • processModuleFilter 负责过滤掉基础包的内容模块,0.56 以下版本没有此方法,因此需要我们自己来实现这个过滤方法。

3.2.1  固定模块 ID
首先是固定 module ID,我们来看 metro createModuleIdFactory的实现,在 metro/src/lib/createModuleIdFactory.js看到该方法,很简单:
// createModuleIdFactory.js
function createModuleIdFactory() {
  const fileToIdMap = new Map();
    let nextId = 0;
    return path => {
      let id = fileToIdMap.get(path);
      if (typeof id !== 'number') {
        id = nextId++;
        fileToIdMap.set(path, id);
      }
      return id;
    };
}

module.exports = createModuleIdFactory;
从上述源码也可以看出,系统使用整数型的方式,从 0 开始遍历所有模块,并依次使 Id 增加 1。所以我们可以修改此处逻辑,以模块路径名称的方式作为 Id 即可。
function createModuleIdFactory() {

  if (!process.env.__PROD__) { // debug 模式
    const fileToIdMap = new Map();
    let nextId = 0;
    return path => {
      let id = fileToIdMap.get(path);
      if (typeof id !== 'number') {
        id = nextId++;
        fileToIdMap.set(path, id);
      }
      return id;
    };
  } else { // 生产打包使用具体包路径代替 id,方便拆包处理
    // 定义项目根目录路径
    const projectRootPath = `${process.cwd()}`;
    // path 为模块路径名称
    return path => {
      let moduleName = '';
      if(path.indexOf('node_modules\\react-native\\Libraries\\') > 0) {
          moduleName = path.substr(path.lastIndexOf('\\') + 1);
      } else if(path.indexOf(projectRootPath)==0){
          moduleName = path.substr(projectRootPath.length + 1);
      }

      moduleName = moduleName.replace('.js', '');
      moduleName = moduleName.replace('.png', '');
      moduleName = moduleName.replace('.jpg', '');
      moduleName = moduleName.replace(/\\/g, '_'); // 适配 Windows 平台路径问题
      moduleName = moduleName.replace(/\//g, '_'); // 适配 macos 平台路径问题

      return moduleName;
    };
  }

}

module.exports = createModuleIdFactory;

这里我加了一个process.env.__PROD__的判断,即在开发模式下依旧使用原模式(因为我发现全局改的话开发模式会运行失败)。在打包的时候注入一个__PROD__环境变量才会进入自定义固定 ID 那段方法。在这段代码中,依据模块路径 path,在其基础上进行自定义路径名称,并作为 Id 返回,使用模块的路径作为 ID,保证返回的 ID 是绝对固定的。

3.2.2 基础包和业务包打包
在完成了打包源码修改后,接下来就是要分别打出基础模块与业务模块的 jsbundle 文件。在打包之前,需要我们分别定义好基础模块与业务模块文件,核心代码如下:

// base.js
require('react-native');
require('react');
... 这里可以引入更多的第三方模块及自己的公共模块

// index.js
class App extends Component {
    render() {
        return (
            <View>
                <Text>
                    hello world
                </Text>
            </View>

        )
    }
}

AppRegistry.registerComponent("business", () => App);

注:1. base.js 为基础模块入口,index.js 为业务模块入口;2.base.js 没有AppRegistry.registerComponent入口,业务模块必须要有这个入口,这个一定要注意,这个是网上很多类似拆包文章没有说清楚的事!!!

接下来就是通过react-native bundle命令来进行打包了,需要两个不同的命令,区别在于打包入口文件参数(--entry-file)不一样:

基础包:
react-native bundle --platform ios --dev false --entry-file base.js --bundle-output __test__/example/common.ios.jsbundle --assets-dest __test__/ios/
业务包:
react-native bundle --platform ios --dev false --entry-file index.js --bundle-output __test__/example/business.ios.jsbundle --assets-dest __test__/ios/

打完包打开 jsbundle 看下require__d对应的 module ID,看下是不是都变成模块路径了,通过这个也可以直观明了地看到 jsbundle 每一行对应的模块。

3.2.3 求出差异包
我们已经通过 react-native bundle 将基础包(common.jsbundle)和业务包(business.jsbundle)生成,接下来就是要生成一个业务包独有的内容,以及差异包(diff.jsbundle)。关于这个差异包,网上流传最多的方法就是通过 linux 的 comm命令,命令如下:
comm -2 -3 ./__test__/ios/business.ios.jsbundle ./__test__/ios/common.ios.jsbundle > ./__test__/ios/diff.ios.jsbundle

选项 -2 表示不显示在第二个文件中出现的内容,-3 表示不显示同时在两个文件中都出现的内容,通过这个组合就可以生成业务模块独有的代码。在实际实践中,我发现求出来的diff.jsbundle依旧比较大。观察diff.jsbundle中的内容,发现diff.jsbundlecommon.jsbundle中依旧有相同的内容!通过查这个comm命令的使用方法得知,这个命令对有序文本的过滤效果是比较好的,但是面对我们杂乱无章的 jsbundle 文本,过滤效果就会出现 bug,因此 comm 命令并不能求解出最优diff.jsbundle

那么换个思路, common.jsbundlebusiness.jsbundle都是纯文本文件,我们已经把各个模块的 ID 固定,而且每个模块的定义 (也就是 jsbundle 中的 __d) 都是作为文本的固定行排列,两者相同的行肯定是一模一样的,那么就很好办了。只要对 business.jsbundle进行逐行扫描,判断每一行是否在 common.jsbundle出现过。如没有出现过,那么肯定就是业务独有的内容,再将这些差异逐行写入 diff.jsbundle,这样既保证了内容的唯一性,也保证了模块定义的顺序,在后续的异步加载 jsbundle 时不至于出现业务模块定义错乱的问题!实现的 node.js 代码如下 (ps:编程语言和求差算法可以进一步优化):
// diff.js: 找出 common 和 business 的不同行

var fs = require('fs');
var readline = require('readline');

function readFileToArr(fReadName,callback){
  var fRead = fs.createReadStream(fReadName);
  var objReadline = readline.createInterface({
      input:fRead
  });
  var arr = new Array();
  objReadline.on('line',function (line) {
      arr.push(line);
      //console.log('line:'+ line);
  });
  objReadline.on('close',function () {
     // console.log(arr);
      callback(arr);
  });
}

var argvs = process.argv.splice(2);
var commonFile = argvs[0]; // common.ios.jsbundle
var businessFile = argvs[1]; // business.ios.jsbundle
var diffOut = argvs[2]; // diff.ios.jsbundle
readFileToArr(commonFile, function (c_data) {
  var diff = [];
  var commonArrs = c_data;
  readFileToArr(businessFile, function (b_data) {
    var businessArrs = b_data;
    for (let i = 0; i < businessArrs.length; i++) {
      if (commonArrs.indexOf(businessArrs[i]) === -1) { // business 中独有的行
        diff.push(businessArrs[i]);
      }
    }
    // console.log(diff.length);
    var newContent = diff.join('\n');
    fs.writeFileSync(diffOut, newContent); // 生成 diff.ios.jsbundle
  });
});
使用方法:
node ./__test__/diff.js ./__test__/ios/common.ios.jsbundle ./__test__/ios/business.ios.jsbundle ./__test__/ios/diff.ios.jsbundle

至此,我们的基础包base.jsbundle和业务包diff.jsbundle就已经完成了,只要交付给主工程应用,主工程预集成基础包base.jsbundle,并在启动时预加载基础包,在进入不同业务时按需加载diff.jsbundle,接下来的工作就是如何在 native 端以此加载这两个 jsbundle 了。

4. 拆分包在 Android 下接入
4.1 react-native 在 Android 下的启动流程简介
在 react-native 项目刚创建时,项目 android 目录下原生代码有两个: MainActivity.javaMainApplication.javaMainActivity.java为原生层应用程序的入口文件, MainApplication.java为整体应用程序的初始化入口文件, MainActivity.java内容如下:
public class MainActivity extends ReactActivity {

    /**
     * 返回 index.js 下 AppRegistry.registerComponent() 方法传入的 appKey,
     * 用来渲染视图。
     */

    @Override
    protected String getMainComponentName() {
        return "RN_bundle_split";
    }
}

MainActivity.java继承了ReactActivity类,通过传入的 appKey 来渲染对应的 react-native 界面。

MainApplication.java内容如下:
public class MainApplication extends Application implements ReactApplication {

  private final ReactNativeHost mReactNativeHost = new ReactNativeHost(this) {
    @Override
    public boolean getUseDeveloperSupport() {
      return BuildConfig.DEBUG;
    }

    @Override
    protected List<ReactPackage> getPackages() {
      return Arrays.<ReactPackage>asList(
          new MainReactPackage()
      );
    }

    @Override
    protected String getJSMainModuleName() {
      return "index";
    }
  };

  @Override
  public ReactNativeHost getReactNativeHost() {
    return mReactNativeHost;
  }

  @Override
  public void onCreate() {
    super.onCreate();
    SoLoader.init(this, /* native exopackage */ false);
  }
}
MainApplication.java主要完成三件事:
  1. 实现 ReactApplication 接口,重写 getReactNativeHost方法,返回 ReactNativeHost 实例;
  2. 定义并初始化 ReactNativeHost,实现 getUseDeveloperSupport、getPackages、getJSMainModuleName 方法,完成初始化设置;
  3. 在 onCreate 生命周期方法中,调用 SoLoader 的 init 方法,启动 C++ 层逻辑代码的初始化加载。

其中,ReactNativeHost可以当做是一个 react-native 运行的环境,如果需要通过加载新的 jsbundle 来加入新的业务页面,那么也就需要一个新的 ReactNativeHost,同时也需要一个新的 reactActivity 来渲染业务界面。

那么疑问来了, MainActivity.javaMainApplication.java是怎么联系起来从而将 react-native 项目完整地跑起来呢?前面有提到,MainActivity 继承了 ReactActivity 类,因此一个 react-native 界面的具体实现就是从 ReactActivity实现的。大致看下 ReactActivity的源码(部分关键代码):
/**
 * Base Activity for React Native applications.
 */

public abstract class ReactActivity extends Activity
implements DefaultHardwareBackBtnHandler, PermissionAwareActivity 
{
  private final ReactActivityDelegate mDelegate;

  protected ReactActivity() {
    mDelegate = createReactActivityDelegate();
  }

  /**
   * 返回从 JavaScript 注册的主要组件的名称,用于组件的渲染。
   * e.g. "RN_bundle_split"
   */

  protected @Nullable String getMainComponentName() {
    return null;
  }

  /**
   * Called at construction time, override if you have a custom delegate implementation.
   */

  protected ReactActivityDelegate createReactActivityDelegate() {
    return new ReactActivityDelegate(this, getMainComponentName());
  }

  @Override
  protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    mDelegate.onCreate(savedInstanceState);
  }

   /**
   * 获取 ReactNativeHost 实例
   */

  protected final ReactNativeHost getReactNativeHost() {
    return mDelegate.getReactNativeHost();
  }

  /**
   * 获取 ReactInstanceManager 实例
   */

  protected final ReactInstanceManager getReactInstanceManager() {
    return mDelegate.getReactInstanceManager();
  }

  /**
   * 加载 JSBundle
   */

  protected final void loadApp(String appKey) {
    mDelegate.loadApp(appKey);
  }
}
ReactActivity 类中实现如下:
  1. 继承 Activity,实现 DefaultHardwareBackBtnHandler、PermissionAwareActivity 两个接口,重写其中的返回事件,及请求权限的方法。
  2. 构造函数中调用 createReactActivityDelegate 方法,传入 this、和 getMainComponentName 方法返回值,创建 ReactActivityDelegate 实例。
  3. 重写 Activity 生命周期方法,调用 delegate 实例的对应生命周期方法。
  4. 定义获取 ReactNativeHost、ReactInstanceManager 实例方法。
  5. 定义 loadApp 方法。

很明显,ReactActivity 中采用了委托的方式,将所有行为全权交给了 ReactActivityDelegate 去处理。好处也很明显,降低代码耦合,提升了可扩展性。也就是ReactActivityDelegateMainActivity.javaMainApplication.java的行为关联了起来。

至此,我们至少简单了解了 react-native 从启动到界面呈现需要做的事:
  1. MainActivity.java定义 appKey,指定界面的渲染入口;
  2. MainApplication.java实例化 ReactNativeHost及其他一些 react-native 的环境配置项;
  3. ReactActivity中实例化ReactActivityDelegate,MainActivity.javaMainApplication.java的行为关联起来,最后在ReactActivityDelegate中调用createRootView渲染视图。

在接入多个 jsbundle 时,我们就要依据这些 react-native 的加载原理来进行接入。

4.2 接入多个 jsbundle
依据 react-native 的加载原理,接入多个 jsbundle 还要互不影响,那么就需要新建一个 ReactNativeHost。我们新建一个 MyReactApplication类来初始化 ReactNativeHost,同时提供 getReactNativeHost供接下来的自定义 delegate调用:
// MyReactApplication.java
public class MyReactApplication extends Application implements ReactApplication {
    public static MyReactApplication mInstance;
    private WeakReference<Application> appReference;
    public static ReactNativeHost mReactNativeHost;

    private MyReactApplication() {
    }

    public static MyReactApplication getInstance() {
        if (mInstance == null) {
            synchronized (MyReactApplication.class) {
                if (mInstance == null) {
                    mInstance = new MyReactApplication();
                }
            }
        }
        return mInstance;
    }

    // 初始化 ReactNativeHost
    public void init(Application application) {
        appReference = new WeakReference<>(application);
        mReactNativeHost = new ReactNativeHost(application) {
            @Override
            protected String getBundleAssetName() {
                // 指向业务包
                return "business.jsbundle";
            }

            @Override
            public boolean getUseDeveloperSupport() {
                return false;
            }

            @Override
            protected List<ReactPackage> getPackages() {
                return Arrays.<ReactPackage>asList(
                        new MainReactPackage()
                );
            }
        };
    }


    @Override
    public ReactNativeHost getReactNativeHost() {
        return mReactNativeHost;
    }
}
有了 MyReactApplication这个类,需要对这个类初始化,可以在 MainApplicationonCreate中进行初始化:
public class MainApplication extends Application implements ReactApplication {
    ...
  @Override
  public void onCreate() {
    super.onCreate();
    SoLoader.init(this, /* native exopackage */ false);
    // MyReactApplication 初始化
    MyReactApplication.getInstance().init(this);
  }
}
我们还要实现自定义的 ReactActivityDelegate来服务于新的 react-native 环境。下面是自定义实现的 MyReactActivityDelegategetReactNativeHost方法获取的是 MyReactApplicationReactNativeHost,其他方法直接使用 ReactActivityDelegate类中的私有方法:
public class MyReactActivityDelegate extends ReactActivityDelegate {
    ...
    @Override
    public ReactNativeHost getReactNativeHost() {
        return MyReactApplication.getInstance().getReactNativeHost();
    }
    ...
}
最后,就是实现一个新的 SubActivity来渲染新的 jsbundle 界面了。 SubActivity代码很简单,需要返回 appKey,需要注意的是继承 ReactActivity类,还要覆盖 ReactActivityDelegate
public class SubActivity extends ReactActivity {

    @Nullable
    @Override
    protected String getMainComponentName() {
        return "RN_bundle_split_business";
    }

    @Override
    protected ReactActivityDelegate createReactActivityDelegate() {
        // 使用 MyReactActivityDelegate 覆盖原 ReactActivityDelegate
        return new MyReactActivityDelegate(this, getMainComponentName());
    }

}

至此,一个可以正常加载 jsbundle 的Activity已经完成,使用 native 方法startActivity跳转即可。

4.3 jsbundle 异步加载
现在我们已经拥有一个可以和主项目 react-native 环境相对独立的子项目 rn 环境,那么现在问题来了,在上面的 MyReactApplication初始化 ReactNativeHost的时候,我们看到,最后用到的 jsbundle 只有一个 business.jsbundle,我们拆包得到的是 common.jsbundlebusiness.jsbundle两个包。那怎么实现先加载 common.jsbundle,再加载 business.jsbundle呢?我们再来回顾下子应用 SubActivity从初始化到渲染的界面流程:
  1. MyReactApplication在需要加载 Activity的时候初始化,此时也将 ReactNativeHost初始化;
  2. SubActivity初始化MyReactActivityDelegate,在MyReactActivityDelegate初始化过程中通过startReactApplication 执行 createReactContextInBackground()方法实现 ReactContext 的创建及 Bundle 的加载逻辑,最终将视图绑定,完成渲染。

因此,RN 加载 js 代码、绑定视图的逻辑可以分开异步执行。利用这个特性,就可以将加载基础包代码、加载业务包代码以及最后绑定视图分步执行,我们在刚才SubActivity的基础上进行改造。

4.3.1 初始化 ReactContext 上下文环境,加载基础包
这次我们改造的是之前的 SubActivity,我们将这个 Activity 继承普通的 Activity类:
public class SubActivity extends Activity
    implements DefaultHardwareBackBtnHandler, PermissionAwareActivity 
{
            ...
}
接下来就是加载基础包,加载基础包也就是将 MyReactApplication中的 ReactNativeHost初始化的包改成 common.jsbundle:
...
public void init(Application application) {
        appReference = new WeakReference<>(application);
        mReactNativeHost = new ReactNativeHost(application) {
            @Override
            protected String getBundleAssetName() {
                return "common.jsbundle";
            }

            @Override
            public boolean getUseDeveloperSupport() {
                return false;
            }

            @Override
            protected List<ReactPackage> getPackages() {
                return Arrays.<ReactPackage>asList(
                        new MainReactPackage()
                );
            }
        };
    }
...
同时将 MyReactApplicationMyReactActivityDelegate放在 SubActivity.javaonCreate中进行初始化,同时执行 createReactContextInBackground()完成 ReactContext的初始化,监听基础包初始化完成,核心代码如下:
@Override
    protected void onCreate(final Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        Log.d("rndebug", "初始化 MyReactActivityDelegate");
        mDelegate = new MyReactActivityDelegate(this, "RN_bundle_split_business");

        Log.d("rndebug", "初始化 MyReactApplication");
        MyReactApplication.getInstance().init(MainApplication.mainApplication);


        final ReactInstanceManager manager = getReactNativeHost().getReactInstanceManager();

        manager.addReactInstanceEventListener(new ReactInstanceManager.ReactInstanceEventListener() {
            @Override
            public void onReactContextInitialized(ReactContext context) {
                Log.d("rndebug", "基础包加载完毕");
                loadScript(); // 加载业务包
                initView(); // 加载视图
                manager.removeReactInstanceEventListener(this);
            }
        });
        getReactNativeHost().getReactInstanceManager().createReactContextInBackground();

    }
 4.3.2 加载业务包
上一过程中,我们监听了基础包加载完成的回调,在回调里执行了 loadScript()加载业务包,这个方法是在 (1) 初始化的 ReactContext的基础上,通过调用 CatalystInstance 实例的 loadScriptFromAssets() 方法完成对业务 jsBundle 文件的加载,核心代码如下:
public static void loadScriptFromAsset(Context context,
                                           CatalystInstance instance,
                                           String assetName,boolean loadSynchronously) {
        String source = assetName;
        if(!assetName.startsWith("assets://")) {
            source = "assets://" + assetName;
        }
        ((CatalystInstanceImpl)instance).loadScriptFromAssets(context.getAssets(), source,loadSynchronously);
    }
4.3.3 加载视图
待基础包、业务包都加载完成后,调用 initView()方法即完成视图加载:
protected void initView(){
    mDelegate.onCreate(null);
}
事实上, mDelegate.onCreate()内执行的代码如下:
// MyReactActivityDelegate.java
...
    @Override
    public void onCreate(Bundle savedInstanceState) {
        if (mMainComponentName != null) {
            loadApp(mMainComponentName);
        }
        mDoubleTapReloadRecognizer = new DoubleTapReloadRecognizer();
    }

    @Override
    public void loadApp(String appKey) {
        if (mReactRootView != null) {
            throw new IllegalStateException("Cannot loadApp while app is already running.");
        }
        mReactRootView = createRootView();
        mReactRootView.startReactApplication(
                getReactNativeHost().getReactInstanceManager(),
                appKey,
                getLaunchOptions());
        getPlainActivity().setContentView(mReactRootView);
    }
...

这样业务 rn 界面SubActivity就完成了 jsbundle 的异步加载,也可以根据不同的业务包按需加载。

4.3.4 Android 接入小结

从上面的实践来看,Android 下 react-native 基于 JavaScript 的设计还是很灵活的,通过createReactContextInBackground()的调用实现了基础包的预先加载,通过loadScriptFromAssets()的调用加载业务包,在不使用反射的情况下实现了业务模块的异步加载,降低了反射所带来的性能影响。

5. iOS 端接入拆分包
5.1 react-native 在 iOS 下的启动流程简介
与 Android 下 ApplicationActivityDelegate等诸多概念不同,react-native 在 iOS 下的加载简单得多,启动核心文件就一个 AppDelegate.m。这个类似于 Android 的 Application,即在应用首次打开的时候会执行里面的内容, AppDelegate.m的内容也很简单,主要是 RN 的初始化和视图加载:
@implementation AppDelegate
  - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
  NSURL *jsCodeLocation;
  jsCodeLocation = [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@"index" fallbackResource:nil];

  // 初始化 react-native rootview
  RCTRootView *rootView = [[RCTRootView alloc] initWithBundleURL:jsCodeLocation
                                                      moduleName:@"RN_bundle_split"
                                               initialProperties:nil
                                                   launchOptions:launchOptions];
  rootView.backgroundColor = [[UIColor alloc] initWithRed:1.0f green:1.0f blue:1.0f alpha:1];

  self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds];
  UIViewController *rootViewController = [UIViewController new];

  // 将 react-native view 加载到 viewController 中
  rootViewController.view = rootView;
  self.window.rootViewController = rootViewController;
  [self.window makeKeyAndVisible];
  return YES;
}

AppDelegate.m就是获取 jsbundle 的本地路径或线上路径,然后使用RCTRootView初始化一个视图,初始化传入 jsbundle 的路径和 appkey,最后再将视图添加到 iOS 的viewController中,这样 iOS 端整个 RN 的加载渲染就完成了。

5.2 接入多个 jsbundle

基于 iOS 的启动流程,如果要接入多个 jsbundle,只需要新创建一个viewController,再按照AppDelegate.mRootView初始化方法加载不同的 jsbundle 路径和 appKey 就可以轻松实现。

5.3 jsbundle 分步加载
5.3.1 加载基础包
AppDelegate中,react-native 初始化视图是使用了 RCTRootView类提供的 initWithBundleURL方法来加载一个完整的 jsbundle。事实上,查看 RCTRootView类的源码,发现这个类还提供了 initWithBridge方法来初始化 RN 视图,react-native bridge 在 iOS 的概念类似 Android 的 reactContext。因此,先使用 RCTBridge类加载基础包创建一个 bridge:
#import "RCTBridge.h"

NSURL *jsCodeLocation = [NSURL URLWithString:[[NSBundle mainBundle] pathForResource:@"common.jsbundle" ofType:nil]];

bridge = [[RCTBridge alloc] initWithBundleURL:jsCodeLocation
                                 moduleProvider:nil launchOptions:launchOptions];

接下来就是在这个 bridge 的基础上运行业务包内的代码,从而得到完整的 bridge。

5.3.2 加载业务包
js bridge 加载业务包代码,需要使用 react-native RCTBridgeexecuteSourceCode方法,该方法传入的内容为 js 代码并执行,但这个方法并没有开放给开发者使用,因此,我们需要手动将这个方法暴露出来,项目根目录下新创建一个 RCTBridge.h
#import <Foundation/Foundation.h>

@interface RCTBridge (RnLoadJS)

 - (void)executeSourceCode:(NSData *)sourceCode sync:(BOOL)sync;

@end
然后在需要加载业务包的地方 #import "RCTBridge.h",读取业务包的内容并进行加载:
NSLog(@"subapp: 业务包加载开始");
NSString * busJsCodeLocation = [[NSBundle mainBundle] pathForResource:@"business.ios.jsbundle" ofType:nil];

NSData * sourceBus = [NSData dataWithContentsOfFile:busJsCodeLocation options:NSDataReadingMappedIfSafe error:nil];

[(RCTBridge *)bridge.batchedBridge executeSourceCode:sourceBus sync:YES];
NSLog(@"subapp: 业务包加载结束");
5.3.3 加载视图
最后就是使用第一点里提到 RCTRootView类里的 initWithBridge将分别加载了基础包和业务包的 bridge 传入,加上 appKey 初始化一个 RCTRootView,再添加到 viewControllerview即可。
RCTRootView * root = [[RCTRootView alloc] initWithBridge:bridge moduleName:@"RN_bundle_split_business" initialProperties:initialProperties];

self.view = root;
6. 静态资源压缩及热更新方案

静态资源目前的优化控制在文件级别,通过 diff 命令可计算出相邻版本的差异包。

因此不管是 jsbundle 还是静态资源的热更新,目前的优化方案依然是寻求局部最优解,差异包的分发仅仅针对版本号为上一版本的客户端,而面向更老版本的客户端,我们依然采用全量包下发的方案。这是一个工程化的整体考量,首先针对全版本做差异化分发势必大大增加版本发布工作量与复杂度。另外,随着业务子应用逐步趋于稳定,新版本覆盖率往往很高,因此为少数老版本做差异化分发,代价较大。

以上是招商证券在 react-native 热更新优化方面的实践,目前基于 react-native 技术栈及相关优化方案已在公司移动办公和内部业务线中全面铺开。

以上是关于招商证券react-native热更新优化实践的主要内容,如果未能解决你的问题,请参考以下文章

招商证券交易系统宕机上热搜,遭深圳证监局责令整改

招商证券股票软件提醒磁盘文件损坏

招商证券交易系统“崩了”

react-native热更新之CodePush详细介绍及使用方法

金融科技招商证券丨基于流式计算的衍生场内交易实时风控设计

React-Native 热更新尝试(Android)