cover_image

React Native 在「Soul App」的实践 - 拆包与热更新

Soul技术团队
2024年09月30日 05:20

1 背景
动态化是 React Native 的核心特点之一。在使用 React Native 的过程中,动态化是一个不可回避的话题。随着业务开发的快速迭代和新业务线的不断加入,子工程的体积不断增加,导致主工程的体积也随之增大。如果热更新以全量包的形式进行,会大大增加用户更新时的流量消耗,因此业务拆包势在必行。

拆包有以下优点:
1. 减小生成的包体积:降低包传输过程中的异常率,进而提升用户体验。
2. 基础包和业务包分离:使基础包更稳定、业务包更纯粹,提高开发效率。
3. 优化包加载:可以进行基础包预加载,减少页面白屏时间。
通过拆包,可以有效提升应用的性能和用户体验,同时提高开发效率。本文基于 React Native 0.72.10 版本,分享了在 Soul App 中落地使用拆包动态化方案以及遇到的一些问题。

2 包体分析
React Native 使用 Metro 来构建 JavaScript 代码和资源,简单来说,它是一个模块打包器。在制定拆包动态化具体方案之前,我们需要了解其打包流程。要了解 Metro 的打包流程,我们可以从分析构建产物着手,先看看产物的构成。通过分析构建产物,我们可以更好地理解 Metro 的打包机制,从而为拆包动态化方案的制定提供依据。

产物构建

1. 新建验证模块

import { StyleSheet, Text, View, AppRegistry } from "react-native";
class RNDemo extends React.Component {render() { return ( <View style={styles.container}> <Text style={styles.hello}> RNDemo </Text> </View> );}}
const styles = StyleSheet.create({ container: { height: 44, position: 'relative', justifyContent: 'center', alignItems: 'center', }, hello: { fontSize: 20, color: 'blue', },});
AppRegistry.registerComponent("Soul_Rn_Demo", () => RNDemo);
  1. 生成组件包

npx react-native bundle --platform ios --dev false --minify false --entry-file RNDemo.js --bundle-output index.ios.bundle

这里以 iOS 为例, 同时注意这里不要混淆, 方便我们分析

产物分析

查看 index.ios.bundle 文件内容,可以发现包内容还是很规整的,整体上主要由三部分构成:
1. 环境变量 和 require define 方法的预定义(polyfills)
2. 模块代码定义(module define)
3. 执行(require 调用)
下面我们就针对这三块内容来分析一下,它们具体做了什么。

  1. polyfills

polyfills 代码很多(1行-865行),其中主要部分又分成了环境获取、方法定义、模块入口、模块定义。

1.1 环境信息
  • 这部分我们了解下就可以了,也有人把这边单独划分一层:声明层。

var __BUNDLE_START_TIME__=this.nativePerformanceNow?nativePerformanceNow():Date.now(),__DEV__=false,process=this.process||{},__METRO_GLOBAL_PREFIX__='';process.env=process.env||{};process.env.NODE_ENV=process.env.NODE_ENV||"production";
1.2 方法定义
  • 重点关注 __d 和 __r, 它们分别对应模块定义和模块执行的函数, 这两个是关键收口函数。

  global.__r = metroRequire;  global[`${__METRO_GLOBAL_PREFIX__}__d`] = define;  global.__c = clear;  global.__registerSegment = registerSegment;
1.3 模块入口

