目录

元服务如何将PixelMap数据保存到系统相册中

问题现象

在元服务开发中,开发者经常需要将业务中动态生成的图片(即内存中的PixelMap对象)保存到用户的系统相册中。但在实际操作中会面临以下阻碍:

  1. PixelMap本质上是内存中未压缩的原始像素点阵,而系统相册仅支持存储标准编码格式的图片文件(如JPEG、PNG),无法直接存入PixelMap对象。
  2. 元服务暂时无法使用createPicture接口来创建Picture对象。

背景知识

ImagePacker:图像编码压缩类,用于将内存中的PixelMap或ImageSource对象打包并压缩为标准的图片二进制文件流(如image/jpeg)。

SaveButton安全控件:系统提供的安全UI组件。在免权限申请的场景下,必须由用户主动点击该按钮,系统安全校验通过后,会在后台静默授予本次操作“临时写入相册”的权限。

photoAccessHelper:相册访问助手,用于在系统媒体库中创建图片或视频资产,并获取对应的文件访问URI。

解决方案

在元服务中将PixelMap保存到系统相册中,实现思路如下:“图像编码压缩->安全控件换取临时授权->写入媒体库”。

具体实现步骤如下:

  1. 编码压缩PixelMap数据:

    使用image.createImagePacker创建图像编码器,配置所需的图片格式和质量参数(如JPEG格式、100质量)。调用packToData方法,将PixelMap对象转换为标准的图片二进制流(ArrayBuffer)。

    // 第一步:使用 ImagePacker 将 PixelMap 编码为 JPG 格式的二进制流
    const imagePacker = image.createImagePacker();
    const packOptions: image.PackingOption = {
      format: 'image/jpeg',
      quality: 100 // 图片质量 0-100
    };
    // packing 返回的是 ArrayBuffer 格式的图片文件流
    const imageBuffer = await imagePacker.packToData(this.pixelMap, packOptions);
  2. 使用SaveButton触发临时授权:

    在UI界面的搭建中,使用SaveButton安全控件。在用户的真实点击事件中,校验回调的result是否为SaveButtonOnClickResult.SUCCESS,以确保系统已成功下发临时写入权限。

    // 按钮 2:安全控件 SaveButton 用于免权限保存图片
    SaveButton({ icon: SaveIconStyle.FULL_FILLED, text: SaveDescription.SAVE_IMAGE })
      .width('80%')
      .onClick(async (event: ClickEvent, result: SaveButtonOnClickResult) => {
        // 必须严格校验 result 是否为 SUCCESS,这代表系统完成了安全验证并授予了临时权限
        if (result === SaveButtonOnClickResult.SUCCESS) {
          await this.savePixelMapToAlbum();
        } else {
          this.promptAction.showToast({ message: '授权失败,无法保存' });
          console.error('SaveButton authorization failed.');
        }
      })
  3. 创建相册资产并写入数据:

    在确认获得安全控件授权后,使用photoAccessHelper.getPhotoAccessHelper获取相册实例,调用createAsset创建一条图片资产并得到目标文件的URI。接着使用fileIo以读写模式打开上述URI对应的文件,将第1步生成的二进制流写入该文件描述符(fd)。注意:写入完成后务必调用fileIo.close(file.fd)关闭文件流,否则可能导致系统相册刷新延迟,图库中无法立刻显示刚保存的图片。

    // 第二步:通过 photoAccessHelper 创建相册资产并获取 URI
    const context = this.uiContext?.getHostContext();
    const helper = photoAccessHelper.getPhotoAccessHelper(context);
    
    
    // 创建一条图像类型的资产,指定后缀名为 jpg,返回对应文件的 URI
    const uri = await helper.createAsset(photoAccessHelper.PhotoType.IMAGE, 'jpg');
    
    
    // 第三步:使用 fileIo 将编码后的二进制流写入到该 URI 中
    // 以读写和创建模式打开文件
    const file = await fileIo.open(uri, fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE);
    // 写入数据
    await fileIo.write(file.fd, imageBuffer);
    // 必须关闭文件流,否则相册可能无法及时刷新显示
    await fileIo.close(file.fd);
    
    
    // 提示用户保存成功
    this.promptAction.showToast({ message: '已成功保存到系统相册!' });
    console.info('Image successfully saved to gallery. URI:', uri);

完整的Index.ets代码如下:

import { image } from '@kit.ImageKit';
import { photoAccessHelper } from '@kit.MediaLibraryKit';
import { fileIo } from '@kit.CoreFileKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { util } from '@kit.ArkTS';
import { PromptAction } from '@kit.ArkUI';


