HarmonyOS趣味相机实战第9篇:倒计时拍照、防连点与页面退出并发治理

摘要

相机页面增加 3 秒、5 秒、10 秒倒计时并不难,真正困难的是让倒计时和页面生命周期、快门连点、镜头切换、预览释放、异步拍照回调保持一致。用户在倒计时期间连续点击快门会不会启动多个任务?倒计时尚未结束时切到后台,旧任务会不会回来继续拍照?PhotoOutput.capture() 抛异常以后,isCapturing 会不会永远停在 true?页面销毁后到达的 PixelMap 应该由谁释放?这些问题如果不在状态机层面处理,真机上通常会表现为重复拍照、旧页面弹窗、按钮失效或内存持续增长。

本文继续基于 D:/APP/1quweixiangji HarmonyOS ArkTS 趣味相机工程,复盘 Index.ets 当前的倒计时与拍照入口,再给出一套可以直接用于项目改造的并发治理方案。文章不把现有实现包装成最终答案:当前代码已经具备基础防连点、真实预览检查和旧 PixelMap 释放,但还缺少倒计时取消令牌、页面活跃状态、拍照异常的 finally 收口,以及异步结果返回后的二次有效性检查。

本文重点解决以下问题:

  1. 如何让 0、3、5、10 秒定时档位保持单一数据源。
  2. 为什么只检查一次 isCapturing 仍然挡不住倒计时重入。
  3. 页面退出、Surface 销毁或镜头切换时,如何取消旧拍照任务。
  4. 为什么 captureNow() 必须用 try/finally 恢复快门状态。
  5. PhotoOutput 返回 PixelMap 后,如何判断结果是否还属于当前页面。
  6. 如何用状态表、日志和真机回归验证并发边界。

工程背景与源码定位

文件 作用
entry/src/main/ets/pages/Index.ets 定时档位、倒计时遮罩、快门入口、结果预览和页面生命周期
entry/src/main/ets/service/CameraPreviewService.ets CameraKit 预览、PhotoOutput.capture()、真实照片回调和停止预览
entry/src/main/ets/service/CameraDeviceService.ets 前后镜头发现、当前镜头选择和降级状态
entry/src/main/ets/service/PhotoAlbumService.ets 把成功拍摄结果转换成 CapturedPhoto 元数据
entry/src/main/ets/model/DecorationModels.ets CapturedPhotoWatermarkSnapshot 等业务模型
entry/src/main/module.json5 ohos.permission.CAMERA 权限声明和前台使用场景
entry/src/test/LocalUnit.test.ets 当前已有水印快照测试,可继续补充拍照状态机纯函数测试

环境与版本边界

项目 当前值 说明
工程路径 D:/APP/1quweixiangji 本文代码均从该工程提取或基于其边界改造
应用版本 1.0.2 来自 AppScope/app.json5
bundleName com.fun.quweixiangji 当前趣味相机包名
应用模型 HarmonyOS Stage 模型 EntryAbility 加载 pages/Index
target SDK 6.0.2(22) CameraKit 升级后需重新回归回调和释放行为
相机能力 @kit.CameraKit 预览、拍照、闪光灯和前后镜头
图像对象 @ohos.multimedia.image 拍照结果以 PixelMap 交给页面预览
UI 技术 ArkTS + ArkUI @State 驱动倒计时遮罩和快门状态

HarmonyOS 倒计时拍照并发状态治理

本地构建命令:

cd D:\APP\1quweixiangji
$env:JAVA_HOME='D:\Program Files\Huawei\DevEco Studio\jbr'
$env:Path="$env:JAVA_HOME\bin;$env:Path"
& 'D:\Program Files\Huawei\DevEco Studio\tools\hvigor\bin\hvigorw.bat' --mode module -p module=entry@default -p product=default assembleHap --no-daemon

版本兼容、权限与隐私边界

倒计时本身不需要权限,但最终拍照必须建立在前台相机授权和有效预览会话之上。当前模块只声明:

