HarmonyOS趣味相机实战第6篇:相机权限申请、设备发现与前后镜头切换降级

摘要

相机 App 的第一道稳定性门槛,不是拍照 API,而是“能不能正确进入相机能力”。用户可能拒绝权限,设备可能没有前置摄像头,模拟器可能没有真实相机,某些机型只暴露后置,切换镜头时旧预览会话可能还没释放,权限申请还可能抛异常。如果这些状态没有拆清楚,页面就会出现按钮可点但没反应、提示不准确、镜头切换失败、预览黑屏等问题。

本文继续基于 D:/APP/1quweixiangji HarmonyOS ArkTS 水印相机项目,复盘第 6 条工程主线:相机权限、设备发现和前后摄像头切换。重点文件是 module.json5CameraPermissionService.etsCameraDeviceService.etsIndex.ets

本文覆盖:

  1. module.json5 为什么要声明 ohos.permission.CAMERAusedScene
  2. CameraPermissionService.requestCamera() 如何封装运行时授权。
  3. CameraDeviceService.discover() 如何发现前置、后置和总设备数。
  4. resolveActivePosition() 如何处理偏好镜头不可用的降级。
  5. nextPosition() 如何避免切到不存在的镜头。
  6. 页面如何把权限、设备、预览和拍照提示串成闭环。

工程背景与源码定位

文件 作用
entry/src/main/module.json5 EntryAbility、页面入口和相机权限声明
entry/src/main/ets/service/CameraPermissionService.ets 运行时相机权限申请,返回 granted/denied/error
entry/src/main/ets/service/CameraDeviceService.ets 摄像头发现、前后镜头可用性判断、默认镜头选择
entry/src/main/ets/service/CameraPreviewService.ets 根据 active camera position 启动 CameraKit 预览
entry/src/main/ets/pages/Index.ets 页面权限弹窗、设备刷新、镜头切换、预览启动和状态提示
entry/src/main/resources/base/element/string.json 权限用途说明资源
entry/src/test/LocalUnit.test.ets 当前已有照片快照测试,后续可补设备选择纯函数测试

环境与版本边界

项目 当前值 说明
当前复盘日期 2026-07-14 文章按当前工程源码与本地配置复盘
工程路径 D:/APP/1quweixiangji 本文只引用该项目已有源码
工程类型 HarmonyOS Stage 模型 页面入口为 pages/Index
target SDK 6.0.2(22) 是否 EOL 或存在 Breaking Change,要以上架时 DevEco/SDK 发布说明为准
compatible SDK 6.0.2(22) 真机验证要覆盖目标机型
bundleName com.fun.quweixiangji 当前应用包名
versionName 1.0.1 当前复盘版本
相机能力 @kit.CameraKit 设备发现、预览、拍照
权限能力 @kit.AbilityKit abilityAccessCtrl 运行时授权

HarmonyOS 相机权限与设备发现链路

构建命令:

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

一、权限声明是运行时授权的前置条件

相机权限首先要在 entry/src/main/module.json5 中声明。项目里使用的是:

{
  "requestPermissions": [
    {
      "name": "ohos.permission.CAMERA",
      "reason": "$string:camera_permission_reason",
      "usedScene": {
        "abilities": [
          "EntryAbility"
        ],
        "when": "inuse"
      }
    }
  ]
}

这段配置有三个关键点:

字段 说明
name 声明相机权限 ohos.permission.CAMERA
reason 指向权限用途说明资源,给用户解释为什么需要相机
usedScene.when 当前是前台拍照,所以使用 inuse

如果只写运行时申请、不在 module.json5 声明,权限链路是不完整的。用户看不到清晰用途,系统授权也可能不符合预期。

二、运行时授权只返回业务状态,不直接操作 UI

CameraPermissionService.ets 把权限请求封装成服务方法:

const CAMERA_PERMISSION: Permissions = 'ohos.permission.CAMERA';

export type CameraPermissionStatus = 'idle' | 'granted' | 'denied' | 'error';

export interface CameraPermissionResult {
  status: CameraPermissionStatus;
  message: string;
}

申请权限:

