关注

HarmonyOS ArkWeb 系列之长按图片保存到相册:SaveButton + photoAccessHelper 实战


在网页里长按一张图片,弹出"保存图片"——这个操作听起来简单,背后却涉及权限、图片下载、媒体库写入好几个环节。HarmonyOS 提供了 SaveButton 这个安全控件,加上 photoAccessHelper,可以在不申请额外权限的情况下把图片保存到相册。

为什么要用 SaveButton?

HarmonyOS 的相册写入需要 ohos.permission.WRITE_IMAGEVIDEO 权限,但这是一个受限权限,普通应用申请很麻烦。

SaveButton 是一个系统安全控件,用户点击它时,系统临时授予当次操作的相册写入权限,不需要应用提前申请权限。只要用户自愿点击"保存图片"按钮,就有权限保存——这个设计既方便开发者,也保护用户。

流程图

完整实现

import { webview } from '@kit.ArkWeb';
import { common } from '@kit.AbilityKit';
import { fileIo } from '@kit.CoreFileKit';
import { systemDateTime } from '@kit.BasicServicesKit';
import { http } from '@kit.NetworkKit';
import { photoAccessHelper } from '@kit.MediaLibraryKit';

@Entry
@Component
struct WebSaveImageDemo {
  // SaveButton 的外观配置
  saveButtonOptions: SaveButtonOptions = {
    icon: SaveIconStyle.FULL_FILLED,       // 图标样式:实心填充
    text: SaveDescription.SAVE_IMAGE,      // 按钮文字:保存图片
    buttonType: ButtonType.Capsule         // 胶囊形状
  };

  controller: webview.WebviewController = new webview.WebviewController();
  @State showMenu: boolean = false;
  @State imgUrl: string = '';  // 当前长按图片的 URL

  context = this.getUIContext().getHostContext() as common.UIAbilityContext;

  // ========== 工具方法:从 rawfile 复制图片 ==========
  copyLocalPicToDir(rawfilePath: string, newFileName: string): string {
    try {
      const srcFileDes = this.context.resourceManager.getRawFdSync(rawfilePath);
      const dstPath = this.context.filesDir + '/' + newFileName;
      const dest: fileIo.File = fileIo.openSync(
        dstPath,
        fileIo.OpenMode.CREATE | fileIo.OpenMode.READ_WRITE
      );

      let bufSize = 4096;
      const buf = new ArrayBuffer(bufSize);
      let offset = 0;
      let readLen = 0;
      let len = 0;

      // 分块读取并写入
      while ((len = fileIo.readSync(srcFileDes.fd, buf, {
        offset: srcFileDes.offset + offset,
        length: bufSize
      })) !== 0) {
        readLen += len;
        fileIo.writeSync(dest.fd, buf, { offset: offset, length: len });
        offset += len;
        if ((srcFileDes.length - readLen) < bufSize) {
          bufSize = srcFileDes.length - readLen;
        }
      }

      fileIo.close(dest.fd);
      return dest.path;
    } catch (error) {
      console.error('复制本地图片失败:', error);
      return '';
    }
  }

  // ========== 工具方法:从网络 URL 下载图片 ==========
  async copyUrlPicToDir(picUrl: string, newFileName: string): Promise<string> {
    let uri = '';
    const httpRequest = http.createHttp();
    try {
      const data: http.HttpResponse = await (
        httpRequest.request(picUrl) as Promise<http.HttpResponse>
      );
      if (data?.responseCode === http.ResponseCode.OK) {
        const dstPath = this.context.filesDir + '/' + newFileName;
        const dest: fileIo.File = fileIo.openSync(
          dstPath,
          fileIo.OpenMode.CREATE | fileIo.OpenMode.READ_WRITE
        );
        fileIo.writeSync(dest.fd, data.result as ArrayBuffer);
        fileIo.close(dest.fd);
        uri = dstPath;
      }
    } catch (error) {
      console.error('下载在线图片失败:', error);
    } finally {
      httpRequest.destroy(); // 一定要销毁,防止内存泄漏
    }
    return uri;
  }

