HarmonyOS实战教程《台词拼图》(三)—— 图片选择与组件截图保存

HarmonyOS实战教程(三)------ 图片选择与组件截图保存

从相册选图、组件截图、打包编码、保存到相册------完整图片操作链路解析。

一、整体流程概览

LineCard 中图片操作的核心链路如下:

复制代码
选择图片(PhotoViewPicker) → 加载显示(Image组件) → 组件截图(componentSnapshot)
→ 打包编码(ImagePacker) → 写入临时文件(fileIo) → 保存到相册(PhotoAccessHelper)

二、图片选择:PhotoViewPicker

2.1 单张图片选择

水印页和自定义台词页只需要选择一张图片:

typescript 复制代码
// WatermarkPage.ets
async selectImage() {
  try {
    let photoSelectOptions = new picker.PhotoSelectOptions();
    photoSelectOptions.MIMEType = picker.PhotoViewMIMETypes.IMAGE_TYPE;  // 只选图片
    photoSelectOptions.maxSelectNumber = 1;                              // 最多1张

    let photoPicker = new picker.PhotoViewPicker();
    let result = await photoPicker.select(photoSelectOptions);

    if (result.photoUris && result.photoUris.length > 0) {
      this.imageUri = result.photoUris[0];  // 获取图片URI
    }
  } catch (err) {
    console.error(`选择图片失败: ${JSON.stringify(err)}`);
  }
}

2.2 多张图片选择

电影台词拼图页支持选择多张图片(最多20张):

typescript 复制代码
// StackLinePage.ets
async selectImages() {
  let photoSelectOptions = new picker.PhotoSelectOptions();
  photoSelectOptions.MIMEType = picker.PhotoViewMIMETypes.IMAGE_TYPE;
  photoSelectOptions.maxSelectNumber = 20;

  let photoPicker = new picker.PhotoViewPicker();
  let result = await photoPicker.select(photoSelectOptions);

  if (result.photoUris && result.photoUris.length > 0) {
    await this.loadImages(result.photoUris);  // 批量加载
  }
}

2.3 追加图片

已有的图片基础上追加更多:

typescript 复制代码
// StackLinePage.ets
async appendImages() {
  let photoSelectOptions = new picker.PhotoSelectOptions();
  photoSelectOptions.MIMEType = picker.PhotoViewMIMETypes.IMAGE_TYPE;
  photoSelectOptions.maxSelectNumber = 10;  // 追加最多10张

  let photoPicker = new picker.PhotoViewPicker();
  let result = await photoPicker.select(photoSelectOptions);

  if (result.photoUris && result.photoUris.length > 0) {
    await this.loadImages(result.photoUris);  // 追加到已有列表
  }
}