static async requestCamera(context: common.UIAbilityContext): Promise<CameraPermissionResult> {
  try {
    const atManager: abilityAccessCtrl.AtManager = abilityAccessCtrl.createAtManager();
    const result = await atManager.requestPermissionsFromUser(context, [CAMERA_PERMISSION]);
    const granted: boolean = result.authResults.length > 0 && result.authResults[0] === 0;
    if (granted) {
      return {
        status: 'granted',
        message: '相机权限已开启,可以接入真实预览'
      };
    }
    return {
      status: 'denied',
      message: '相机权限未开启,请开启后使用真实取景'
    };
  } catch (error) {
    hilog.error(DOMAIN, TAG, 'request camera permission failed: %{public}s', JSON.stringify(error));
    return {
      status: 'error',
      message: '相机权限申请失败,请稍后重试'
    };
  }
}

这个服务层没有直接显示弹窗,也没有改页面状态。它只负责把系统授权结果转换为业务状态:

系统结果 业务状态 页面动作
authResults[0] === 0 granted 继续发现摄像头
用户拒绝 denied 展示权限未开启提示
API 抛异常 error 展示申请失败提示

页面和服务解耦后,权限逻辑更容易复用和测试。

三、页面先给业务说明,再触发系统授权

页面里点击“启用相机”时,先打开自定义权限说明,而不是直接弹系统授权:

Button('启用相机')
  .onClick(() => {
    this.openCameraPermissionPrompt();
  })

确认后再请求系统权限:

private async requestCameraPermission(): Promise<void> {
  this.cameraPermissionPromptVisible = false;
  const context: common.UIAbilityContext = this.getUIContext().getHostContext() as common.UIAbilityContext;
  const result = await CameraPermissionService.requestCamera(context);
  this.cameraPermissionStatus = result.status;
  this.cameraStatusText = result.message;
  if (result.status === 'granted') {
    this.refreshCameraDevices(context, this.activeCameraPosition);
  } else {
    this.cameraAvailable = false;
    this.captureStatusText = result.message;
  }
}

这条链路的好处是:

  1. 用户先理解为什么要开相机。
  2. 授权成功后立即发现设备。
  3. 授权失败后页面不会继续启动 CameraKit。
  4. 权限异常也有明确提示,不会静默失败。

四、设备发现:不要假设前后摄像头都存在

CameraDeviceService.discover() 负责发现设备:

static discover(context: common.UIAbilityContext, preferredPosition: CameraPositionKey): CameraDeviceState {
  try {
    const manager: camera.CameraManager = camera.getCameraManager(context);
    const supportedDevices: camera.CameraDevice[] = manager.getSupportedCameras();
    const hasFront: boolean = CameraDeviceService.hasCamera(
      manager,
      camera.CameraPosition.CAMERA_POSITION_FRONT
    );
    const hasBack: boolean = CameraDeviceService.hasCamera(
      manager,
      camera.CameraPosition.CAMERA_POSITION_BACK
    );
    const activePosition: CameraPositionKey =
      CameraDeviceService.resolveActivePosition(preferredPosition, hasFront, hasBack);
    const message: string = CameraDeviceService.buildMessage(supportedDevices.length, hasFront, hasBack, activePosition);

    return {
      available: supportedDevices.length > 0,
      totalCount: supportedDevices.length,
      hasFront,
      hasBack,
      activePosition,
      message
    };
  } catch (error) {
    hilog.error(DOMAIN, TAG, 'discover camera failed: %{public}s', JSON.stringify(error));
    return {
      available: false,
      totalCount: 0,
      hasFront: false,
      hasBack: false,
      activePosition: preferredPosition,
      message: '摄像头检测失败,请确认设备相机可用'
    };
  }
}

这里没有假设“手机一定有前后双摄”,而是显式记录:

字段 说明
available 是否有任意可用摄像头
totalCount 系统返回的支持设备数量
hasFront 前置是否可用
hasBack 后置是否可用
activePosition 最终选中的镜头
message 给页面展示的状态文案

对真机适配来说,这比只存一个 cameraAvailable 更可靠。

五、单个镜头探测要捕获异常

判断某个方向是否可用:

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;
  }
}

getCameraDevice() 可能因为设备不支持该方向而抛异常。这里捕获后返回 false,避免整个设备发现失败。

这种处理对多机型很重要:

设备情况 处理方式
只有后置 hasFront=false,默认后置
只有前置 hasBack=false,默认前置
没有真实相机 available=false,页面提示
某方向 API 抛异常 该方向不可用,不影响另一个方向

六、偏好镜头不可用时自动降级

镜头选择逻辑:

