HarmonyOS趣味相机实战第8篇:PhotoOutput拍照回调、质量档位与真实照片确认机制
HarmonyOS趣味相机实战第8篇:PhotoOutput拍照回调、质量档位与真实照片确认机制
摘要
相机项目里,“点击拍照”不是调用一次 capture() 就结束。真正稳定的拍照链路要回答几个问题:拍照输出是否就绪,用户选择的分辨率如何映射到 CameraKit 质量档位,前置镜头是否要镜像,photoAvailable 回调是不是本次拍照产生的,回调里的 JPEG 如何变成 PixelMap,人体识别是否需要等待,失败时如何返回不会误导用户的状态。
本文继续基于 D:/APP/1quweixiangji HarmonyOS ArkTS 水印相机项目,复盘 CameraPreviewService.capturePhoto() 的真实拍照机制。它不是单纯的 UI 文章,而是围绕 PhotoOutput、PhotoCaptureSetting、photoAvailable、waitForPhotoAvailable()、waitForPhotoVision()、analyzeCapturedPhoto()、captureMessage() 和页面 captureNow() 串起一条可验证的工程链路。
本文覆盖:
- 页面分辨率标签如何映射到
CameraPhotoQualityPreference。 capturePhoto()如何检查photoOutput是否就绪。- 拍照前如何记录
captureStartedAt,避免旧回调污染本次结果。 PhotoCaptureSetting如何处理质量档位和前置镜像。photoAvailable回调如何触发照片分析。- JPEG 如何解码成 PixelMap,并在所有权转移后交给页面展示。
- 拍照成功、未收到真实照片、识别目标数量如何转换成用户文案。
工程背景与源码定位
| 文件 | 作用 |
|---|---|
entry/src/main/ets/service/CameraPreviewService.ets |
拍照主服务,封装 PhotoOutput.capture()、回调、PixelMap 解码和资源释放 |
entry/src/main/ets/pages/Index.ets |
页面拍照入口、质量档位选择、结果预览和照片元数据创建 |
entry/src/main/ets/service/PhotoAlbumService.ets |
拍照成功后创建 CapturedPhoto 元数据 |
entry/src/main/ets/model/DecorationModels.ets |
CapturedPhoto、WatermarkSnapshot 等模型 |
entry/src/main/module.json5 |
相机权限声明 |
entry/src/test/LocalUnit.test.ets |
当前已有水印快照测试,可补拍照状态纯函数测试 |
环境与版本边界
| 项目 | 当前值 | 说明 |
|---|---|---|
| 当前复盘日期 | 2026-07-14 | 按当前源码和本地配置复盘 |
| 工程路径 | D:/APP/1quweixiangji |
本文只引用该项目已有源码 |
| 工程类型 | HarmonyOS Stage 模型 | EntryAbility 加载 pages/Index |
| target SDK | 6.0.2(22) |
升级 SDK 后要回归 CameraKit PhotoOutput 行为 |
| 相机能力 | @kit.CameraKit |
PhotoOutput、PhotoCaptureSetting、photoAvailable |
| 图像能力 | @ohos.multimedia.image |
JPEG 解码为 PixelMap |
| 视觉能力 | @kit.CoreVisionKit |
可选人体目标分析 |
| 页面语言 | 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
版本兼容、EOL 与真机回归策略
截至本文复盘日期 2026-07-14,文章只保证和当前项目源码、当前 DevEco 本地构建配置一致。target SDK: 6.0.2(22) 是否已经 EOL、是否存在 CameraKit Breaking Change,不能靠文章静态判断,正式发版前必须以 DevEco Studio、HarmonyOS SDK 发布说明和 AppGallery Connect 审核要求为准。
建议把 CameraKit 拍照链路的版本边界写成检查表:
| 检查项 | 当前实现 | 升级 SDK 后要回归 |
|---|---|---|
PhotoOutput.capture() |
使用 PhotoCaptureSetting 传入 quality 和 mirror |
参数枚举是否变化,前置镜像是否仍生效 |
photoAvailable |
通过 photoOutput.on('photoAvailable') 监听 |
回调名、回调时机、错误对象是否变化 |
camera.Photo |
读取 photo.main 并获取 JPEG component |
图片组件类型和释放策略是否变化 |
ImageSource |
image.createImageSource(component.byteBuffer) |
解码 API 和 PixelMap 创建行为是否变化 |
QualityLevel |
high/medium/low 映射到三档质量 | 是否新增质量档或旧枚举废弃 |
| 前置镜像 | mirror: activeCameraPosition === 'front' |
不同设备前置预览和成片方向是否一致 |
| 超时阈值 | 350ms 等照片,1600ms 等视觉结果 | 低端机是否需要更长回调窗口 |
真机回归建议至少覆盖四类设备状态:
| 场景 | 操作 | 验收点 |
|---|---|---|
| 后置拍照 | 后置预览下连续拍 10 张 | 每张都有真实照片,结果弹窗正常 |
| 前置拍照 | 切前置后拍照 | 成片方向和用户预期一致 |
| 低质量档 | 切到 5MP (4:3) 拍照 |
captureSummary 能记录对应分辨率 |
| 异常路径 | 快速切后台、返回、再拍照 | 不出现旧回调污染或假成功 |
如果正式上架时接入真实文件保存或分享,还要补充相册/文件权限、隐私政策和用户授权说明。当前文章只复盘拍照回调与 PixelMap 预览交付,不宣称已经完成系统图库写入。
一、页面先把分辨率标签映射成质量偏好
页面里维护了三档分辨率标签:
private resolutionLabels: string[] = ['12MP (4:3)', '8MP (4:3)', '5MP (4:3)'];
private resolutionQualities: CameraPhotoQualityPreference[] = ['high', 'medium', 'low'];
展示标签:
private resolutionLabel(): string {
return this.resolutionLabels[this.selectedResolutionIndex] ?? this.resolutionLabels[0];
}
拍照质量偏好:
private captureQualityPreference(): CameraPhotoQualityPreference {
return this.resolutionQualities[this.selectedResolutionIndex] ?? 'high';
}
这一步把 UI 标签和拍照参数解耦:页面展示“12MP/8MP/5MP”,服务层只接受 high/medium/low 三档偏好。
二、拍照入口先防重复和倒计时
页面拍照入口:
private async capturePhoto(): Promise<void> {
if (this.isCapturing || this.countdownText.length > 0) {
return;
}
if (this.timerSeconds > 0) {
await this.runCountdown();
}
await this.captureNow();
}
真正拍照:
private async captureNow(): Promise<void> {
if (this.isCapturing) {
return;
}
this.isCapturing = true;
await this.releasePreviewPixelMap();
if (this.shutterSoundEnabled) {
this.triggerShutterFeedback();
}
this.captureStatusText = '正在拍照';
}
这里先防重复,再释放上一张预览图。连续拍照时这一步很关键,否则旧 PixelMap 会一直挂在页面状态里。
三、没有真实预览时不拍照
页面在调用服务前检查预览状态:
if (!this.isRealPreviewRunning()) {
this.captureStatusText = '请先启用相机预览';
this.isCapturing = false;
return;
}
服务返回后继续判断:
const captureState: CameraCaptureState =
await CameraPreviewService.capturePhoto(this.captureQualityPreference(), false);
this.captureStatusText = captureState.message;
if (!captureState.realPhotoReceived || captureState.photoPixelMap === undefined) {
this.isCapturing = false;
return;
}
只有真正收到照片并拿到 PixelMap,页面才创建照片记录。这样能避免“没拍到真实照片却保存相册”的假成功。
四、capturePhoto() 先检查 PhotoOutput
服务层入口:
static async capturePhoto(
qualityPreference: CameraPhotoQualityPreference = 'high',
analyzeTargets: boolean = true
): Promise<CameraCaptureState> {
if (CameraPreviewService.photoOutput === null) {
return {
success: false,
message: '相机拍照输出尚未就绪',
realPhotoReceived: false,
photoEventCount: CameraPreviewService.photoEventCount,
visionTargetCount: 0
};
}
}
photoOutput === null 通常说明预览会话还没启动、启动失败或已经释放。这里直接返回业务状态,不让页面继续等待不存在的回调。
五、用 captureStartedAt 区分本次拍照
拍照前记录时间:
const captureStartedAt: number = Date.now();
清空上一轮视觉结果:
CameraPreviewService.lastPhotoVisionAt = 0;
CameraPreviewService.lastPhotoVisionTargets = [];
CameraPreviewService.lastPhotoVisionPromise = null;
为什么要这样做?因为 photoAvailable 是异步回调。如果上一轮回调很晚才到,不能把它误判成本次拍照结果。captureStartedAt 就是本次拍照的时间边界。
六、PhotoCaptureSetting 处理质量档位和前置镜像
拍照设置:
const setting: camera.PhotoCaptureSetting = {
quality: CameraPreviewService.captureQualityLevel(qualityPreference),
mirror: CameraPreviewService.activeCameraPosition === 'front'
};
await CameraPreviewService.photoOutput.capture(setting);
质量映射:
private static captureQualityLevel(qualityPreference: CameraPhotoQualityPreference): camera.QualityLevel {
if (qualityPreference === 'medium') {
return camera.QualityLevel.QUALITY_LEVEL_MEDIUM;
}
if (qualityPreference === 'low') {
return camera.QualityLevel.QUALITY_LEVEL_LOW;
}
return camera.QualityLevel.QUALITY_LEVEL_HIGH;
}
映射表:
| 页面偏好 | CameraKit 质量 |
|---|---|
high |
QUALITY_LEVEL_HIGH |
medium |
QUALITY_LEVEL_MEDIUM |
low |
QUALITY_LEVEL_LOW |
前置镜头设置 mirror=true,避免自拍结果和预览体验割裂。
七、photoAvailable 回调负责触发照片分析
绑定回调:
private static bindPhotoAvailable(photoOutput: camera.PhotoOutput): void {
const callback: AsyncCallback<camera.Photo> = (error: BusinessError, photo: camera.Photo): void => {
if (error) {
hilog.warn(DOMAIN, TAG, 'photo available failed: %{public}s', JSON.stringify(error));
return;
}
CameraPreviewService.lastPhotoAvailableAt = Date.now();
CameraPreviewService.photoEventCount += 1;
const promise: Promise<CameraPhotoVisionResult> =
CameraPreviewService.analyzeCapturedPhoto(photo, CameraPreviewService.shouldAnalyzeTargetsOnCapture);
CameraPreviewService.lastPhotoVisionPromise = promise;
};
CameraPreviewService.photoAvailableCallback = callback;
photoOutput.on('photoAvailable', callback);
}
回调做三件事:
- 标记最后一次收到真实照片的时间。
- 累加照片事件计数。
- 把
camera.Photo交给analyzeCapturedPhoto()解码和可选识别。
八、等待真实照片回调
等待逻辑:
private static waitForPhotoAvailable(captureStartedAt: number, timeout: number): Promise<boolean> {
if (CameraPreviewService.lastPhotoAvailableAt >= captureStartedAt) {
return Promise.resolve(true);
}
return new Promise<boolean>((resolve: (value: boolean) => void) => {
setTimeout(() => {
resolve(CameraPreviewService.lastPhotoAvailableAt >= captureStartedAt);
}, timeout);
});
}
当前超时时间是 350ms:
const realPhotoReceived: boolean =
await CameraPreviewService.waitForPhotoAvailable(captureStartedAt, 350);
这不是等待无限长,而是给真实照片回调一个短窗口。如果窗口内没有收到本次回调,就返回 false,页面不会创建假照片。
九、等待视觉分析结果
如果收到了真实照片,再等待视觉分析:
const visionResult: CameraPhotoVisionResult = realPhotoReceived ?
await CameraPreviewService.waitForPhotoVision(captureStartedAt, 1600) :
{ targets: [] };
等待实现:
private static async waitForPhotoVision(captureStartedAt: number, timeout: number): Promise<CameraPhotoVisionResult> {
const deadline: number = Date.now() + timeout;
while (Date.now() < deadline) {
if (CameraPreviewService.lastPhotoVisionPromise !== null) {
try {
return await CameraPreviewService.lastPhotoVisionPromise;
} catch (error) {
hilog.warn(DOMAIN, TAG, 'wait photo vision failed: %{public}s', JSON.stringify(error));
return { targets: [] };
}
}
if (CameraPreviewService.lastPhotoVisionAt >= captureStartedAt) {
return { targets: CameraPreviewService.lastPhotoVisionTargets };
}
await new Promise<void>((resolve: () => void) => {
setTimeout(() => resolve(), 40);
});
}
return { targets: CameraPreviewService.lastPhotoVisionTargets };
}
这里的策略是:可以等视觉结果,但不能让用户无限等。1600ms 超时后返回已有目标,保证拍照流程继续。
十、JPEG 解码成 PixelMap,并控制所有权
照片分析入口:
private static async analyzeCapturedPhoto(
photo: camera.Photo,
analyzeTargets: boolean = true
): Promise<CameraPhotoVisionResult> {
let pixelMap: image.PixelMap | null = null;
let source: image.ImageSource | null = null;
let keepPixelMap: boolean = false;
}
解码:
const mainImage: image.Image = photo.main;
const component: image.Component = await mainImage.getComponent(image.ComponentType.JPEG);
source = image.createImageSource(component.byteBuffer);
pixelMap = await source.createPixelMap();
不分析目标时,直接把 PixelMap 交给页面:
if (!analyzeTargets) {
keepPixelMap = true;
return {
targets: [],
pixelMap
};
}
分析目标后也把 PixelMap 交给调用方:
const targets: DetectedTarget[] =
await CoreVisionHumanService.analyzePixelMap(
pixelMap,
CameraPreviewService.previewRotation,
CameraPreviewService.activeCameraPosition === 'front'
);
keepPixelMap = true;
return {
targets: CameraPreviewService.toCameraTargets(targets),
pixelMap
};
keepPixelMap=true 表示 PixelMap 所有权已经交给页面,服务层不能释放它。
十一、finally 释放 ImageSource 和 camera.Photo
资源释放:
finally {
if (pixelMap !== null && !keepPixelMap) {
await pixelMap.release();
}
if (source !== null) {
await source.release();
}
await photo.release();
}
这里的所有权边界很清楚:
| 对象 | 成功时谁释放 | 失败时谁释放 |
|---|---|---|
| PixelMap | 页面释放 | 服务层释放 |
| ImageSource | 服务层释放 | 服务层释放 |
| camera.Photo | 服务层释放 | 服务层释放 |
这能避免重复释放,也避免失败路径泄漏资源。
十二、返回 CameraCaptureState 给页面
拍照成功返回:
return {
success: true,
message: CameraPreviewService.captureMessage(realPhotoReceived, visionResult.targets.length),
realPhotoReceived,
photoEventCount: CameraPreviewService.photoEventCount,
visionTargetCount: visionResult.targets.length,
photoPixelMap: visionResult.pixelMap
};
失败返回:
return {
success: false,
message: '真实拍照失败,已使用当前预览合成',
realPhotoReceived: false,
photoEventCount: CameraPreviewService.photoEventCount,
visionTargetCount: 0
};
页面只需要看 realPhotoReceived 和 photoPixelMap,不用关心 CameraKit 回调细节。
十三、captureMessage 让状态文案不误导用户
文案生成:
private static captureMessage(realPhotoReceived: boolean, visionTargetCount: number): string {
if (!realPhotoReceived) {
return '未获取到真实照片,请重试';
}
if (visionTargetCount > 0) {
return `真实照片已捕获,并按 ${visionTargetCount} 个人物目标对齐`;
}
return '真实照片已捕获,正在使用预览目标对齐';
}
这里分三种:
| 状态 | 文案 | 页面策略 |
|---|---|---|
| 没收到真实照片 | 未获取到真实照片,请重试 | 不创建照片记录 |
| 收到照片且有人物目标 | 真实照片已捕获,并按 N 个目标对齐 | 可用于智能装饰 |
| 收到照片但无目标 | 真实照片已捕获,使用预览目标对齐 | 可正常保存 |
文案不能一律写“拍照成功”,否则失败路径会误导用户。
十四、页面创建照片元数据
服务返回 PixelMap 后,页面创建照片记录:
this.previewPhotoPixelMap = captureState.photoPixelMap;
this.previewShotIndex += 1;
this.previewPhoto = PhotoAlbumService.createPhoto(
this.previewShotIndex,
[],
'无滤镜',
'无相框',
'标准模式',
'real',
`${captureState.message} · ${this.resolutionLabel()}`,
0,
'标准',
0,
this.resolutionLabel(),
this.watermarkSnapshot()
);
这条记录保存的是元数据:
| 字段 | 来源 |
|---|---|
captureSource |
固定为 real |
captureSummary |
拍照文案 + 分辨率标签 |
resolutionLabel |
页面当前分辨率 |
watermark |
拍照瞬间快照 |
status |
初始为 preview |
真正的 PixelMap 只用于结果预览,保存或关闭后会释放。
十五、完整时序图
用户点击拍照
-> capturePhoto()
-> 防重复和倒计时
-> captureNow()
-> releasePreviewPixelMap()
-> CameraPreviewService.capturePhoto(qualityPreference, analyzeTargets)
-> 检查 photoOutput
-> captureStartedAt = Date.now()
-> PhotoCaptureSetting(quality, mirror)
-> photoOutput.capture(setting)
-> photoAvailable 回调
-> analyzeCapturedPhoto()
-> JPEG -> ImageSource -> PixelMap
-> waitForPhotoAvailable(350ms)
-> waitForPhotoVision(1600ms)
-> 返回 CameraCaptureState
-> 页面写 previewPhotoPixelMap
-> PhotoAlbumService.createPhoto()
-> ResultPreviewDialog
这条链路把异步 CameraKit 回调收束成页面能理解的状态对象。
十六、异常状态机:不要把失败包装成成功
拍照链路可以抽象为四个状态:
| 状态 | 条件 | 页面动作 |
|---|---|---|
output_not_ready |
photoOutput === null |
提示输出未就绪,不创建照片 |
capture_failed |
photoOutput.capture() 抛异常 |
提示真实拍照失败,不创建照片 |
photo_timeout |
350ms 内没有本次 photoAvailable |
提示未获取真实照片,不创建照片 |
photo_ready |
收到本次照片且有 PixelMap | 显示结果预览,可保存或转文档 |
页面现在判断的是:
if (!captureState.realPhotoReceived || captureState.photoPixelMap === undefined) {
this.isCapturing = false;
return;
}
这个判断比只看 success 更可靠。因为 success 表示服务调用流程没有抛异常,而 realPhotoReceived 才表示本次真的拿到了 CameraKit 回调。上线前要重点避免下面两类反模式:
| 反模式 | 后果 |
|---|---|
success=true 就创建照片 |
可能保存没有真实图的假记录 |
| 超时后继续用旧 PixelMap | 用户看到上一张照片 |
十七、建议补充的测试
可以优先补纯函数测试:
| 测试点 | 输入 | 期望 |
|---|---|---|
| 质量映射 | high/medium/low |
映射到对应 QualityLevel |
| 文案生成 | realPhotoReceived=false |
返回“未获取到真实照片” |
| 文案生成 | realPhotoReceived=true, targets=2 |
文案包含 2 个目标 |
| 分辨率标签 | index 越界 | 回退第一档 |
| 拍照状态 | photoOutput=null |
返回 success=false |
photoAvailable 更适合集成测试或真机测试,因为它依赖 CameraKit 回调。
十八、常见问题排查
| 现象 | 可能原因 | 排查方式 |
|---|---|---|
| 点击拍照提示输出未就绪 | photoOutput 为空 |
确认预览会话已经启动 |
| 拍照后没有弹窗 | realPhotoReceived=false 或无 PixelMap |
查 waitForPhotoAvailable() |
| 显示旧照片 | 上一轮回调污染本次 | 查 captureStartedAt 比较 |
| 前置照片左右反了 | mirror 未设置 |
查 activeCameraPosition === 'front' |
| 分辨率按钮无效 | 标签和质量数组不同步 | 查 resolutionLabels 和 resolutionQualities |
| 连续拍照卡顿 | 旧 PixelMap 未释放 | 查 captureNow() 前的 releasePreviewPixelMap() |
| 拍照后内存涨 | ImageSource 或 photo 未释放 | 查 analyzeCapturedPhoto() finally |
| 等待太久 | 视觉分析无限等待 | 查 waitForPhotoVision() 超时 |
| 失败还保存相册 | 页面只看 success,没看 realPhotoReceived |
查 captureNow() 判断 |
| 目标识别数不稳定 | 人体分析非必选 | 允许无目标但真实照片成功 |
| 升级 SDK 后拍照异常 | CameraKit 回调或枚举变化 | 按版本兼容表回归 PhotoOutput |
十九、上线前验收清单
- 预览未启动时点击拍照不会创建照片记录。
photoOutput为空时返回明确错误。- 三档质量按钮能循环切换。
high/medium/low能映射到 CameraKit 质量档位。- 前置镜头拍照使用 mirror。
- 拍照前释放上一张预览 PixelMap。
captureStartedAt能区分本次回调。waitForPhotoAvailable()超时后不会假成功。waitForPhotoVision()有超时,不阻塞页面。- JPEG 能解码成 PixelMap。
- PixelMap 交给页面后由页面释放。
- ImageSource 和 camera.Photo 在服务层释放。
- 未收到真实照片时不生成
CapturedPhoto。 - 收到真实照片后
captureSummary包含拍照文案和分辨率。 - 2026-07-14 之后升级 SDK 时,回归
PhotoOutput.capture()、photoAvailable和QualityLevel行为。 - 正式上架前确认当前 HarmonyOS SDK 是否 EOL 或有 CameraKit Breaking Change。
- 前置、后置、高/中/低三档质量都完成真机回归。
- 异常状态机四类失败路径不会生成相册记录。
总结
1quweixiangji 的拍照链路把 CameraKit 的异步回调封装成了页面可理解的状态:页面只负责防重复、选择质量档位、展示结果和创建照片元数据;CameraPreviewService 负责检查 photoOutput、构造 PhotoCaptureSetting、触发 capture()、等待本次 photoAvailable、解码 PixelMap、可选分析人体目标,并通过 CameraCaptureState 返回结果。
这种封装的核心价值是“不把异步回调直接暴露给页面”。页面看到的是 realPhotoReceived、photoPixelMap、visionTargetCount 和 message,因此可以稳定决定是否显示结果弹窗、是否保存相册、是否提示用户重试。对 HarmonyOS 相机项目来说,这比简单调用一次 capture() 要可靠得多。
更多推荐


所有评论(0)