☰
鸿蒙自定义相机前后切换实战:会话重建与状态同步
2026/9/29 17:57:27 网站建设 项目流程

1. 为什么要跟系统相机较劲:自定义相机的前后切换到底难在哪

做鸿蒙自定义相机,最容易被低估的就是前后摄像头切换。系统相机里一个八角按钮完成的动作,放到自定义相机里却涉及会话重建、状态同步、方向翻转和一堆兼容性问题。我最初接手公司内部巡检App时,需求文档只有一行"相机模块支持前后切换,默认后置",当时觉得顶多改个设备ID,结果真正写起来,才发现鸿蒙的相机栈和Android、iOS的习惯完全不一样。

那台设备巡检App本身业务很朴素:登录后用自定义相机扫描设备二维码,识别成功后跳到信息录入页,录入页里需要拍摄设备铭牌照片,还支持在拍照页手动切换到前置摄像头拍操作人员人脸。这个场景是典型的"嵌入式自定义相机"——相机画面必须存在于App自身的页面里,上面要叠加取景框、按钮、遮罩动画,系统相机完全给不了这套交互。更麻烦的是,二维码扫描和后前景衡平拍摄共用同一个相机实例,但二维码扫描需要后置,人脸核对需要前置,所以前后切换频率很高,业务上一旦切换失败,用户只能重启App,这压力就全落在相机模块上。

在动手之前,我先把"前后切换"拆成了四种不同语义,方便后续设计代码结构。第一种是纯手动切换,用户点按钮切换画面来源,这是本文重点。第二种是业务自动切换,比如扫描模块识别到条码后自动切到前置做人脸比对。第三种是双摄同屏,直播和AR类场景可能要同时挂两个摄像头输出到不同Surface,这已经不是"切换",而是"并存"。第四种是前后台切换,App进入后台要释放相机,回到前台再恢复——这虽然不是摄像头之间的切换,但和摄像头切换共用同一套释放重建逻辑,也是最容易被忽略的。如果你的需求只是第一种,可以往下看;如果是二三四种的任意组合,建议把相机模块设计成状态机,否则代码写到后面会纠结到爆。

我在这一系列文章里使用的环境基线是HarmonyOS NEXT,API 12,DevEco Studio 5.0以上,语言ArkTS。为什么特别强调版本?因为Camera Kit在API 10到API 12之间改动实在不小,后面我会提到的createSession(camera.SceneMode.NORMAL_PHOTO),在老版本上只能写成createSession()。如果你照抄新代码到API 9工程,直接编译不过。所以文中的示例代码以API 12为准,老版本迁移时注意查SDK变更日志。

2. 鸿蒙相机API选型:别把CameraPicker当成万能钥匙,也别跳进CameraManager的坑

2.1 CameraPicker到底能做什么

很多初学者看到鸿蒙Camera Kit的文档,第一反应是"用CameraPicker啊,省事"。CameraPicker确实省事,它是系统封装好的拍照/录像选择器,调用后直接弹出系统相机或相册界面,你不需要申请相机权限,也不需要管理Surface。但是,你所能控制的仅限于"等它返回一个文件路径",相机预览画面、前后切换按钮、取景框、叠加水印,这些通通不是你能控制的。它适合做"发个朋友圈拍张照"这类操作,但做不了集成在业务流程里的自定义相机。

我在需求调研阶段就否掉了CameraPicker,原因是巡检App需要在相机画面中央画一个对齐框,用来引导用户把二维码放进框内。这种叠加UI的需求,只有自定义相机才做得到。另外,CameraPicker走的是独立页面流程,Android上还好,在鸿蒙上如果App页面本身横竖屏状态特殊,CameraPicker返回后Activity/Want的转场会有明显闪烁,放在边夹流程里很出戏。

2.2 Camera Kit的两层模型:Manager和Session

