HarmonyOS趣味相机实战第26篇:ArkUI拍照结果预览、PixelMap释放与操作分流
HarmonyOS趣味相机实战第26篇:ArkUI拍照结果预览、PixelMap释放与操作分流
摘要
相机调用 capture() 成功只是拍照流程的中点。用户还需要看结果、核对水印、决定重拍、保存到相册或转成图片文档。这个短暂的结果页同时持有大体积 PixelMap、照片元数据和多个异步操作,如果关闭与保存都能重复触发,很容易出现资源未释放、同一照片重复落盘、页面跳转错乱或空白预览。
本文基于 D:/APP/1quweixiangji 的 Index.ets,从状态建模、Stack 图层合成、空资源占位、文字溢出、操作分流、PixelMap 所有权和防重入几个方面复盘拍照结果预览。最终目标是形成一个明确状态机:一张预览图只有一个拥有者,每条退出路径只提交一次副作用,并且都能回收图像资源。
工程环境与涉及对象
| 项目 | 配置 |
|---|---|
| 开发语言 | ArkTS |
| UI | ArkUI 声明式组件 |
| 图像对象 | @kit.ImageKit PixelMap |
| 照片元数据 | CapturedPhoto |
| 本地相册 | PhotoAlbumService |
| 图片文档 | PhotoDocumentService |
| 结果弹层 | ResultPreviewDialog |
| 预览图高度 | 250 vp |