private static resolveActivePosition(
  preferredPosition: CameraPositionKey,
  hasFront: boolean,
  hasBack: boolean
): CameraPositionKey {
  if (preferredPosition === 'front' && hasFront) {
    return 'front';
  }
  if (preferredPosition === 'back' && hasBack) {
    return 'back';
  }
  if (hasBack) {
    return 'back';
  }
  return 'front';
}

这个策略按优先级执行:

  1. 用户偏好前置且前置可用,使用前置。
  2. 用户偏好后置且后置可用,使用后置。
  3. 偏好不可用但后置可用,使用后置。
  4. 后置不可用,退到前置。

它避免了一个常见问题:用户上次选择前置,但这台设备没有前置,页面仍然拿前置启动预览,结果启动失败。

七、切换镜头时不要切到不存在的方向

下一镜头选择:

static nextPosition(current: CameraPositionKey, hasFront: boolean, hasBack: boolean): CameraPositionKey {
  if (current === 'front' && hasBack) {
    return 'back';
  }
  if (current === 'back' && hasFront) {
    return 'front';
  }
  return current;
}

页面切换:

private switchCameraPosition(): void {
  if (!this.isCameraGranted()) {
    this.openCameraPermissionPrompt();
    return;
  }
  const nextPosition: CameraPositionKey =
    CameraDeviceService.nextPosition(this.activeCameraPosition, this.hasFrontCamera, this.hasBackCamera);
  const context: common.UIAbilityContext = this.getUIContext().getHostContext() as common.UIAbilityContext;
  this.refreshCameraDevices(context, nextPosition);
}

这里有两个细节:

  1. 没授权时,切换镜头按钮会打开权限说明,不会静默失败。
  2. 如果另一侧镜头不存在,nextPosition() 返回当前镜头,不会强行切换。

八、设备刷新后自动启动预览

设备刷新逻辑:

private refreshCameraDevices(context: common.UIAbilityContext, preferredPosition: CameraPositionKey): void {
  const state: CameraDeviceState = CameraDeviceService.discover(context, preferredPosition);
  this.cameraAvailable = state.available;
  this.cameraTotalCount = state.totalCount;
  this.hasFrontCamera = state.hasFront;
  this.hasBackCamera = state.hasBack;
  this.activeCameraPosition = state.activePosition;
  this.cameraDeviceText = state.message;
  this.captureStatusText = state.available ? state.message : '相机权限已开,但没有检测到可用摄像头';
  if (state.available && this.previewSurfaceId.length > 0) {
    this.startCameraPreview();
  }
}

它把服务层状态同步到页面:

服务状态 页面状态
available cameraAvailable
totalCount cameraTotalCount
hasFront hasFrontCamera
hasBack hasBackCamera
activePosition activeCameraPosition
message cameraDeviceTextcaptureStatusText

如果 Surface 已经准备好,就直接启动预览。否则等 XComponent.onLoad() 后再启动。

九、预览启动也有三重前置条件

启动预览前:

private async startCameraPreview(): Promise<void> {
  if (!this.isCameraGranted() || !this.cameraAvailable || this.previewSurfaceId.length === 0) {
    return;
  }
  this.previewStatus = 'starting';
  this.previewStatusText = '正在启动相机预览';
  const context: common.UIAbilityContext = this.getUIContext().getHostContext() as common.UIAbilityContext;
  const result: CameraPreviewState = await CameraPreviewService.startPreview(
    context,
    this.previewSurfaceId,
    this.activeCameraPosition
  );
}

三重条件:

条件 缺失时后果
权限已授予 不能启动 CameraKit
有可用摄像头 启动会话失败
Surface ID 已存在 预览输出没有承载面

这三个条件任何一个不满足,都不应该进入 CameraPreviewService.startPreview()

十、状态文案要同时服务用户和调试

CameraDeviceService.buildMessage()

private static buildMessage(
  totalCount: number,
  hasFront: boolean,
  hasBack: boolean,
  activePosition: CameraPositionKey
): string {
  if (totalCount === 0) {
    return '未检测到可用摄像头';
  }
  const activeText: string = activePosition === 'front' ? '前置' : '后置';
  const frontText: string = hasFront ? '前置可用' : '前置不可用';
  const backText: string = hasBack ? '后置可用' : '后置不可用';
  return `${activeText}摄像头已准备,${frontText}${backText}`;
}

这类文案不只是给用户看的,也是调试线索:

