HarmonyOS趣味相机实战第11篇:从单摄切换升级前后双摄同开、能力探测与降级设计
摘要
当前 D:/APP/1quweixiangji 趣味相机已经完成前后镜头发现、单镜头预览、镜头切换和 PhotoOutput 拍照,但它一次只维护一个 CameraInput、一个 PhotoSession 和一个 XComponent Surface。若要升级为"前后双摄同开",不能简单地连续调用两次 createCameraInput():设备可能没有前后两个逻辑摄像头,也可能不支持并发;支持并发的机型还需要使用并发能力结果提供的输出配置,并以受限能力模式打开输入。
本文以当前单摄代码为起点,给出一套 API 18+ 前后双摄升级设计。文章明确区分"项目已经实现"和"下一阶段设计":当前版本 1.0.2 仍是前后镜头切换,尚未上线双摄 UI 与双路保存;本文提供的是可以落地的架构、代码骨架、降级策略和真机验收清单,不把设计稿描述成现成功能。
工程背景与源码定位
| 文件 | 当前作用 | 双摄升级后的责任 |
|---|---|---|
entry/src/main/ets/service/CameraDeviceService.ets |
发现前后镜头并选择单个 activePosition | 增加前后设备组合和并发能力探测 |
entry/src/main/ets/service/CameraPreviewService.ets |
维护单路 CameraInput、PhotoSession、PreviewOutput、PhotoOutput | 拆分成双路显式资源状态与统一释放 |
entry/src/main/ets/pages/Index.ets |
单 XComponent 预览、镜头切换和拍照按钮 | 增加主画面与画中画双 Surface 布局 |
entry/src/main/ets/model/DecorationModels.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) |
高于前后双摄并发所需的 API 18 起点 |
| 应用模型 | HarmonyOS Stage 模型 | EntryAbility 加载 pages/Index |
| 当前相机模式 | 单 CameraInput + 单 PhotoSession | 前后镜头之间重建预览 |
| 升级目标 | 前后双摄同时预览与分别拍照 | 必须以真机并发能力为准 |
| 受限模式 | CAMERA_LIMITED_CAPABILITY |
并发模式不是完整单摄控制能力集合 |

