React Native 拆包原理和实践

持续完善中...

一、拆包关键之bridge

1、bridge原理

RCTBridge是对JavaScriptCore中Bridge的封装,每个bridge都是一个独立的js环境。

RN的启动流程可以简单概括为:

  • Native编译并启动
  • 创建js虚拟机环境
  • 创建 bridge,拥有独立的context js运行环境,并负责原生和js线程的通信(通过不同bridge加载的js代码,可以存在相同的全局变量,不会冲突)
  • 通过 bridge 获取js线程来解析js代码(可以是远程包和离线包)
  • 运行js代码,并根据参数创建 RootView

bridge在RN中起到承上启下的作用,在做RN拆包的时候是重点考虑的对象。目前RN拆包针对brdige有两种主流方案,分别是单bridge和多bridge。

2、单bridge和多bridge的选择

优势 劣势
不用管理bridge的缓存和复用问题 不重启APP的情况下想要更新bundle需要做更多的配置,比较繁琐,且更新bundle并不会清除bridge中的旧bundle,存在少量内存浪费
占用内存更少 由于不同模块都是运行在同一个bridge环境中,如果存在相同的全局变量会造成代码污染
优势 劣势
不同模块之间使用了bridge隔离,不用担心全局变量污染的问题 由于bridge很占用内存,所以需要手动维护bridge的缓存和复用问题,避免APP内存溢出(CRN维护了5个上限的bridge)
不重启APP的情况下更新bundle很方便,只需要重新指定路径加载或者执行reload 占用内存多

二、基础包和业务包的拆分

1、metro 介绍和打包流程

react-native metro 分析
metro是一种支持ReactNative的打包工具,我们现在也是基于他来进行拆包的,metro打包流程分为以下几个步骤:

  • Resolution:Metro需要从入口点构建所需的所有模块的图,要从另一个文件中找到所需的文件,需要使用Metro解析器。在现实开发中,这个阶段与Transformation阶段是并行的。
  • Transformation:所有模块都要经过Transformation阶段,Transformation负责将模块转换成目标平台可以理解的格式(如React Naitve)。模块的转换是基于拥有的核心数量来进行的。
  • Serialization:所有模块一经转换就会被序列化,Serialization会组合这些模块来生成一个或多个包,包就是将模块组合成一个JavaScript文件的包,序列化的时候提供了一些列的方法让开发者自定义一些内容,比如模块id,模块过滤等。

观察一下原生Metro代码的node_modules/metro/src/lib/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;

逻辑比较简单,如果查到map里没有记录这个模块则id自增,然后将该模块记录到map中,所以从这里可以看出,官方代码生成moduleId的规则就是自增,所以这里要替换成我们自己的配置逻辑,我们要做拆包就需要保证这个id不能重复,但是这个id只是在打包时生成,如果我们单独打业务包,基础包,这个id的连续性就会丢失,所以对于id的处理,我们还是可以参考上述开源项目,每个包有十万位间隔空间的划分,基础包从0开始自增,业务A从1000000开始自增,又或者通过每个模块自己的路径或者uuid等去分配,来避免碰撞,但是字符串会增大包的体积,这里不推荐这种做法。所以总结起来js端拆包还是比较容易的,这里就不再赘述

2、Plain Bundle 分析

通过react-native bundle --platform android --dev false --entry-file index.common.js --bundle-output {输出bundle的路径} --assets-dest {资源路径} --config {自定义打包配置} --minify false 打出基础包(minify设为false便于查看源码)

