React Native 鸿蒙化沙箱加载实战:bundle 与图片资源沙箱路径加载全解析

原创2026-10-13 13:36:451,382 阅读
文章标签:移动开发跨平台OpenHarmony

React Native 鸿蒙化沙箱加载实战:bundle 与图片资源沙箱路径加载全解析

本指南以 ohos_react_native 仓库中的 Sandbox 示例工程 为蓝本,系统讲解在 OpenHarmony 上如何把打包好的 JS bundle 和图片资源推入应用沙箱,再通过 FileJSBundleProvider 加载沙箱中的 bundle、在 React Native 侧以 file:// 路径加载沙箱图片的完整方案。读完本文,你将掌握沙箱场景下 bundle 与静态资源的路径合成规则、rawfile 与沙箱两种资源组织方式的差异,以及从 pnpm dev 打包到 DevEco Studio 运行的全套实操步骤。

一、示例工程定位与整体结构

Sandbox 示例位于 docs/Samples/Sandbox,由两部分组成,分别回答两个核心问题:

  • OpenHarmony 侧:如何将 rawfile 中的 bundle 与图片资源"推"到应用沙箱目录,并从沙箱加载 bundle 运行 React Native 应用;
  • React Native 侧:如何写出能够命中沙箱路径的图片引用(file:// URI 与 require 两种写法并存)。

工程目录结构如下(见 README.md):

├── SandBoxNativeProject           # OpenHarmony 原生工程(ArkTS)
│   └── Index.ets                  # 页面入口,含 downloadBundle(推文件到沙箱)
│                                  #   与 FileJSBundleProvider(从沙箱加载 bundle)
└── SandBoxRnProject               # React Native 工程(TS/TSX)
    └── examples.tsx               # RN 侧加载沙箱图片的示例页面

1.1 两个工程各自的职责

  • SandBoxNativeProject 是一个基于 RNAbility 的 OpenHarmony 应用。它把打包进 rawfile 的 bundle 字节流和 assets 目录整体拷贝到 context.filesDir(应用沙箱目录),随后以 new FileJSBundleProvider(...) 构造 bundle 提供器,让 Index.ets 中的 RNApp 从沙箱文件加载运行。
  • SandBoxRnProject 是一个标准 React Native 工程(依赖 react-native@0.86.3 与 @react-native-oh/react-native-harmony@0.86.1),通过 pnpm dev 命令将 bundle 输出到原生工程的 rawfile 目录下,examples.tsx 中同时演示了 require 打包资源与 file:// 沙箱 URI 两种图片加载方式。

二、OpenHarmony 侧:把 bundle 与资源推入沙箱

沙箱加载的第一个关键动作是把资源从 rawfile(随安装包分发、只读)搬运到应用沙箱(可读可写、可动态更新)。示例工程在 Index.ets 中实现了一整套"写入—校验—加载"流程。

2.1 核心:downloadBundle 写入沙箱

downloadBundle() 使用资源管理器的 getRawFileContent 读取 rawfile 中的 bundle 字节,再通过 @ohos.file.fs 的流式 API 写入沙箱:

async downloadBundle() {
  if (this.rnAbility) {
    // 将 bundle 下载并保存到沙箱
    let uint8Array =
      await this.rnAbility.context.resourceManager.getRawFileContent('rawfile/bundle/sandbox.harmony.bundle');
    let rawBuffer = uint8Array.buffer;
    let num = rawBuffer.byteLength;
    // 获取沙箱路径
    const sandboxDir = this.rnAbility.context.filesDir;
    const bundlePath = sandboxDir + '/' + this.bundlePath;
    let stream = fs.createStreamSync(bundlePath, 'w');
    stream.writeSync(rawBuffer);
    stream.closeSync();

    // 将 assets 资源复制到沙箱,C++ 端根据 bundle 路径推导 assets 前缀为 file:///沙箱目录/assets/
    const assetsSandboxDir = sandboxDir + '/' + this.assetsPath;
    await this.copyRawfileDirToSandbox('rawfile/assets', assetsSandboxDir);

    this.hasBundle = true;
  }
}

几个值得注意的细节:

  • bundlePath 与 assetsPath 是相对于 rnAbility.context.filesDir 的相对路径(代码注释中明确说明),最终沙箱 bundle 的完整路径为 filesDir + '/' + 'sandbox.harmony.bundle';
  • fs.createStreamSync(bundlePath, 'w') 以写模式创建文件流,writeSync 写入后必须 closeSync 关闭流,否则数据可能未落盘;
  • 注释点出了沙箱路径推导的关键规则:C++ 端会依据 bundle 所在路径推导图片资源前缀为 file:///<沙箱目录>/assets/,这正是后文第三节路径合成规则在代码中的直接体现。

2.2 辅助:递归拷贝 rawfile 目录到沙箱

bundle 引用的图片不会自动出现在沙箱里,需要把 rawfile 下的 assets 目录整体复制过去。示例用 copyRawfileDirToSandbox 递归实现:

async copyRawfileDirToSandbox(rawfilePath: string, sandboxPath: string) {
  // 确保目标目录存在
  if (!fs.accessSync(sandboxPath)) {
    fs.mkdirSync(sandboxPath, true);
  }
  // 读取 rawfile 目录下的文件列表
  const fileNames = await this.rnAbility!.context.resourceManager.getRawFileList(rawfilePath);
  for (const fileName of fileNames) {
    const srcRawPath = rawfilePath + '/' + fileName;
    const destPath = sandboxPath + '/' + fileName;
    // 判断是文件还是目录:尝试读取内容,如果成功则为文件
    try {
      const content = await this.rnAbility!.context.resourceManager.getRawFileContent(srcRawPath);
      let stream = fs.createStreamSync(destPath, 'w');
      stream.writeSync(content.buffer);
      stream.closeSync();
    } catch (e) {
      // 读取内容失败,可能是目录,递归处理
      await this.copyRawfileDirToSandbox(srcRawPath, destPath);
    }
  }
}

该函数的技巧在于:用"能否读取到文件内容"来区分 rawfile 条目是文件还是子目录——能读到内容就写文件,抛异常则说明是目录,于是递归进入下一层。这样无论 assets 嵌套多深,都能完整地镜像到沙箱。

2.3 启动校验:checkBundleUpdated

为避免每次启动都重复搬运,页面在 aboutToAppear 阶段调用 checkBundleUpdated():先 fs.statSync 检查沙箱中 bundle 文件与 assets 目录是否存在,若 bundle 尺寸为 0 或路径不存在,则触发 downloadBundle() 重新写入;否则直接置 hasBundle = true,进入渲染分支。

checkBundleUpdated(): void {
  if (this.rnAbility) {
    const sandboxDir = this.rnAbility.context.filesDir;
    const bundlePath = sandboxDir + '/' + this.bundlePath;
    const assetsDir = sandboxDir + '/' + this.assetsPath;
    try {
      const bundleStat = fs.statSync(bundlePath);
      fs.statSync(assetsDir); // 检查 assets 目录是否也存在
      if (bundleStat.size === 0) {
        this.downloadBundle();
      } else {
        this.hasBundle = true;
      }
    } catch (e) {
      this.downloadBundle();
    }
  }
}

这一模式对"首次安装写入、后续启动直接复用"的场景非常实用,也避免了对沙箱的无谓重复写操作。

三、OpenHarmony 侧:用 FileJSBundleProvider 加载沙箱 bundle

文件推入沙箱后,下一步是让 RNOH 从沙箱文件读取 bundle。示例在 RNApp 上通过 jsBundleProvider 注入加载器:

if (this.rnAbility && this.hasBundle) {
  RNApp({
    rnInstanceConfig: {
      createRNPackages,
      enableCAPIArchitecture: true,
      arkTsComponentNames: [],
      assetsDest: 'assets',                    // 声明 assets 目录名
    },
    appKey: 'Sandbox',                          // 与 RN 侧 AppRegistry.registerComponent 对应
    wrappedCustomRNComponentBuilder: wrappedCustomRNComponentBuilder,
    onSetUp: (rnInstance) => {
      rnInstance.enableFeatureFlag('ENABLE_RN_INSTANCE_CLEAN_UP');
    },
    jsBundleProvider: new FileJSBundleProvider(this.rnAbility.context.filesDir + '/' + this.bundlePath)
  });
}

3.1 JSBundleProvider 抽象体系

从源码看,RNOH 把所有 bundle 来源抽象成了 JSBundleProvider 基类(JSBundleProvider.ts),核心接口为 getURL() 与 getBundle(onProgress?, onProviderSwitch?),其设计意图是"解耦 RNOH 核心逻辑与 bundle 的加载方式,由应用开发者决定使用哪个具体的 provider 以及何时加载"(源码注释原文)。

仓库中实现了四类 Provider 与一个装饰器:

Provider 加载来源 典型场景
FileJSBundleProvider 沙箱文件系统路径 本地化/离线加载 bundle,本示例即用此类
ResourceJSBundleProvider resource://RAWFILE/... rawfile 随包内置资源加载(默认路径 bundle.harmony.js)
MetroJSBundleProvider Metro dev server(HTTP/HTTPS) 开发调试热更新
AnyJSBundleProvider 依次尝试多个 provider 多来源容错、自动切换
TraceJSBundleProviderDecorator 包装任意 provider 为 getBundle 打 trace,便于性能分析

3.2 FileJSBundleProvider 的源码行为

FileJSBundleProvider 的 getBundle() 实现非常直白(JSBundleProvider.ts):

async getBundle(onProgress?, onProviderSwitch?): Promise<FileJSBundle> {
  try {
    const status = await fs.access(this.path, fs.OpenMode.READ_ONLY);
    if (status) {
      return { filePath: this.path };
    } else {
      throw new Error("The file can't be accessed.");
    }
  } catch (err) {
    throw new JSBundleProviderError({
      whatHappened: `Couldn't access JSBundle in ${this.path}`,
      extraData: err,
      howCanItBeFixed: [`Check if a bundle exists at "${this.path}" on your device.`]
    });
  }
}

要点:

  • 构造参数 (path: string, appKeys: string[] = []),其中 appKeys 默认空数组,getAppKeys() 返回它供 RNApp 判定当前 bundle 支持的 AppKey;
  • 加载前先 fs.access(path, READ_ONLY) 校验文件可读,失败抛出带修复建议的 JSBundleProviderError;
  • 因为不是 Metro provider,getHotReloadConfig() 固定返回 null(测试用例也验证了这一行为)。

对应单元测试位于 JSBundleProvider.test.ts,覆盖了"文件可访问时返回 {filePath}"、"文件不可访问抛 JSBundleProviderError"等分支,可作为接入沙箱加载时的行为参考。

3.3 assetsDest 与资源定位的关联

rnInstanceConfig 中的 assetsDest: 'assets' 用于让 RN 实例知道 bundle 引用的资源放在哪个目录名之下。在 RNOH 侧,WorkerRNInstance 的 assetsDest 字段注释写明"the path for RN to locate assets",RN 侧图片的 file:// 前缀正是由 assetsDest 参与拼装;而在打包侧,bundle-harmony.ts 的 --assets-dest <path> 参数则控制打包时资源输出目录。两端目录名保持一致,是沙箱场景资源能正确命中的前提。

四、React Native 侧:沙箱图片加载示例

RN 侧示例页 examples.tsx 在同一行内展示了三种图片来源,便于直接对比效果:

const Sandbox = () => {
  return (
    <View style={{ marginTop: 20, backgroundColor: '#def5ee', width: '90%',
                   borderRadius: 10, marginLeft: 'auto', marginRight: 'auto' }}>
      <Text style={{ fontWeight: 600, marginTop: 20 }}>沙箱bundle渲染</Text>
      <View style={{ display: 'flex', alignItems: 'center', flexDirection: 'row', width: '100%' }}>
        {/* 方式一:require 打包进 bundle 的资源 */}
        <Image source={require('../../../assets/expo.png')} style={{ width: 100, height: 100, margin: 10 }} />
        {/* 方式二:file:// URI 直接指向沙箱目录 */}
        <Image source={{ uri: 'file:///data/storage/el2/base/files/call.png' }}
               style={{ width: 100, height: 100, margin: 10 }} />
        {/* 方式三:require 打包资源(与方式一同源) */}
        <Image source={require('../../../assets/call.png')} style={{ width: 100, height: 100, margin: 10 }} />
      </View>
    </View>
  );
};

其中方式二 file:///data/storage/el2/base/files/call.png 即为沙箱图片的典型路径写法——直接以应用沙箱目录下的真实文件路径作为 Image 的 source.uri。结合原生侧 copyRawfileDirToSandbox 的搬运动作,这条链路是:rawfile/assets/call.png → 沙箱 filesDir/assets/call.png → RN 侧 file:///.../files/call.png 加载。

注意:/data/storage/el2/base/files/ 与 context.filesDir 指向同一目录(el2/base/files),路径前缀需根据实际设备环境与代码中取得的 filesDir 保持一致。

五、打包命令与 Metro 配置

5.1 pnpm dev:面向沙箱工程的打包命令

SandBoxRnProject/package.json 中的 dev 脚本是打通两个工程的关键:

"dev": "react-native bundle-harmony --dev false --entry-file ./src/bundles/Sandbox/index.js \
        --bundle-output ../SandBoxNativeProject/entry/src/main/resources/rawfile/bundle/sandbox.harmony.bundle \
        --assets-dest ../SandBoxNativeProject/entry/src/main/resources/rawfile \
        --config ./sandbox.config.js"

各参数与 bundle-harmony.ts 中声明的 option 一一对应:

参数 说明 本示例取值
--dev [boolean] false 时禁用警告并对 bundle 做压缩(minify) false
--entry-file <path> RN 入口文件(默认 index.js) ./src/bundles/Sandbox/index.js
--bundle-output <path> bundle 输出路径(默认 .../bundle.harmony.js) 原生工程 rawfile 下的 bundle/sandbox.harmony.bundle
--assets-dest <path> 资源输出目录(默认见 ASSETS_DEFAULT_DEST_PATH) 原生工程 rawfile 根目录
--config <path> Metro 配置文件 ./sandbox.config.js
--js-engine <string> 运行引擎,hermes 生成 HBC,any 生成 JS(默认 any) 未指定(默认 any)

bundle 输出在 rawfile/bundle/ 下、assets 输出在 rawfile/ 下,与原生侧 getRawFileContent('rawfile/bundle/sandbox.harmony.bundle') 和 copyRawfileDirToSandbox('rawfile/assets', ...) 的读取路径严格对应。

bundle 的入口注册在 index.js:

import { AppRegistry } from 'react-native';
import App from './examples';

AppRegistry.registerComponent('Sandbox', () => App);

Sandbox 这个 AppKey 必须与原生侧 RNApp({ appKey: 'Sandbox' }) 保持一致,否则 RN 侧无法找到对应组件。

5.2 sandbox.config.js:引入鸿蒙 Metro 配置

sandbox.config.js 通过 createHarmonyMetroConfig 让 Metro 感知鸿蒙平台:

const { mergeConfig, getDefaultConfig } = require('@react-native/metro-config');
const { createHarmonyMetroConfig } = require('@react-native-oh/react-native-harmony/metro.config');

const config = {
  transformer: {
    getTransformOptions: async () => ({
      transform: {
        experimentalImportSupport: false,
        inlineRequires: true,
      },
    }),
  },
};

module.exports = mergeConfig(
  getDefaultConfig(__dirname),
  createHarmonyMetroConfig({
    reactNativeHarmonyPackageName: '@react-native-oh/react-native-harmony',
  }),
  config,
);

六、沙箱场景图片路径合成规则(重点)

图片资源在沙箱中到底落在哪里?仓库配套文档 沙箱加载bundle.md 与 image组件资源沙箱路径加载.md 给出了精确的推导规则。

6.1 打包阶段的路径与资源组织

假设打包根路径为 /,RN 代码位于 /aaa/bbb/c.tsx,三种 require 写法及其生成的资源前缀如下:

图片 RN 写法 生成路径前缀
图片1 require('./d.png')(与代码同目录) /aaa/bbb/
图片2 require('../../eee/f.png') /eee/
图片3 require('../ggg/h.png') /aaa/ggg/

资源被打包进 assets 目录(目录名由打包参数指定,开发者可自定义),最终结构为:

assets
├── aaa/bbb/d.png
├── aaa/ggg/h.png
└── eee/f.png

6.2 沙箱场景:bundle 路径即前缀,无 assets 目录

假设 bundle 位于沙箱 /data/storage/base/files/dir1/bundle.harmony.js,那么图片的真实沙箱路径为:

图片 沙箱实际路径
图片1 /data/storage/base/files/dir1/aaa/bbb/d.png
图片2 /data/storage/base/files/dir1/eee/f.png
图片3 /data/storage/base/files/dir1/aaa/ggg/h.png

重点规则:沙箱场景下,图片路径以 bundle 所在路径为前缀,以图片相对工程打包路径的相对路径为后缀,合成完整路径;这里没有 assets 目录层级。

因此在沙箱中加载 bundle 时,务必把资源和 bundle 一起复制到同一目录树(示例中二者都位于 filesDir 下),图片的 file:// URI 才能命中。

6.3 RAWFILE 场景:固定 assets 前缀

对比来看,若 bundle 位于 resource://RAWFILE/dir1/bundle.harmony.js,图片路径则统一为:

图片 RAWFILE 实际路径
图片1 resource://RAWFILE/assets/aaa/bbb/d.png
图片2 resource://RAWFILE/assets/eee/f.png
图片3 resource://RAWFILE/assets/aaa/ggg/h.png

重点规则:RAWFILE 场景下,图片路径前缀固定为 resource://RAWFILE/assets/,加上图片相对打包路径的相对路径合成完整路径;这里有 assets 目录。

两种场景的差异可归纳为下表:

对比项 沙箱场景 RAWFILE 场景
前缀来源 bundle 所在路径(如 .../files/dir1/) 固定 resource://RAWFILE/assets/
是否含 assets 目录 不含 含
资源可写性 可写、可动态更新 只读、随包分发

七、完整运行步骤

按 README.md 的 Procedure,端到端跑通沙箱示例需要四步:

  1. 生成 bundle:在 SandBoxRnProject 目录下执行 pnpm i 安装依赖,然后执行 pnpm dev 生成 bundle 与 assets(输出到原生工程 rawfile 目录);
  2. 同步原生工程:用 DevEco Studio 打开 SandBoxNativeProject,等待自动同步完成(如需手动同步,执行 Sync and Refresh Project);
  3. 签名配置:点击 File > Project Structure > Signing Configs,登录并完成签名(真机/模拟器运行的必要前提);
  4. 运行:点击右上角 run 启动应用。首次启动时原生侧会把 rawfile 中的 bundle 与 assets 写入沙箱,随后通过 FileJSBundleProvider 加载运行 RN 页面。

八、另一种方案:hdc 推送文件到沙箱

除"代码内从 rawfile 拷贝"之外,仓库配套文档 sandbox-loading-bundle.md 还给出了第二种途径——使用 hdc 命令把文件直接推送到应用沙箱目录。这种方式适合开发期快速验证、或需要下发外部产物(如 OTA 更新的 bundle)的场景。具体命令用法可参考华为开发者文档 "Pushing Files to an Application Sandbox Directory"(沙箱加载bundle.md 中给出了原始外链,此处不再展开)。

两种方式的取舍:

  • rawfile 拷贝(示例默认):随安装包分发、离线可用、首次启动自动写入,适合正式发布;
  • hdc 推送:不依赖打包流程,可单独更新文件,适合开发调试与动态下发验证。

九、注意事项

结合示例代码与配套文档,沙箱加载场景需留意以下几点:

  • 两个工程的配置必须对齐:RN 侧 AppRegistry.registerComponent('Sandbox') 与原生侧 RNApp({ appKey: 'Sandbox' }) 的 AppKey 一致;bundle 输出路径、assets 输出路径与原生侧读取路径一致;assetsDest 目录名(assets)与打包时 --assets-dest 保持一致;
  • 路径前缀随场景变化:沙箱场景没有 assets 目录,路径以 bundle 位置为前缀;RAWFILE 场景固定带 assets 前缀,二者不可混用,否则图片 404;
  • 沙箱访问权限按环境调整:README 明确提醒,沙箱路径使用与文件访问权限需要根据实际环境调整(例如真实设备与模拟器的 filesDir 路径形态、应用沙箱的读写权限约束);
  • 善用 JSBundleProvider 体系:如果希望"本地文件优先、Metro 兜底",可组合 AnyJSBundleProvider 与多个子 provider 实现自动降级,源码与测试用例(JSBundleProvider.test.ts)提供了现成的行为参考。

十、运行效果预览

示例 README 以截图记录了"通过代码把 bundle 推入沙箱后"的实际运行效果(screenshots/Screenshots.jpeg):

沙箱bundle渲染效果截图:页面标题“沙箱bundle渲染”,下方并排展示三张 100x100 图片,验证沙箱图片路径加载成功

页面标题"沙箱bundle渲染"由 examples.tsx 渲染,三张并排图片分别来自打包资源与 file:// 沙箱 URI,直观验证了沙箱路径加载的可行性。

延伸阅读

登录后查看全文
ohos_react_native