华为 CameraKit 官方入口:
- Camera Kit 简介:
https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/camera-overview - 相机权限:
https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/camera-preparation - CameraManager API:
https://developer.huawei.com/consumer/cn/doc/harmonyos-references/arkts-apis-camera-cameramanager - CameraInput API:
https://developer.huawei.com/consumer/cn/doc/harmonyos-references/arkts-apis-camera-camerainput
前后双摄同开从 API 18 起提供相关能力,但最终是否可用取决于当前设备返回的并发信息。即使应用 target SDK 满足要求,也不能跳过真机能力探测。
一、当前项目是怎样完成单摄切换的
当前设备服务记录:
ts
export type CameraPositionKey = 'front' | 'back';
export interface CameraDeviceState {
available: boolean;
totalCount: number;
hasFront: boolean;
hasBack: boolean;
activePosition: CameraPositionKey;
message: string;
}
页面只维护一个当前方向:
ts
@State activeCameraPosition: CameraPositionKey = 'back';
@State previewSurfaceId: string = '';
切换时重新发现设备并启动单路预览:
ts
const nextPosition: CameraPositionKey = CameraDeviceService.nextPosition(
this.activeCameraPosition,
this.hasFrontCamera,
this.hasBackCamera
);
this.refreshCameraDevices(context, nextPosition);
CameraPreviewService.startPreview() 开头先释放旧会话,再按方向取一个摄像头:
ts
await CameraPreviewService.stopPreview();
const cameraPosition: camera.CameraPosition = position === 'front' ?
camera.CameraPosition.CAMERA_POSITION_FRONT :
camera.CameraPosition.CAMERA_POSITION_BACK;
const device: camera.CameraDevice = manager.getCameraDevice(
cameraPosition,
camera.CameraType.CAMERA_TYPE_DEFAULT
);
这是一套稳定的"二选一"架构,但不能直接承载双摄,因为服务内部资源都是单例字段。
二、双摄升级的第一条规则:先确认两个逻辑设备
不要只看 getSupportedCameras().length >= 2。设备列表里可能包含多个后置物理摄像头,不等于一定存在一前一后两个目标设备。
应按位置明确获取:
ts
const manager: camera.CameraManager = camera.getCameraManager(context);
const frontDevice: camera.CameraDevice = manager.getCameraDevice(
camera.CameraPosition.CAMERA_POSITION_FRONT,
camera.CameraType.CAMERA_TYPE_DEFAULT
);
const backDevice: camera.CameraDevice = manager.getCameraDevice(
camera.CameraPosition.CAMERA_POSITION_BACK,
camera.CameraType.CAMERA_TYPE_DEFAULT
);
任意一个方向不可用,就不能进入前后并发路径。此时应回到项目已经稳定实现的单摄切换。
三、必须调用 getCameraConcurrentInfos
拿到两个设备后,继续查询:
ts
const concurrentInfos: Array<camera.CameraConcurrentInfo> =
manager.getCameraConcurrentInfos([frontDevice, backDevice]);
判断:
ts
if (concurrentInfos.length === 0) {
return {
supported: false,
reason: '当前设备不支持前后双摄同时预览'
};
}
这个结果才是双摄入口的开关:
| 探测结果 | 产品行为 |
|---|---|
| 前置或后置缺失 | 保留单摄模式,不显示双摄入口 |
concurrentInfos 为空 |
提供顺序双拍或普通镜头切换 |
| 返回有效并发信息 | 继续匹配输出能力并建立双路会话 |
| 查询抛异常 | 记录错误,安全退回单摄 |
不能先以并发模式打开 CameraInput,再补查能力。能力查询必须在打开输入之前完成。
四、定义清晰的双摄状态模型
当前 CameraPreviewService 只有一套静态资源。升级时建议显式定义每一路:
ts
export interface CameraStreamState {
position: CameraPositionKey;
surfaceId: string;
device?: camera.CameraDevice;
input?: camera.CameraInput;
previewOutput?: camera.PreviewOutput;
photoOutput?: camera.PhotoOutput;
session?: camera.PhotoSession;
}
export interface DualCameraState {
supported: boolean;
front: CameraStreamState;
back: CameraStreamState;
concurrentInfos: Array<camera.CameraConcurrentInfo>;
generation: number;
}
这样释放、重建、拍照和错误日志都能明确指出是哪一路,而不是依赖 activeCameraPosition 猜测。
五、使用受限能力模式打开两路输入
通过并发能力检查后,再创建输入:
ts
const frontInput: camera.CameraInput = manager.createCameraInput(frontDevice);
const backInput: camera.CameraInput = manager.createCameraInput(backDevice);
await frontInput.open(camera.CameraConcurrentType.CAMERA_LIMITED_CAPABILITY);
await backInput.open(camera.CameraConcurrentType.CAMERA_LIMITED_CAPABILITY);
这里的 CAMERA_LIMITED_CAPABILITY 很重要。前后并发模式不是把两套完整单摄能力简单叠加,控制面和输出组合受到设备并发能力约束。
根据当前官方指南,并发受限模式应重点在以下基础控制范围内验证:
- 闪光灯。
- 曝光。
- 变焦。
- 曝光补偿。
- 对焦。
- 防抖。
- 色彩空间。
即使某个控制在普通单摄会话可用,也不能默认它在并发会话同样可用。
六、输出 Profile 必须来自并发能力结果
当前单摄服务通过普通输出能力选择预览和拍照 Profile。双摄路径不能直接复用默认 Profile,因为并发模式下可用的分辨率、格式和输出组合可能更少。
正确原则:
- 从匹配设备组合的
CameraConcurrentInfo中读取输出能力。 - 为前后两路分别选择真实存在的 PreviewProfile。
- 只有在并发结果支持时才创建 PhotoOutput。
- 不要写死两路都使用单摄最高分辨率。
可以先实现"预览优先"的最小闭环:
text
探测并发能力
-> 选择两路低风险 PreviewProfile
-> 双路预览稳定
-> 再增加前后 PhotoOutput
-> 最后增加合成与装饰
先上双路最高画质并同时开启人体识别,容易在真机上同时遇到带宽、内存和功耗问题。
七、页面需要两个独立 Surface
当前页面只有:
ts
private previewController: XComponentController = new XComponentController();
@State previewSurfaceId: string = '';
双摄需要两个控制器和 Surface ID:
ts
private backPreviewController: XComponentController = new XComponentController();
private frontPreviewController: XComponentController = new XComponentController();
@State backSurfaceId: string = '';
@State frontSurfaceId: string = '';
布局可以先采用主画面加画中画:
ts
Stack() {
XComponent({
id: 'backPreview',
type: XComponentType.SURFACE,
controller: this.backPreviewController
})
.width('100%')
.height('100%')
XComponent({
id: 'frontPreview',
type: XComponentType.SURFACE,
controller: this.frontPreviewController
})
.width(112)
.height(148)
.position({ x: 188, y: 18 })
}
只有两个 Surface 都完成 onLoad 后,才进入双摄启动。任一路 Surface 销毁,都应停止双摄会话,不能让 CameraInput 继续向失效 Surface 输出。
八、双 Surface 就绪要有统一闸门
推荐方法:
ts
private canStartDualPreview(): boolean {
return this.cameraPermissionStatus === 'granted' &&
this.backSurfaceId.length > 0 &&
this.frontSurfaceId.length > 0 &&
!this.isDualStarting;
}
每个 Surface onLoad 后都调用同一个尝试启动方法:
ts
private tryStartDualPreview(): void {
if (!this.canStartDualPreview()) {
return;
}
this.startDualPreview();
}
这样不依赖前置或后置 Surface 谁先加载,也不会启动两次。
九、双路拍照不要假设一个回调返回两张图
如果目标是分别保存前后两张照片,建议每一路维护自己的 PhotoOutput 和回调:
ts
export interface DualCaptureResult {
frontPixelMap?: image.PixelMap;
backPixelMap?: image.PixelMap;
frontReceived: boolean;
backReceived: boolean;
startedAt: number;
}
拍照入口:
text
记录 captureStartedAt
-> 清空前后上一轮结果
-> 触发 frontPhotoOutput.capture()
-> 触发 backPhotoOutput.capture()
-> 分别等待两个 photoAvailable
-> 两路成功后进入合成
-> 任一路失败则按产品策略降级
不要因为后置先返回,就把上一轮前置结果配对进本次。每一路回调都要用时间边界或 capture generation 判断归属。
十、合成照片应在应用层完成
如果产品需要一张"前后同框"图片,推荐先拿到两张独立照片,再在应用层做画中画或上下拼接。这样资源归属清楚,也便于选择模板。
业务模型可以记录:
ts
export interface DualCapturedPhoto {
id: string;
createdAt: string;
frontPhotoId: string;
backPhotoId: string;
layout: 'pictureInPicture' | 'verticalSplit' | 'horizontalSplit';
watermark?: WatermarkSnapshot;
}
合成时的关键点:
- 两个 PixelMap 的方向和镜像分别处理。
- 前置通常需要按产品预期镜像,后置通常不镜像。
- 先统一到目标画布尺寸,再计算裁剪区域。
- 合成完成后释放前后原始 PixelMap。
- 如果用户取消合成,仍要释放两路结果。
十一、不支持双摄时的三级降级
双摄不是所有机型的基础能力,产品必须把降级设计放在入口,而不是失败后只弹"拍摄失败"。
降级一:普通前后切换
直接复用项目当前 nextPosition() 和单路预览,是最稳定的兜底。
降级二:顺序双拍
用户先拍后置,应用自动切前置,再拍第二张,最后合成。它不是同时拍摄,UI 文案必须写"顺序双拍",不能误导为双摄同开。
降级三:单路拍照加历史图片
当前摄像头拍一张,另一块区域从本地相册选择历史照片。此路径不依赖双摄并发,但需要额外设计图库选择和隐私说明。
能力路由示例:
ts
export type DualCameraMode = 'concurrent' | 'sequential' | 'single';
export function resolveDualCameraMode(
hasFront: boolean,
hasBack: boolean,
concurrentCount: number
): DualCameraMode {
if (hasFront && hasBack && concurrentCount > 0) {
return 'concurrent';
}
if (hasFront && hasBack) {
return 'sequential';
}
return 'single';
}
十二、权限与隐私控制
前后双摄拍照仍使用:
json5
"name": "ohos.permission.CAMERA"
如果只拍照片,不应因为"双摄"额外申请麦克风权限。只有加入带声音的视频录制时才需要重新评估 ohos.permission.MICROPHONE。
隐私说明需要明确:
- 双摄模式会同时处理前后画面。
- 是否保存两张原图,还是只保存合成图。
- 人体关键点或分割轮廓是否落盘。
- 用户关闭双摄后是否立即停止并释放两路资源。
当前项目不把人体目标持久化,升级时应延续最小留存原则。
十三、双路资源释放顺序
建议为每一路统一释放:
text
取消回调和采样任务
-> session.stop()
-> session.release()
-> photoOutput.release()
-> previewOutput.release()
-> cameraInput.close()
双摄总释放要做到:
- 先把静态引用或状态引用置空,防止新操作拿到旧对象。
- 前后两路分别 try/catch,某一路失败不阻止另一条释放。
- 页面退出、任一 Surface 销毁、权限撤销和模式降级都走同一入口。
- 迟到的前后 PixelMap 按 generation 判断,失效结果立即释放。
十四、错误码与恢复策略
双摄比单摄更容易遇到资源冲突。需要重点识别:
| 错误信号 | 含义方向 | 建议恢复 |
|---|---|---|
7400102 |
操作不允许 | 检查调用顺序、会话状态和能力边界 |
7400107 |
相机冲突 | 释放旧会话,确认没有其他应用占用 |
7400108 |
安全原因禁用相机 | 提示用户检查系统安全设置 |
7400201 |
相机服务致命错误 | 完整释放后重建,仍失败则退回单摄 |
不要在 concurrentInfos 为空时反复重试打开双摄。空结果是能力不支持,不是瞬时网络错误。
十五、建议补充的测试
纯函数测试
ts
expect(resolveDualCameraMode(true, true, 1)).assertEqual('concurrent');
expect(resolveDualCameraMode(true, true, 0)).assertEqual('sequential');
expect(resolveDualCameraMode(false, true, 0)).assertEqual('single');
真机集成测试
| 场景 | 操作 | 验收点 |
|---|---|---|
| 支持并发机型 | 打开双摄入口 | 两路 Surface 都有稳定预览 |
| 不支持并发机型 | 打开双摄入口 | 自动进入顺序双拍,不报崩溃 |
| 缺少前置 | 启动应用 | 只显示单摄模式 |
| 连续双拍 | 连续执行 10 次 | 前后结果不会跨轮次配对 |
| 快速切后台 | 双摄预览时切后台 | 两路会话都释放,返回可重建 |
| Surface 销毁 | 关闭画中画组件 | 双摄立即停止,不保留悬空输入 |
| 前后合成 | 保存画中画 | 方向、镜像、水印和裁剪正确 |
| 内存验证 | 连续拍摄并取消 | PixelMap 不持续增长 |
十六、常见问题排查
| 现象 | 可能原因 | 排查方式 |
|---|---|---|
| 两个摄像头都存在却不能同开 | 设备组合不支持并发 | 查 getCameraConcurrentInfos() |
| 打开第二路报冲突 | 未使用受限并发模式或旧会话未释放 | 查 open 模式与资源释放 |
| 双路预览一黑一亮 | 某个 Surface 未就绪或 Profile 不匹配 | 查两个 Surface ID 和并发输出能力 |
| 单摄 Profile 可用,双摄失败 | 错用了普通输出能力 | 从 CameraConcurrentInfo 选择 Profile |
| 前置方向反了 | 没有单独处理镜像 | 前后 PixelMap 分别处理方向 |
| 两张照片错配 | 没有 capture generation | 以同一轮次聚合前后回调 |
| 退出页面仍占相机 | 只释放一路资源 | 前后流统一 stop/release/close |
| 不支持机型反复报错 | 把空并发信息当瞬时失败 | 直接进入顺序双拍降级 |
| 双摄卡顿严重 | 两路分辨率或识别负载过高 | 先降低预览 Profile,再逐步增加能力 |
| 闪光灯在双摄失效 | 并发受限控制面不同 | 重新探测目标模式支持 |
| 模拟器能跑真机失败 | 模拟器不能代表并发硬件能力 | 必须真机覆盖 |
| UI 宣称同时拍但实际顺序拍 | 降级文案不诚实 | 明确标注"顺序双拍" |
十七、上线前验收清单
- 当前单摄实现与双摄升级边界已明确。
- 前后设备使用位置 API 分别获取。
- 打开输入前调用
getCameraConcurrentInfos()。 - 空并发结果直接进入降级,不盲目重试。
- 两路输入使用
CAMERA_LIMITED_CAPABILITY打开。 - PreviewProfile 和 PhotoProfile 来自并发能力结果。
- 页面拥有两个独立 XComponent Surface。
- 两个 Surface 都就绪后只启动一次双摄。
- 任一 Surface 销毁都会停止双摄。
- 前后 PhotoOutput 回调按 capture generation 聚合。
- 前置镜像和后置方向分别验证。
- 合成取消、失败和成功路径都释放 PixelMap。
- 不支持并发时提供顺序双拍或单摄模式。
- 降级 UI 不把顺序拍摄描述为同时拍摄。
- 拍照双摄不额外申请麦克风权限。
- 双摄原图和合成图保存策略写入隐私说明。
7400102、7400107、7400108、7400201有明确恢复路径。- 支持机型、不支持机型和单摄设备都完成真机回归。
总结
从单摄切换升级到前后双摄同开,真正的难点不是多放一个 XComponent,而是把能力探测、输出 Profile、两路资源所有权、异步回调配对和不支持设备降级设计清楚。当前 1quweixiangji 的单摄服务已经提供权限、设备发现、预览、拍照和释放底座;双摄升级应在此基础上新增并发能力路由,而不是破坏稳定的单摄主流程。
可复用的核心顺序是:先确认前后设备,再查询 getCameraConcurrentInfos(),根据并发结果选择输出能力,以 CAMERA_LIMITED_CAPABILITY 打开输入,双 Surface 稳定后再接双路拍照,最后做应用层合成。任何一步不满足,都应诚实退回顺序双拍或普通单摄。这样双摄能力才是可用的产品功能,而不是只在少数设备偶然运行的演示代码。