HarmonyOS趣味相机实战第12篇:CameraKit错误分层、相机占用恢复与用户可重试状态机

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;

恢复策略:

  1. 第一次失败,完整释放并等待短暂退避。
  2. 第二次失败,允许再重建一次。
  3. 超过上限,停止自动重试,显示"重新启用相机"按钮。
  4. 用户主动点击后把计数清零,开始新一轮。

安全禁用和权限拒绝不应该自动重试,因为外部状态没有改变。

八、用会话代际淘汰旧启动结果

镜头切换、页面退出和 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,还要看 realPhotoReceivedphotoPixelMap。这避免把旧预览画面或空记录保存到相册。

十一、迟到回调要验证任务归属

当前服务使用 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 保持预览,允许用户再次拍照

十六、真机故障注入流程

权限路径

  1. 首次启动拒绝权限。
  2. 确认不发现设备、不启动预览。
  3. 再次授权后确认能恢复。

相机占用

  1. 让另一相机应用持有摄像头。
  2. 返回趣味相机启动预览。
  3. 确认识别冲突、限次重试并给出明确提示。

Surface 生命周期

  1. 预览运行时销毁 XComponent。
  2. 确认旧会话释放。
  3. Surface 重建后只启动一次新会话。

拍照超时

  1. 连续拍摄并快速切后台。
  2. 验证迟到回调不会创建旧照片。
  3. 迟到 PixelMap 被释放。

服务错误

  1. 记录错误码、镜头方向和 generation。
  2. 完整释放后限次重建。
  3. 超过重试上限后停止循环。

十七、常见问题排查

现象 可能原因 排查方式
反复授权仍黑屏 实际是设备或 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。
  • 预览启动失败时释放全部半成品资源。
  • 7400102740010774001087400201 有业务映射。
  • 自动重试有次数上限。
  • 权限拒绝和安全禁用不自动重试。
  • 预览异步结果按 generation 校验。
  • 拍照失败不默认销毁仍可用的预览。
  • 页面检查 realPhotoReceived 和 photoPixelMap。
  • 迟到 PixelMap 在失效路径释放。
  • 每个系统资源独立 stop/release/close。
  • 释放入口可重复调用。
  • 错误文案包含可执行的恢复动作。
  • 日志包含镜头、状态、代际和错误码。
  • 日志不记录照片、水印或人体数据。
  • 拒绝权限、相机占用、切后台、Surface 重建和拍照超时完成真机验证。
  • SDK 升级后重新确认错误码和 CameraKit 生命周期行为。

总结

相机稳定性不是给每个 catch 加一句"请重试",而是先判断错误属于权限、设备、Surface、预览、拍照还是释放层,再为这一层选择正确恢复动作。1quweixiangji 当前已经通过服务层状态对象、预览失败统一释放、真实照片确认和 PixelMap 所有权控制打好了基础。

进一步完善时,应加入错误码业务映射、预览与拍照 generation、限次重试、迟到结果释放和带下一步动作的用户文案。这样"相机打不开"就能被拆成可观测、可恢复、可测试的状态机;相机冲突不会无限循环,拍照超时不会生成假记录,页面退出也不会接收旧任务结果。

相关推荐
最爱老式锅包肉3 小时前
HarmonyOS技术精讲-Camera Kit(相机服务)第8篇:曝光控制深入解析
数码相机·华为·harmonyos
Helen_cai4 小时前
OpenHarmony 路由封装 RouterUtil 页面跳转统一管理(API Version23+)
运维·服务器·nginx·华为·harmonyos
爸爸6194 小时前
鸿蒙应用开发实战【08】— AppStorage 全局状态与跨页面通信
ubuntu·华为·harmonyos·鸿蒙系统
ldsweet5 小时前
HarmonyOS技术精讲-剪贴板服务:进阶技巧与延迟粘贴
华为·harmonyos
一缕清烟在人间5 小时前
鸿蒙应用开发实战【27】— DatabaseService 数据库初始化
javascript·数据库·harmonyos·鸿蒙系统
拥抱太阳06165 小时前
HarmonyOS 应用开发《掌上英语》第13篇:面向对象 vs 函数式:ArkTS 中的编程范式选择
harmonyos
全堆鸿蒙5 小时前
鸿蒙应用开发实战【34】— 批量操作:batchUpdateStatus 与 deleteMany
华为·harmonyos·鸿蒙系统
解局易否结局5 小时前
鸿蒙 PC 应用开发实战:多显示器、显示密度与跨屏适配
华为·计算机外设·harmonyos
贾伟康5 小时前
【安心陪诊 Agent v1.1】首页可信设计实战:HTML/CSS 状态卡与急症分流
css·html·harmonyos·ai agent·首页设计