ReactNative

React Native 拆包原理和实践

2020-10-14  本文已影响0人  peaktan

持续完善中...

一、拆包关键之bridge

1、bridge原理

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

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

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打包流程分为以下几个步骤:

观察一下原生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包。这里需要考虑两个问题:

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

2、热更新改造

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

3、混合开发的路由方案

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方案。但这又造成了开发和正式环境的不一致问题,可能会出现开发环境正常,正式环境报错的问题,很难定位。

参考文章

上一篇 下一篇

猜你喜欢

热点阅读