React Native 鸿蒙化沙箱加载实战:bundle 与图片资源沙箱路径加载全解析
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,端到端跑通沙箱示例需要四步:
- 生成 bundle:在 SandBoxRnProject 目录下执行
pnpm i安装依赖,然后执行pnpm dev生成 bundle 与 assets(输出到原生工程 rawfile 目录); - 同步原生工程:用 DevEco Studio 打开 SandBoxNativeProject,等待自动同步完成(如需手动同步,执行 Sync and Refresh Project);
- 签名配置:点击 File > Project Structure > Signing Configs,登录并完成签名(真机/模拟器运行的必要前提);
- 运行:点击右上角 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渲染"由 examples.tsx 渲染,三张并排图片分别来自打包资源与 file:// 沙箱 URI,直观验证了沙箱路径加载的可行性。
延伸阅读
- 示例完整说明:Sandbox/README.md
- 原生侧核心实现:Index.ets
- RN 侧示例页面:examples.tsx
- 打包命令与 Metro 配置:package.json、sandbox.config.js
- 路径合成规则:沙箱加载bundle.md、image组件资源沙箱路径加载.md
- Provider 源码与测试:JSBundleProvider.ts、JSBundleProvider.test.ts
