HarmonyOS趣味相机实战第26篇:ArkUI拍照结果预览、PixelMap释放与操作分流

摘要

相机调用 capture() 成功只是拍照流程的中点。用户还需要看结果、核对水印、决定重拍、保存到相册或转成图片文档。这个短暂的结果页同时持有大体积 PixelMap、照片元数据和多个异步操作,如果关闭与保存都能重复触发,很容易出现资源未释放、同一照片重复落盘、页面跳转错乱或空白预览。

本文基于 D:/APP/1quweixiangjiIndex.ets,从状态建模、Stack 图层合成、空资源占位、文字溢出、操作分流、PixelMap 所有权和防重入几个方面复盘拍照结果预览。最终目标是形成一个明确状态机:一张预览图只有一个拥有者,每条退出路径只提交一次副作用,并且都能回收图像资源。

工程环境与涉及对象

项目 配置
开发语言 ArkTS
UI ArkUI 声明式组件
图像对象 @kit.ImageKit PixelMap
照片元数据 CapturedPhoto
本地相册 PhotoAlbumService
图片文档 PhotoDocumentService
结果弹层 ResultPreviewDialog
预览图高度 250 vp

HarmonyOS趣味相机结果预览流程

一、结果页不是一个普通弹窗

它承担四类职责:

  1. 展示本次真实拍摄的 PixelMap。
  2. 展示与拍摄时一致的水印快照。
  3. 提供重拍、转文档、保存三条互斥出口。
  4. 释放不再使用的图像资源并更新主页面状态。

因此不要把所有逻辑直接写进三个按钮的 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 是唯一显示条件。关闭时只要先把该状态清空,弹层就会离开组件树。不要同时维护 showDialogpreviewPhoto 两个布尔/对象状态,否则可能出现“弹层已隐藏但资源仍被持有”或“弹层显示却没有照片”的非法组合。

如果业务确实需要加载态,可改为显式联合状态:

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)
      }
    }
  }
}

如果结果页直接绑定 customPlacecurrentTimeText,用户在异步等待期间修改模板,预览文案可能与照片记录不一致。以 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() 有两个作用:

  1. UI 立即失去对旧对象的引用。
  2. 第二次进入释放方法时不会重复释放同一对象。

这相当于一次所有权转移:局部变量临时接管释放责任,成员状态不再拥有该资源。

九、关闭路径必须同时清理两类状态

重拍与右上角关闭都调用同一方法:

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

每次只允许一个转换。特别是 savingconverting 不能并发;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 由谁释放、页面跳到哪里、重复点击如何处理”,结果预览就不再是容易泄漏的临时弹窗,而是相机主链路中可测试、可维护的一段事务流程。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