RN也有微前端框架了? Xtransfer的RN优化实践(一)多bundle架构

Xtransfer在RN上的优化与实践(一) 多bundle架构

前言

9月初,Xtransfer正式开源了基于React Native0.72版本的XRN框架(www.infoq.cn/article/cXC...),开源地址为(github.com/xtransferor...)。这一在Xtransfer内部两年来经过多个项目沉淀、兼容三端(安卓、iOS和纯血鸿蒙)的框架,帮助前端团队节省了70%的人力,也开创性的实现了类似qiankun等Web微前端框架的多Bundle架构,使得多个团队可以用单独仓库单独Bundle的模式并行开发、独立发布,极大提升了业务需求的研发效率。本篇文章会重点阐述XRN是如何实现多bundle架构、过程中遇到过哪些技术难点、未来会继续如何优化。

背景

随着Xtransfer国际业务的扩展及用户对APP体验要求的提升,传统的React Native单Bundle架构已难以满足性能与开发效率的需求。在该模式下,所有业务线共享同一代码仓库进行开发,这不仅导致频繁的代码发布冲突,还使得代码变更范围难以控制,影响了生产环境的稳定性。此外,由于所有功能代码被打包在一起,即使用户仅使用APP的部分功能,也需加载全部代码,从而显著增加了首次启动时间,降低了用户体验。因此,亟需对现有的单Bundle架构进行优化。

探索

在开发XRN之前,我们对携程的CRN、美团的MRN以及Shopee的RN方案进行了深入调研。在综合对比几家方案后。最终我们选择了多Bundle多引擎架构,这一选择基于以下几大优势:

多Bundle多引擎架构图

  • 运行时隔离:每个Bundle运行于独立线程中,确保了环境变量不会互相污染,同时限制了代码变更的影响范围。

  • 高效调试:仅需加载修改过的Bundle代码进行调试,无需重新加载整个应用程序,显著提升了开发效率。

  • 灵活构建:支持独立构建各个Bundle,并且整体构建时间不受Bundle数量影响,有助于控制应用的整体构建时长。

  • 独立发布:各项目可独立部署、灰度测试及回滚,有效缓解了发布过程中的阻塞问题。

然而还有一些坑是这些方案里没有提及的、需要靠自己摸索解决,其中最主要就是社区生态的兼容性,比如 code-push,code-psuh 是微软基于 RN 开源的一款热更新框架。因为多 Bundle 场景下要求不同 Bundle 的运行时对象要相互独立,但是 code-push 的下载后的存储路径以及内部的一些静态变量在 APP 全局单例的,所以在多 Bundle 场景使用 code-push 作为热更新方案需要对其进行二开改造。除了 code-push 外还有Sentry、React Navigation、 甚至一些付费的 SDK都需要二开。

不过不用担心这些坑是如何解决的,后续都会在我的文章里体现。如果你现在已经遇到类似的问题,欢迎私聊交流。

落地

XRN 的核心目标是将大型应用按业务场景拆分为更小、可独立运行的模块,从而提升开发效率与性能。在单 Bundle 升级为多 Bundle 过程主要分为以下三个步骤项目拆分开发调试、拆包。

项目拆分

在多 Bundle 架构里每个 Bundle 都是独立运行、独立开发的。所以首先要将各个业务线的 Bundle 代码、native 代码互相拆分为独立仓库。Bundle 代码与 Native 代码的关系就像 html 与浏览器的关系一样。只要保证 html 里使用的 api 浏览器是支持的,仓库代码是否在一起不影响使用。

  • Bundle 代码拆分: 按照业务线来拆分,拆分完成后需要单独注册入口。
javascript 复制代码
import {AppRegistry} from 'react-native';
import App from './App';

AppRegistry.registerComponent('xrngo-multi-bundle-1', () => App);
  • Native 代码拆分: 由于 RN 是通过 autolink 机制(RN 版本大于 0.60)自动将 native module 中的原生代码导入到原生仓库的。所以原生项目拆分时需要保留 package.json 中包含 native 代码的依赖并且要与各 Bundle 内依赖版本保持一致。比较简单的做法是可以在原生工程根目录建立一个空的 RN 项目。
