HarmonyOS趣味相机实战第12篇:CameraKit错误分层、相机占用恢复与用户可重试状态机
摘要
CameraKit 相机项目最难排查的故障往往都被用户描述成一句"相机打不开"。实际原因可能完全不同:相机权限被拒绝、设备没有可用镜头、XComponent Surface 尚未创建、旧 PhotoSession 没释放、相机被其他应用占用、photoAvailable 没在超时窗口内返回,甚至只是页面拿到了旧任务的迟到 PixelMap。如果所有错误都显示"请重试",用户无法恢复,开发者也无法从日志定位。
本文继续基于 D:/APP/1quweixiangji HarmonyOS ArkTS 趣味相机项目,把相机故障拆成权限、设备、Surface、预览、拍照和释放六层,复盘现有服务如何把系统异常转换成业务状态,并给出可直接落地的错误码映射、恢复动作、任务代际和真机回归方案。
工程背景与源码定位
| 文件 | 主要错误边界 |
|---|---|
entry/src/main/ets/service/CameraPermissionService.ets |
运行时权限 granted、denied、error |
entry/src/main/ets/service/CameraDeviceService.ets |
摄像头发现、方向不可用和默认镜头降级 |
entry/src/main/ets/service/CameraPreviewService.ets |
Surface、CameraInput、PhotoSession、输出和拍照回调 |
entry/src/main/ets/pages/Index.ets |
页面状态、用户提示、重试入口和结果所有权 |
entry/src/main/module.json5 |
ohos.permission.CAMERA 声明 |
entry/src/test/LocalUnit.test.ets |
当前水印快照测试,可扩展错误路由纯函数测试 |
环境与版本边界
| 项目 | 当前值 | 说明 |
|---|---|---|
| 工程路径 | D:/APP/1quweixiangji |
本文按当前源码分析 |
| 应用版本 | 1.0.2 |
来自 AppScope/app.json5 |
| target SDK | 6.0.2(22) |
SDK 升级后要回归错误码和会话行为 |
| 应用模型 | HarmonyOS Stage 模型 | EntryAbility 加载单页工作台 |
| 相机能力 | @kit.CameraKit |
设备、输入、会话、预览和拍照 |
| 日志能力 | @kit.PerformanceAnalysisKit |
使用 hilog 记录可公开诊断信息 |
| 图像对象 | @ohos.multimedia.image |
PixelMap、ImageSource 与照片组件 |

