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