HarmonyOS趣味相机实战第9篇:倒计时拍照、防连点与页面退出并发治理
HarmonyOS趣味相机实战第9篇:倒计时拍照、防连点与页面退出并发治理
摘要
相机页面增加 3 秒、5 秒、10 秒倒计时并不难,真正困难的是让倒计时和页面生命周期、快门连点、镜头切换、预览释放、异步拍照回调保持一致。用户在倒计时期间连续点击快门会不会启动多个任务?倒计时尚未结束时切到后台,旧任务会不会回来继续拍照?PhotoOutput.capture() 抛异常以后,isCapturing 会不会永远停在 true?页面销毁后到达的 PixelMap 应该由谁释放?这些问题如果不在状态机层面处理,真机上通常会表现为重复拍照、旧页面弹窗、按钮失效或内存持续增长。
本文继续基于 D:/APP/1quweixiangji HarmonyOS ArkTS 趣味相机工程,复盘 Index.ets 当前的倒计时与拍照入口,再给出一套可以直接用于项目改造的并发治理方案。文章不把现有实现包装成最终答案:当前代码已经具备基础防连点、真实预览检查和旧 PixelMap 释放,但还缺少倒计时取消令牌、页面活跃状态、拍照异常的 finally 收口,以及异步结果返回后的二次有效性检查。
本文重点解决以下问题:
- 如何让 0、3、5、10 秒定时档位保持单一数据源。
- 为什么只检查一次
isCapturing仍然挡不住倒计时重入。 - 页面退出、Surface 销毁或镜头切换时,如何取消旧拍照任务。
- 为什么
captureNow()必须用try/finally恢复快门状态。 - PhotoOutput 返回 PixelMap 后,如何判断结果是否还属于当前页面。
- 如何用状态表、日志和真机回归验证并发边界。
工程背景与源码定位
| 文件 | 作用 |
|---|---|
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 |
CapturedPhoto、WatermarkSnapshot 等业务模型 |
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 驱动倒计时遮罩和快门状态 |

本地构建命令:
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()、photoAvailable、CameraInput 释放、前置镜像和快速切后台场景,不要只验证编译通过。
一、当前页面如何保存定时档位
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;
}
}
}
关键点有三个:
- 每一秒醒来都检查页面和代际,不只在倒计时开始时检查。
- 返回
boolean,调用方不能无条件进入拍照。 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 释放仍需要真机集成测试,不能用纯函数测试替代。
十四、真机回归流程
正常路径
- 启动后置预览,定时选择 3 秒。
- 点击一次快门,确认只出现一条倒计时。
- 倒计时结束后只触发一次真实拍照。
- 结果预览显示本次 PixelMap。
- 关闭结果后再次拍照,确认旧 PixelMap 已释放。
连点路径
- 定时选择 5 秒。
- 在 1 秒内连续点击快门 10 次。
- 验证只有一个 generation、一个倒计时和一个
PhotoOutput.capture()。
页面退出路径
- 定时选择 10 秒并启动。
- 在第 8 秒切到后台或离开页面。
- 等待超过 10 秒后返回。
- 验证没有自动拍照、没有旧结果弹窗、没有状态文案覆盖新页面。
Surface 重建路径
- 启动倒计时后触发页面重建或镜头切换。
- 确认旧 generation 失效。
- 新预览稳定后重新点击快门,确认新任务正常完成。
异常路径
- 让另一应用占用相机,或在拍照前让预览停止。
- 验证页面给出明确错误。
- 验证
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 秒四个来源值。
- 快门按钮文案由当前状态派生,不维护重复布尔值。
isCountingDown和isCapturing分别表达倒计时与拍照状态。- 第一个
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 等系统资源仍必须释放。把这三点落实后,连续点击、页面退出、镜头切换和迟到回调就不再是零散补丁,而会被统一收束到一套可验证的拍照状态机中。
更多推荐


所有评论(0)