开发调试

XRN 设计时充分考虑到了多数使用 React Native (RN) 技术栈开发应用的开发者主要具备前端背景这一特点。因此,XRN 提供了一键配置 native 开发环境及安装必要包的功能,无需前端开发者手动修改或编译原生代码。这样既简化了开发流程,也降低了技术门槛。

  • 调试包管理 :确保 RN 应用调试时使用的宿主环境(APP)与 Bundle 内依赖的能力版本匹配。例如,若客户端运行的 RN 版本为 0.60 而 Bundle 依赖的是 0.70,则 APP 无法加载 Bundle 代码。因此,开发者需下载与 Bundle 分支代码相匹配的 APP 版本。下面是 XRN 里如何保证业务代码与 APP 容器能力方案:

  • 多 Bundle 调试 :通过自定义 DevInternalSettings 支持同时调试多个 Bundle 项目。在 xrn.config.json 中配置每个 Bundle 的运行端口,服务启动时各 Bundle 将根据配置连接至对应的服务。此外,XRN 在调试面板内提供了扫码连接及 Bundle 调试开关以方便控制调试过程。

javascript 复制代码
 "bundles": [
            {
                "name": "xt-app-user",
                "codePushKey": "WDWheBTkJ1ChmolWqcZfRUUrRYCQ4ksvOXqog",
                "port": 8082
            },
            {
                "name": "xt-app-settlement",
                "codePushKey": "cnHUJbYLqtPBJ5oocXVj3JxtAqTQ4ksvOXqog",
                "port": 8086
            },
            ....
        ]
拆分common包

RN 框架本质上是通过 js 代码告诉 native 组件该如何渲染 UI。所以避免不了对 js 代码的加载与解析,在 APP 上我们发现随着 Bundle 体积的增大,首次打开 APP 的白屏时间会越长。通过分析 RN 源码发现,RN 容器在启动过程中分为以下几个阶段

  1. 创建 ReactInstanceManager(Android)/ RCTBridge(iOS)

  2. 加载 Bundle 并执行 index.js

  3. 渲染根组件

可以看到整个流程主要卡点就是在 run.js 这一阶段。因为这个阶段需要创建 js 引擎并且对整个 js 文件进行解析,所以这个阶段会随着体积的增大、耗时也会越来越大。通过以上分析很显然如果能将一个包拆分为几个片段并按需加载或者预加载,能大大减少白屏时间。

基本思路:

先把各个 Bundle 共有的依赖分离出来当作一个 common 包,在加载 Bundle 之前提前把 common 包加载完成,这样就能减少加载 Bundle 的时间。具体方案如下:

实现方案:
  • common 包基线生成:在 APP 打包时会把约定 common 包依赖配置在 Native 代码里,在 APP 打包时会生成 meta.json 文件
javascript 复制代码
{
  "node_modules/pretty-format/node_modules/react-is/cjs/react-is.development.js": {
    id: 1,
    version: "3.2.1"
  },
  "node_modules/react-native/index.js": {
    id: 2,
    version: "0.70.12"
  },
  // ...
}

meta.json 文件生成的伪代码

javascript 复制代码
/**
 * Metro configuration for React Native
 * 用于构建 common Bundle 的 metro 配置
 */

const { findVersion } = require("./utils");
const fileToIdMap = new Map();
const fs = require("fs");