本地构建命令:
powershell
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
一、先定义错误发生在哪一层
建议把相机链路看成六层闸门:
text
权限
-> 设备
-> Surface
-> 预览会话
-> 拍照回调
-> 资源释放
每一层的责任不同:
| 层级 | 成功条件 | 失败后是否应该继续 |
|---|---|---|
| 权限 | 用户授予 CAMERA | 否,不能发现或打开相机 |
| 设备 | 找到至少一个可用镜头 | 否,展示设备不可用 |
| Surface | XComponent 返回有效 Surface ID | 等待,不应提前启动预览 |
| 预览 | PhotoSession 成功 start | 否,释放半成品并允许重试 |
| 拍照 | 本次收到真实 photoAvailable 与 PixelMap | 预览可保持,允许再次拍照 |
| 释放 | 所有对象 stop/release/close | 失败也要继续释放其他对象 |
只有先确定层级,恢复动作才不会错。
二、权限错误:拒绝和 API 异常不是一回事
权限服务定义:
ts
export type CameraPermissionStatus =
'idle' | 'granted' | 'denied' | 'error';
export interface CameraPermissionResult {
status: CameraPermissionStatus;
message: string;
}
申请逻辑:
ts
const result = await atManager.requestPermissionsFromUser(
context,
['ohos.permission.CAMERA']
);
const granted: boolean =
result.authResults.length > 0 && result.authResults[0] === 0;
拒绝返回:
ts
return {
status: 'denied',
message: '相机权限未开启,请开启后使用真实取景'
};
API 异常返回:
ts
catch (error) {
hilog.error(DOMAIN, TAG,
'request camera permission failed: %{public}s',
JSON.stringify(error));
return {
status: 'error',
message: '相机权限申请失败,请稍后重试'
};
}
两种状态的恢复不同:
| 状态 | 用户动作 |
|---|---|
| denied | 再次阅读权限用途,前往系统设置或重新授权 |
| error | 稍后重试,必要时重启应用 |
页面在非 granted 时必须把 cameraAvailable 设为 false,不能继续走设备发现。
三、设备错误:不要把没有镜头误判成权限问题
设备服务先读取:
ts
const manager: camera.CameraManager = camera.getCameraManager(context);
const supportedDevices: camera.CameraDevice[] =
manager.getSupportedCameras();
然后分别探测前后方向:
ts
private static hasCamera(
manager: camera.CameraManager,
position: camera.CameraPosition
): boolean {
try {
const device: camera.CameraDevice = manager.getCameraDevice(
position,
camera.CameraType.CAMERA_TYPE_DEFAULT
);
return device !== undefined;
} catch (error) {
hilog.warn(DOMAIN, TAG,
'camera position unavailable: %{public}s',
JSON.stringify(error));
return false;
}
}
返回状态包含设备总数、前后镜头和最终方向。这样可以区分:
- 权限已开但模拟器没有真实相机。
- 只有后置,没有前置。
- 用户偏好前置,但当前设备只能降级到后置。
- CameraManager 查询本身抛异常。
没有设备时,提示应是"未检测到可用摄像头",而不是让用户反复授权。
四、Surface 错误:尚未就绪应该等待
预览服务入口先检查:
ts
if (surfaceId.length === 0) {
return {
status: 'error',
message: '预览画面尚未就绪'
};
}
页面则通过 XComponent 获取:
ts
private onPreviewSurfaceReady(): void {
this.previewSurfaceId =
this.previewController.getXComponentSurfaceId();
if (this.isCameraGranted() && this.cameraAvailable) {
this.startCameraPreview();
}
}
严格来说,"Surface 尚未就绪"通常是等待状态,不是不可恢复错误。页面应该使用三重闸门:
ts
if (!this.isCameraGranted() ||
!this.cameraAvailable ||
this.previewSurfaceId.length === 0) {
return;
}
权限、设备和 Surface 谁最后完成,谁触发统一的 tryStartPreview(),避免重复启动。
五、预览失败必须释放半成品资源
预览服务启动前先调用:
ts
await CameraPreviewService.stopPreview();
之后依次创建 CameraManager、Device、CameraInput、PreviewOutput、PhotoOutput 和 PhotoSession。任何一步失败,catch 中执行:
ts
catch (error) {
hilog.error(DOMAIN, TAG,
'start preview failed: %{public}s',
JSON.stringify(error));
await CameraPreviewService.stopPreview();
return {
status: 'error',
message: '相机预览启动失败,请检查权限或相机占用'
};
}
这里的核心不是提示文案,而是再次调用统一释放。否则可能留下已经 open 的 CameraInput 或只配置了一半的 PhotoSession,下一次重试就更容易报相机冲突。
六、把 CameraKit 错误码映射成恢复动作
相机输入和会话相关场景需要重点关注以下错误信号:
| 错误码 | 方向 | 用户提示 | 程序恢复 |
|---|---|---|---|
7400102 |
操作不允许 | 当前相机状态不可用 | 检查调用顺序,释放后重建 |
7400107 |
相机冲突 | 相机可能被占用 | 释放本应用旧会话,提示关闭其他相机应用 |
7400108 |
安全原因禁用 | 相机被系统安全策略禁用 | 引导检查系统设置,不循环重试 |
7400201 |
相机服务致命错误 | 相机服务异常 | 完整释放,限次重建,仍失败则停止 |
可以定义业务错误:
ts
export type CameraErrorKind =
'permissionDenied' |
'deviceUnavailable' |
'surfaceNotReady' |
'cameraConflict' |
'securityDisabled' |
'serviceFatal' |
'captureTimeout' |
'unknown';
错误对象只负责分类,页面根据 kind 选择按钮和文案。
七、避免无限自动重试
相机冲突和服务异常不适合无上限重建。推荐:
ts
const MAX_PREVIEW_RETRY: number = 2;
private previewRetryCount: number = 0;
恢复策略:
- 第一次失败,完整释放并等待短暂退避。
- 第二次失败,允许再重建一次。
- 超过上限,停止自动重试,显示"重新启用相机"按钮。
- 用户主动点击后把计数清零,开始新一轮。
安全禁用和权限拒绝不应该自动重试,因为外部状态没有改变。
八、用会话代际淘汰旧启动结果
镜头切换、页面退出和 Surface 重建可能同时触发异步启动。建议:
ts
private previewGeneration: number = 0;
private async startCameraPreview(): Promise<void> {
const generation: number = ++this.previewGeneration;
this.previewStatus = 'starting';
const result: CameraPreviewState =
await CameraPreviewService.startPreview(
context,
this.previewSurfaceId,
this.activeCameraPosition
);
if (generation !== this.previewGeneration) {
return;
}
this.previewStatus = result.status;
this.previewStatusText = result.message;
}
页面退出或 Surface 销毁时递增 generation。旧启动即使晚到,也不能覆盖新状态。
九、拍照错误不应该摧毁可用预览
服务在 photoOutput 为空时返回:
ts
return {
success: false,
message: '相机拍照输出尚未就绪',
realPhotoReceived: false,
photoEventCount: CameraPreviewService.photoEventCount,
visionTargetCount: 0
};
页面调用后继续检查:
ts
if (!captureState.realPhotoReceived ||
captureState.photoPixelMap === undefined) {
this.isCapturing = false;
return;
}
拍照失败时,预览会话可能仍然可用。正确行为通常是:
- 不创建
CapturedPhoto。 - 不进入结果弹层。
- 保持预览运行。
- 恢复快门按钮。
- 给用户一次明确重试机会。
除非 CameraKit 返回会话级或服务级错误,否则不要因为单次拍照超时就重建整个预览。
十、区分 capture 调用成功和真实照片到达
PhotoOutput.capture() 没抛异常,只能说明触发调用被接受。项目继续等待本次 photoAvailable:
ts
const realPhotoReceived: boolean =
await CameraPreviewService.waitForPhotoAvailable(
captureStartedAt,
350
);
若没有真实回调,返回:
ts
message: '未获取到真实照片,请重试'
页面不能只看 success,还要看 realPhotoReceived 和 photoPixelMap。这避免把旧预览画面或空记录保存到相册。
十一、迟到回调要验证任务归属
当前服务使用 captureStartedAt 区分本次照片:
ts
if (CameraPreviewService.lastPhotoAvailableAt >= captureStartedAt) {
return Promise.resolve(true);
}
页面层还应增加 capture generation。若用户已经退出页面、重新拍摄或切换镜头,迟到结果要立即释放:
ts
if (generation !== this.captureGeneration || !this.pageActive) {
if (captureState.photoPixelMap !== undefined) {
await captureState.photoPixelMap.release();
}
return;
}
"不再展示"不代表"可以不释放"。PixelMap 所有权已经交给页面,失效结果仍由页面收口。
十二、释放错误要逐项隔离
统一释放中通常包含:
text
取消视觉采样
-> 移除 photoAvailable/metadata 回调
-> session.stop()
-> session.release()
-> metadataOutput.release()
-> photoOutput.release()
-> previewOutput.release()
-> cameraInput.close()
某个 release 失败,不能阻断后续对象释放。推荐每一步独立 try/catch,并记录对象类型。释放入口要保持幂等:多次调用不会因为静态引用仍指向旧对象而重复操作。
十三、页面提示必须带下一步动作
对用户有用的错误提示至少包含"发生了什么"和"现在能做什么":
| 错误 | 不推荐文案 | 推荐文案 |
|---|---|---|
| 权限拒绝 | 相机失败 | 相机权限未开启,请授权后重试 |
| 无设备 | 相机失败 | 未检测到可用摄像头 |
| 相机冲突 | 请重试 | 相机可能被占用,请关闭其他相机应用后重试 |
| 安全禁用 | 请重试 | 相机被系统禁用,请检查安全设置 |
| 拍照超时 | 拍照成功 | 未获取到真实照片,请保持预览后重试 |
| 服务异常 | 未知错误 | 相机服务异常,已停止自动重试 |
错误文案不要暴露完整异常栈、文件路径或系统内部对象。
十四、日志字段设计
建议每次预览与拍照记录:
ts
hilog.info(DOMAIN, TAG,
'preview generation=%{public}d position=%{public}s status=%{public}s',
generation,
this.activeCameraPosition,
result.status);
推荐字段:
| 字段 | 用途 |
|---|---|
previewGeneration |
区分预览重建轮次 |
captureGeneration |
区分拍照轮次 |
activeCameraPosition |
定位前置或后置差异 |
previewStatus |
判断失败阶段 |
surfaceReady |
判断 Surface 闸门 |
photoEventCount |
判断回调是否到达 |
errorCode |
映射 CameraKit 恢复动作 |
不要记录照片内容、PixelMap 字节、水印地点、用户备注或人体关键点。
十五、建议补充的纯函数测试
错误码路由可以抽成纯函数:
ts
export function mapCameraError(code: number): CameraErrorKind {
if (code === 7400107) {
return 'cameraConflict';
}
if (code === 7400108) {
return 'securityDisabled';
}
if (code === 7400201) {
return 'serviceFatal';
}
return 'unknown';
}
测试矩阵:
ts
expect(mapCameraError(7400107)).assertEqual('cameraConflict');
expect(mapCameraError(7400108)).assertEqual('securityDisabled');
expect(mapCameraError(7400201)).assertEqual('serviceFatal');
expect(mapCameraError(-1)).assertEqual('unknown');
还可以测试哪些错误允许自动重试:
| kind | 自动重试 |
|---|---|
| permissionDenied | 否 |
| deviceUnavailable | 否 |
| surfaceNotReady | 等待 Surface,不计为失败 |
| cameraConflict | 限次重试 |
| securityDisabled | 否 |
| serviceFatal | 完整释放后限次重试 |
| captureTimeout | 保持预览,允许用户再次拍照 |
十六、真机故障注入流程
权限路径
- 首次启动拒绝权限。
- 确认不发现设备、不启动预览。
- 再次授权后确认能恢复。
相机占用
- 让另一相机应用持有摄像头。
- 返回趣味相机启动预览。
- 确认识别冲突、限次重试并给出明确提示。
Surface 生命周期
- 预览运行时销毁 XComponent。
- 确认旧会话释放。
- Surface 重建后只启动一次新会话。
拍照超时
- 连续拍摄并快速切后台。
- 验证迟到回调不会创建旧照片。
- 迟到 PixelMap 被释放。
服务错误
- 记录错误码、镜头方向和 generation。
- 完整释放后限次重建。
- 超过重试上限后停止循环。
十七、常见问题排查
| 现象 | 可能原因 | 排查方式 |
|---|---|---|
| 反复授权仍黑屏 | 实际是设备或 Surface 层错误 | 分层检查权限、设备、Surface |
| 切镜头后旧文案覆盖新状态 | 异步启动结果过期 | 增加 previewGeneration |
| 预览失败后一直冲突 | 半成品资源未释放 | catch 中统一 stopPreview |
| 点击拍照无结果 | photoAvailable 未在窗口内返回 | 查 captureStartedAt 与事件计数 |
| 拍照超时却生成照片 | 页面只检查 success | 同时检查 realPhotoReceived 和 PixelMap |
| 页面退出后弹出旧照片 | 迟到结果未校验代际 | 检查 captureGeneration |
| 内存持续增长 | 失效 PixelMap 未释放 | 所有取消路径显式 release |
| release 抛错后仍占相机 | 一步失败阻断后续释放 | 每个对象独立 try/catch |
| 相机冲突无限重试 | 没有重试上限 | 设置 MAX_PREVIEW_RETRY |
| 安全禁用仍循环打开 | 错误码路由错误 | 7400108 不自动重试 |
| 用户不知道如何恢复 | 文案只有"失败" | 文案加入下一步动作 |
| 日志泄露用户内容 | 记录了照片或水印正文 | 只记录状态、代际和错误码 |
十八、上线前验收清单
- 权限 denied 与 permission API error 分开处理。
- 未授权时不进入设备发现。
- 无设备时不提示用户重复授权。
- Surface 未就绪时等待,不启动 PhotoSession。
- 预览启动失败时释放全部半成品资源。
7400102、7400107、7400108、7400201有业务映射。- 自动重试有次数上限。
- 权限拒绝和安全禁用不自动重试。
- 预览异步结果按 generation 校验。
- 拍照失败不默认销毁仍可用的预览。
- 页面检查 realPhotoReceived 和 photoPixelMap。
- 迟到 PixelMap 在失效路径释放。
- 每个系统资源独立 stop/release/close。
- 释放入口可重复调用。
- 错误文案包含可执行的恢复动作。
- 日志包含镜头、状态、代际和错误码。
- 日志不记录照片、水印或人体数据。
- 拒绝权限、相机占用、切后台、Surface 重建和拍照超时完成真机验证。
- SDK 升级后重新确认错误码和 CameraKit 生命周期行为。
总结
相机稳定性不是给每个 catch 加一句"请重试",而是先判断错误属于权限、设备、Surface、预览、拍照还是释放层,再为这一层选择正确恢复动作。1quweixiangji 当前已经通过服务层状态对象、预览失败统一释放、真实照片确认和 PixelMap 所有权控制打好了基础。
进一步完善时,应加入错误码业务映射、预览与拍照 generation、限次重试、迟到结果释放和带下一步动作的用户文案。这样"相机打不开"就能被拆成可观测、可恢复、可测试的状态机;相机冲突不会无限循环,拍照超时不会生成假记录,页面退出也不会接收旧任务结果。