一、结果页不是一个普通弹窗
它承担四类职责:
- 展示本次真实拍摄的 PixelMap。
- 展示与拍摄时一致的水印快照。
- 提供重拍、转文档、保存三条互斥出口。
- 释放不再使用的图像资源并更新主页面状态。
因此不要把所有逻辑直接写进三个按钮的 onClick。页面只表达交互,资源和数据操作放在具名方法里,更容易审查每条路径是否完整。
二、用两个状态表达图像与元数据
项目保留:
@State private previewPhoto: CapturedPhoto | null = null;
@State private previewPhotoPixelMap: image.PixelMap | null = null;
两者含义不同:
previewPhoto是轻量元数据,控制弹层是否出现。previewPhotoPixelMap是需要显式释放的原生图像资源。
拍照成功后的赋值顺序应让 UI 不观察到不完整状态:
this.previewPhotoPixelMap = captureState.photoPixelMap;
this.previewShotIndex += 1;
this.previewPhoto = PhotoAlbumService.createPhoto(
this.previewShotIndex,
[],
'无滤镜',
'无相框',
'标准模式',
'real',
`${captureState.message} · ${this.resolutionLabel()}`,
0,
'标准',
0,
this.resolutionLabel(),
this.watermarkSnapshot()
);
先准备 PixelMap,再让 previewPhoto 触发弹层,可以减少第一帧只出现占位背景的概率。
三、弹层显示条件必须唯一
页面根 Stack 根据状态挂载结果预览:
build() {
Stack() {
this.AppTabs()
if (this.previewPhoto) {
this.ResultPreviewDialog()
}
}
}
previewPhoto !== null 是唯一显示条件。关闭时只要先把该状态清空,弹层就会离开组件树。不要同时维护 showDialog 与 previewPhoto 两个布尔/对象状态,否则可能出现“弹层已隐藏但资源仍被持有”或“弹层显示却没有照片”的非法组合。
如果业务确实需要加载态,可改为显式联合状态:
type PreviewPhase = 'idle' | 'loading' | 'ready' | 'submitting';
状态越清晰,按钮禁用与资源释放越容易对应。
四、用Stack分离底图与语义覆盖层
结果图采用 Stack:
Stack() {
if (this.previewPhotoPixelMap !== null) {
Image(this.previewPhotoPixelMap)
.width('100%')
.height(250)
.objectFit(ImageFit.Cover)
.borderRadius(22)
} else {
Column()
.width('100%')
.height(250)
.backgroundColor('#1B1B24')
.borderRadius(22)
}
this.ResultStatusOverlay()
this.ResultWatermarkOverlay()
}
.width('100%')
.height(250)
.clip(true)
底图、状态文案和水印是三个独立图层。这样的好处是:PixelMap 不可用时仍有稳定尺寸;水印文本不参与图像旋转或镜像;所有子层受统一裁剪,不会越过圆角区域。
五、固定尺寸避免异步资源造成跳动
图像加载前后都设置 250 vp 高度,外层也固定 250 vp。若占位只写背景色而不写高度,PixelMap 到达时弹层会突然增高,三个操作按钮整体下移。
稳定布局至少需要:
外层 Stack 高度 = 图片高度 = 占位高度
图片宽度 = 100%
objectFit 策略明确
clip 在统一父容器执行
对于拍照结果,ImageFit.Cover 能填满区域,但会裁剪边缘。若用户需要核对完整构图,应改为 Contain 并为留白提供中性色背景。选择 Cover 还是 Contain 是产品决策,不能依赖默认值。
六、水印使用拍摄快照而不是当前输入框
项目只在拍摄时记录 WatermarkSnapshot,结果页读取照片上的快照:
if (this.watermarkEnabled && this.previewPhoto?.watermark) {
Column() {
Blank()
Row() {
Column({ space: 2 }) {
Text(this.previewPhoto.watermark.title)
Text(this.previewPhoto.watermark.locationText)
}
.layoutWeight(1)
Column({ space: 2 }) {
Text(this.previewPhoto.watermark.timeText)
Text(this.previewPhoto.watermark.note)
}
}
}
}
如果结果页直接绑定 customPlace 或 currentTimeText,用户在异步等待期间修改模板,预览文案可能与照片记录不一致。以 previewPhoto.watermark 为准,能保证“看到的结果”和“最终保存的元数据”来自同一拍摄时刻。
条件也应优先读取快照自身:
const watermark = this.previewPhoto?.watermark;
if (watermark?.enabled === true) {
// render snapshot
}
这样当前页面总开关变化不会影响已经拍下的结果。
七、长文案必须有溢出策略
地点和备注来自用户输入,长度不可预测。状态摘要已经使用:
Text(this.previewPhoto?.captureSummary ?? this.captureStatusText)
.fontSize(11)
.fontColor('#FFFFFF')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
水印区域也应设置边界:
Text(watermark.note)
.fontSize(11)
.fontColor('#E9F1ED')
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
不能只限制字体大小而不限制布局。极长连续字符可能挤压时间列,推荐给左右列明确 layoutWeight,并对文字设置最大行数。
八、PixelMap所有权必须写进代码结构
项目集中释放预览图:
private async releasePreviewPixelMap(): Promise<void> {
const pixelMap: image.PixelMap | null =
this.previewPhotoPixelMap;
this.previewPhotoPixelMap = null;
if (pixelMap !== null) {
try {
await pixelMap.release();
} catch (_) {
}
}
}
先把成员设为 null,再 await release() 有两个作用:
- UI 立即失去对旧对象的引用。
- 第二次进入释放方法时不会重复释放同一对象。
这相当于一次所有权转移:局部变量临时接管释放责任,成员状态不再拥有该资源。
九、关闭路径必须同时清理两类状态
重拍与右上角关闭都调用同一方法:
private async closePreview(): Promise<void> {
this.previewPhoto = null;
await this.releasePreviewPixelMap();
}
不要在两个按钮中分别写一份清理代码。重复代码很容易让一个路径只清理元数据、另一个路径只释放图片。
页面销毁时也要兜底:
async aboutToDisappear(): Promise<void> {
await this.closePreview();
await CameraPreviewService.release();
}
具体生命周期签名按工程 API 版本调整,但原则是离开页面后不能继续持有大图资源。
十、保存到相册的提交顺序
项目保存流程:
private async savePreviewPhoto(): Promise<void> {
if (!this.previewPhoto) {
return;
}
this.album = await PhotoAlbumService.persistPhoto(
this.previewPhoto
);
this.previewPhoto = null;
await this.releasePreviewPixelMap();
this.activeTab = 1;
this.captureStatusText = '照片已保存到本地相册';
}
顺序体现了提交语义:先确认存在照片,再等待持久化,成功后清理临时状态,最后跳转相册并更新提示。若持久化抛错,预览仍保留,用户可以重试,不会发生数据没保存但弹层已经消失。
十一、转文档是另一条互斥出口
private async convertPreviewPhotoToDocument(): Promise<void> {
if (!this.previewPhoto) {
return;
}
this.documents = await PhotoDocumentService.convertPhoto(
this.previewPhoto
);
this.previewPhoto = null;
await this.releasePreviewPixelMap();
this.activeTab = 2;
this.captureStatusText = '已生成照片文档';
}
它与保存相册共享清理步骤,但业务副作用不同。可以抽取只处理退出的私有方法,不能把两个服务调用粗暴合并:
private async finishPreview(targetTab: number): Promise<void> {
this.previewPhoto = null;
await this.releasePreviewPixelMap();
this.activeTab = targetTab;
}
服务调用成功后再调用 finishPreview,错误时保持当前预览。
十二、按钮必须防止异步重复提交
用户快速连点“保存到相册”两次,两个异步函数都可能在第一轮清空状态前读取到同一个 previewPhoto。需要提交锁:
@State private previewSubmitting: boolean = false;
private async savePreviewPhoto(): Promise<void> {
if (!this.previewPhoto || this.previewSubmitting) {
return;
}
this.previewSubmitting = true;
try {
this.album = await PhotoAlbumService.persistPhoto(
this.previewPhoto
);
await this.finishPreview(1);
this.captureStatusText = '照片已保存到本地相册';
} catch (error) {
this.captureStatusText = '保存失败,请重试';
} finally {
this.previewSubmitting = false;
}
}
三个操作按钮在提交期间都应禁用,避免保存与转文档同时发生:
Button('保存到相册')
.enabled(!this.previewSubmitting)
.onClick(() => this.savePreviewPhoto())
十三、不要让按钮文字挤坏三列布局
项目使用 Row({ space: 10 }) 与三个 layoutWeight(1) 按钮。三列在窄屏上可用,但要检查较大字体和本地化文本。
可选策略:
- 主操作“保存”占两份权重,次操作使用图标或短文本。
- 小屏改为两行布局,第一行两个次操作,第二行主操作。
- 按钮固定最小高度,不用文字撑开。
- 使用系统字体放大 1.3 倍做真机检查。
按钮视觉层级应与风险对应:保存使用主色,重拍与转文档使用次色。关闭按钮不能和保存按钮同等突出。
十四、错误反馈要留在当前上下文
持久化失败时不应直接关闭弹层。推荐保留照片并显示可重试提示:
catch (error) {
hilog.error(DOMAIN, TAG,
'persist preview failed: %{public}s',
JSON.stringify(error));
this.captureStatusText = '保存失败,请稍后重试';
}
日志记录错误类型,不记录水印备注、地点或 PixelMap 内容。用户看到的是可执行建议,开发者看到的是诊断信息,两者职责分开。
十五、状态机比零散布尔值更容易验证
结果预览可以画成以下状态:
idle
-> capturing
-> ready
-> discarding -> idle
-> saving -> album
-> converting -> documents
-> failed -> ready
每次只允许一个转换。特别是 saving 与 converting 不能并发;discarding 后旧异步任务不能再把状态写回页面。
若页面复杂度继续增长,可以把 phase、photo 和 pixelMap 包进一个受控模型,而不是增加多个互相依赖的布尔变量。
十六、资源与数据的测试矩阵
| 场景 | 元数据 | PixelMap | 页面结果 |
|---|---|---|---|
| 拍照成功 | 创建 preview | 持有一次 | 显示弹层 |
| 右上角关闭 | 清空 | release 一次 | 回拍摄页 |
| 点击重拍 | 清空 | release 一次 | 回拍摄页 |
| 保存成功 | 写入 saved 后清空 | release 一次 | 切相册 |
| 保存失败 | 保留 | 保留 | 显示重试 |
| 转文档成功 | 创建文档后清空 | release 一次 | 切文档页 |
| 转文档失败 | 保留 | 保留 | 显示重试 |
| 连点保存 | 只提交一次 | release 一次 | 无重复记录 |
| 页面退出 | 清空 | release 一次 | 无资源泄漏 |
可以为释放器注入计数代理,验证每个 PixelMap 最多释放一次;服务层用 fake 实现控制成功、失败和延迟。
十七、UI验收清单
- PixelMap 到达前后弹层尺寸不跳动。
- Cover 裁剪符合产品预期,关键内容不被误裁。
- 水印读取拍摄时快照,不跟随当前模板变化。
- 地点、备注和摘要超长时不会覆盖按钮。
- 三个出口在提交期间互斥且不可重复点击。
- 保存失败或转文档失败时预览仍可重试。
- 关闭、重拍、保存、转文档都会进入统一清理流程。
- 页面退出有 PixelMap 释放兜底。
- 日志不包含图片、地点和备注等用户内容。
- 大字体与窄屏下按钮仍可识别和点击。
十八、常见问题定位
| 现象 | 高概率原因 | 修复点 |
|---|---|---|
| 连拍后内存持续上涨 | 旧 PixelMap 未释放 | 统一 release 方法 |
| 点击两次出现两条照片 | 异步提交未加锁 | previewSubmitting |
| 保存失败后照片消失 | 清理发生在 await 之前 | 成功后再 finish |
| 水印内容突然变化 | 结果页读取当前输入状态 | 读取 WatermarkSnapshot |
| 空白图导致布局塌陷 | 占位没有稳定尺寸 | Stack/Image/占位同高 |
| 文字越过图片边界 | 缺少 clip 或 maxLines | 父容器裁剪与溢出策略 |
总结
拍照结果页的核心不是把 PixelMap 放进 Image,而是把资源所有权和用户决策建模清楚。previewPhoto 控制弹层存在,previewPhotoPixelMap 由统一释放器管理;水印来自拍摄快照;保存、转文档和重拍是互斥状态转换;任何失败都保留可重试上下文。
当每条出口都能回答“数据是否已提交、PixelMap 由谁释放、页面跳到哪里、重复点击如何处理”,结果预览就不再是容易泄漏的临时弹窗,而是相机主链路中可测试、可维护的一段事务流程。
更多推荐



所有评论(0)