module.exports = {
  serializer: {
    createModuleIdFactory: () => {
      let nextId = 0;
      return (path) => {
        let id = fileToIdMap.get(path);
        if (typeof id !== "number") {
          id = nextId++;
          fileToIdMap.set(path, id);
        }
        return id;
      };
    },
    processModuleFilter: (() => {
      let timeId = null;
      return (module) => {
        timeId && clearTimeout(timeId);
        timeId = setTimeout(() => {
          const platform = process.env.PLATFORM;
          const basePath = process.env.BASE_PATH;
          fs.writeFileSync(`${platform}.meta.json`, JSON.stringify([...fileToIdMap].reduce((acc, [key, value]) => {
            const version = findVersion(key)
            key = key.replace(/^\/private/, '').replace(basePath + '/', '');
            if (key === 'index.js') {
              return acc;
            }
            acc[key] = {
              id: value,
              version: version || '0.0.0'
            };
            return acc;
          }, {})));
        }, 2000);
        return true;
      };
    })(),
  },
  transformer: {
    getTransformOptions: () => ({
      transform: {
        experimentalImportSupport: false,
        inlineRequires: true,
      },
    }),
  },
};
javascript 复制代码
const module = 'node_modules/react-native/Libraries/vendor/emitter/EventEmitter.js'
const singerModule = 'node_modules/pretty-format/node_modules/react-is/cjs/react-is.development.js'

function isDirectorySync(path) {
  try {
    return fs.statSync(path).isDirectory();
  } catch (error) {
    console.error(`Error checking path ${path}:`, error);
    return false;
  }
}

function parsePathToArray(filePath) {
  const parts = filePath.split(path.sep);
  const result = [];
  let currentPath = '';

  for (const part of parts) {
    currentPath = currentPath ? path.join(currentPath, part) : part;
    result.push(currentPath);
  }

  return result;
}

function findVersion(module: string) {
  const keys = parsePathToArray(module).reverse()
  const filepaths = []
  for (const k of keys) {
    if (/node_modules$/.test(keys[k])) {
      break
    } else {
      filepaths.push(keys[k])
    }
  }
  const packagePath = filepaths.find(key => {
    if (isDirectorySync(key)) {
      const filepath = path.join(key, 'package.json')
      if (fs.access(filepath)) {
        return true
      }
    }
    return null
  })
  return packagePath && JSON.parse(packagePath).version
}

findVersion('node_modules/pretty-format/node_modules/react-is/cjs/react-is.development.js')
// react-is/package.json -> version: 3.2.1
  • 排除 common 包内容: metro.config 可以通过processModuleFilter 匹配与 meta.json 的文件内的路径和版本一致的三方包,同时在createModuleIdFactory 中返回 common 包里已经生成好的 id。关键实现如下:
javascript 复制代码
/**
 * 创建模块过滤器
 */
function createModuleFilter(
  basePath: string,
  moduleHashLookup: Record<string, Record<string, number>>,
  metaConfig: MetaConfig,
  localPackages: LocalPackage[],
  dependencyKeys: string[],
  hashCache: Map<string, string>
) {
  return (module: { path: string }) => {
    let modulePath = module.path.replace(basePath + "/", "");
    if (basePath.endsWith("/example")) {
      modulePath = modulePath.replace(
        basePath.replace("/example", "") + "/",
        ""
      );
    }

    const result = processModuleCommon(
      modulePath,
      module.path,
      basePath,
      moduleHashLookup,
      metaConfig,
      localPackages,
      hashCache
    );

    // 如果找到了common模块,过滤掉
    if (result.commonModule) {
      return false;
    }

    // 新逻辑:检查本地包的hash,用于处理版本相同但内容变化的情况
    if (result.useHashVerify && result.hashToIdMap && result.fileHash) {
      if (result.hashToIdMap[result.fileHash]) {
        return false; // 已存在相同hash的模块,过滤掉
      }
    }

    // 检查版本一致性,只检测原生包
    const packageName = modulePath.split(PATH_SEPARATOR)[0];
    if (
      dependencyKeys.some((key) => key.split(PATH_SEPARATOR)[0] === packageName)
    ) {
      logger.error(
        `版本号不一致 ${modulePath} 基线版本: ${result.commonModule?.version} 本地版本: ${result.version}`
      );
    }

    // 过滤特定模块(仅限新版本应用)
    if (
      [
        "__prelude__",
        "node_modules/metro-runtime/src/polyfills/require.js",
      ].includes(modulePath) &&
      !metaConfig.useOldApp
    ) {
      return false;
    }

    return true;
  };
}
  • common 包与 biz 包融合: biz 包加载在做之前我们也调研了市面上的方案,基本所有的方案都是通过监听 common 包的加载完成时机来实现的,这种方案的缺点是对原有的流程破坏比较严重,并且把 Bundle 加载的逻辑分散在各个文件中不利于管理。