文案 说明
未检测到可用摄像头 权限可能已开,但设备层没有摄像头
前置摄像头已准备,前置可用,后置不可用 后置不可切换
后置摄像头已准备,前置不可用,后置可用 前置按钮不应切换
摄像头检测失败 CameraKit 查询阶段异常

好的状态文案能少看一半日志。

十一、权限、设备、Surface 的完整时序

完整链路可以整理为:

用户点击启用相机
  -> 打开业务权限说明
  -> requestPermissionsFromUser
  -> granted
  -> CameraDeviceService.discover
  -> 解析 hasFront / hasBack / activePosition
  -> 如果 XComponent Surface 已就绪
  -> CameraPreviewService.startPreview
  -> PhotoSession.start

Surface 另一条链路:

XComponent.onLoad
  -> previewController.getXComponentSurfaceId()
  -> 如果权限已开且设备可用
  -> CameraPreviewService.startPreview

这两个链路谁先完成都可以。权限先完成,就等 Surface;Surface 先完成,就等权限和设备。

十二、建议补充的单元测试

CameraDeviceService.resolveActivePosition() 当前是 private,后续可以把纯选择逻辑抽出来测试。建议覆盖:

preferred hasFront hasBack 期望
front true true front
front false true back
back true false front
back false true back
front true false front

nextPosition() 已经是 public,可以直接测试:

expect(CameraDeviceService.nextPosition('front', true, true)).assertEqual('back');
expect(CameraDeviceService.nextPosition('back', true, true)).assertEqual('front');
expect(CameraDeviceService.nextPosition('back', false, true)).assertEqual('back');
expect(CameraDeviceService.nextPosition('front', true, false)).assertEqual('front');

权限服务则更适合集成测试或手工真机测试,因为它依赖系统授权弹窗。

十三、常见问题排查

现象 可能原因 排查方式
点击启用相机无反应 没拿到 UIAbilityContext 或权限请求异常 requestCameraPermission()
用户拒绝后仍启动预览 页面没有判断 result.status 确认 denied 时设置 cameraAvailable=false
模拟器提示没有摄像头 getSupportedCameras() 返回空 totalCountcameraDeviceText
只有后置仍能点前置 切换逻辑没判断 hasFront nextPosition()
偏好前置但启动失败 没做 resolveActivePosition() 降级 查 activePosition
Surface 已加载但不预览 权限或设备未就绪 查三重条件
授权成功但没有自动预览 Surface ID 为空 onPreviewSurfaceReady()
切换镜头后黑屏 旧会话没释放干净 CameraPreviewService.startPreview() 是否先 stopPreview()
状态文案误导用户 文案没有包含前后镜头可用性 buildMessage()
上架审核质疑权限 权限用途说明不清楚 检查 reason 和隐私政策

十四、上线前验收清单

  • module.json5 已声明 ohos.permission.CAMERA
  • 权限 reason 指向有效资源字符串。
  • 未授权时展示业务权限说明。
  • 用户拒绝后不启动 CameraKit。
  • 权限申请异常时有错误提示。
  • 授权成功后立即执行摄像头发现。
  • 能正确识别前置可用、后置可用、双摄可用和无摄像头。
  • 偏好镜头不可用时能自动降级。
  • 切换镜头不会切到不存在的方向。
  • Surface ID 为空时不启动预览。
  • Surface 就绪后能根据当前权限和设备状态启动预览。
  • 设备发现失败时不影响页面其他功能。
  • 切换镜头前后能释放并重启预览会话。
  • 状态文案能显示当前 activePosition 和前后镜头可用性。
  • 真机覆盖前置、后置、拒绝权限、二次授权、后台返回、模拟器无摄像头等场景。
  • 2026-07-14 之后如果升级 SDK,要复核 CameraKit 和权限 API 是否有 Breaking Change。

总结

1quweixiangji 的相机入口设计把权限、设备和预览拆成三层:module.json5 负责声明权限,CameraPermissionService 负责运行时授权,CameraDeviceService 负责发现前后摄像头和选择可用镜头,Index.ets 负责把授权结果、设备状态、Surface 就绪和预览启动串起来。

这套拆分的价值在于降级清晰:没权限就不发现设备,没设备就不启动预览,偏好镜头不可用就自动换可用方向,Surface 没就绪就等待。相机项目能稳定跑起来,靠的不是一个拍照按钮,而是这些边界状态都被认真处理。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