metroRequire 是执行模块的起点,这里只需要记住方法流程就可以,metroRequire -> guardedLoadModule -> loadModuleImplementation -> factory。也就是最终通过 factory 来加载和执行模块的。

  function metroRequire(moduleId) {    //$FlowFixMe: at this point we know that moduleId is a number    var moduleIdReallyIsNumber = moduleId;    var module = modules[moduleIdReallyIsNumber];    return module && module.isInitialized ? module.publicModule.exports : guardedLoadModule(moduleIdReallyIsNumber, module);  }
function guardedLoadModule(moduleId, module) { if (!inGuard && global.ErrorUtils) { inGuard = true; var returnValue; try { returnValue = loadModuleImplementation(moduleId, module); } catch (e) { // TODO: (moti) T48204692 Type this use of ErrorUtils. global.ErrorUtils.reportFatalError(e); } inGuard = false; return returnValue; } else { return loadModuleImplementation(moduleId, module); } }
function loadModuleImplementation(moduleId, module) { if (!module && moduleDefinersBySegmentID.length > 0) { var _definingSegmentByMod; var segmentId = (_definingSegmentByMod = definingSegmentByModuleID.get(moduleId)) != null ? _definingSegmentByMod : 0; var definer = moduleDefinersBySegmentID[segmentId]; if (definer != null) { definer(moduleId); module = modules[moduleId]; definingSegmentByModuleID.delete(moduleId); } } var nativeRequire = global.nativeRequire; if (!module && nativeRequire) { var _unpackModuleId = unpackModuleId(moduleId), _segmentId = _unpackModuleId.segmentId, localId = _unpackModuleId.localId; nativeRequire(localId, _segmentId); module = modules[moduleId]; } if (!module) { throw unknownModuleError(moduleId); } if (module.hasError) { throw module.error; } // We must optimistically mark module as initialized before running the // factory to keep any require cycles inside the factory from causing an // infinite require loop. module.isInitialized = true; var _module = module, factory = _module.factory, dependencyMap = _module.dependencyMap; try { var moduleObject = module.publicModule; moduleObject.id = moduleId;
// keep args in sync with with defineModuleCode in // metro/src/Resolver/index.js // and metro/src/ModuleGraph/worker.js factory(global, metroRequire, metroImportDefault, metroImportAll, moduleObject, moduleObject.exports, dependencyMap);
// avoid removing factory in DEV mode as it breaks HMR { // $FlowFixMe: This is only sound because we never access `factory` again module.factory = undefined; module.dependencyMap = undefined; } return moduleObject.exports; } catch (e) { module.hasError = true; module.error = e; module.isInitialized = false; module.publicModule.exports = undefined; throw e; } finally {} }

2. 模块定义

  • define 方法负责模块定义,会收拢模块参数,生成 mod 对象,然后存储在全局变量 modules 中。

  function define(factory, moduleId, dependencyMap) {    if (modules[moduleId] != null) {      // prevent repeated calls to `global.nativeRequire` to overwrite modules      // that are already loaded      return;    }    var mod = {      dependencyMap: dependencyMap,      factory: factory,      hasError: false,      importedAll: EMPTY,      importedDefault: EMPTY,      isInitialized: false,      publicModule: {        exports: {}      }    };    modules[moduleId] = mod;  }
  • 找到 RNDemo 定义的位置,看下模块 RNDemo 是怎么定义的:

__d(function (global, _$$_REQUIRE, _$$_IMPORT_DEFAULT, _$$_IMPORT_ALL, module, exports, _dependencyMap) {  var _classCallCheck2 = _$$_REQUIRE(_dependencyMap[0])(_$$_REQUIRE(_dependencyMap[1]));  var _createClass2 = _$$_REQUIRE(_dependencyMap[0])(_$$_REQUIRE(_dependencyMap[2]));  var _possibleConstructorReturn2 = _$$_REQUIRE(_dependencyMap[0])(_$$_REQUIRE(_dependencyMap[3]));  var _getPrototypeOf2 = _$$_REQUIRE(_dependencyMap[0])(_$$_REQUIRE(_dependencyMap[4]));  var _inherits2 = _$$_REQUIRE(_dependencyMap[0])(_$$_REQUIRE(_dependencyMap[5]));  var _reactNative = _$$_REQUIRE(_dependencyMap[6]);  function _callSuper(t, o, e) { return o = (0, _getPrototypeOf2.default)(o), (0, _possibleConstructorReturn2.default)(t, _isNativeReflectConstruct() ? Reflect.construct(o, e || [], (0, _getPrototypeOf2.default)(t).constructor) : o.apply(t, e)); }  function _isNativeReflectConstruct() { try { var t = !Boolean.prototype.valueOf.call(Reflect.construct(Boolean, [], function () {})); } catch (t) {} return (_isNativeReflectConstruct = function _isNativeReflectConstruct() { return !!t; })(); }  var BU1 = /*#__PURE__*/function (_React$Component) {    function BU1() {      (0, _classCallCheck2.default)(this, BU1);      return _callSuper(this, BU1, arguments);    }    (0, _inherits2.default)(BU1, _React$Component);    return (0, _createClass2.default)(BU1, [{      key: "render",      value: function render() {        return /*#__PURE__*/(0, _$$_REQUIRE(_dependencyMap[7]).jsx)(_reactNative.View, {          style: styles.container,          children: /*#__PURE__*/(0, _$$_REQUIRE(_dependencyMap[7]).jsx)(_reactNative.Text, {            style: styles.hello,            children: " RNDemo "          })        });      }    }]);  }(React.Component);  var styles = _reactNative.StyleSheet.create({    container: {      height: 44,      position: 'relative',      justifyContent: 'center',      alignItems: 'center'    },    hello: {      fontSize: 20,      color: 'blue'    }  });  _reactNative.AppRegistry.registerComponent("Soul_Rn_Demo", function () {    return BU1;  });},0,[1,2,3,7,9,10,12,194]);

可以看到,就是调用 polyfills 里面的 define 方法,把参数传递进去。第一个参数就是 metroRequire 最终需要使用的 factory 方法。0 代表 MoudleIdMoudleId 后面会详细说明),后面的数组是当前模块的依赖项。总结起来,就是将整体模块生成一个模块数据对象,然后放在全局变量 modules 里面。

现在只是构建了对象数据结构,那么什么时候使用呢?

3. 模块执行

这一步其实就简单了, 在文件最后。可以看到执行了初始化了两个 module 。45 是系统核心模块,0 就是对应我们的 RNDemo 模块。__r 就会执行 metroRequire, 最终执行上面 function 方法。

__r(45);__r(0);

MoudleId

生成的 bundle 文件里面其实就是通过 MoudleId 来关联模块。如果我们知道模块唯一标识 MoudleId 生成规则,然后把不同页面的放在不同包里面,也通过 MoudleId 来关联模块,就可以进行分包动态化了。那么 MoudleId 是怎么生成的呢?我们可以看看运行打包命令后,引擎内部做了什么?

构建流程

了解整个 Metro 打包流程是相对复杂的,考虑我们其实最关心的是 MoudleId 生成规则,这里只简单介绍下打包流程。

  1. CLI 入口

运行命令 react-native 的时候,首先触发的是 react-native-community

var cli = require('@react-native-community/cli');
if (require.main === module) { cli.run();}
  1. bundle 命令

CLI 启动后,会加载内置的命令,这里我们只需要关注 bundle 打包命令。整体调用流程: run -> setupAndRun -> require("./commands") -> bundle

3. 加载 buildBundle

bundle 命令最终会加载 buildBundle 方法, 其中核心是 buildBundleWithConfig 。整体上,它主要做了以下几件事:

  • 合并 Metro 默认配置和自定义配置。

  • 解析配置,构建 requestOpts 对象, 作为打包函数入参。

  • 实例化 Metro Server(React Native 的打包工具和开发服务器)。

  • 启动 Metro Server 构建 bundle。

  • 处理资源文件。

  • 关闭 Metro Server。

最终,我们知道实际打包是通过 Metro Server 去实现的。

4. Metro Server

Metro Server 内部是构建的核心,东西很多。主要分为三步:解析、转化和生成。这三步东西其实很多,这里不重点介绍,感兴趣的同学可以查看这里:

https://metrobundler.dev/docs/concepts/

5. MoudleId 生成流程
MoudleId 的生成流程就是在上面加载 buildBundle 的过程中,下面省略了非关键的代码。列举了 MoudleId 生成流程。

async function buildBundle(args, ctx, output = outputBundle) {  const config = await (0, _loadMetroConfig.default)(ctx, {    maxWorkers: args.maxWorkers,    resetCache: args.resetCache,    config: args.config  });  return buildBundleWithConfig(args, config, output);}
----------------------------------------------------------------------------------------async function loadMetroConfig(ctx, options = {}) { const overrideConfig = getOverrideConfig(ctx); if (options.reporter) { overrideConfig.reporter = options.reporter; } const cwd = ctx.root; /** **/ /** 特别注意这边 **/ return (0, _metroConfig().mergeConfig)(await (0, _metroConfig().loadConfig)({ cwd, ...options }), overrideConfig);}
----------------------------------------------------------------------------------------// 注意 argvInput 里面包含了,命令的参数。async function loadConfig(argvInput = {}, defaultConfigOverrides = {}) { const argv = { ...argvInput, config: overrideArgument(argvInput.config), };
const configuration = await loadMetroConfigFromDisk( argv.config, argv.cwd, defaultConfigOverrides );
/** **//
// Set the watchfolders to include the projectRoot, as Metro assumes that is // the case // $FlowFixMe[incompatible-variance] // $FlowFixMe[incompatible-indexer] // $FlowFixMe[incompatible-call] return mergeConfig(configWithArgs, overriddenConfig);}
----------------------------------------------------------------------------------------async function loadMetroConfigFromDisk(path, cwd, defaultConfigOverrides) { // 这里的 path 就是打包传递的配置参数,如果传递了,则直接加载该文件;否则遍历下面4个文件,加载。 // "metro.config.js", // "metro.config.cjs", // "metro.config.json", // "package.json", const resolvedConfigResults = await resolveConfig(path, cwd); const { config: configModule, filepath } = resolvedConfigResults; const rootPath = dirname(filepath); const defaults = await getDefaultConfig(rootPath);
/** **/ /** 加载完成和默认配置合并 **/ // $FlowFixMe[incompatible-variance] // $FlowFixMe[incompatible-call] return mergeConfig(defaultConfig, configModule);}
----------------------------------------------------------------------------------------function getDefaultConfig( projectRoot /*: string */) /*: ConfigT */ { const config = { /** **/ };
/** 这里两个地方的默认配置 **/ return mergeConfig( getBaseConfig.getDefaultValues(projectRoot), config, );}
----------------------------------------------------------------------------------------const getDefaultValues = (projectRoot) => ({ /** **/ serializer: { polyfillModuleNames: [], getRunModuleStatement: (moduleId) => `__r(${JSON.stringify(moduleId)});`, getPolyfills: () => [], getModulesRunBeforeMainModule: () => [], processModuleFilter: (module) => true, createModuleIdFactory: defaultCreateModuleIdFactory, experimentalSerializerHook: () => {}, customSerializer: null, isThirdPartyModule: (module) => /(?:^|[/\\])node_modules[/\\]/.test(module.path), }, /** **/});

6. 两个核心方法
最终我们看到了和 ModuleId 相关的方法 createModuleIdFactory, 这个其实就是一个自增方法,从 0 开始。

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,那么就可以进行拆包了。

同时,我们关注到了 processModuleFilter 方法,它和 createModuleIdFactory 一样,可以通过配置文件重写。当该方法返回 false 时,代表不打入包,对应下面的 filter 方法。否则,就打入包。我们正是通过这个方法来控制如何分包。

function processModules(  modules,  {    filter = () => true,    createModuleId,    dev,    includeAsyncPaths,    projectRoot,    serverRoot,    sourceUrl,  }) {  return [...modules]    .filter(isJsModule)    .filter(filter)    .map((module) => [      module,      wrapModule(module, {        createModuleId,        dev,        includeAsyncPaths,        projectRoot,        serverRoot,        sourceUrl,      }),    ]);}

3 拆包实现

参考业内做法,我们重新实现了 createModuleIdFactory 方法,也是从 0 开始计算,通过路径为 Key,递增生成对应 MoudleId

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);            writeBuildInfo(          "./soulCommonInfo.json", path, fileToIdMap.get(path) ); }
return id; };}
module.exports = { serializer: { createModuleIdFactory: createModuleIdFactory, },};

但是这样有个弊端:一旦调整了库引入位置或者增加了新引用,由于引入顺序变化,会导致上面 Path 参数的顺序变化,最终导致 ModuleID 发生变化。一旦用户使用的是新基础包,原先的页面就会出现找不到方法的情况。

那么如何在这两种情况下兼容老包呢?

其实也很简单,我们每次生成基础包时不要重新生成,而是在之前的基础上生成。这样历史 ModuleID 从缓存读取,新 ModuleID 在之前的基础上重新生成。脚本修改如下:

const fs = require('fs');
function createModuleIdFactory() { const fileToIdMap = new Map(); let nextId = 0;
  const file = "./soulCommonInfo.json"; const stats = fs.statSync(file); if (stats.size === 0) { clean(file); } else { const cache = require(file); if (cache && cache instanceof Object) { for (const [path, id] of Object.entries(cache)) { nextId = Math.max(nextId, id+1); fileToIdMap.set(path, id); } } }
return (path) => { let id = fileToIdMap.get(path);
if (typeof id !== "number") { id = nextId++; fileToIdMap.set(path, id); if (!hasBuildInfo(file, path)) { writeBuildInfo( file, path, fileToIdMap.get(path)        );      } }
return id; };}
module.exports = { serializer: { createModuleIdFactory: createModuleIdFactory, },};

4 热更新

完成拆包后,接下来的问题是如何将生成好的基础包、业务包与热更新流程配合使用。

1. 包加载顺序和时机

首先,我们梳理一下包之间的特点和关系:

  • 基础包是整个 React Native 容器运行的基础,所有业务包都依赖基础包。

  • 一个引擎,基础包只加载一次。一个页面,业务包也只加载一次。

  • 基础包内容较多,基础包包体(1M+)会比业务包(几十 KB)大很多。

  • 正常打开一个 React Native 页面,需要保证基础包和对应业务包都加载完成。

基于上述特点,应用启动后,我们可以先预加载基础包,然后在打开对应页面时加载对应的业务包。这样既保证了页面打开速度,又避免了一次加载过多业务包的问题。

2. 分包加载流程

确定了包加载顺序和时机后,还有以下问题需要解决:

  • 以 iOS 为例,引擎只提供了一个 sourceURLForBridge 回调方法来设置包体路径。设置完基础包后,如何再加载业务包?

  • 如何确定基础包或者业务包已经加载完成?

  • 打开页面时,如果基础包没有加载完成,如何处理?

基于上述问题,我们逐步分析:

2.1 基础包加载:基础包通过 sourceURLForBridge 回调方法来设置。至于业务包的加载,以 iOS 为例,可以使用 RCTCxxBridgeexecuteSourceCode 方法在当前的 RN 实例上下文中执行一段 JS 代码,以此达到增量加载的目的。需要注意的是,executeSourceCodeRCTCxxBridge 的私有方法,需要我们用 Category 将其暴露出来。

2.2 判断加载完成:判断基础包加载完成,可以通过 RCTJavaScriptDidLoadNotification。对于业务包加载是否完成,仅判断 sourceURLForBridge 是否执行完成是不够的,可能包已经加入,但由于某些原因显示失败。这种情况虽然可以在 JS 侧使用错误边界等方式捕获,但实践中有些情况 JS 侧捕获不到。我们需要一种机制保证容器准确知道是否加载异常。这里我们通过在 JS 侧注册完成页面后,由 JS 侧主动通知原生侧,以感知注册结果。

2.3 处理未加载完成的情况打开页面时,判断基础包是否加载完成。如果基础包加载完成,直接打开页面;否则,我们会先缓存打开页面的信息。如果此时基础包还没有开始加载,主动唤起加载。如果已经在加载,就等基础包加载完成后再读取缓存打开页面。

图片

3. 热更新流程

确定包加载后,我们开始整体热更新流程的建设:

  • 基础包内置:考虑到基础包改动频率很低,基础包可以内置在 App 中,这样提高了加载成功率,同时也方便预加载。

  • 业务包兜底:内置一份业务包作为兜底,当首次打开页面或出现异常时,加载兜底业务包。

  • 业务包更新:打开页面时,我们会校验是否需要更新业务包。考虑到包体大小,业务包是经过压缩的。同时,我们兼顾了安全性:业务包需要签发后才能分发,本地会对包体进行校验。

  • 平台支撑:我们开发了自己的构建平台和灰度平台。构建平台负责构建和 CDN 部署,灰度平台负责下发,端侧从灰度平台拉取更新数据,整体形成自动化闭环。


图片

基于上述设计,目前 Soul App 已经全面拥抱 React Native,同时在内部其他 App 中使用。


5 后续规划

动态化只是 React Native 的一小步,但它是基础建设中重要的一环。后续我们将结合 Soul App 的实际情况,在 React Native 的稳定性、性能等方面进行进一步的深入探究。

以上即为本次分享的内容。

感谢您的阅读,如果喜欢欢迎多多关注/留言/点赞。



继续滑动看下一个
Soul技术团队
向上滑动看下一个