  // ========== 长按弹出的菜单 ==========
  @Builder
  MenuBuilder() {
    Column() {
      Row() {
        // SaveButton:用户点击后系统授予临时相册写权限
        SaveButton(this.saveButtonOptions)
          .onClick(async (event, result: SaveButtonOnClickResult) => {
            if (result === SaveButtonOnClickResult.SUCCESS) {
              try {
                let filePath = '';

                // 判断图片来源:本地 rawfile 还是网络 URL
                if (this.imgUrl.includes('rawfile')) {
                  // 提取 rawfile 文件名
                  const rawFileName = this.imgUrl.substring(
                    this.imgUrl.lastIndexOf('/') + 1
                  );
                  filePath = this.copyLocalPicToDir(rawFileName, 'saveImage.png');
                } else if (
                  this.imgUrl.includes('http') ||
                  this.imgUrl.includes('https')
                ) {
                  // 网络图片:下载后保存
                  const timestamp = systemDateTime.getTime();
                  filePath = await this.copyUrlPicToDir(
                    this.imgUrl,
                    `onlinePic_${timestamp}.png`
                  );
                }

                if (filePath) {
                  // 创建媒体库写入请求
                  const phAccessHelper = photoAccessHelper.getPhotoAccessHelper(
                    this.context
                  );
                  const changeRequest =
                    photoAccessHelper.MediaAssetChangeRequest.createImageAssetRequest(
                      this.context,
                      filePath
                    );
                  await phAccessHelper.applyChanges(changeRequest);
                  console.info('图片保存成功:', filePath);
                }
              } catch (err) {
                console.error(`保存失败:${err.code}, ${err.message}`);
              }
            } else {
              console.error('SaveButton 点击结果不是 SUCCESS');
            }
            this.showMenu = false;
          })
      }
      .margin({ top: 20, bottom: 20 })
      .justifyContent(FlexAlign.Center)
    }
    .width(200)
    .backgroundColor(Color.White)
    .borderRadius(10)
    .shadow({ radius: 10, color: '#20000000' })
  }

  build() {
    Column() {
      Web({ src: $rawfile('index.html'), controller: this.controller })
        .onContextMenuShow((event) => {
          if (event) {
            // 使用 getLastHitTest 获取长按位置的图片信息
            const hitValue = this.controller.getLastHitTest();
            this.imgUrl = hitValue.extra; // extra 里是图片的 src URL
          }
          this.showMenu = true;
          return true; // 阻止系统菜单
        })
        .bindContextMenu(this.MenuBuilder, ResponseType.LongPress)
        .fileAccess(true)
        .javaScriptAccess(true)
        .domStorageAccess(true)
        .width('100%')
        .height('100%')
    }
  }
}

getLastHitTest 是什么

controller.getLastHitTest() 返回最近一次触摸测试的结果:

const hitValue = this.controller.getLastHitTest();
// hitValue.type:命中元素的类型(图片/链接/文字等)
// hitValue.extra:附加信息
//   - 如果是图片:extra 是图片的 src URL
//   - 如果是链接:extra 是链接的 href

注意:要在 onContextMenuShow 回调里调,时机太早或太晚都可能拿到错误的结果。

SaveButton 外观配置

SaveButtonOptions 有三个属性:

属性可选值说明
iconSaveIconStyle.FULL_FILLED / LINE图标样式
textSaveDescription.SAVE_IMAGE / SAVE_FILE / SAVE按钮文字
buttonTypeButtonType.Capsule / Normal / Circle按钮形状

图片保存到相册的核心步骤

// 步骤1:获取 photoAccessHelper 实例
const phAccessHelper = photoAccessHelper.getPhotoAccessHelper(context);

// 步骤2:创建图片资源写入请求(传入本地文件路径)
const changeRequest =
  photoAccessHelper.MediaAssetChangeRequest.createImageAssetRequest(
    context,
    localFilePath  // 必须是 App 沙盒范围内的路径
  );

// 步骤3:应用变更(实际写入)
await phAccessHelper.applyChanges(changeRequest);

注意:localFilePath 必须是 App 沙盒内的路径(filesDircacheDir 等),不能是 rawfile 的 fd 路径。所以网络图片和 rawfile 图片都需要先复制到 filesDir 里。

常见问题

Q:SaveButton 点击后 result 不是 SUCCESS

检查 saveButtonOptions 配置是否正确,texticon 至少要设置一个。另外 SaveButton 在布局上要完全可见,被其他组件遮挡会导致点击无效。

Q:图片保存成功但相册里看不到

检查 createImageAssetRequest 传入的路径是否正确。rawfile 的 fd 路径(形如 /dev/rawfile/xxx)不能直接用,必须先复制到 filesDir 下的真实文件路径。

写在最后

SaveButton 这个安全控件是 HarmonyOS 安全设计的体现——用户主动点击才授权,而不是应用在后台悄悄写入相册。配合上完整的图片下载逻辑,保存图片到相册的体验做起来还是很流畅的。

转载自CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/qq_33681891/article/details/161145851

评论

赞0

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--