"requestPermissions": [
  {
    "name": "ohos.permission.CAMERA",
    "reason": "$string:camera_permission_reason",
    "usedScene": {
      "abilities": ["EntryAbility"],
      "when": "inuse"
    }
  }
]

工程没有因为倒计时额外申请麦克风、相册读写或定位权限。文章讨论的 PixelMap 只用于本次结果预览;如果后续把真实图片写入系统图库、读取媒体位置或上传云端,需要重新确认权限、隐私政策和用户授权文案。

CameraKit 的异步时序和错误行为可能随 SDK 版本变化。升级 DevEco Studio 或 HarmonyOS SDK 后,要重点回归 PhotoOutput.capture()photoAvailableCameraInput 释放、前置镜像和快速切后台场景,不要只验证编译通过。

一、当前页面如何保存定时档位

Index.ets 维护四个定时选项:

private timerOptions: number[] = [0, 3, 5, 10];
@State timerSeconds: number = 0;
@State countdownText: string = '';
@State isCapturing: boolean = false;

切换逻辑通过当前值查找下标,再循环到下一档:

private cycleTimer(): void {
  const currentIndex: number = this.timerOptions.indexOf(this.timerSeconds);
  const nextIndex: number = currentIndex >= 0 ?
    (currentIndex + 1) % this.timerOptions.length : 0;
  this.timerSeconds = this.timerOptions[nextIndex];
}

这种写法有两个优点:

设计点 价值
档位集中在 timerOptions UI 文案和执行逻辑不会各自维护一套常量
找不到当前值时回退下标 0 状态异常时自动回到“关闭定时”

按钮文案也直接读取 timerSeconds

Button(this.timerSeconds === 0 ? '关闭定时' : `${this.timerSeconds}s`)
  .onClick(() => {
    this.cycleTimer();
  })

拍照按钮同样由这一状态派生:

Button(this.timerSeconds > 0 ? `${this.timerSeconds}s 后拍照` : '拍照')

这避免了“按钮显示 5 秒,执行却读取 3 秒”的双状态问题。

二、当前拍照入口已经做了基础防重入

项目当前入口:

private async capturePhoto(): Promise<void> {
  if (this.isCapturing || this.countdownText.length > 0) {
    return;
  }
  if (this.timerSeconds > 0) {
    await this.runCountdown();
  }
  await this.captureNow();
}

这里用两个条件拦截重复操作:

条件 表示的状态
isCapturing 已经进入真实拍照流程
countdownText.length > 0 倒计时已经开始

倒计时开始后,runCountdown() 会立刻写入数字,因此后续点击一般会被 countdownText 挡住:

private async runCountdown(): Promise<void> {
  for (let seconds: number = this.timerSeconds; seconds > 0; seconds--) {
    this.countdownText = `${seconds}`;
    this.captureStatusText = `${seconds}s 后拍照`;
    await this.delay(1000);
  }
  this.countdownText = '';
}

这是一个可用的第一版,但它把“是否正在倒计时”隐式绑定在展示字符串上。展示文案适合渲染,不适合作为唯一并发锁。

三、只用 countdownText 会留下哪些竞争窗口

异步代码里,UI 文案和任务生命周期不是同一个概念。当前实现至少有四个风险窗口。

1. 倒计时结束到 captureNow 开始之间

runCountdown() 最后先执行:

this.countdownText = '';

随后 Promise 返回,调用方才继续:

await this.captureNow();

在这个很短的窗口里,countdownText 已空而 isCapturing 还可能是 false。如果用户再次点击,就有机会创建第二条拍照链路。

2. 页面退出后旧 delay 仍会完成

delay() 只包装 setTimeout

private delay(milliseconds: number): Promise<void> {
  return new Promise<void>((resolve: () => void) => {
    setTimeout(() => resolve(), milliseconds);
  });
}

aboutToDisappear() 会停止预览,但不会取消正在等待的倒计时 Promise:

async aboutToDisappear(): Promise<void> {
  await this.releasePreviewPixelMap();
  await CameraPreviewService.stopPreview();
}