真正要做自定义相机,必须用@kit.CameraKit里的camera模块。这个模块可以理解为两层结构:底层是CameraManager,负责探测设备、管理CameraInput和Output;上层是CaptureSession,负责把输入输出组合成一个会话语义。举个例子,CameraManager好比是停车场的闸机,管着哪辆车能进、哪辆能出;CaptureSession则是你从停车场开走的整条路线,它决定了这辆车从哪个口进、去哪个车位、最后从哪个口出。很多刚上手的人只看到闸机,以为拿到CameraManager就能切换摄像头,忽略了变化,于是反复创建输入输出时不冲突还好,一冲突就报"Camera device not exist"之类的诡异错误。

从API 12开始,创建Session必须带场景模式,NORMAL_PHOTO表示普通拍照场景,另外还有NORMAL_VIDEO等。会话的最大作用,是让你在beginConfig到commitConfig之间把CameraInput和PreviewOutput绑在一起。切换摄像头之所以麻烦,就是因为你不能简单地把Session里的Input引用换掉——正规流程是拆掉整个会话重建。

2.3 前置和后置的物理能力差异:Profile不匹配引发的连锁问题

不同摄像头拥有完全不同的CameraOutputCapability。我在一台平板设备上实测,后置摄像头能输出3840x2160,前置摄像头最大只支持1920x1080。如果切换后仍然沿用后置的PreviewProfile去创建前置输出,createPreviewOutput会直接抛异常;即便某些机型不抛,画面比例也会从16:9变成4:3,取景框全歪。所以切换摄像头时,第一个要处理的就是"重新获取目标摄像头的Profile"。

我踩过的一个隐蔽坑是:后置拍摄时用户把变焦放大了,切到前置后我还保留着后置的Profile对象,但前置的摄像头FOV本来就窄,再加上夸张的变焦值,生成的预览画面会发生明显的裁切。后来我的做法是每切一次,就根据目标设备重新查询getSupportedOutputCapability(device),再从中挑一个合适的分辨率。这个查询操作很便宜,但必须每次做,不能缓存。

还有一个更微妙的点:同一个设备在不同场景模式下的能力也不同。NORMAL_PHOTO和NORMAL_VIDEO对应的分辨率集合可能差挺多。如果你在拍照模式创建工作,却拿视频模式的Profile去创建输出,部分设备会给你一个低一级的分辨率。所以简单起见,我在确认Profile时直接问cameraManager.createPreviewOutput(profile, surfaceId),但保证profile来自目标设备在当前场景模式下的支持列表。这一步做对了,后面切换的成功率会高很多。

3. 跑通自定义相机预览的最小骨架:XComponent、CameraInput和CaptureSession三件套

3.1 初始化前的权限筹备

在开发自定义相机前,先保证权限申请和XComponent能正常运作。CAMERA权限是高危权限,需要在module.json5里声明,然后在主Ability首次启动时向用户动态申请。这步不能省略,如果漏了,后面createCameraManager或createCameraInput会直接抛权限异常,而不是弹窗。权限代码我放在onPageShow里做一次申请,避免每次启动都弹窗。

import { abilityAccessCtrl, Permissions, common } from '@kit.AbilityKit'; async requestCameraPermission(): Promise<boolean> { let context = getContext(this) as common.UIAbilityContext; let atManager = abilityAccessCtrl.createAtManager(); let permissions: Array<Permissions> = ['ohos.permission.CAMERA']; let result = await atManager.requestPermissionsFromUser(context, permissions); return result.authResults.length > 0 && result.authResults[0] === 0; }

页面侧用XComponent承载预览画面。XComponent的type必须设为'surface',然后在onCreated回调里拿到surfaceId,再把它传给相机管理器。这里有一个经验:surfaceId在前端是字符串,在底层是原生Surface句柄,你拿到的surfaceId必须是有效的,否则createPreviewOutput即使不报错也会黑屏。确认有效性的最简单方式是在onCreated回调里打日志看长度,一般API 12下是几十位的数字字符串。

3.2 第一个能动的预览:从CameraManager到CaptureSession

下面这一段是我在项目里用的最小初始化流程,你可以直接参照。核心顺序是:拿Manager -> 找设备 -> 建Input -> 建Output -> 建Session -> 拼装 -> start。