function (global) {
  "use strict";

  global.__r = metroRequire;
  global.__d = define;
  global.__c = clear;
  global.__registerSegment = registerSegment;
  var modules = clear();
  var EMPTY = {};
  var _ref = {},
      hasOwnProperty = _ref.hasOwnProperty;

  function clear() {
    modules = Object.create(null);
    return modules;
  }

  function define(factory, moduleId, dependencyMap) {
    if (modules[moduleId] != null) {
      return;
    }

    modules[moduleId] = {
      dependencyMap: dependencyMap,
      factory: factory,
      hasError: false,
      importedAll: EMPTY,
      importedDefault: EMPTY,
      isInitialized: false,
      publicModule: {
        exports: {}
      }
    };
  }

  function metroRequire(moduleId) {
    var moduleIdReallyIsNumber = moduleId;
    var module = modules[moduleIdReallyIsNumber];
    return module && module.isInitialized ? module.publicModule.exports : guardedLoadModule(moduleIdReallyIsNumber, module);
  }

这里主要看__r,__d两个变量,赋值了两个方法metroRequire,define,具体逻辑也很简单,define相当于在表中注册,require相当于在表中查找,js代码中的import,export编译后就就转换成了__d与__r

三、拆包的后遗症

1、按序加载基础包和业务包

将RN的js业务拆出了公共模块之后,在bridge加载bundle的时候需要优先加载common包。这里需要考虑两个问题:

  • RCTBridge需要叠加加载bundle
    由于RCTBridge并没有提供多次加载bunlde的方法,但是其内部又一个私有方法实现了该功能(- (void)executeSourceCode:(NSData *)sourceCode sync:(BOOL)sync;),在iOS中我们可以通过Category的方式将该方法暴露出来
  • bundle加载完成获取回调
    我们必须要在common bunlde加载完成之后再去加载业务模块,所以我们需要获取到bundle加载完成的回调。然而RCTBridge并没有提供回调入口,但是其有一个loading属性,我们可以使用一个do while循环阻塞线程,直到loading为false代码再往下走

如果是多bridge方案,每个bridge都得先加载common包,再加载具体业务包,这样会很浪费内存。

2、热更新改造

  • 单bridge热更新
    单bridge的叠加加载问题已经解决了,但是叠加加载并不会覆盖已经加载过的bundle包,如果在不重启APP的情况下,单bridge将无法实现热更新。解决办法是在打更新包的时候,得更新需要热更的bundle包的模块ID,具体可参考:react-native实现不重启App的情况下更新分包
    第二个问题是热更之后资源路径发生变化。需要制定热更之后的bundle从沙盒加载资源,否则会出现资源文件找不到的问题。

  • 多bridge热更新
    多bridge方案进行热更时,无需考虑单bridge reload影响全局的问题,只需要reload当前需要更新的bridge就行,如果模块划分比较细,这样做通常更有优势。

如果使用静默升级,那么可以在下载完bundle包之后先不做替换或者reload,而是等到下一次进入APP的时候从新的路径加载bundle,这样做可以使用户进行无感知的更新。

3、混合开发的路由方案

  • 纯RN路由
    适用于纯RN,使用react-navigation即可,仅需使用AppRegistry.registerComponent注册一个根组件,只会存在一个VC或activity,所有的路由跳转其实都是在同一个VC或activity内跳转。如果后期要扩展混合路由,纯RN改造会比较大

  • 纯Native路由
    每个RN页面,都使用AppRegistry.registerComponent单独注册,然后在Native端利用注册的组件创建的单独的RootView,并最终创建单独的VC承载。由于都使用Native路由,所以可以很方便的进行Native和RN路由的统一,管理一套路由表即可。但是如果项目中需要引入其他团队开发的RN bundle包,其他团队如果使用的是纯RN路由,那么这个时候就不兼容了,所以纯Native路由方式不太适合需要引入其他团队开发的bundle的场景

  • 混合路由
    混合路由指的是有一部分Native路由,有一部分RN路由,携程CRN目前走的就是混合路由路线。如果有些模块需要在其他App内复用,建议采用携程的模式,他们对路由进行了优化(没开源),管理起来应该会方便些。

4、路由表的调整

拆包之后路由表怎么维护呢?由于拆分成了多个bundle,路由表散落在了多个bundle中,不同bundle之间如何跳转。如果路由名产生了冲突,就会导致跳转异常和错乱,所以这里就需要给每个路由加上一个所属bundle标识。

5、多bundle的debug

各种操作拆完包后,突然有个问题,怎么调试呢?起初还想着怎么让Native在初始化时直接加载全部bundle。但后来突然想明白,拆包的本质就是通过设置多个入口文件将代码给分割,那调试的时候我们直接将入口文件都在放在index.js里不就行了么。这样就实现了跟RN单包一样的调试。这个操作需要再js端提供一个引用所有模块入口的文件,然后Native端设置debug标识来做bundle加载区分。

多bundle的情况下还尝试过区分端口来独立启动和调试不同模块,暂时不调试的模块就加载本地一个提前打包好的bundle。但是实践过程发现当开启 Remote JS Debug 的时候,所有的bridge都会重新调用reload,那么这会导致什么问题吗?

这里要说下Remote JS Debug的原理和command + Rcommand + D + Reload 的区别。

这是command + R 的源代码

#if RCT_DEV
  RCTExecuteOnMainQueue(^{
    RCTRegisterReloadCommandListener(self);
  });
  #endif

void RCTRegisterReloadCommandListener(id<RCTReloadListener> listener)
{
  RCTAssertMainQueue(); // because registerKeyCommandWithInput: must be called on the main thread
  static dispatch_once_t onceToken;
  dispatch_once(&onceToken, ^{
    listeners = [NSHashTable weakObjectsHashTable];
    [[RCTKeyCommands sharedInstance] registerKeyCommandWithInput:@"r"
                                                   modifierFlags:UIKeyModifierCommand
                                                          action:
     ^(__unused UIKeyCommand *command) {
       RCTTriggerReloadCommandListeners();
     }];
  });
  [listeners addObject:listener];
}

void RCTTriggerReloadCommandListeners(void)
{
  RCTAssertMainQueue();
  // Copy to protect against mutation-during-enumeration.
  // If listeners hasn't been initialized yet we get nil, which works just fine.
  NSArray<id<RCTReloadListener>> *copiedListeners = [listeners allObjects];
  for (id<RCTReloadListener> l in copiedListeners) {
    [l didReceiveReloadCommand];
  }
}

开发环境会监听command + R键盘事件,一旦监听到指令就会遍历所有注册过得bridge,并执行其didReceiveReloadCommand方法,最后调用reload方法。所以如果当前初始化了多个bridge,就会将注册的bridge全都reload一遍,即使加载的是离线包的bridge,也会触发一个8081端口的bridge,由于此时可能没有开启8081端口服务,那么屏幕就会爆红。

所以在多bridge方案中,如果要方便调试,要么在底层做改造,要么区分开发和正式场景,在开发场景使用单bridge方案。但这又造成了开发和正式环境的不一致问题,可能会出现开发环境正常,正式环境报错的问题,很难定位。

参考文章