ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

鸿蒙自定义相机前后切换实战:会话重建与状态同步

鸿蒙自定义相机前后切换实战:会话重建与状态同步 1. 为什么要跟系统相机较劲自定义相机的前后切换到底难在哪做鸿蒙自定义相机最容易被低估的就是前后摄像头切换。系统相机里一个八角按钮完成的动作放到自定义相机里却涉及会话重建、状态同步、方向翻转和一堆兼容性问题。我最初接手公司内部巡检App时需求文档只有一行相机模块支持前后切换默认后置当时觉得顶多改个设备ID结果真正写起来才发现鸿蒙的相机栈和Android、iOS的习惯完全不一样。那台设备巡检App本身业务很朴素登录后用自定义相机扫描设备二维码识别成功后跳到信息录入页录入页里需要拍摄设备铭牌照片还支持在拍照页手动切换到前置摄像头拍操作人员人脸。这个场景是典型的嵌入式自定义相机——相机画面必须存在于App自身的页面里上面要叠加取景框、按钮、遮罩动画系统相机完全给不了这套交互。更麻烦的是二维码扫描和后前景衡平拍摄共用同一个相机实例但二维码扫描需要后置人脸核对需要前置所以前后切换频率很高业务上一旦切换失败用户只能重启App这压力就全落在相机模块上。在动手之前我先把前后切换拆成了四种不同语义方便后续设计代码结构。第一种是纯手动切换用户点按钮切换画面来源这是本文重点。第二种是业务自动切换比如扫描模块识别到条码后自动切到前置做人脸比对。第三种是双摄同屏直播和AR类场景可能要同时挂两个摄像头输出到不同Surface这已经不是切换而是并存。第四种是前后台切换App进入后台要释放相机回到前台再恢复——这虽然不是摄像头之间的切换但和摄像头切换共用同一套释放重建逻辑也是最容易被忽略的。如果你的需求只是第一种可以往下看如果是二三四种的任意组合建议把相机模块设计成状态机否则代码写到后面会纠结到爆。我在这一系列文章里使用的环境基线是HarmonyOS NEXTAPI 12DevEco 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(): Promiseboolean { let context getContext(this) as common.UIAbilityContext; let atManager abilityAccessCtrl.createAtManager(); let permissions: ArrayPermissions [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: Arraycamera.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前置摄像头天然有镜像问题。前置自拍时预览画面像镜子一样用户习惯这种翻转效果但如果你用前置拍文件、拍人脸用于识别就不应该镜像。这里要分清两个APIPreviewOutput.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再调MirrorApp UI上会闪一下因为start时还是非镜像这个闪烁可以用一个简单动画遮罩盖住。如果你想第一帧就是镜像只能在业务层签名支持之前短暂隐藏预览层。这些细节很恼人但是自定义相机和系统相机体验差距的来源之一。5. 高频问题和性能优化把切换从能用打磨到顺手5.1 快速连点导致会话重建风暴前后切换最大的敌人是用户手快。连点切换按钮时如果不做串行控制第一个切换还在release的过程中第二个已经进来createCameraInput两路请求会同时操作相机服务。我的实测结果是轻则某个Input创建失败重则第二个切换永久卡死必须杀进程才能恢复。因为Camera Kit的底层对同一个摄像头设备有占用锁前一个Input还没释放完新的Input不能创建而Release本身又是异步动作肉眼看着代码是顺序await实际线程之间可能穿插。最简单有效的控制手段是加一个串行队列。我用一个Promise链每次点击都把切换动作排到上一个切换动作后面private switchQueue: Promisevoid Promise.resolve(); switchCameraQueued(): Promisevoid { 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()返回Promiseawait它之后还需要再等一帧。我是这样做的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是不是只设了一半。把这些基础细节打磨好切换模块比任何花哨优化都更值得投入。
返回列表