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 关键注意事项
picker来自@kit.CoreFileKit:import { picker } from '@kit.CoreFileKit';photoUris返回的是 URI 格式(如file://...),可以直接赋给Image组件的src。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. 后续打包保存...
}
关键注意事项:
- 延迟等待 :截图前必须
delay(500),确保布局完全渲染完成。否则可能截到空白或不完整的画面。 - 组件 id :只有设置了
id的组件才能被截图。 - componentSnapshot 来自
@kit.ArkUI:import { 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.ImageKit:import { 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 |
核心经验:
- 截图前要延迟等待布局完成。
- 保存到相册必须经过用户授权,无法静默保存。
- 临时文件要及时清理,但也不能过早删除。
- PixelMap 要显式释放,避免内存泄漏。
下一篇,我们将深入 图片裁剪、分割与水印 的具体实现。
系列导航: