前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >React Native 拆包原理和实践

React Native 拆包原理和实践

作者头像
网罗开发
发布2021-01-29 16:45:39
4.7K0
发布2021-01-29 16:45:39
举报
文章被收录于专栏:网罗开发

一、拆包关键之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:react-native-multibundler

优势

劣势

不用管理 bridge 的缓存和复用问题

不重启 APP 的情况下想要更新 bundle 需要做更多的配置,比较繁琐,且更新 bundle 并不会清除 bridge 中的旧 bundle,存在少量内存浪费

占用内存更少

由于不同模块都是运行在同一个 bridge 环境中,如果存在相同的全局变量会造成代码污染

  • 多 bridge:携程 CRN

优势

劣势

不同模块之间使用了 bridge 隔离,不用担心全局变量污染的问题

由于 bridge 很占用内存,所以需要手动维护 bridge 的缓存和复用问题,避免APP 内存溢出( CRN 维护了5个上限的 bridge)

不重启 APP 的情况下更新 bundle很方便,只需要重新指定路径加载或者执行 reload

占用内存多

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

1、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 文件,代码为:

代码语言:javascript
复制
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 便于查看源码 )

代码语言:javascript
复制
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 + R command + D + Reload 的区别。

这是 command + R 的源代码

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

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2021-01-12,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 网罗开发 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 一、拆包关键之bridge
    • 1、bridge 原理
      • 2、单 bridge 和多 bridge 的选择
      • 二、基础包和业务包的拆分
      • 三、拆包的后遗症
        • 1、按序加载基础包和业务包
          • 2、热更新改造
            • 3、混合开发的路由方案
              • 4、路由表的调整
                • 5、多 bundle 的 debug
                领券
                问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档