javascript 复制代码
    private val reactInstanceEventListener = ReactInstanceEventListener {
        val moduleName = getBizModuleName()
        val react = getReactDelegate()
        //react.reactRootView 为 null 时,loadApp(第一次场景)
        if (react.reactRootView == null) {
            loadApp(moduleName)
        } else {
            //特殊情况下,会报 Assertions.assertCondition(this.mReactInstanceManager == null, "This root view has already been attached to a catalyst instance manager");
            //场景:快速点击,启动两个一样的bundle,调用 startReactApplication 导致 两个 ReactRootView 中 mReactInstanceManager 都不为 null,
            // 第一个Activity 不会绘制页面(即不会调用attachRootViewToInstance),ReactInstanceManager#mAttachedReactRoots 只有第二个页面的 ReactRootView,
            // codepush强更的时候,只会调用第二个页面的 ReactRootView#unmountReactApplication,所以重新创建 ReactContext 的时候,第一个页面的 startApplication 会报错
            //此处手动调用 unmountReactApplication,解决此问题
            if (react.reactRootView.reactInstanceManager != null) {
                react.reactRootView.unmountReactApplication()
                updateSentryError()
            }
            //Reload时,已经Attach ReactRootView,loadApp 会报错,所以这里需要手动调用startReactApplication
            react.reactRootView.startReactApplication(reactInstanceManager, moduleName, getLaunchOptions())
            react.reactRootView.requestLayout()
        }
    }

我们的想法是把 Bundle 的加载通过自定义 Bundle loader 的方式实现。这样的好处是对 rn 的原有流程不用 改动而且 common 包与 biz 包加载是在一个类里管理的。

javascript 复制代码
package xrn.modules.multibundle.bundle.split

import android.app.Application
import com.facebook.react.bridge.JSBundleLoader
import com.facebook.react.bridge.JSBundleLoaderDelegate


open class SplitBundleLoader(
    val application: Application,
    commonOnly: Boolean,
    var jsBundleFileDelegate: JSBundleFileProvider = DefaultJSBundleFileProvider(),
) : JSBundleLoader(), JSBundleFileProvider, JSBundleLoaderCallback {

    protected var loadCommonOnly = commonOnly

    private var loadBizLock = (Any() as Object)
    private var mBundleName: String = ""

    private val mLoaderCallbacks by lazy { mutableSetOf<JSBundleLoaderCallback>() }

    override fun getCommonJSBundleFile(): String {
        return jsBundleFileDelegate.getCommonJSBundleFile()
    }

    override fun getBizJSBundleFile(bundleName: String): String {
        return jsBundleFileDelegate.getBizJSBundleFile(bundleName)
    }

    override fun loadScript(delegate: JSBundleLoaderDelegate): String {
        beforeLoadCommonBundle()
        loadCommonBundle(delegate)
        afterLoadCommonBundle()
        if (loadCommonOnly) {
            synchronized(loadBizLock) {
                try {
                    loadBizLock.wait()
                } catch (e: InterruptedException) {
                    // TODO
                }
            }
        }
        beforeLoadBizBundle(mBundleName)
        val sourceUrl = loadBizBundle(delegate)
        afterLoadBizBundle(mBundleName)
        return sourceUrl
    }

    fun addLoaderCallback(callback: JSBundleLoaderCallback) {
        if (!mLoaderCallbacks.contains(callback)) {
            mLoaderCallbacks.add(callback)
        }
    }

    override fun beforeLoadCommonBundle() {
        mLoaderCallbacks.forEach { it.beforeLoadCommonBundle() }
    }

    override fun afterLoadCommonBundle() {
        mLoaderCallbacks.forEach { it.afterLoadCommonBundle() }
    }

    override fun beforeLoadBizBundle(bundleName: String) {
        mLoaderCallbacks.forEach { it.beforeLoadBizBundle(bundleName) }
    }

    override fun afterLoadBizBundle(bundleName: String) {
        mLoaderCallbacks.forEach { it.afterLoadBizBundle(bundleName) }
    }

    protected open fun loadCommonBundle(delegate: JSBundleLoaderDelegate): String {
        return loadScript(delegate, getCommonJSBundleFile())
    }

    protected open fun loadBizBundle(delegate: JSBundleLoaderDelegate): String {
        return loadScript(delegate, getBizJSBundleFile(mBundleName))
    }

    protected fun loadScript(delegate: JSBundleLoaderDelegate, jsBundleFile: String?): String {
        if (jsBundleFile.isNullOrBlank()) return ""

        if (jsBundleFile.startsWith(ASSETS_PREFIX)) {
            delegate.loadScriptFromAssets(application.assets, jsBundleFile, false)
        } else {
            delegate.loadScriptFromFile(jsBundleFile, jsBundleFile, false)
        }
        return jsBundleFile
    }

    fun setBundleName(bundleName: String) {
        this.mBundleName = bundleName
    }

    fun getBundleName(): String {
        return mBundleName
    }

    fun resumeLoadBizBundleIfNeeded() {
        if (loadCommonOnly) {
            loadCommonOnly = false
            synchronized(loadBizLock) {
                loadBizLock.notifyAll()
            }
        }
    }


    companion object {
        const val ASSETS_PREFIX = "assets://"
    }

}
  • common包预加载: common 包与业务包拆分完成以后,另一个好处就是可以分步加载 Bundle 了。因为 Bundle 加载的主要耗时是 js 引擎的初始化。如果能提前加载好 js 引擎并把 common 包解析完成,也能大大的缩短首屏时间。核心思路:要始终保持 common 包缓存池中有一个空闲的common 包,以便于下次加载时只初始化 bizBundle 即可。需要注意的是要及时根据内存状态、最近使用情况、Bundle 的业务优先级场景清理已经缓存 Bundle 容器。

  • 效果展示