因此旧倒计时醒来后仍会走到 captureNow()。虽然当前代码会通过 isRealPreviewRunning() 拦住真实拍照,但页面状态仍可能被旧任务改写成“请先启用相机预览”。

3. 镜头切换会重建预览会话

切换前后镜头会重新发现设备并调用 startCameraPreview()。如果倒计时属于旧会话,倒计时结束时应该确认当前 Surface、镜头位置和预览代际仍然有效,而不是默认“只要页面还在就能拍”。

4. capturePhoto 抛异常时状态可能无法恢复

当前 captureNow() 在多个 return 分支手工设置:

this.isCapturing = false;

但如果 releasePreviewPixelMap()CameraPreviewService.capturePhoto() 抛出未捕获异常,最后一行不会执行,按钮会一直认为正在拍照。异步状态锁应由 finally 统一释放。

四、用显式状态和代际令牌治理倒计时

建议新增三个字段:

@State private isCountingDown: boolean = false;
private captureGeneration: number = 0;
private pageActive: boolean = false;

含义如下:

字段 责任
isCountingDown 纯业务状态,决定是否允许再次启动倒计时
captureGeneration 每次取消或新建任务时递增,用于淘汰旧异步任务
pageActive 页面是否仍允许接收拍照结果

页面出现和消失时更新状态:

async aboutToAppear(): Promise<void> {
  this.pageActive = true;
  await this.refreshAlbum();
  await this.refreshDocuments();
}

async aboutToDisappear(): Promise<void> {
  this.pageActive = false;
  this.captureGeneration += 1;
  this.isCountingDown = false;
  this.countdownText = '';
  await this.releasePreviewPixelMap();
  await CameraPreviewService.stopPreview();
}

这里没有试图取消底层 setTimeout,而是通过代际令牌让旧任务醒来后自动失效。这种方式对 Promise 链更简单,也不会依赖额外计时器句柄。

五、把 runCountdown 改成返回是否仍有效

改造后的倒计时不只负责显示数字,还要告诉调用方“任务是否仍属于当前代际”:

private async runCountdown(generation: number, seconds: number): Promise<boolean> {
  this.isCountingDown = true;
  try {
    for (let current: number = seconds; current > 0; current--) {
      if (!this.pageActive || generation !== this.captureGeneration) {
        return false;
      }
      this.countdownText = `${current}`;
      this.captureStatusText = `${current}s 后拍照`;
      await this.delay(1000);
    }
    return this.pageActive && generation === this.captureGeneration;
  } finally {
    if (generation === this.captureGeneration) {
      this.countdownText = '';
      this.isCountingDown = false;
    }
  }
}

关键点有三个:

  1. 每一秒醒来都检查页面和代际,不只在倒计时开始时检查。
  2. 返回 boolean,调用方不能无条件进入拍照。
  3. finally 只清理当前代际,旧任务不能覆盖新任务的 UI。

六、拍照入口先占用一次完整任务

改造后的入口:

private async capturePhoto(): Promise<void> {
  if (!this.pageActive || this.isCapturing || this.isCountingDown) {
    return;
  }

  const generation: number = ++this.captureGeneration;
  const selectedSeconds: number = this.timerSeconds;

  if (selectedSeconds > 0) {
    const valid: boolean = await this.runCountdown(generation, selectedSeconds);
    if (!valid) {
      return;
    }
  }

  if (!this.pageActive || generation !== this.captureGeneration) {
    return;
  }
  await this.captureNow(generation);
}

这里在任何 await 之前就生成任务代际,并把用户点击时的 timerSeconds 快照保存到 selectedSeconds。如果用户在倒计时期间切换定时档位,本次任务仍按启动时的秒数执行,下一次拍照才使用新档位,行为更可预测。

七、captureNow 用 try/finally 统一释放快门锁

推荐把拍照方法改成:

