RCTBridge 是对 JavaScriptCore 中 Bridge 的封装,每个 bridge 都是一个独立的js环境。
RN 的启动流程可以简单概括为:
bridge 在 RN 中起到承上启下的作用,在做 RN 拆包的时候是重点考虑的对象。目前RN拆包针对 brdige 有两种主流方案,分别是单 bridge 和多 bridge。
优势 | 劣势 |
---|---|
不用管理 bridge 的缓存和复用问题 | 不重启 APP 的情况下想要更新 bundle 需要做更多的配置,比较繁琐,且更新 bundle 并不会清除 bridge 中的旧 bundle,存在少量内存浪费 |
占用内存更少 | 由于不同模块都是运行在同一个 bridge 环境中,如果存在相同的全局变量会造成代码污染 |
优势 | 劣势 |
---|---|
不同模块之间使用了 bridge 隔离,不用担心全局变量污染的问题 | 由于 bridge 很占用内存,所以需要手动维护 bridge 的缓存和复用问题,避免APP 内存溢出( CRN 维护了5个上限的 bridge) |
不重启 APP 的情况下更新 bundle很方便,只需要重新指定路径加载或者执行 reload | 占用内存多 |
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 端拆包还是比较容易的,这里就不再赘述。
通过 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
将 RN 的 js 业务拆出了公共模块之后,在 bridge 加载 bundle 的时候需要优先加载common 包。这里需要考虑两个问题:
- (void)executeSourceCode:(NSData *)sourceCode sync:(BOOL)sync;
),在 iOS 中我们可以通过 Category 的方式将该方法暴露出来do while
循环阻塞线程,直到 loading 为 false 代码再往下走如果是多 bridge 方案,每个 bridge 都得先加载 common 包,再加载具体业务包,这样会很浪费内存。
如果使用静默升级,那么可以在下载完 bundle 包之后先不做替换或者 reload,而是等到下一次进入 APP 的时候从新的路径加载 bundle,这样做可以使用户进行无感知的更新。
AppRegistry.registerComponent
注册一个根组件,只会存在一个 VC 或activity,所有的路由跳转其实都是在同一个 VC 或 activity 内跳转。如果后期要扩展混合路由,纯RN改造会比较大AppRegistry.registerComponent
单独注册,然后在Native 端利用注册的组件创建的单独的 RootView,并最终创建单独的 VC承载。由于都使用 Native 路由,所以可以很方便的进行 Native 和 RN 路由的统一,管理一套路由表即可。但是如果项目中需要引入其他团队开发的 RN bundle 包,其他团队如果使用的是纯 RN 路由,那么这个时候就不兼容了,所以纯 Native 路由方式不太适合需要引入其他团队开发的 bundle 的场景没开源
),管理起来应该会方便些。拆包之后路由表怎么维护呢?由于拆分成了多个 bundle,路由表散落在了多个bundle 中,不同 bundle 之间如何跳转。如果路由名产生了冲突,就会导致跳转异常和错乱,所以这里就需要给每个路由加上一个所属 bundle 标识。
各种操作拆完包后,突然有个问题,怎么调试呢?起初还想着怎么让 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 的源代码
#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 方案。但这又造成了开发和正式环境的不一致问题,可能会出现开发环境正常,正式环境报错的问题,很难定位。