@Entry
@Component
struct Index {
  // 用于界面展示和后续保存的 PixelMap 对象
  @State pixelMap: image.PixelMap | undefined = undefined;
  promptAction: PromptAction = this.getUIContext().getPromptAction();
  uiContext = this.getUIContext();
  /**
   * 1. 动态生成测试用 PixelMap (为了 Demo 能直接跑起来,无需本地图片)
   */
  async generatePixelMap() {
    const width = 300;
    const height = 300;
    const bufferSize = width * height * 4;
    const buffer = new ArrayBuffer(bufferSize);
    const dataView = new DataView(buffer);


    // 填充渐变色数据
    for (let i = 0; i < bufferSize; i += 4) {
      const idx = i / 4;
      const x = idx % width;
      const y = Math.floor(idx / width);
      dataView.setUint8(i, 255);
      dataView.setUint8(i + 1, x % 255);
      dataView.setUint8(i + 2, y % 255);
      dataView.setUint8(i + 3, 255);
    }


    try {
      // 使用元服务兼容的 1x1 占位图方式创建 ImageSource
      const dummyBase64 = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAANSURBVBhXY/j/fw8DAwAA/wD/b6y2ZgAAAABJRU5ErkJggg==';
      const base64Str = dummyBase64.split(',')[1];
      const base64Helper = new util.Base64Helper();
      const dummyBuffer = base64Helper.decodeSync(base64Str).buffer as ArrayBuffer;
      const imageSource = image.createImageSource(dummyBuffer);


      // 解码并分配真实内存
      const decodingOpts: image.DecodingOptions = {
        editable: true,
        desiredSize: { width: width, height: height },
        desiredPixelFormat: image.PixelMapFormat.RGBA_8888
      };
      const pm = await imageSource.createPixelMap(decodingOpts);


      // 写入真实数据
      await pm.writeBufferToPixels(buffer);
      this.pixelMap = pm;
      this.promptAction.showToast({ message: '图片生成成功,可以保存了' });
    } catch (error) {
      console.error(`生成图片失败: ${(error as BusinessError).message}`);
    }
  }


  /**
   * 2. 核心保存逻辑:将 PixelMap 编码并存入系统相册
   */
  async savePixelMapToAlbum() {
    if (!this.pixelMap) {
      this.promptAction.showToast({ message: '请先生成图片' });
      return;
    }


    try {
      // 第一步:使用 ImagePacker 将 PixelMap 编码为 JPG 格式的二进制流
      const imagePacker = image.createImagePacker();
      const packOptions: image.PackingOption = {
        format: 'image/jpeg',
        quality: 100 // 图片质量 0-100
      };
      // packing 返回的是 ArrayBuffer 格式的图片文件流
      const imageBuffer = await imagePacker.packToData(this.pixelMap, packOptions);


      // 第二步:通过 photoAccessHelper 创建相册资产并获取 URI
      const context = this.uiContext?.getHostContext();
      const helper = photoAccessHelper.getPhotoAccessHelper(context);


      // 创建一条图像类型的资产,指定后缀名为 jpg,返回对应文件的 URI
      const uri = await helper.createAsset(photoAccessHelper.PhotoType.IMAGE, 'jpg');


      // 第三步:使用 fileIo 将编码后的二进制流写入到该 URI 中
      // 以读写和创建模式打开文件
      const file = await fileIo.open(uri, fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE);
      // 写入数据
      await fileIo.write(file.fd, imageBuffer);
      // 必须关闭文件流,否则相册可能无法及时刷新显示
      await fileIo.close(file.fd);


      // 提示用户保存成功
      this.promptAction.showToast({ message: '已成功保存到系统相册!' });
      console.info('Image successfully saved to gallery. URI:', uri);
    } catch (error) {
      const err = error as BusinessError;
      console.error(`保存相册失败: Code ${err.code}, Message: ${err.message}`);
      this.promptAction.showToast({ message: `保存失败: ${err.message}` });
    }
  }


  build() {
    Column({ space: 20 }) {
      Text('保存 PixelMap 图片')
        .fontSize(20)
        .fontWeight(FontWeight.Bold)
        .margin({ top: 30, bottom: 20 })


      // 图片展示区
      if (this.pixelMap) {
        Image(this.pixelMap)
          .width(250)
          .height(250)
          .objectFit(ImageFit.Contain)
          .border({ width: 1, color: Color.Gray, radius: 8 })
      } else {
        Column() {
          Text('点击下方按钮生成图片')
            .fontColor(Color.Gray)
        }
        .width(250)
        .height(250)
        .backgroundColor('#F5F5F5')
        .justifyContent(FlexAlign.Center)
        .border({ width: 1, color: Color.Gray, style: BorderStyle.Dashed, radius: 8 })
      }


      // 按钮 1:普通按钮用于生成图片
      Button('1. 动态生成 PixelMap')
        .width('80%')
        .onClick(() => {
          this.generatePixelMap();
        })


      // 按钮 2:安全控件 SaveButton 用于免权限保存图片
      SaveButton({ icon: SaveIconStyle.FULL_FILLED, text: SaveDescription.SAVE_IMAGE })
        .width('80%')
        .onClick(async (event: ClickEvent, result: SaveButtonOnClickResult) => {
          // 必须严格校验 result 是否为 SUCCESS,这代表系统完成了安全验证并授予了临时权限
          if (result === SaveButtonOnClickResult.SUCCESS) {
            await this.savePixelMapToAlbum();
          } else {
            this.promptAction.showToast({ message: '授权失败,无法保存' });
            console.error('SaveButton authorization failed.');
          }
        })
    }
    .width('100%')
    .height('100%')
    .alignItems(HorizontalAlign.Center)
  }
}