private async captureNow(generation: number): Promise<void> {
  if (this.isCapturing || !this.pageActive || generation !== this.captureGeneration) {
    return;
  }

  this.isCapturing = true;
  try {
    await this.releasePreviewPixelMap();
    if (!this.pageActive || generation !== this.captureGeneration) {
      return;
    }

    if (!this.isRealPreviewRunning()) {
      this.captureStatusText = '请先启用相机预览';
      return;
    }

    if (this.shutterSoundEnabled) {
      this.triggerShutterFeedback();
    }
    this.captureStatusText = '正在拍照';

    const captureState: CameraCaptureState =
      await CameraPreviewService.capturePhoto(this.captureQualityPreference(), false);

    if (!this.pageActive || generation !== this.captureGeneration) {
      if (captureState.photoPixelMap !== undefined) {
        await captureState.photoPixelMap.release();
      }
      return;
    }

    this.captureStatusText = captureState.message;
    if (!captureState.realPhotoReceived || captureState.photoPixelMap === undefined) {
      return;
    }

    this.previewPhotoPixelMap = captureState.photoPixelMap;
    this.createPreviewPhoto(captureState);
  } catch (error) {
    if (this.pageActive && generation === this.captureGeneration) {
      this.captureStatusText = '拍照失败,请检查相机占用后重试';
    }
  } finally {
    if (generation === this.captureGeneration) {
      this.isCapturing = false;
    }
  }
}

这个版本最重要的不是 catch 文案,而是异步结果返回后的二次校验和 PixelMap 释放:

if (!this.pageActive || generation !== this.captureGeneration) {
  if (captureState.photoPixelMap !== undefined) {
    await captureState.photoPixelMap.release();
  }
  return;
}

服务层已经把成功 PixelMap 的所有权交给页面。即使页面已经退出,调用方仍然必须接住并释放这个对象,否则“结果被丢弃”会变成内存泄漏。

八、为什么还要在真实预览前后各检查一次

当前页面判断预览是否运行:

private isRealPreviewRunning(): boolean {
  return this.previewStatus === 'running';
}

这个检查必须保留,但不能只在倒计时开始前检查。3 秒或 10 秒内可能发生:

  • 用户拒绝或撤销相机权限。
  • XComponent Surface 被销毁。
  • 页面进入后台并停止预览。
  • 前后镜头切换导致会话重建。
  • 相机被其他应用占用。

所以正确顺序是:倒计时开始前检查页面可用,倒计时结束后检查代际,进入 CameraKit 前再检查真实预览,异步结果返回后再检查页面是否仍有效。

九、Surface 销毁也应使旧任务失效

当前 Surface 销毁逻辑:

private async onPreviewSurfaceDestroy(): Promise<void> {
  this.previewSurfaceId = '';
  this.previewStatus = 'idle';
  this.previewStatusText = '真实预览未启动';
  await CameraPreviewService.stopPreview();
}

建议在最前面增加:

this.captureGeneration += 1;
this.isCountingDown = false;
this.countdownText = '';

这样 Surface 销毁不仅停止 CameraKit,也明确取消绑定在旧 Surface 上的倒计时和拍照结果。

镜头切换同理。可以在开始重建预览前递增一个 previewGeneration,再把它和拍照任务代际组合校验。对于当前单页面项目,复用 captureGeneration 已经足够;如果以后加入双摄、画中画或多个 Surface,建议把“页面任务代际”和“预览会话代际”拆开。

十、倒计时遮罩只负责显示,不承担并发锁

当前遮罩:

@Builder
CountdownOverlay() {
  Text(this.countdownText)
    .fontSize(54)
    .fontWeight(FontWeight.Bold)
    .fontColor('#FFFFFF')
    .width(104)
    .height(104)
    .textAlign(TextAlign.Center)
    .backgroundColor('#66000000')
    .borderRadius(52)
    .position({ x: 106, y: 140 })
}

渲染条件:

if (this.countdownText.length > 0) {
  this.CountdownOverlay();
}

这个 UI 可以继续保留。改造重点是:遮罩由 countdownText 渲染,而防重复由 isCountingDown 决定。显示状态和并发状态分离后,即使文案需要临时变成“准备拍照”,也不会意外解除锁。

