# RNSaveMedia
**Repository Path**: scenario-samples/rnsave-media
## Basic Information
- **Project Name**: RNSaveMedia
- **Description**: 在应用开发过程中,应用会遇到需要将媒体资源(图片或者视频)保存到设备图库的情况。通过安全控件的方式,应用无需申请相册管理模块权限'ohos.permission.WRITE_IMAGEVIDEO'就可以将用户指定的媒体资源保存到图库中。
本示例演示RN应用中使用Fabric自定义组件实现RN侧引用ArkTS侧的SaveButton,保存媒体资源文件到设备图库。
- **Primary Language**: Unknown
- **License**: Not specified
- **Default Branch**: master
- **Homepage**: None
- **GVP Project**: No
## Statistics
- **Stars**: 0
- **Forks**: 0
- **Created**: 2026-02-06
- **Last Updated**: 2026-02-06
## Categories & Tags
**Categories**: Uncategorized
**Tags**: None
## README
# RN使用安全控件保存媒体资源文件示例
## 场景介绍
在应用开发过程中,应用会遇到需要将媒体资源(图片或者视频)保存到设备图库的情况。通过安全控件的方式,应用无需申请相册管理模块权限'ohos.permission.WRITE_IMAGEVIDEO'就可以将用户指定的媒体资源保存到图库中。
本示例演示RN应用中使用[Fabric](https://gitcode.com/openharmony-sig/ohos_react_native/blob/master/docs/zh-cn/%E8%87%AA%E5%AE%9A%E4%B9%89%E7%BB%84%E4%BB%B6.md)自定义组件实现RN侧引用ArkTS侧的[SaveButton](https://developer.huawei.com/consumer/cn/doc/harmonyos-references/ts-security-components-savebutton),保存媒体资源文件到设备图库。
## 效果预览
## 实现思路
1.声明Fabric。
```
import {HostComponent, ViewProps} from 'react-native';
import {
DirectEventHandler,
Int32,
} from 'react-native/Libraries/Types/CodegenTypes';
import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent';
export type OnSuccessEventData = Readonly<{}>;
export type OnFailureEventData = Readonly<{
errorCode: Int32;
errorMessage: string;
}>;
export type CustomButtonParams = Readonly<{
buttonStyle?: Int32;
icon?: Int32;
text?: Int32;
fontColor?: Int32;
backgroundColor?: string;
borderRadius?: Int32;
}>;
interface SaveButtonProps extends ViewProps {
uri: string;
title?: string;
customButtonParams?: CustomButtonParams;
onSuccess?: DirectEventHandler;
onFailure?: DirectEventHandler;
}
export default codegenNativeComponent(
'SaveButton',
) as HostComponent;
```
2.ArkS侧实现。
```
import { RNOHContext, RNViewBase } from '@rnoh/react-native-openharmony';
import { RNC } from '@rnoh/react-native-openharmony/generated';
import { fileIo as fs } from '@kit.CoreFileKit';
import { photoAccessHelper } from '@kit.MediaLibraryKit';
import { common } from '@kit.AbilityKit';
import { http } from '@kit.NetworkKit';
@Component
export struct RNSaveButton {
public static readonly NAME = RNC.SaveButton.NAME;
public ctx!: RNOHContext;
public tag: number = 0;
@State descriptorWrapper: RNC.SaveButton.DescriptorWrapper = {} as RNC.SaveButton.DescriptorWrapper;
private eventEmitter: RNC.SaveButton.EventEmitter | undefined = undefined;
private cleanUpCallbacks: (() => void)[] = [];
uiContext: UIContext = this.getUIContext();
context: common.UIAbilityContext = this.uiContext.getHostContext() as common.UIAbilityContext;
aboutToAppear() {
this.eventEmitter = new RNC.SaveButton.EventEmitter(this.ctx.rnInstance, this.tag);
this.onDescriptorWrapperChange(this.ctx.descriptorRegistry.findDescriptorWrapperByTag(this.tag)!);
this.cleanUpCallbacks.push(this.ctx.descriptorRegistry.subscribeToDescriptorChanges(this.tag,
(descriptor) => {
this.onDescriptorWrapperChange(new RNC.SaveButton.DescriptorWrapper(descriptor));
}
));
}
private onDescriptorWrapperChange(descriptorWrapper: RNC.SaveButton.DescriptorWrapper) {
this.descriptorWrapper = descriptorWrapper;
}
aboutToDisappear() {
this.cleanUpCallbacks.forEach(cb => cb());
}
build() {
RNViewBase({ ctx: this.ctx, tag: this.tag }) {
SaveButton({
icon: this.descriptorWrapper.props.customButtonParams?.icon,
text: this.descriptorWrapper.props.customButtonParams?.text,
buttonType: this.descriptorWrapper.props.customButtonParams?.buttonStyle
})
.onClick(async (_event: ClickEvent, result: SaveButtonOnClickResult) => {
if (result === SaveButtonOnClickResult.SUCCESS) {
try {
let sourceUri = this.descriptorWrapper.props.uri;
let photoType = photoAccessHelper.PhotoType.IMAGE;
let extension = sourceUri.split('.').pop() ?? '';
let options: photoAccessHelper.CreateOptions = {};
let media: ArrayBufferLike | null = null;
if (['mp4', 'mov'].includes(extension?.toLowerCase())) {
photoType = photoAccessHelper.PhotoType.VIDEO
}
if (this.descriptorWrapper.props.title) {
options.title = this.descriptorWrapper.props.title;
}
if (sourceUri.startsWith('asset://')) {
media = this.context.resourceManager.getRawFileContentSync(sourceUri.replace('asset://',
this.ctx.rnInstance.getAssetsDest())).buffer;
} else if (sourceUri.startsWith('resource://')) {
media = this.context.resourceManager.getRawFileContentSync(sourceUri.replace('resource://rawfile/', ''))
.buffer;
} else if (sourceUri.startsWith('http://') || sourceUri.startsWith('https://')) {
let httpResult = (await http.createHttp().request(sourceUri));
if (httpResult.responseCode === 200 && httpResult.result instanceof ArrayBuffer) {
media = httpResult.result;
}
} else {
if (!fs.accessSync(sourceUri)) {
this.eventEmitter?.emit('failure', { errorCode: -1, errorMessage: '保存失败' })
return;
}
let file = fs.openSync(sourceUri, fs.OpenMode.READ_ONLY);
media = new ArrayBuffer(fs.statSync(file.fd).size);
fs.readSync(file.fd, media);
}
if (!media) {
this.eventEmitter?.emit('failure', { errorCode: -1, errorMessage: '保存失败' })
return;
}
let helper = photoAccessHelper.getPhotoAccessHelper(this.context);
// onClick触发后一分钟内通过createAsset接口创建图片文件,一分钟后createAsset权限收回。
let uri = await helper.createAsset(photoType, extension, options);
// 使用uri打开文件,可以持续写入内容,写入过程不受时间限制
let file = fs.openSync(uri, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE);
fs.writeSync(file.fd, media);
fs.closeSync(file.fd);
this.eventEmitter?.emit('success', new Object());
} catch (error) {
console.error(`errorCode is ${error.code}, errorMessage is ${error.message}`);
this.eventEmitter?.emit('failure', { errorCode: error.code, errorMessage: error.message });
}
} else {
this.eventEmitter?.emit('failure', { errorCode: -1, errorMessage: '设置权限失败' });
}
})
.width(this.descriptorWrapper.width)
.height(this.descriptorWrapper.height)
.borderRadius(this.descriptorWrapper.props.customButtonParams?.borderRadius)
.backgroundColor(this.descriptorWrapper.props.customButtonParams?.backgroundColor)
.fontColor(this.descriptorWrapper.props.customButtonParams?.fontColor)
}
}
}
```
3.RN侧保存。
```
{
Toast.show('保存成功', Toast.SHORT);
}}
onFailure={e => {
console.error(
`errCode: ${e.nativeEvent.errorCode},errMessage: ${e.nativeEvent.errorMessage}`,
);
}}
/>
```
## 说明
- RN工程目录下执行`npm i`命令安装依赖。
- 用DevEco Studio打开harmony工程。
- RN工程目录下执行`npm run dev`命令安装包和生成bundle。
## 约束与限制
- 本示例支持API Version 20 Release及以上版本。
- 本示例支持HarmonyOS 6.0.0 Release SDK及以上版本。
- 本示例需要使用DevEco Studio 6.0.0 Release及以上版本进行编译运行。
- 本示例使用0.72.96版本RNOH。
## 工程目录
```
RNSaveMedia // RN工程
├──harmony // harmony工程
│ ├──entry/src/main/cpp // native相关配置
│ │ ├──CMakeLists.txt
│ │ └──PackageProvider.cpp
│ ├──entry/src/main/ets
│ │ ├──entryability
│ │ │ └──EntryAbility.ets
│ │ ├──pages
│ │ │ └──Index.ets // harmony侧入口
│ │ ├──RNComponent
│ │ │ └──RNSaveButton.ets // 自定义组件实现
│ │ └──RNPackagesFactory.ets
│ └──entry/src/main/resources/rawfile // bundle、静态资源存放目录
├──App.tsx // 截图保存图库实现
├──rn-save-button // Fabric
│ ├──src/specs
│ │ └──SaveButtonNativeComponent.ts
│ └──index.ts
├──index.js // 入口文件
├──metro.config.js // 打包配置
```
## 参考文档
[Fabric自定义组件](https://gitcode.com/openharmony-sig/ohos_react_native/blob/master/docs/zh-cn/%E8%87%AA%E5%AE%9A%E4%B9%89%E7%BB%84%E4%BB%B6.md)
[RNOH使用文档](https://gitcode.com/openharmony-sig/ohos_react_native/blob/master/docs/zh-cn/%E7%8E%AF%E5%A2%83%E6%90%AD%E5%BB%BA.md)