import { camera } from '@kit.CameraKit'; import { common } from '@kit.AbilityKit'; private cameraManager: camera.CameraManager | undefined = undefined; private cameraInput: camera.CameraInput | undefined = undefined; private previewOutput: camera.PreviewOutput | undefined = undefined; private captureSession: camera.CaptureSession | undefined = undefined; async initCamera(surfaceId: string, targetPosition: camera.CameraPosition = camera.CameraPosition.CAMERA_POSITION_BACK) { // 1. 拿到CameraManager let context = getContext(this) as common.UIAbilityContext; this.cameraManager = camera.getCameraManager(context); // 2. 找到目标摄像头,优先按position查找 let devices: Array<camera.CameraDevice> = this.cameraManager.getSupportedCameras(); let targetDevice = devices.find(device => device.cameraPosition === targetPosition); if (!targetDevice) { targetDevice = devices[0]; console.warn('No target camera, fallback to first device'); } // 3. 创建CameraInput this.cameraInput = this.cameraManager.createCameraInput(targetDevice); await this.cameraInput.open(); // 4. 根据目标camera能力挑选预览Profile let outputCapability = this.cameraManager.getSupportedOutputCapability(targetDevice); let previewProfile = this.choosePreviewProfile(outputCapability); // 5. 创建PreviewOutput并绑定surfaceId this.previewOutput = this.cameraManager.createPreviewOutput(previewProfile, surfaceId); // 6. 创建CaptureSession this.captureSession = this.cameraManager.createSession(camera.SceneMode.NORMAL_PHOTO); this.captureSession.beginConfig(); this.captureSession.addInput(this.cameraInput); this.captureSession.addOutput(this.previewOutput); await this.captureSession.commitConfig(); await this.captureSession.start(); } private choosePreviewProfile(capability: camera.CameraOutputCapability): camera.Profile { const targetWidth = 1920; // 优先1080P,太高意义不大 let profiles = capability.previewProfiles; let best = profiles[0]; for (let profile of profiles) { if (profile.size.width === targetWidth) { best = profile; break; } } return best; }

这个流程本身不难,但有几个隐藏细节。第一,createCameraInput之后必须显式open(),否则后续addInput会失败。第二,Surface在页面可见后才能获取,如果页面还在后台,onCreated不会触发。第三,beginConfig之后如果任何一步add失败,要记得abortConfig(),否则session处于流浪状态。在切换摄像头时,我见过不少人只是重新beginConfig,而没有处理掉上一次的session,结果相机服务直接被占用。

3.3 生命周期里最容易漏掉的释放逻辑

自定义相机比系统相机多了一堆疏漏点,其中最大的就是生命周期释放。App在后退、切后台、最小化时,CameraInput和CaptureSession必须释放,否则设备相机可能被这个进程一直占着,导致其他应用拍照也是黑的。在鸿蒙上,我通常在自定义Page组件里监听onPageHide和onPageShow。onPageHide时执行stopSession并释放输入输出,onPageShow时根据上下文决定是否需要重新初始化。如果业务要求App在后台还要继续“监听”相机,那么必须使用后台任务或者保持前台运行的方式,这里就不展开了,大部分B端一碰就死的规则还是让相机释放更稳妥。

生命周期设计的另一个要点是幂等性。我开始写的release函数不够幂等,快速返回页面时被调用了两遍,第二遍释放空对象直接抛错。后来在每个释放子函数里加了一个if (this.cameraInput) { ... }判断,并且把this.cameraInput立即置空。这样即使快速切换也不会出现二次释放。这个"先清引用再执行释放"的习惯,帮我省了后续很多崩溃日志。

4. 前后摄像头切换的完整实现:释放旧会话、重建新会话与状态同步

4.1 一步一步的切换代码

现在到了本文最核心的部分。前后摄像头切换,我归纳为三步走:停会话,释放输入输出,用新Device重建。也许你会想,能不能removeInput之后addInput?在API 12上,CaptureSession确实有removeInput和removeOutput,但实测在切换摄像头时,旧CameraInput和新CameraInput不能同时存在于同一个Session里,而且部分设备上remove之后Session内部的buffers会出现残留,导致新Input无法正常预览。我最终采用的方案是彻底销毁重建,稳定压倒一切。

async switchCamera() { let nextPosition = this.currentPosition === camera.CameraPosition.CAMERA_POSITION_BACK ? camera.CameraPosition.CAMERA_POSITION_FRONT : camera.CameraPosition.CAMERA_POSITION_BACK; // 1. 停止会话 if (this.captureSession) { await this.captureSession.stop(); await this.captureSession.release(); this.captureSession = undefined; } // 2. 释放输出与输入 if (this.previewOutput) { await this.previewOutput.release(); this.previewOutput = undefined; } if (this.cameraInput) { await this.cameraInput.release(); this.cameraInput = undefined; } // 3. 用新位置重新初始化 this.currentPosition = nextPosition; await this.initCamera(this.surfaceId, nextPosition); }

很多人会觉得这个release顺序无所谓,其实非常有讲究。Session.release()必须放在CameraInput.release()之前。如果反过来,Input已经释放,Session还认为它绑定着输入,你去release Session时内部会试图对已释放的Input做清理,轻则告警,重则底层报RTP异常导致概率性崩溃。同理,PreviewOutput.release()其实可以尽早,因为Output没有Input那么依赖Session,但我习惯连同Input一起依次释放,流程简单统一。

再提一步异常恢复。release和重建之间如果相机服务刚好被其他应用抢走,createCameraInput可能抛ServiceUnavailable。我在switchCamera外层套了try-catch,失败后将currentPosition回滚到切换前的值,并且弹一条Toast让用户重试。这个回滚逻辑别省——实测在多个应用轮番占用相机的情况下,切换失败概率能到千分之几,对B端设备虽然不算高,但一旦失败App就卡在无画面状态,反馈体验很差。

4.2 状态同步:闪光灯、变焦、对焦、曝光一个都不能少

切换完成后,Session是新的,但用户期望还是原来的配置。我遇到过最典型的场景:用户在后置模式下把闪光灯打开拍单据,切到前置后再切回来,发现闪光灯状态丢了,按钮显示关闭,可实际后置闪光灯又亮了,因为底层CameraInput被重建后默认配置被重置。UI状态和硬件状态不一致,这个bug特别难查。

我的做法是在组件里维护一个CameraState,它不只记录页面按钮状态,而是作为"期望状态"在每次相机初始化完成后应用。状态包括变焦比例、是否开闪光灯、对焦模式和曝光值。切换完成后的RestoreState代码大概长这样:

private restoreCameraState() { // 闪光灯 if (this.cameraInput) { let flashMode = this.cameraState.flashOn ? camera.FlashMode.FLASH_MODE_ALWAYS_OPEN : camera.FlashMode.FLASH_MODE_CLOSE; try { this.cameraInput.setFlashMode(flashMode); } catch (err) { // 前置摄像头通常没有闪光灯,不用处理,保持UI为关闭态即可 } } // 变焦:需要根据目标设备能力决定是否恢复 if (this.cameraState.zoomRatio > 1) { try { this.cameraInput?.setZoomRatio(this.cameraState.zoomRatio); } catch (err) { // 前置或部分设备不支持高倍变焦,重置换挡为1 this.cameraState.zoomRatio = 1; } } // 对焦模式:默认连续对焦 try { this.cameraInput?.setFocusMode(camera.FocusMode.FOCUS_MODE_CONTINUOUS_AUTO); } catch (err) { console.warn('set continuous autofocus failed'); } }

这里最有争议的是变焦恢复。我的实际选择是"切换即重置,用户再手动调回来"。因为绝大多数用户切到前置后并不想保持后置的2倍变焦,前置的取景范围本来就小,再加上变焦会变得特别局促。而且前置摄像头一般只支持数码变焦,在高倍率下画质下降严重,硬恢复只会放大卡顿。所以restoreCameraState里我强制把zoomRatio重置为1,只恢复闪光灯和对焦模式。这样做不完美,但在绝大多数业务场景里更符合直觉。

4.3 镜像设置的两个层次:viewMirror与photoMirror

前置摄像头天然有镜像问题。前置自拍时,预览画面像镜子一样,用户习惯这种"翻转"效果;但如果你用前置拍文件、拍人脸用于识别,就不应该镜像。这里要分清两个API:PreviewOutput.setViewMirror(boolean)控制预览画面的左右翻转,PhotoOutput.setPhotoMirror(boolean)控制照片输出是否翻转。我在很多教程里看到只设置viewMirror,结果预览是正常的,照片一拍出来却是反向的。

在API 12上,我建议切换完成后这样设置:

private adjustMirrorByPosition(device: camera.CameraDevice, isPreview: boolean = true) { const isFront = device.cameraPosition === camera.CameraPosition.CAMERA_POSITION_FRONT; // 预览镜像:自拍场景一般要镜像,业务识别场景不要镜像 if (isFront && this.cameraState.previewMirror) { this.previewOutput?.setViewMirror(true); } else { this.previewOutput?.setViewMirror(false); } // 照片镜像:绝大部分业务不需要镜像,所以直接关掉 if (this.photoOutput) { this.photoOutput?.setPhotoMirror(false); } }

需要注意,这个设置必须在Session已经start之后调用才稳定生效。我曾经在beginConfig阶段调setViewMirror,结果有的设备生成了镜像,有的设备忽略了。切到前置后先start,再调Mirror,App UI上会闪一下(因为start时还是非镜像),这个闪烁可以用一个简单动画遮罩盖住。如果你想第一帧就是镜像,只能在业务层签名支持之前短暂隐藏预览层。这些细节很恼人,但是自定义相机和系统相机体验差距的来源之一。

5. 高频问题和性能优化:把切换从"能用"打磨到"顺手"

5.1 快速连点导致会话重建风暴

前后切换最大的敌人,是用户手快。连点切换按钮时,如果不做串行控制,第一个切换还在release的过程中,第二个已经进来createCameraInput,两路请求会同时操作相机服务。我的实测结果是:轻则某个Input创建失败,重则第二个切换永久卡死,必须杀进程才能恢复。因为Camera Kit的底层对同一个摄像头设备有占用锁,前一个Input还没释放完,新的Input不能创建,而Release本身又是异步动作,肉眼看着代码是顺序await,实际线程之间可能穿插。

最简单有效的控制手段是加一个串行队列。我用一个Promise链,每次点击都把切换动作排到上一个切换动作后面:

private switchQueue: Promise<void> = Promise.resolve(); switchCameraQueued(): Promise<void> { this.switchQueue = this.switchQueue.then(() => this.switchCamera()); return this.switchQueue; }

这样即使玩家一秒点了五次切换,底层也只会按顺序执行五个完整的切换流程,杜绝并发崩溃。注意,这样如果连续点五次,最终还是执行了五次完整切换,最后落在某个方向上。如果你希望更精确地忽略中间请求,可以用"只有队列为空时才提交,否则标记pending"的简单节流版。但业务上,用户连点本身是乱操作,按顺序依次切换已经足够宽容。

5.2 黑屏、花屏和surfaceId复用陷阱

切换后黑屏是问题数最高的反馈。一是在XComponent没有重建Surface的情况下直接复用旧surfaceId,二是在旧Output尚未完全释放时,同一surfaceId被新的PreviewOutput接管。在部分图形栈实现里,Surface的所有权没有在恰当时机转移,新Output绑上去后一直拿不到帧缓冲区,于是黑屏。

我试过切换前把XComponent隐藏,切换后显示,但这只是视觉手段,不解决根本问题。更稳的做法是每次切换都重新为XComponent获取一个新的surfaceId,即先销毁XComponent再重建。比如在ArkUI里给XComponent加一个key值,切换时先this.needReCreateSurface = true,然后通过条件渲染重建组件,让新onCreated回调拿到全新Surface句柄。代价是重建XComponent会有几十毫秒到一百毫秒的空白期,但相比黑屏,这点代价值得。

如果你不希望频繁销毁Surface,也可以尝试复用surfaceId但保证旧PreviewOutput释放完成。previewOutput.release()返回Promise,await它之后还需要再等一帧。我是这样做的:release后用await new Promise(resolve => setTimeout(resolve, 50))强制等50ms,再重建Output。实测大部分机型能解决,但有个别平板仍然黑屏。最终我在兼容层选择了"重建XComponent"方案,切换耗时大约增加20ms,换来的是稳定。

5.3 画面方向漂移的根源与修正

前后切换后画面横竖屏方向错乱,也是常见问题。摄像头传感器有一个固定的安装方向,通常后置是90度,前置是270度,这在SensorOrientation属性里可以看到。鸿蒙的PreviewOutput会根据Display的方向自动旋转?不能说"自动",实际上是开发者要让Session知道预览的预期方向。在不同华为设备上,不做direction处理,横屏进入页面后切到前置,预览画面会歪着。

我的处理方案很粗暴:首先在项目里锁定竖屏,然后在每次创建Session后调用session.setPreviewOrientation(90)。如果你要兼容横竖屏切换,需要监听屏幕旋转,把映射表做出来。我简单给出竖屏场景的代码:

private async configureOrientation() { if (this.captureSession) { try { this.captureSession.setPreviewOrientation(90); // 竖屏固定90 } catch (e) { console.warn('setPreviewOrientation not supported'); } } }

这个调用时机在commitConfig之后、start之前或之后都可以,不同设备表现略有差异。我把它放在commitConfig后立刻执行,再start。如果你看到切换后画面内容旋转90度,优先检查这个值;如果方向对但预览被拉伸,去看3.2里的profile选择是否正确。方向问题不会让App崩溃,但用户立刻能感知,属于"不崩溃但很伤逼格"的坑。

5.4 我压到120ms的三个关键优化

最后说说性能。最开始我的完整切换流程耗时200-400ms,切换时黑屏明显。做了三个优化后,在Mate系列机型上稳定压到120ms左右,体感已经接近系统相机。

第一个优化是缓存Profile。每次查询getSupportedOutputCapability虽然不贵,但加上对象分配和设备IO,在低端机上也要几毫秒到几十毫秒。我在页面加载时把前置和后置各自的previewProfile都查好,切换时直接取用。注意,如果设备支持随时热拔插,这种缓存会失效,需要监听onCameraStatusChanged事件做失效处理。B端固定设备很少拔插,所以我直接缓存。

第二个优化是重建XComponent和重建Session并行化。释放和创建之间存在硬依赖,但创建XComponent的time和会话重建可以并行。我在切换前先申请新的surfaceId,并把initCamera中的createSession和createPreviewOutput并行执行(用Promise.all),因为两者没有依赖关系。实际收益大约30-50ms。

第三个优化是减少无谓的全局状态同步。切换完切换restoreCameraState放到start()之后并和UI更新错开。第一次写时我们把状态恢复逻辑放在start之前,结果前几帧调用部分API被拒,白白浪费时间。放到start之后,一次秒级操作就能全部同步。

这三点做完,切换依然有可感知的停顿,但用户不会觉得卡——他们会觉得“有个切换过程”,但不至于烦。如果你的业务对切换流畅度要求更高,可以再从底层Surface预分配和双会话轮转方向做文章,但那样复杂度会成倍增长,个人觉得除非做消费级相机App,否则不值当。

做完整套切换后,我最深刻的体会是:自定义相机里的前后切换,难点从来不是"切换"这个动作本身,而是切换前后的一整套状态维护、资源顺序和异常兜底。如果你在鸿蒙自定义相机上遇到了切换后黑屏、崩溃、照片镜像反了之类的问题,先不要怀疑API,从头检查一遍:会话释放顺序是否正确,目标摄像头的Profile有没有重新取,前置Mirror是不是只设了一半。把这些基础细节打磨好,切换模块比任何花哨优化都更值得投入。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询