建议同时让快门按钮在任务期间不可点击,并显示明确状态:

Button(this.isCountingDown ? '倒计时中' : (this.isCapturing ? '拍照中' : '拍照'))
  .enabled(!this.isCountingDown && !this.isCapturing)

按钮禁用用于用户反馈,方法入口的状态检查用于逻辑安全,两者缺一不可。

十一、完整状态机

状态 进入条件 允许操作 退出条件
idle 页面活跃且无任务 切定时、切镜头、点击快门 点击快门
countingDown 定时秒数大于 0 取消或等待,不允许重复快门 倒计时完成、页面退出、Surface 销毁
capturing 代际有效且预览运行 等待结果,不允许重复快门 成功、失败或超时
previewReady 收到真实 PixelMap 保存、转文档、关闭预览 用户完成操作
cancelled 页面或预览代际变化 释放迟到 PixelMap 新任务开始或页面恢复
error 预览不可用或 CameraKit 异常 提示重试、重新申请权限 预览恢复

状态转换可简化为:

idle
  -> countingDown
  -> capturing
  -> previewReady
  -> idle

countingDown/capturing
  -> cancelled(页面退出、Surface 销毁、镜头会话变化)

capturing
  -> error(PhotoOutput 未就绪、相机占用、真实照片超时)

十二、日志应该记录任务代际和关键时间点

真机上的并发问题很难只靠截图定位。建议为每次拍照生成可公开的本地任务编号,并记录以下节点:

hilog.info(DOMAIN, TAG,
  'capture start generation=%{public}d timer=%{public}d',
  generation, selectedSeconds);

推荐日志字段:

字段 用途
generation 判断日志是否属于同一次拍照
timerSeconds 确认用户启动时选择的定时档位
previewStatus 判断倒计时结束时预览是否仍运行
activeCameraPosition 确认前后镜头是否在任务期间变化
photoEventCount 判断真实照片回调是否到达
realPhotoReceived 判断页面是否应该创建照片记录
pageActive 判断迟到结果是否应被丢弃并释放

日志里不要记录照片字节、用户自定义水印正文、位置文本或可识别的人体数据。

十三、建议抽出的纯函数测试

当前测试覆盖水印快照。并发治理可以先抽出几个不依赖 CameraKit 的纯函数:

export function canStartCapture(
  pageActive: boolean,
  isCountingDown: boolean,
  isCapturing: boolean
): boolean {
  return pageActive && !isCountingDown && !isCapturing;
}

export function isCaptureGenerationValid(
  expected: number,
  current: number,
  pageActive: boolean
): boolean {
  return pageActive && expected === current;
}

测试矩阵:

用例 输入 期望
页面活跃且空闲 true, false, false 允许拍照
已在倒计时 true, true, false 拒绝第二次启动
已在拍照 true, false, true 拒绝第二次启动
页面退出 false, false, false 拒绝拍照
代际一致 expected=3,current=3,active=true 结果有效
代际已变化 expected=3,current=4,active=true 结果无效
页面已退出 expected=3,current=3,active=false 结果无效

CameraKit 回调、Surface 销毁和 PixelMap 释放仍需要真机集成测试,不能用纯函数测试替代。

十四、真机回归流程

正常路径

  1. 启动后置预览,定时选择 3 秒。
  2. 点击一次快门,确认只出现一条倒计时。
  3. 倒计时结束后只触发一次真实拍照。
  4. 结果预览显示本次 PixelMap。
  5. 关闭结果后再次拍照,确认旧 PixelMap 已释放。

连点路径

  1. 定时选择 5 秒。
  2. 在 1 秒内连续点击快门 10 次。
  3. 验证只有一个 generation、一个倒计时和一个 PhotoOutput.capture()

页面退出路径

  1. 定时选择 10 秒并启动。
  2. 在第 8 秒切到后台或离开页面。
  3. 等待超过 10 秒后返回。
  4. 验证没有自动拍照、没有旧结果弹窗、没有状态文案覆盖新页面。

