一、权限体系与安全模型

HarmonyOS NEXT 采用严格的权限管理机制,保存文件到相册涉及两种不同的授权模式:

1. 动态权限申请(传统方式)

在 module.json5 中声明权限后,使用 abilityAccessCtrl 向用户弹窗申请:

json

{
  "requestPermissions": [
    {
      "name": "ohos.permission.WRITE_IMAGEVIDEO",
      "reason": "$string:permission_reason"
    }
  ]
}

此方式需要用户明确授权,适用于需要频繁读写媒体库的场景。

2. 安全控件(推荐方式)

从 API 12 开始,官方推荐使用 SaveButton 安全控件。用户点击控件时,应用自动获得10秒的媒体库写入授权,无需额外权限申请,也不会触发权限弹窗,大幅提升用户体验。

注意PhotoViewPicker.save() 方法在 API 12 中已被废弃,请优先使用 SaveButton 或 photoAccessHelper.createAsset() 方式。


二、方案一:使用 SaveButton 安全控件(最推荐)

适用场景

保存网络下载图片、应用内生成的图片或视频,无复杂路径选择需求。

核心代码示例

typescript

import { photoAccessHelper } from '@kit.MediaLibraryKit';
import { fileIo } from '@kit.CoreFileKit';
import { common } from '@kit.AbilityKit';
import { promptAction } from '@kit.ArkUI';
import { BusinessError } from '@kit.BasicServicesKit';

async function savePhotoToGallery(context: common.UIAbilityContext, imageData: ArrayBuffer) {
  let helper = photoAccessHelper.getPhotoAccessHelper(context);
  try {
    // 1. 创建图片资源(触发权限授权)
    let uri = await helper.createAsset(photoAccessHelper.PhotoType.IMAGE, 'jpg');
    // 2. 打开文件并写入数据
    let file = await fileIo.open(uri, fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE);
    await fileIo.write(file.fd, imageData);
    await fileIo.close(file.fd);
    promptAction.showToast({ message: '已保存至相册!' });
  } catch (error) {
    const err: BusinessError = error as BusinessError;
    console.error(`保存失败: ${err.code}, ${err.message}`);
  }
}

@Entry
@Component
struct SaveButtonDemo {
  build() {
    Column() {
      Image($r('app.media.demo_image'))
        .width('100%')
        .height(400)
      
      // 保存控件 - 点击即获得临时授权
      SaveButton()
        .padding({ top: 12, bottom: 12, left: 24, right: 24 })
        .onClick(async (event, result) => {
          if (result === SaveButtonOnClickResult.SUCCESS) {
            const context = getContext(this) as common.UIAbilityContext;
            // 需要准备图片数据(ArrayBuffer)
            // const imageData = await fetchImageData();
            // await savePhotoToGallery(context, imageData);
          } else {
            promptAction.showToast({ message: '授权失败' });
          }
        })
    }
  }
}

关键约束

  • 10秒倒计时:从点击到调用 createAsset 必须在 10 秒内完成,否则权限收回;但文件写入操作不受限

  • 控件可见性:SaveButton 必须可见且可被用户识别,样式不能与普通按钮混淆

  • 大文件处理:视频超过 2GB 时建议分段写入或使用 MediaStore API 处理


三、方案二:使用 PhotoViewPicker 选择保存路径

适用场景

需要用户主动选择保存文件夹的场景(如文档管理类应用)。

核心代码示例

typescript

import picker from '@ohos.file.picker';
import fs from '@ohos.file.fs';

async function saveWithPicker() {
  const photoSaveOptions = new picker.PhotoSaveOptions();
  photoSaveOptions.newFileNames = ['my_photo.jpg']; // 可选文件名
  
  const photoViewPicker = new picker.PhotoViewPicker();
  try {
    const photoSaveResult = await photoViewPicker.save(photoSaveOptions);
    const uri = photoSaveResult[0];
    console.info('保存路径 URI:', uri);
    
    // 后续通过 uri 写入数据
    let file = fs.openSync(uri, fs.OpenMode.READ_WRITE);
    fs.writeSync(file.fd, 'image data');
    fs.closeSync(file);
  } catch (err) {
    console.error(`保存失败: ${err.message}`);
  }
}

注意PhotoViewPicker.save() 在 API 12 中已被标记为废弃,新项目应避免使用。


四、方案三:使用 photoAccessHelper 直接创建媒体资源

适用场景

已经拥有媒体数据(ArrayBuffer 或 PixelMap),需直接写入相册。

从网络下载并保存图片

typescript

import { photoAccessHelper } from '@kit.MediaLibraryKit';
import { http } from '@kit.NetworkKit';
import fs from '@ohos.file.fs';

