1. 概述
本文描述了一种基于Metro工具的构建差分包的方法,同时实现了在App中差分包的异步加载。通过实验,对比同步的加载方式,异步加载方式会减少 20% ~ 25%(20 ~ 200 ms)的页面加载时间消耗。
项目代码:https://github.com/MarcusMa/react-native-async-load-bundle
2. 相关背景
2.1 React Native 构建Bundle文件的目的
使用 ReactNative 开发的业务,无论是通过静态内置还是动态下发的方式发布,都需要将业务 JavaScript 代码打包成 Bundle文件。构建Bundle文件的主要有以下几个目的:
- RN代码使用 JSX 语法描述 UI 视图,然而标准的 JS 引擎显然不支持 JSX,所以需要将 JSX 语法转换成标准的 JS 语法;
- RN代码同时使用的 ES 6语言标准,目前 iOS、Android 上的 JS 引擎还不支持 ES 6,因此需要转换;
- JS 业务代码会依赖多个不同的模块(JS 文件),RN 在打包时将所有依赖的模块打包到一个 Bundle 文件中,较好地解决了这种复杂的依赖关系;
- JS 代码的混淆。
2.2 Bundle文件结构及内容说明
React Native打包形成的Bundle文件的内容从上到下依次是:
- Polyfills:定义基本的JS环境(如:
__d()
函数、__r()
函数、__DEV__
变量等) - Module定义:使用
__d()
函数定义所有用到的模块,该函数为每个模块赋予了一个模块ID,模块之间的依赖关系都是通过这个ID进行关联的。 - Require调用:使用
__r()
函数引用根模块。
业务不同的两个Bundle文件,会在Polyfills部分及Module定义部分有大量重复,因为每个业务的JS文件中必定是与需要引用react及react-native两个模块的,该重复部分大约500K左右。
2.2.1 define()函数
__d()
函数实际是define()
函数,他的三个参数分别为:factory方法、module ID以及dependencyMap。
function define(factory, moduleId, dependencyMap) {
if (moduleId in modules) {
// that are already loaded
return;
}
modules[moduleId] = { dependencyMap};
// other code ....
};
特别注意,它用
modules
变量对传入的模块进行了缓存控制。
2.2.2 require()函数
__r()
函数实际是require()
,这个方法首先判断所要加载的模块是否已经存在并初始化完成。若是,则直接返回模块的exports
,否则调用guardedLoadModule
等方法对模块进行初始化。
function require(moduleId) {
const module = modules[moduleId];
return module && module.isInitialized
? module.exports
: guardedLoadModule(moduleIdReallyIsNumber, module);
}
function guardedLoadModule(moduleId, module) {
return loadModuleImplementation(moduleId, module);
}
function loadModuleImplementation(moduleId, module) {
module.isInitialized = true;
const exports = (module.exports = {});
var _module = module;
const factory = _module.factory,
dependencyMap = _module.dependencyMap;
const moduleObject = { exports };
factory(global, require, moduleObject, exports, dependencyMap);
return (module.exports = moduleObject.exports);
}
特别注意它是使用
module.isInitialized
控制模块的初始化。
2.3 Metro 工具
随着React Native 版本迭代,官方已经逐步将bundle文件生成流程规范化,并为此设计了独立的打包模块 – Metro。Metro 通过输入一个需要打包的JS文件及几个配置参数,返回一个包含了所有依赖内容的JS文件。
Metro将打包的过程分为了3个依次执行的阶段:
- 解析(Resolution):计算得到所有的依赖模块,形成依赖树,该过程是多线程并行执行。
- 转义(Transformation):将模块内容转义为React Native可识别的格式,该过程是多线程并行执行。
-
序列化(Serialization):将所有的模块合并到一个文件中输出。
Metro工具提供了配置功能,开发人员可以通过配置RN项目中的metro.config.js文件修改bundle文件的生成流程。
3. 基于Metro工具的新拆包方法
拆包主要是将一个RN业务完整Bundle文件(简称Business文件)与提前打包完成的基础文件(简称:Common文件)进行比较,拆分出更小的业务包(简称:Diff文件)。目前比较易用的拆包方式是基于文本内容层面的差分再合并,即用google-diff-match-path
或者BSDiff
算法得到的差分包,这些差分包都是不可以直接运行的,需要经由“还原”的过程才能正常加载使用。此外,携程提供自主研发的、基于JS代码层面的拆包方案moles,但该方案主要针对React Native 0.44版本。
目前不使用基于JS代码层面拆包方案,主要是因为React Native 0.55以前版本是不支持原生拆包,需要对React Native源码进行改造。而Metro工具的提出为拆包提供了新的思路和方法。
新拆包方法主要关注的是Metro工具在“序列化”阶段时调用的 createModuleIdFactory(path)
方法和processModuleFilter(module)
。createModuleIdFactory(path)
是传入的模块绝对路径path
,并为该模块返回一个唯一的Id
。processModuleFilter(module)
则可以实现对模块进行过滤,使其不被写入到最后的bundle文件中。
官方的createModuleIdFactory(path)
方法是返回个数字。(如前所述,该数字在 require
方法中进行被调用,以此来实现模块的导入和初始化)
"use strict";
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;
};
}
官方的实现存在的问题是Id
值从0开始分配,所以任意改动业务代码可能引起模块构建的顺序变动,致使同一个模块在两次构建分配了有2个不同的Id
值。
针对官方实现的问题,我们重新声明一个createModuleIdFactory(path)
方法,该方法使用当前模块文件的路径的哈希值作为分配模块的Id的依据,并建立哈希值与模块Id对应关系的本地存在文件,每次编译Bundle文件前先读取本地关系文件来初始化内部缓存,当需要分配Id
时,先从内部缓存中查找,查找不到则新分配Id
并存储变化。
由上述步骤可以到达同一个模块,无论编译顺序如何,返回的Id
是同一个。关键代码如下:
// 详见 metro.config.base.js
// 省略其他代码
function getFindKey(path) {
let md5 = crypto.createHash("md5");
md5.update(path);
let findKey = md5.digest("hex");
return findKey;
}
// 省略其他代码
buildCreateModuleIdFactoryWithLocalStorage = function(buildConfig) {
// 省略其他代码
moduleIdsJsonObj = getOrCreateModuleIdsJsonObj(moduleIdsMapFilePath);
// 省略其他代码
return () => {
return path => {
let findKey = getFindKey(path);
if (moduleIdsJsonObj[findKey] == null) {
moduleIdsJsonObj[findKey] = {
id: ++currentModuleId,
type: buildConfig.type
};
saveModuleIdsJsonObj(moduleIdsMapFilePath, moduleIdsJsonObj);
}
let id = moduleIdsJsonObj[findKey].id;
return id;
};
};
};
同时,为了能够在processModuleFilter(module)
方法中对模块进行过滤,需要在构建Common文件时,标记某个模块是否已包含在Common文件中。为此,我们在保存模块id对应关系时,额外加上了type
字段,该字段的值来源于构建脚本执行时传入的参数。当构建Common文件时,该值为common
,当构建Diff文件时,该值为diff
。
processModuleFilter(module)
方法实现如下:
// 详见 metro.config.base.js
// 省略其他代码
buildProcessModuleFilter = function(buildConfig) {
return moduleObj => {
let path = moduleObj.path;
if (!fs.existsSync(path)) {
return true;
}
if (buildConfig.type == BUILD_TYPE_DIFF) {
let findKey = getFindKey(path);
let storeObj = moduleIdsJsonObj[findKey];
if (storeObj != null && storeObj.type == BUILD_TYPE_COMMON) {
return false;
}
return true;
}
return true;
};
};
// ...
// 省略其他代码
通过上述步骤构建出的Diff文件中,还保留了Pollyfills部分内容,需要进行删除。删除脚步位于./__async_load_shell__/removePollyfills.js
中,代码如下:
const fs = require('fs');
const readline = require('readline');
let argvs = process.argv.splice(2);
let filePath = argvs[0];
var fRead = fs.createReadStream(filePath);
var objReadline = readline.createInterface({
input: fRead,
});
let diff = new Array();
objReadline.on('line', function(line) {
if (line.startsWith('__d') || line.startsWith('__r')) {
diff.push(line);
}
});
objReadline.on('close', function() {
let data = diff.join('\n');
fs.writeFileSync(filePath, data);
});
使用方法如下:
node ./__async_load_shell__/removePolyfill.js __async_load_output__/diff.ios.bundle
通过以上步骤可以打包出Common文件和Diff文件。项目中的Business文件是基于React Native的模板工程,而Common源文件如下:
// 详见common.js
require('react-native');
require('react');
为了进一步提高使用便捷性,我们在__async_load_shell__
文件夹中定义便捷脚本,同时在package.json
文件中定义快捷指令,具体如下:
//详见package.json文件
{
"scripts": {
"build_android_common_bundle": "./__async_load_shell__/build_android_common_bundle.sh",
"build_ios_common_bundle": "./__async_load_shell__/build_ios_common_bundle.sh",
"build_android_index_bundle": "./__async_load_shell__/build_android_index_bundle.sh",
"build_ios_index_bundle": "./__async_load_shell__/build_ios_index_bundle.sh",
"build_android_index_diff_bundle": "./__async_load_shell__/build_android_index_diff_bundle.sh",
"build_ios_index_diff_bundle": "./__async_load_shell__/build_ios_index_diff_bundle.sh",
"copy_files_to_projects": "./__async_load_shell__/copy_files_to_projects.sh",
// 省略其他代码
},
}
可以使用如下命令快捷进行Bundle文件构建:
npm run build_android_common_bundle
npm run build_android_index_diff_bundle
npm run build_ios_common_bundle
npm run build_ios_index_diff_bundle
3. 异步加载实现
异步加载得利于基于Metro的拆包方法,使得App在进入真正的业务界面前可以先加载Common文件,再加载Diff 文件。
3.1 Android异步加载实现
在Android的实现中,我们构建了一个引导页面AsyncLoadGuideActivity
来初始化RN环境,并且在后台加载Common文件, 这个页面是作为RN容器页面的父页面存在的。 在正式的产品中,这个页面通常使用来展示那些用RN构建的业务的入口。
关于异步加载的代码均放置在com.marcus.rn.async
包中。主要有如下几个实现要点。
我们使用了
ReactNativeHost
对象指定了Common文件的加载路径, 同时通过调用createReactContextInBackground()
来初始化RN环境,并加载 Common文件。为了能够得知Common文件加载结束,我们使用了
ReactInstanceManager
的addReactInstanceEventListener()
方法 添加了自定义监听器,并且监听onReactContextInitialized()
回调。以onReactContextInitialized()
回调触发标志Common文件加载结束;由于原生的
ReactActivityDelegate
类和ReactActivity
类的存在内部变量final
定义限制等问题,我们重新定义了新的类AsyncLoadActivityDelegate
类和AsyncLoadReactActivity
类来适配异步加载的场景;我们构建了单例类
AsyncLoadManager
来统一管理AsyncLoadActivityDelegate
对象创建和分配RN页面加载耗时将通过控制台日志及Toast方式显示, 这个值记录了从启动页面的
onCreate()
到React Native的CONTENT_APPEARED
事件触发为止。由于有全局变量污染的问题,这就要求我们在加载业务前必须进行清理RN运行环境。一种简单的方法是抛出使用过的
AsyncLoadActivityDelegate
对象,保证每次加载业务前的AsyncLoadActivityDelegate
对象都是新创建的并且完成了Common文件的加载, 请参考AsyncLoadManager
类中prepareReactNativeEnv()
方法。
3.2 iOS异步加载实现
- 我们需要暴露
RCTBridge
类中executeSourceCode
方法,这样才能加载自定义的JavaScript代码,新建文件RCTBridge.h
:
// RCTBridge.h
#import <Foundation/Foundation.h>
@interface RCTBridge (RnLoadJS)
- (void)executeSourceCode:(NSData *)sourceCode sync:(BOOL)sync;
@end
- 通过使用
RCTBridgeDelegate
的sourceURLForBridge
方法指定了Common文件位置,并通过调用RCTBridge
的初始化方法[[RCTBridge alloc] initWithDelegate:self launchOptions:launchOptions]
初始化React Native 的运行环境和加载Common文件; - 我们构建了单例类
MMAsyncLoadManager
来统一管理RCTBridge
对象创建和分配; -
RN页面加载耗时将通过控制台日志及Toast方式显示, 这个值记录了从启动页面的
viewDidLoad
到React Native的RCTContentDidAppearNotification
通知触发为止; - 由于有全局变量污染的问题,这就要求我们在加载业务前必须进行清理RN运行环境。一种简单的方法是抛出使用过的
RCTBridge
对象,保证每次加载业务前的RCTBridge
对象都是新创建的并且完成了Common文件的加载, 请参考MMAsyncLoadManager
类中prepareReactNativeEnv()
方法。
4. 实验数据
4.1 Bundle文件比较
Android File | Size | Size After gzip |
---|---|---|
common.android.bundle | 637.0 K | 175K |
index.android.bundle (Original) | 645.0 K | 177K |
diff.android.bundle (Using BSDiff) | 3.9 K | 3.9 K |
diff.android.bundle (Using google-diff-match-patch) | 11.0 K | 3.0 K |
diff.android.bundle (Using Metro) | 8.3 K | 2.5 K |
iOS File | Size | Size After gzip |
---|---|---|
common.ios.bundle | 629.0 K | 173K |
index.ios.bundle (Original) | 637.0 K | 176K |
diff.ios.bundle (Using BSDiff) | 3.9 K | 3.9 K |
diff.ios.bundle (Using google-diff-match-patch) | 11.0 K | 3.0 K |
diff.ios.bundle (Using Metro) | 8.3 K | 2.5 K |
可以在 这里找到
google-diff-match-patch
和BSDiff
的实现代码。
4.2 RN页面加载时间比较
加载方式\设备型号 | Redmi 3 | Huawei P20 | iPhone 6s | iPhone XS MAX |
---|---|---|---|---|
同步加载 | 868.2 ms | 337.8 ms | 405.3 ms | 109.2 ms |
异步加载 | 643.4 ms | 253.2 ms | 300.2 ms | 88.3 ms |
-25.89% | -25.04% | -25.88% | -18.68% |