Surface 重建路径

  1. 启动倒计时后触发页面重建或镜头切换。
  2. 确认旧 generation 失效。
  3. 新预览稳定后重新点击快门,确认新任务正常完成。

异常路径

  1. 让另一应用占用相机,或在拍照前让预览停止。
  2. 验证页面给出明确错误。
  3. 验证 isCapturing 最终恢复为 false,用户可以再次尝试。

十五、常见问题排查

现象 可能原因 排查方式
连点后拍出多张 countdownText 之外没有显式任务锁 检查 isCountingDown 和 generation
倒计时结束后重复触发 清空文案到占用快门之间有竞争窗口 在第一个 await 前生成任务代际
切后台后仍自动拍照 delay Promise 没有失效检查 页面退出时递增 generation
切镜头后旧任务拍照 任务未绑定预览会话 Surface 或镜头切换时使旧代际失效
拍照失败后按钮一直禁用 异常分支没有恢复 isCapturing finally 统一释放锁
返回页面突然出现旧照片 异步结果回来后未二次校验 检查 pageActive 和 generation
取消后内存增长 迟到 PixelMap 被直接丢弃 无效结果也要显式 release()
倒计时数字卡住 取消路径没有清理显示状态 当前代际的 finally 清空文案
显示秒数与实际不一致 运行期间读取变化后的 timerSeconds 启动时保存 selectedSeconds 快照
预览未启动仍进入拍照 只在倒计时前检查状态 CameraKit 调用前再次检查预览
日志无法串起一次拍照 没有任务编号 每次点击记录 generation
升级 SDK 后偶发异常 CameraKit 回调时序变化 回归 PhotoOutput、Surface 和释放路径

十六、上线前验收清单

  • 定时档位只有 0、3、5、10 秒四个来源值。
  • 快门按钮文案由当前状态派生,不维护重复布尔值。
  • isCountingDownisCapturing 分别表达倒计时与拍照状态。
  • 第一个 await 之前已经创建本次任务 generation。
  • 倒计时每秒醒来都会检查页面和代际。
  • 页面退出时会递增 generation 并清空倒计时显示。
  • Surface 销毁时会取消旧拍照任务。
  • 镜头切换不会让旧倒计时绑定到新预览。
  • 倒计时结束后、CameraKit 调用前再次检查真实预览。
  • captureNow() 使用 try/finally 恢复 isCapturing
  • 异步拍照结果返回后再次检查页面和代际。
  • 被取消的迟到 PixelMap 会显式释放。
  • 有效 PixelMap 交给结果页后由页面负责释放。
  • 未收到真实照片时不会生成 CapturedPhoto
  • 连续点击 10 次只产生一次拍照调用。
  • 10 秒倒计时中切后台不会在后台自动拍照。
  • 相机被占用时能提示错误,之后仍可重试。
  • 日志包含任务代际、定时档位、预览状态和真实回调状态。
  • 日志不包含照片内容、位置水印或人体识别原始数据。
  • SDK 升级后完成前置、后置、倒计时、切后台和 Surface 重建回归。

总结

倒计时拍照表面上只是 setTimeout 和一个数字遮罩,工程上却是一条跨越页面状态、Surface 生命周期、CameraKit 会话、PhotoOutput 回调和 PixelMap 所有权的异步链路。1quweixiangji 当前已经有基础防连点、预览状态检查和旧 PixelMap 释放;要把它提升到稳定真机行为,关键是增加显式的 isCountingDown、任务 generation、页面活跃状态和 try/finally 收口。

最值得复用的原则有三条:展示文案不能充当唯一并发锁;每个跨 await 的结果都要重新确认自己是否仍属于当前页面和当前会话;即使结果已经失效,拿到的 PixelMap 等系统资源仍必须释放。把这三点落实后,连续点击、页面退出、镜头切换和迟到回调就不再是零散补丁,而会被统一收束到一套可验证的拍照状态机中。

Logo

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

更多推荐