async function downloadAndSave(url: string, context: Context) {
  let helper = photoAccessHelper.getPhotoAccessHelper(context);
  let uri = await helper.createAsset(photoAccessHelper.PhotoType.IMAGE, 'jpg');
  let file = fs.openSync(uri, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE);
  
  let httpRequest = http.createHttp();
  httpRequest.on('dataReceive', (data: ArrayBuffer) => {
    fs.writeSync(file.fd, data);
  });
  
  await httpRequest.requestInStream(url, { method: http.RequestMethod.GET });
  httpRequest.on('dataEnd', () => {
    fs.closeSync(file);
    promptAction.showToast({ message: '下载并保存成功' });
  });
}

保存组件快照(截图)

typescript

import componentSnapshot from '@ohos.arkui.componentSnapshot';
import image from '@ohos.multimedia.image';
import photoAccessHelper from '@ohos.file.photoAccessHelper';

async function saveComponentSnapshot(context: Context) {
  // 1. 获取组件快照(需要给组件设置 .id('targetView'))
  const pixelMap = await componentSnapshot.get('targetView');
  
  // 2. 编码为 JPEG
  const imagePacker = image.createImagePacker();
  const options: image.PackingOption = { 
    format: 'image/jpeg', 
    quality: 100 
  };
  const data = await imagePacker.packing(pixelMap, options);
  
  // 3. 保存到相册
  let helper = photoAccessHelper.getPhotoAccessHelper(context);
  let uri = await helper.createAsset(photoAccessHelper.PhotoType.IMAGE, 'jpg');
  let file = await fs.open(uri, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE);
  await fs.write(file.fd, data);
  await fs.close(file.fd);
}

五、视频保存的特殊考量

1. 大文件处理

视频文件通常较大(可能超过 2GB),SaveButton 的 10 秒授权窗口可能不足以完成全部写入。官方建议:

  • 利用 createAsset 后的写入操作不受时间限制的特性

  • 使用 分段写入策略:分批次写入数据,避免内存溢出

2. 视频压缩后保存

如果需要先压缩视频再保存,可借助 videocompressor 库处理,压缩完成后同样使用 photoAccessHelper.createAsset() 写入相册。

typescript

// 压缩后保存示例(伪代码)
const compressedData = await compressVideo(originalUri);
let uri = await helper.createAsset(photoAccessHelper.PhotoType.VIDEO, 'mp4');
let file = await fs.open(uri, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE);
await fs.write(file.fd, compressedData);
await fs.close(file.fd);

六、方案对比与选型建议

方案 权限方式 用户体验 推荐场景
SaveButton 点击即授权,无弹窗 ⭐⭐⭐⭐⭐ 绝大多数保存场景(图片、视频、截图)
PhotoViewPicker 用户选择路径 ⭐⭐⭐ 需要指定保存路径的文档管理类应用(已废弃,不推荐新项目)
动态权限申请 弹窗授权,永久有效 ⭐⭐⭐ 需要频繁操作媒体库的老项目(新项目建议迁移至 SaveButton)

最佳实践建议

  1. 新项目一律采用 SaveButton,无需处理复杂权限逻辑,用户体验最佳

  2. 如需后台自动保存(用户无直接点击),则必须申请 ohos.permission.WRITE_IMAGEVIDEO 权限,但需注意严格遵循隐私政策

  3. 视频保存注意处理大文件场景,利用写入操作不限制时间的特性

  4. 保存前对图片进行适当压缩(如 packing 质量压缩、scale 尺寸压缩),可显著提升保存速度和用户体验


七、常见问题

Q1: SaveButton 点击后没有反应?

  • 检查控件是否设置了有效的 onClick 回调

  • 确认控件可见且未被遮挡

  • 查看日志是否有 ACL(访问控制列表)相关错误

Q2: 保存图片时提示权限不足?

  • 若使用 SaveButton,确认点击到调用 createAsset 在 10 秒内

  • 若使用动态权限,确认 module.json5 中已声明权限且用户已授权

Q3: 如何生成唯一文件名避免覆盖?

typescript

function getTimeStr(): string {
  const now = new Date();
  return `${now.getFullYear()}${now.getMonth()+1}${now.getDate()}_${now.getHours()}${now.getMinutes()}${now.getSeconds()}`;
}
// 使用: `photo_${getTimeStr()}.jpg`[citation:8]

Q4: 如何拦截 systemShare 的“保存到图库”事件?

无法直接拦截。如有强制前置权限校验需求,应放弃 systemShare,改用自定义分享面板,在自定义保存按钮中控制完整权限逻辑。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