未做预加载和预下载前效果,可以看到打开子bundle 时会先进行热更新下载,然后有一段 loading 加载 bundle。

预加载和预下载效果实现以后:可以看到在前一个 bundle 点击跳转后,下一个 bundle 无需再进行热更新下载和 bundle 加载。子 bundle 首屏时间减少约 90%以上

总结与展望

通过对RN原生框架的改造优化,XRN 已基本解决了多团队开发 app 的效率和体验问题。对于初具规模的业务开发团队,选择 XRN 作为跨平台的解决方案,不仅可以极大地降低开发成本也能获得和 Native 一致的用户体验。

当然 XRN 还有很多能力需要补充,目前我们已经在规划中的几个大的能力:

  • app 多版本发布兼容。

  • 0.70 新特性全面适配。

  • Bundle 间的同屏渲染。

如果你感兴趣可以关注XRN,欢迎一起共建。

下期预告:Xtransfer的RN优化实践(二)增量热更新

参考资料

juejin.cn/post/708558...

cloud.tencent.com/developer/a...

相关推荐
Mintopia2 小时前
Next 全栈之 API 测试:Supertest 与 MSW 双雄记 🥷⚔️
前端·javascript·next.js
泽泽爱旅行2 小时前
awk 语法解析-前端学习
linux·前端
鹏多多2 小时前
纯前端人脸识别利器:face-api.js手把手深入解析教学
前端·javascript·人工智能
无奈何杨3 小时前
CoolGuard增加枚举字段支持,条件编辑优化,展望指标取值不同
前端·后端
掘金安东尼3 小时前
工具过多:如何管理前端工具泛滥?
前端
江城开朗的豌豆3 小时前
从生命周期到useEffect:我的React函数组件进化之旅
前端·javascript·react.js
brzhang3 小时前
当AI接管80%的执行,你“不可替代”的价值,藏在这20%里
前端·后端·架构
江城开朗的豌豆3 小时前
React组件传值:轻松掌握React组件通信秘籍
前端·javascript·react.js
Sailing3 小时前
别再放任用户乱填 IP 了!一套前端 IP 与 CIDR 校验的高效方案
前端·javascript·面试