2.4 关键注意事项

  1. picker 来自 @kit.CoreFileKitimport { picker } from '@kit.CoreFileKit';
  2. photoUris 返回的是 URI 格式(如 file://...),可以直接赋给 Image 组件的 src
  3. MIMEType 设置为 IMAGE_TYPE 可以过滤掉视频等其他类型。

三、图片尺寸获取:onComplete 回调

图片选择后需要获取原始尺寸,用于后续的画布计算。这通过 Image 组件的 onComplete 回调实现:

typescript 复制代码
// CustomLinePage.ets
Image(this.imageUri)
  .width('100%')
  .objectFit(ImageFit.ScaleDown)
  .onComplete((msg) => {
    if (msg) {
      this.updateImageSize(msg.width, msg.height);
    }
  })

onComplete 返回的尺寸是原始图片的像素尺寸(不是渲染尺寸),这对计算缩放比例至关重要。

3.1 延迟更新策略

在多图场景下,图片是先添加到列表,等 onComplete 回调时才更新尺寸:

typescript 复制代码
// StackLinePage.ets - loadImages
async loadImages(uris: string[]) {
  for (let uri of uris) {
    const newItem: ImageItem = {
      uri: uri,
      id: `img_${Date.now()}_${Math.random()}`,
      width: 0,    // 初始尺寸未知
      height: 0
    };
    this.imageList.push(newItem);
  }
  this.gridDataSource.setDataArray(this.imageList);
  this.calculateCanvasSize();  // 用0尺寸先算一次
}

// 图片加载完成回调中更新
updateImageSize(index: number, width: number, height: number) {
  if (this.imageList[index].width === 0) {
    // 首次获取尺寸,检查分辨率是否匹配
    if (!this.isResolutionSimilar(width, height)) {
      this.imageList.splice(index, 1);
      return;
    }
  }
  this.imageList[index].width = width;
  this.imageList[index].height = height;
  this.imageList = [...this.imageList];  // 触发UI更新
  this.calculateCanvasSize();             // 重新计算
}

四、画布尺寸动态计算

4.1 单图 + 台词(CustomLinePage)

typescript 复制代码
calculateCanvasSize() {
  if (!this.imageUri || this.imageWidth === 0) {
    this.canvasHeight = 0;
    return;
  }

  // 图片按宽度100%缩放后的高度
  let scaledImageHeight = (this.imageHeight / this.imageWidth) * this.canvasWidth;

  // 台词高度:每条38px
  const dialogueHeight = this.dialogueList.length * 38;

  // 总高度 = 图片高度 + 台词高度
  this.canvasHeight = scaledImageHeight + dialogueHeight;
}

4.2 多图堆叠(StackLinePage)

堆叠场景更复杂------每张图片有不同的偏移量:

typescript 复制代码
calculateCanvasSize() {
  if (this.imageList.length === 0) {
    this.canvasHeight = 0;
    return;
  }

  // 1. 计算每张图片缩放后的高度
  let scaledHeights: number[] = [];
  for (let i = 0; i < this.imageList.length; i++) {
    let img = this.imageList[i];
    let imgWidth = img.width || 300;
    let imgHeight = img.height || 300;
    let actualWidth = Math.min(imgWidth, this.canvasWidth);
    let scaledHeight = (imgHeight / imgWidth) * actualWidth;
    scaledHeights.push(scaledHeight);
  }

  // 2. 计算画布总高度 = max(所有图片的 bottom 位置)
  let maxBottom = 0;
  for (let i = 0; i < this.imageList.length; i++) {
    const position = (this.imageList.length - 1 - i) * this.globalOffset;
    const bottom = position + scaledHeights[i];
    if (bottom > maxBottom) maxBottom = bottom;
  }
  this.canvasHeight = maxBottom;

  // 3. 计算滑块最大偏移量(完全展开不重叠)
  if (this.imageList.length > 1) {
    let totalHeight = scaledHeights.reduce((sum, h) => sum + h, 0);
    this.maxOffset = Math.ceil(
      (totalHeight - scaledHeights[0]) / (this.imageList.length - 1)
    );
    this.maxOffset = Math.max(this.maxOffset, 50);
  }
}

核心思想: 画布高度不是简单的图片高度之和,而是要找到所有图片中底部位置最靠下的那个(因为图片有偏移,底部位置 = offset + height)。

五、组件截图:componentSnapshot

这是 LineCard 最关键的技术点之一------将 UI 组件直接截图为 PixelMap。

5.1 给目标组件设置 id

typescript 复制代码
// CustomLinePage.ets - 预览区
Column() {
  Image(this.imageUri).width('100%')
  Column() {
    ForEach(this.dialogueList, (dialogue: DialogueItem) => {
      Text(dialogue.text)...
    })
  }
}
.id(this.stackId)  // 关键:设置id,用于截图定位
.width('100%')
.height(this.canvasHeight > 0 ? this.canvasHeight : 300)

5.2 截图

typescript 复制代码
// 核心代码(所有页面通用)
async generateAndSave() {
  // 1. 等待布局稳定
  await this.delay(500);

  // 2. 截取指定id的组件
  let pixelMap = await componentSnapshot.get(this.stackId);

  // 3. 后续打包保存...
}

关键注意事项:

  1. 延迟等待 :截图前必须 delay(500),确保布局完全渲染完成。否则可能截到空白或不完整的画面。
  2. 组件 id :只有设置了 id 的组件才能被截图。
  3. componentSnapshot 来自 @kit.ArkUIimport { componentSnapshot } from '@kit.ArkUI';

六、图片打包:ImagePacker

将 PixelMap 编码为 JPEG/PNG 格式的 Buffer:

typescript 复制代码
let imagePackerApi = image.createImagePacker();
let packOpts: image.PackingOption = {
  format: 'image/jpeg',
  quality: 95     // JPEG质量(1-100)
};
let buffer = await imagePackerApi.packing(pixelMap, packOpts);
console.info(`图片打包完成,大小: ${buffer.byteLength} bytes`);

image 来自 @kit.ImageKitimport { image } from '@kit.ImageKit';

七、保存到相册:完整流程

这是最复杂的部分,涉及文件写入、URI转换、用户授权等多个步骤。

7.1 完整保存流程

typescript 复制代码
async generateAndSave() {
  this.isGenerating = true;
  let tempFilePath = '';

  try {
    // Step 1: 截图
    await this.delay(500);
    let pixelMap = await componentSnapshot.get(this.stackId);

    // Step 2: 打包为JPEG
    let imagePackerApi = image.createImagePacker();
    let packOpts: image.PackingOption = { format: 'image/jpeg', quality: 95 };
    let buffer = await imagePackerApi.packing(pixelMap, packOpts);

    // Step 3: 写入应用沙箱临时文件
    let context = getContext(this) as common.UIAbilityContext;
    let filesDir = context.filesDir;                        // 应用沙箱文件目录
    let fileName = `line${Date.now()}.jpg`;                 // 用时间戳命名
    tempFilePath = `${filesDir}/${fileName}`;

    let tempFile = fs.openSync(tempFilePath, fs.OpenMode.CREATE | fs.OpenMode.READ_WRITE);
    fs.writeSync(tempFile.fd, buffer);
    fs.closeSync(tempFile.fd);

    // Step 4: 等待文件写入完成
    await this.delay(100);

    // Step 5: 验证文件
    let stat = fs.statSync(tempFilePath);
    if (stat.size === 0) {
      throw new Error('文件写入失败,大小为0');
    }

    // Step 6: 路径转URI
    let srcFileUri = fileUri.getUriFromPath(tempFilePath);

    // Step 7: 配置保存选项
    let photoCreationConfigs: Array<photoAccessHelper.PhotoCreationConfig> = [{
      fileNameExtension: 'jpg',
      photoType: photoAccessHelper.PhotoType.IMAGE
    }];

    // Step 8: 弹窗授权保存到系统相册
    let desFileUris: Array<string> = await this.phAccessHelper.showAssetsCreationDialog(
      [srcFileUri],
      photoCreationConfigs
    );

    // Step 9: 复制文件到相册
    if (desFileUris && desFileUris.length > 0) {
      let desFile = await fs.open(desFileUris[0], fs.OpenMode.WRITE_ONLY);
      let srcFile = await fs.open(tempFilePath, fs.OpenMode.READ_ONLY);
      await fs.copyFile(srcFile.fd, desFile.fd);
      fs.closeSync(srcFile);
      fs.closeSync(desFile);
    }

  } catch (err) {
    // 错误处理...
  } finally {
    // Step 10: 清理临时文件(延迟2秒,确保复制完成)
    setTimeout(() => {
      if (tempFilePath && fs.accessSync(tempFilePath)) {
        fs.unlinkSync(tempFilePath);
      }
    }, 2000);

    this.isGenerating = false;
  }
}

7.2 流程图

复制代码
截图(PixelMap) → 打包(JPEG Buffer) → 写临时文件 → 转URI
→ showAssetsCreationDialog(用户授权) → 复制到相册 → 清理临时文件

7.3 各步骤详解

Step 3:写入临时文件

为什么不能直接保存到相册?因为 HarmonyOS 的安全模型要求:

  • 应用只能写入自己的沙箱目录(context.filesDir
  • 保存到系统相册需要通过 PhotoAccessHelper 的授权流程
typescript 复制代码
import { fileIo as fs } from '@kit.CoreFileKit';
import { fileUri } from '@kit.CoreFileKit';
Step 6:路径转URI

fileUri.getUriFromPath() 将沙箱路径转为系统可识别的 URI 格式。这一步不能省略,否则 showAssetsCreationDialog 会报错。

Step 8:用户授权弹窗

showAssetsCreationDialog 会弹出系统级对话框,让用户确认是否保存到相册。这是 HarmonyOS 的隐私保护机制,应用无法绕过。

用户同意后返回目标文件的 URI 数组;用户拒绝则返回空数组。

Step 10:延迟清理

临时文件不能立即删除,因为 copyFile 可能还在进行中。延迟 2 秒清理是安全的做法。

7.4 错误码处理

typescript 复制代码
catch (err) {
  let error = err as BusinessError;
  if (error.code === 13900042) {
    this.showAlert('用户拒绝授权');
  } else if (error.code === 14000011) {
    this.showAlert('系统内部错误,请重启应用');
  } else if (error.code === 100015) {
    this.showAlert('系统相册响应超时');
  } else {
    this.showAlert(`保存失败,错误码: ${error.code}`);
  }
}
错误码 含义 处理方式
13900042 用户拒绝授权 提示用户
14000011 系统内部错误 建议重启
100015 相册响应超时 建议稍后重试

7.5 批量保存(图片分割)

图片分割功能需要一次保存多张图片:

typescript 复制代码
// ImageSplitPage.ets - saveImages
let srcFileUris: string[] = [];
let photoCreationConfigs: Array<photoAccessHelper.PhotoCreationConfig> = [];

for (let i = 0; i < this.puzzleItems.length; i++) {
  // 打包每一块
  const buffer = await imagePackerObj.packing(item.pixelMap, packOpts);
  // 写入临时文件
  let tempFilePath = `${filesDir}/split_${Date.now()}_${i}.jpg`;
  fs.writeSync(fs.openSync(tempFilePath, fs.OpenMode.CREATE | fs.OpenMode.READ_WRITE).fd, buffer);
  // 收集URI
  srcFileUris.push(fileUri.getUriFromPath(tempFilePath));
  photoCreationConfigs.push({ fileNameExtension: 'jpg', photoType: photoAccessHelper.PhotoType.IMAGE });
}

// 一次性弹窗授权保存所有图片
let desFileUris = await this.phAccessHelper.showAssetsCreationDialog(
  srcFileUris,
  photoCreationConfigs
);

// 批量复制
for (let i = 0; i < desFileUris.length; i++) {
  let desFile = await fs.open(desFileUris[i], fs.OpenMode.WRITE_ONLY);
  let srcFile = await fs.open(tempFilePaths[i], fs.OpenMode.READ_ONLY);
  await fs.copyFile(srcFile.fd, desFile.fd);
  fs.closeSync(srcFile);
  fs.closeSync(desFile);
}

关键点: 一次传入多个 URI 和配置,系统弹窗只出现一次,用户同意后批量保存。

八、PhotoAccessHelper 初始化

typescript 复制代码
// 在 aboutToAppear 中初始化
aboutToAppear() {
  let context = getContext(this) as common.UIAbilityContext;
  this.phAccessHelper = photoAccessHelper.getPhotoAccessHelper(context);
}

photoAccessHelper 来自 @kit.MediaLibraryKit

九、资源释放

图片分割页特别注重资源管理,在 aboutToDisappear 中释放所有资源:

typescript 复制代码
// ImageSplitPage.ets
aboutToDisappear() {
  this.closeCurrentImage();       // 关闭文件描述符
  this.closePreviousImage();      // 清理之前的临时文件
  this.releasePreviewPixelMaps(); // 释放预览PixelMap
  this.releasePuzzleItems();      // 释放保存用PixelMap
}

private releasePreviewPixelMaps() {
  this.previewPixelMaps.forEach((pixelMap) => {
    try {
      if (pixelMap) pixelMap.release();
    } catch (err) {
      console.warn(`释放PixelMap失败: ${JSON.stringify(err)}`);
    }
  });
  this.previewPixelMaps = [];
}

PixelMap 不释放会导致内存泄漏,特别是在频繁切换图片的场景中。

十、总结

本篇完整拆解了 LineCard 的图片操作链路:

步骤 API 来源 Kit
图片选择 PhotoViewPicker @kit.CoreFileKit
尺寸获取 Image.onComplete @kit.ArkUI
组件截图 componentSnapshot.get @kit.ArkUI
图片打包 ImagePacker.packing @kit.ImageKit
文件写入 fileIo.openSync/writeSync @kit.CoreFileKit
路径转URI fileUri.getUriFromPath @kit.CoreFileKit
保存到相册 showAssetsCreationDialog @kit.MediaLibraryKit
文件复制 fileIo.copyFile @kit.CoreFileKit

核心经验:

  1. 截图前要延迟等待布局完成。
  2. 保存到相册必须经过用户授权,无法静默保存。
  3. 临时文件要及时清理,但也不能过早删除。
  4. PixelMap 要显式释放,避免内存泄漏。

下一篇,我们将深入 图片裁剪、分割与水印 的具体实现。


系列导航:

相关推荐
●VON1 小时前
鸿蒙 PC Markdown 编辑器原生右键弹层兼容
安全·华为·编辑器·harmonyos·鸿蒙
达子6662 小时前
第9章_HarmonyOs图解 用Java开发UI
java·ui·harmonyos
网络工程小王4 小时前
【HCIE-AI】10.pytorch模型迁移分析
人工智能·学习·华为·llama
ZENERGY-众壹4 小时前
跨品牌逆变器升级:华为锦浪德业 API 对接的 7 个坑
分布式·华为·数据归一化·光伏运维·逆变器api
●VON4 小时前
鸿蒙 PC Markdown 编辑器系统浏览器与图片查看器独立验收
华为·编辑器·harmonyos·鸿蒙
红烧大青虫5 小时前
HarmonyOS开发实战:小分享-WaterFlow 瀑布流布局实现模板墙
华为·harmonyos·鸿蒙
程序员黑豆5 小时前
鸿蒙应用开发:Stack堆叠组件实战——实现微信消息角标效果
前端·harmonyos
Catrice05 小时前
HarmonyOS ArkTS 实战:实现一个心情日记与情绪追踪应用
华为·harmonyos
条tiao条6 小时前
鸿蒙本地存储三剑客
华为·harmonyos·arkts·鸿蒙·存储
●VON6 小时前
鸿蒙 PC Markdown 编辑器通信架构:受限 ArkTS-JavaScript Bridge
华为·架构·编辑器·harmonyos·鸿蒙