HarmonyOS趣味相机实战第6篇:相机权限申请、设备发现与前后镜头切换降级
HarmonyOS趣味相机实战第6篇:相机权限申请、设备发现与前后镜头切换降级
摘要
相机 App 的第一道稳定性门槛,不是拍照 API,而是“能不能正确进入相机能力”。用户可能拒绝权限,设备可能没有前置摄像头,模拟器可能没有真实相机,某些机型只暴露后置,切换镜头时旧预览会话可能还没释放,权限申请还可能抛异常。如果这些状态没有拆清楚,页面就会出现按钮可点但没反应、提示不准确、镜头切换失败、预览黑屏等问题。
本文继续基于 D:/APP/1quweixiangji HarmonyOS ArkTS 水印相机项目,复盘第 6 条工程主线:相机权限、设备发现和前后摄像头切换。重点文件是 module.json5、CameraPermissionService.ets、CameraDeviceService.ets 和 Index.ets。
本文覆盖:
module.json5为什么要声明ohos.permission.CAMERA和usedScene。CameraPermissionService.requestCamera()如何封装运行时授权。CameraDeviceService.discover()如何发现前置、后置和总设备数。resolveActivePosition()如何处理偏好镜头不可用的降级。nextPosition()如何避免切到不存在的镜头。- 页面如何把权限、设备、预览和拍照提示串成闭环。
工程背景与源码定位
| 文件 | 作用 |
|---|---|
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 运行时授权 |

构建命令:
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;
}
}
这条链路的好处是:
- 用户先理解为什么要开相机。
- 授权成功后立即发现设备。
- 授权失败后页面不会继续启动 CameraKit。
- 权限异常也有明确提示,不会静默失败。
四、设备发现:不要假设前后摄像头都存在
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';
}
这个策略按优先级执行:
- 用户偏好前置且前置可用,使用前置。
- 用户偏好后置且后置可用,使用后置。
- 偏好不可用但后置可用,使用后置。
- 后置不可用,退到前置。
它避免了一个常见问题:用户上次选择前置,但这台设备没有前置,页面仍然拿前置启动预览,结果启动失败。
七、切换镜头时不要切到不存在的方向
下一镜头选择:
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);
}
这里有两个细节:
- 没授权时,切换镜头按钮会打开权限说明,不会静默失败。
- 如果另一侧镜头不存在,
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 |
cameraDeviceText 和 captureStatusText |
如果 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() 返回空 |
看 totalCount 和 cameraDeviceText |
| 只有后置仍能点前置 | 切换逻辑没判断 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 没就绪就等待。相机项目能稳定跑起来,靠的不是一个拍照按钮,而是这些边界状态都被认真处理。
更多推荐


所有评论(0)