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

摘要

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

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

工程环境与涉及对象

项目配置
开发语言ArkTS
UIArkUI 声明式组件
图像对象@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 是唯一显示条件。关闭时只要先把该状态清空,弹层就会离开组件树。不要同时维护 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() 有两个作用:

  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

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

Logo

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

更多推荐