前后切換實(shí)戰(zhàn):會(huì)話重建與狀態(tài)同步)
1. 為什么要跟系統(tǒng)相機(jī)較勁自定義相機(jī)的前后切換到底難在哪做鴻蒙自定義相機(jī)最容易被低估的就是前后攝像頭切換。系統(tǒng)相機(jī)里一個(gè)八角按鈕完成的動(dòng)作放到自定義相機(jī)里卻涉及會(huì)話重建、狀態(tài)同步、方向翻轉(zhuǎn)和一堆兼容性問(wèn)題。我最初接手公司內(nèi)部巡檢App時(shí)需求文檔只有一行相機(jī)模塊支持前后切換默認(rèn)后置當(dāng)時(shí)覺(jué)得頂多改個(gè)設(shè)備ID結(jié)果真正寫(xiě)起來(lái)才發(fā)現(xiàn)鴻蒙的相機(jī)棧和Android、iOS的習(xí)慣完全不一樣。那臺(tái)設(shè)備巡檢App本身業(yè)務(wù)很樸素登錄后用自定義相機(jī)掃描設(shè)備二維碼識(shí)別成功后跳到信息錄入頁(yè)錄入頁(yè)里需要拍攝設(shè)備銘牌照片還支持在拍照頁(yè)手動(dòng)切換到前置攝像頭拍操作人員人臉。這個(gè)場(chǎng)景是典型的嵌入式自定義相機(jī)——相機(jī)畫(huà)面必須存在于App自身的頁(yè)面里上面要疊加取景框、按鈕、遮罩動(dòng)畫(huà)系統(tǒng)相機(jī)完全給不了這套交互。更麻煩的是二維碼掃描和后前景衡平拍攝共用同一個(gè)相機(jī)實(shí)例但二維碼掃描需要后置人臉核對(duì)需要前置所以前后切換頻率很高業(yè)務(wù)上一旦切換失敗用戶只能重啟App這壓力就全落在相機(jī)模塊上。在動(dòng)手之前我先把前后切換拆成了四種不同語(yǔ)義方便后續(xù)設(shè)計(jì)代碼結(jié)構(gòu)。第一種是純手動(dòng)切換用戶點(diǎn)按鈕切換畫(huà)面來(lái)源這是本文重點(diǎn)。第二種是業(yè)務(wù)自動(dòng)切換比如掃描模塊識(shí)別到條碼后自動(dòng)切到前置做人臉比對(duì)。第三種是雙攝同屏直播和AR類(lèi)場(chǎng)景可能要同時(shí)掛兩個(gè)攝像頭輸出到不同Surface這已經(jīng)不是切換而是并存。第四種是前后臺(tái)切換App進(jìn)入后臺(tái)要釋放相機(jī)回到前臺(tái)再恢復(fù)——這雖然不是攝像頭之間的切換但和攝像頭切換共用同一套釋放重建邏輯也是最容易被忽略的。如果你的需求只是第一種可以往下看如果是二三四種的任意組合建議把相機(jī)模塊設(shè)計(jì)成狀態(tài)機(jī)否則代碼寫(xiě)到后面會(huì)糾結(jié)到爆。我在這一系列文章里使用的環(huán)境基線是HarmonyOS NEXTAPI 12DevEco Studio 5.0以上語(yǔ)言ArkTS。為什么特別強(qiáng)調(diào)版本因?yàn)镃amera Kit在API 10到API 12之間改動(dòng)實(shí)在不小后面我會(huì)提到的createSession(camera.SceneMode.NORMAL_PHOTO)在老版本上只能寫(xiě)成createSession()。如果你照抄新代碼到API 9工程直接編譯不過(guò)。所以文中的示例代碼以API 12為準(zhǔn)老版本遷移時(shí)注意查SDK變更日志。2. 鴻蒙相機(jī)API選型別把CameraPicker當(dāng)成萬(wàn)能鑰匙也別跳進(jìn)CameraManager的坑2.1 CameraPicker到底能做什么很多初學(xué)者看到鴻蒙Camera Kit的文檔第一反應(yīng)是用CameraPicker啊省事。CameraPicker確實(shí)省事它是系統(tǒng)封裝好的拍照/錄像選擇器調(diào)用后直接彈出系統(tǒng)相機(jī)或相冊(cè)界面你不需要申請(qǐng)相機(jī)權(quán)限也不需要管理Surface。但是你所能控制的僅限于等它返回一個(gè)文件路徑相機(jī)預(yù)覽畫(huà)面、前后切換按鈕、取景框、疊加水印這些通通不是你能控制的。它適合做發(fā)個(gè)朋友圈拍張照這類(lèi)操作但做不了集成在業(yè)務(wù)流程里的自定義相機(jī)。我在需求調(diào)研階段就否掉了CameraPicker原因是巡檢App需要在相機(jī)畫(huà)面中央畫(huà)一個(gè)對(duì)齊框用來(lái)引導(dǎo)用戶把二維碼放進(jìn)框內(nèi)。這種疊加UI的需求只有自定義相機(jī)才做得到。另外CameraPicker走的是獨(dú)立頁(yè)面流程Android上還好在鴻蒙上如果App頁(yè)面本身橫豎屏狀態(tài)特殊CameraPicker返回后Activity/Want的轉(zhuǎn)場(chǎng)會(huì)有明顯閃爍放在邊夾流程里很出戲。2.2 Camera Kit的兩層模型Manager和Session真正要做自定義相機(jī)必須用kit.CameraKit里的camera模塊。這個(gè)模塊可以理解為兩層結(jié)構(gòu)底層是CameraManager負(fù)責(zé)探測(cè)設(shè)備、管理CameraInput和Output上層是CaptureSession負(fù)責(zé)把輸入輸出組合成一個(gè)會(huì)話語(yǔ)義。舉個(gè)例子CameraManager好比是停車(chē)場(chǎng)的閘機(jī)管著哪輛車(chē)能進(jìn)、哪輛能出CaptureSession則是你從停車(chē)場(chǎng)開(kāi)走的整條路線它決定了這輛車(chē)從哪個(gè)口進(jìn)、去哪個(gè)車(chē)位、最后從哪個(gè)口出。很多剛上手的人只看到閘機(jī)以為拿到CameraManager就能切換攝像頭忽略了變化于是反復(fù)創(chuàng)建輸入輸出時(shí)不沖突還好一沖突就報(bào)Camera device not exist之類(lèi)的詭異錯(cuò)誤。從API 12開(kāi)始創(chuàng)建Session必須帶場(chǎng)景模式NORMAL_PHOTO表示普通拍照?qǐng)鼍傲硗膺€有NORMAL_VIDEO等。會(huì)話的最大作用是讓你在beginConfig到commitConfig之間把CameraInput和PreviewOutput綁在一起。切換攝像頭之所以麻煩就是因?yàn)槟悴荒芎?jiǎn)單地把Session里的Input引用換掉——正規(guī)流程是拆掉整個(gè)會(huì)話重建。2.3 前置和后置的物理能力差異Profile不匹配引發(fā)的連鎖問(wèn)題不同攝像頭擁有完全不同的CameraOutputCapability。我在一臺(tái)平板設(shè)備上實(shí)測(cè)后置攝像頭能輸出3840x2160前置攝像頭最大只支持1920x1080。如果切換后仍然沿用后置的PreviewProfile去創(chuàng)建前置輸出createPreviewOutput會(huì)直接拋異常即便某些機(jī)型不拋畫(huà)面比例也會(huì)從16:9變成4:3取景框全歪。所以切換攝像頭時(shí)第一個(gè)要處理的就是重新獲取目標(biāo)攝像頭的Profile。我踩過(guò)的一個(gè)隱蔽坑是后置拍攝時(shí)用戶把變焦放大了切到前置后我還保留著后置的Profile對(duì)象但前置的攝像頭FOV本來(lái)就窄再加上夸張的變焦值生成的預(yù)覽畫(huà)面會(huì)發(fā)生明顯的裁切。后來(lái)我的做法是每切一次就根據(jù)目標(biāo)設(shè)備重新查詢getSupportedOutputCapability(device)再?gòu)闹刑粢粋€(gè)合適的分辨率。這個(gè)查詢操作很便宜但必須每次做不能緩存。還有一個(gè)更微妙的點(diǎn)同一個(gè)設(shè)備在不同場(chǎng)景模式下的能力也不同。NORMAL_PHOTO和NORMAL_VIDEO對(duì)應(yīng)的分辨率集合可能差挺多。如果你在拍照模式創(chuàng)建工作卻拿視頻模式的Profile去創(chuàng)建輸出部分設(shè)備會(huì)給你一個(gè)低一級(jí)的分辨率。所以簡(jiǎn)單起見(jiàn)我在確認(rèn)Profile時(shí)直接問(wèn)cameraManager.createPreviewOutput(profile, surfaceId)但保證profile來(lái)自目標(biāo)設(shè)備在當(dāng)前場(chǎng)景模式下的支持列表。這一步做對(duì)了后面切換的成功率會(huì)高很多。3. 跑通自定義相機(jī)預(yù)覽的最小骨架XComponent、CameraInput和CaptureSession三件套3.1 初始化前的權(quán)限籌備在開(kāi)發(fā)自定義相機(jī)前先保證權(quán)限申請(qǐng)和XComponent能正常運(yùn)作。CAMERA權(quán)限是高危權(quán)限需要在module.json5里聲明然后在主Ability首次啟動(dòng)時(shí)向用戶動(dòng)態(tài)申請(qǐng)。這步不能省略如果漏了后面createCameraManager或createCameraInput會(huì)直接拋權(quán)限異常而不是彈窗。權(quán)限代碼我放在onPageShow里做一次申請(qǐng)避免每次啟動(dòng)都彈窗。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; }頁(yè)面?zhèn)扔肵Component承載預(yù)覽畫(huà)面。XComponent的type必須設(shè)為surface然后在onCreated回調(diào)里拿到surfaceId再把它傳給相機(jī)管理器。這里有一個(gè)經(jīng)驗(yàn)surfaceId在前端是字符串在底層是原生Surface句柄你拿到的surfaceId必須是有效的否則createPreviewOutput即使不報(bào)錯(cuò)也會(huì)黑屏。確認(rèn)有效性的最簡(jiǎn)單方式是在onCreated回調(diào)里打日志看長(zhǎng)度一般API 12下是幾十位的數(shù)字字符串。3.2 第一個(gè)能動(dòng)的預(yù)覽從CameraManager到CaptureSession下面這一段是我在項(xiàng)目里用的最小初始化流程你可以直接參照。核心順序是拿Manager - 找設(shè)備 - 建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. 找到目標(biāo)攝像頭優(yōu)先按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. 創(chuàng)建CameraInput this.cameraInput this.cameraManager.createCameraInput(targetDevice); await this.cameraInput.open(); // 4. 根據(jù)目標(biāo)camera能力挑選預(yù)覽Profile let outputCapability this.cameraManager.getSupportedOutputCapability(targetDevice); let previewProfile this.choosePreviewProfile(outputCapability); // 5. 創(chuàng)建PreviewOutput并綁定surfaceId this.previewOutput this.cameraManager.createPreviewOutput(previewProfile, surfaceId); // 6. 創(chuàng)建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; // 優(yōu)先1080P太高意義不大 let profiles capability.previewProfiles; let best profiles[0]; for (let profile of profiles) { if (profile.size.width targetWidth) { best profile; break; } } return best; }這個(gè)流程本身不難但有幾個(gè)隱藏細(xì)節(jié)。第一createCameraInput之后必須顯式open()否則后續(xù)addInput會(huì)失敗。第二Surface在頁(yè)面可見(jiàn)后才能獲取如果頁(yè)面還在后臺(tái)onCreated不會(huì)觸發(fā)。第三beginConfig之后如果任何一步add失敗要記得abortConfig()否則session處于流浪狀態(tài)。在切換攝像頭時(shí)我見(jiàn)過(guò)不少人只是重新beginConfig而沒(méi)有處理掉上一次的session結(jié)果相機(jī)服務(wù)直接被占用。3.3 生命周期里最容易漏掉的釋放邏輯自定義相機(jī)比系統(tǒng)相機(jī)多了一堆疏漏點(diǎn)其中最大的就是生命周期釋放。App在后退、切后臺(tái)、最小化時(shí)CameraInput和CaptureSession必須釋放否則設(shè)備相機(jī)可能被這個(gè)進(jìn)程一直占著導(dǎo)致其他應(yīng)用拍照也是黑的。在鴻蒙上我通常在自定義Page組件里監(jiān)聽(tīng)onPageHide和onPageShow。onPageHide時(shí)執(zhí)行stopSession并釋放輸入輸出onPageShow時(shí)根據(jù)上下文決定是否需要重新初始化。如果業(yè)務(wù)要求App在后臺(tái)還要繼續(xù)“監(jiān)聽(tīng)”相機(jī)那么必須使用后臺(tái)任務(wù)或者保持前臺(tái)運(yùn)行的方式這里就不展開(kāi)了大部分B端一碰就死的規(guī)則還是讓相機(jī)釋放更穩(wěn)妥。生命周期設(shè)計(jì)的另一個(gè)要點(diǎn)是冪等性。我開(kāi)始寫(xiě)的release函數(shù)不夠冪等快速返回頁(yè)面時(shí)被調(diào)用了兩遍第二遍釋放空對(duì)象直接拋錯(cuò)。后來(lái)在每個(gè)釋放子函數(shù)里加了一個(gè)if (this.cameraInput) { ... }判斷并且把this.cameraInput立即置空。這樣即使快速切換也不會(huì)出現(xiàn)二次釋放。這個(gè)先清引用再執(zhí)行釋放的習(xí)慣幫我省了后續(xù)很多崩潰日志。4. 前后攝像頭切換的完整實(shí)現(xiàn)釋放舊會(huì)話、重建新會(huì)話與狀態(tài)同步4.1 一步一步的切換代碼現(xiàn)在到了本文最核心的部分。前后攝像頭切換我歸納為三步走停會(huì)話釋放輸入輸出用新Device重建。也許你會(huì)想能不能removeInput之后addInput在API 12上CaptureSession確實(shí)有removeInput和removeOutput但實(shí)測(cè)在切換攝像頭時(shí)舊CameraInput和新CameraInput不能同時(shí)存在于同一個(gè)Session里而且部分設(shè)備上remove之后Session內(nèi)部的buffers會(huì)出現(xiàn)殘留導(dǎo)致新Input無(wú)法正常預(yù)覽。我最終采用的方案是徹底銷(xiāo)毀重建穩(wěn)定壓倒一切。async switchCamera() { let nextPosition this.currentPosition camera.CameraPosition.CAMERA_POSITION_BACK ? camera.CameraPosition.CAMERA_POSITION_FRONT : camera.CameraPosition.CAMERA_POSITION_BACK; // 1. 停止會(huì)話 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); }很多人會(huì)覺(jué)得這個(gè)release順序無(wú)所謂其實(shí)非常有講究。Session.release()必須放在CameraInput.release()之前。如果反過(guò)來(lái)Input已經(jīng)釋放Session還認(rèn)為它綁定著輸入你去release Session時(shí)內(nèi)部會(huì)試圖對(duì)已釋放的Input做清理輕則告警重則底層報(bào)RTP異常導(dǎo)致概率性崩潰。同理PreviewOutput.release()其實(shí)可以盡早因?yàn)镺utput沒(méi)有Input那么依賴Session但我習(xí)慣連同Input一起依次釋放流程簡(jiǎn)單統(tǒng)一。再提一步異常恢復(fù)。release和重建之間如果相機(jī)服務(wù)剛好被其他應(yīng)用搶走createCameraInput可能拋ServiceUnavailable。我在switchCamera外層套了try-catch失敗后將currentPosition回滾到切換前的值并且彈一條Toast讓用戶重試。這個(gè)回滾邏輯別省——實(shí)測(cè)在多個(gè)應(yīng)用輪番占用相機(jī)的情況下切換失敗概率能到千分之幾對(duì)B端設(shè)備雖然不算高但一旦失敗App就卡在無(wú)畫(huà)面狀態(tài)反饋體驗(yàn)很差。4.2 狀態(tài)同步閃光燈、變焦、對(duì)焦、曝光一個(gè)都不能少切換完成后Session是新的但用戶期望還是原來(lái)的配置。我遇到過(guò)最典型的場(chǎng)景用戶在后置模式下把閃光燈打開(kāi)拍單據(jù)切到前置后再切回來(lái)發(fā)現(xiàn)閃光燈狀態(tài)丟了按鈕顯示關(guān)閉可實(shí)際后置閃光燈又亮了因?yàn)榈讓覥ameraInput被重建后默認(rèn)配置被重置。UI狀態(tài)和硬件狀態(tài)不一致這個(gè)bug特別難查。我的做法是在組件里維護(hù)一個(gè)CameraState它不只記錄頁(yè)面按鈕狀態(tài)而是作為期望狀態(tài)在每次相機(jī)初始化完成后應(yīng)用。狀態(tài)包括變焦比例、是否開(kāi)閃光燈、對(duì)焦模式和曝光值。切換完成后的RestoreState代碼大概長(zhǎng)這樣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) { // 前置攝像頭通常沒(méi)有閃光燈不用處理保持UI為關(guān)閉態(tài)即可 } } // 變焦需要根據(jù)目標(biāo)設(shè)備能力決定是否恢復(fù) if (this.cameraState.zoomRatio 1) { try { this.cameraInput?.setZoomRatio(this.cameraState.zoomRatio); } catch (err) { // 前置或部分設(shè)備不支持高倍變焦重置換擋為1 this.cameraState.zoomRatio 1; } } // 對(duì)焦模式默認(rèn)連續(xù)對(duì)焦 try { this.cameraInput?.setFocusMode(camera.FocusMode.FOCUS_MODE_CONTINUOUS_AUTO); } catch (err) { console.warn(set continuous autofocus failed); } }這里最有爭(zhēng)議的是變焦恢復(fù)。我的實(shí)際選擇是切換即重置用戶再手動(dòng)調(diào)回來(lái)。因?yàn)榻^大多數(shù)用戶切到前置后并不想保持后置的2倍變焦前置的取景范圍本來(lái)就小再加上變焦會(huì)變得特別局促。而且前置攝像頭一般只支持?jǐn)?shù)碼變焦在高倍率下畫(huà)質(zhì)下降嚴(yán)重硬恢復(fù)只會(huì)放大卡頓。所以restoreCameraState里我強(qiáng)制把zoomRatio重置為1只恢復(fù)閃光燈和對(duì)焦模式。這樣做不完美但在絕大多數(shù)業(yè)務(wù)場(chǎng)景里更符合直覺(jué)。4.3 鏡像設(shè)置的兩個(gè)層次viewMirror與photoMirror前置攝像頭天然有鏡像問(wèn)題。前置自拍時(shí)預(yù)覽畫(huà)面像鏡子一樣用戶習(xí)慣這種翻轉(zhuǎn)效果但如果你用前置拍文件、拍人臉用于識(shí)別就不應(yīng)該鏡像。這里要分清兩個(gè)APIPreviewOutput.setViewMirror(boolean)控制預(yù)覽畫(huà)面的左右翻轉(zhuǎn)PhotoOutput.setPhotoMirror(boolean)控制照片輸出是否翻轉(zhuǎn)。我在很多教程里看到只設(shè)置viewMirror結(jié)果預(yù)覽是正常的照片一拍出來(lái)卻是反向的。在API 12上我建議切換完成后這樣設(shè)置private adjustMirrorByPosition(device: camera.CameraDevice, isPreview: boolean true) { const isFront device.cameraPosition camera.CameraPosition.CAMERA_POSITION_FRONT; // 預(yù)覽鏡像自拍場(chǎng)景一般要鏡像業(yè)務(wù)識(shí)別場(chǎng)景不要鏡像 if (isFront this.cameraState.previewMirror) { this.previewOutput?.setViewMirror(true); } else { this.previewOutput?.setViewMirror(false); } // 照片鏡像絕大部分業(yè)務(wù)不需要鏡像所以直接關(guān)掉 if (this.photoOutput) { this.photoOutput?.setPhotoMirror(false); } }需要注意這個(gè)設(shè)置必須在Session已經(jīng)start之后調(diào)用才穩(wěn)定生效。我曾經(jīng)在beginConfig階段調(diào)setViewMirror結(jié)果有的設(shè)備生成了鏡像有的設(shè)備忽略了。切到前置后先start再調(diào)MirrorApp UI上會(huì)閃一下因?yàn)閟tart時(shí)還是非鏡像這個(gè)閃爍可以用一個(gè)簡(jiǎn)單動(dòng)畫(huà)遮罩蓋住。如果你想第一幀就是鏡像只能在業(yè)務(wù)層簽名支持之前短暫隱藏預(yù)覽層。這些細(xì)節(jié)很惱人但是自定義相機(jī)和系統(tǒng)相機(jī)體驗(yàn)差距的來(lái)源之一。5. 高頻問(wèn)題和性能優(yōu)化把切換從能用打磨到順手5.1 快速連點(diǎn)導(dǎo)致會(huì)話重建風(fēng)暴前后切換最大的敵人是用戶手快。連點(diǎn)切換按鈕時(shí)如果不做串行控制第一個(gè)切換還在release的過(guò)程中第二個(gè)已經(jīng)進(jìn)來(lái)createCameraInput兩路請(qǐng)求會(huì)同時(shí)操作相機(jī)服務(wù)。我的實(shí)測(cè)結(jié)果是輕則某個(gè)Input創(chuàng)建失敗重則第二個(gè)切換永久卡死必須殺進(jìn)程才能恢復(fù)。因?yàn)镃amera Kit的底層對(duì)同一個(gè)攝像頭設(shè)備有占用鎖前一個(gè)Input還沒(méi)釋放完新的Input不能創(chuàng)建而Release本身又是異步動(dòng)作肉眼看著代碼是順序await實(shí)際線程之間可能穿插。最簡(jiǎn)單有效的控制手段是加一個(gè)串行隊(duì)列。我用一個(gè)Promise鏈每次點(diǎn)擊都把切換動(dòng)作排到上一個(gè)切換動(dòng)作后面private switchQueue: Promisevoid Promise.resolve(); switchCameraQueued(): Promisevoid { this.switchQueue this.switchQueue.then(() this.switchCamera()); return this.switchQueue; }這樣即使玩家一秒點(diǎn)了五次切換底層也只會(huì)按順序執(zhí)行五個(gè)完整的切換流程杜絕并發(fā)崩潰。注意這樣如果連續(xù)點(diǎn)五次最終還是執(zhí)行了五次完整切換最后落在某個(gè)方向上。如果你希望更精確地忽略中間請(qǐng)求可以用只有隊(duì)列為空時(shí)才提交否則標(biāo)記pending的簡(jiǎn)單節(jié)流版。但業(yè)務(wù)上用戶連點(diǎn)本身是亂操作按順序依次切換已經(jīng)足夠?qū)捜荨?.2 黑屏、花屏和surfaceId復(fù)用陷阱切換后黑屏是問(wèn)題數(shù)最高的反饋。一是在XComponent沒(méi)有重建Surface的情況下直接復(fù)用舊surfaceId二是在舊Output尚未完全釋放時(shí)同一surfaceId被新的PreviewOutput接管。在部分圖形棧實(shí)現(xiàn)里Surface的所有權(quán)沒(méi)有在恰當(dāng)時(shí)機(jī)轉(zhuǎn)移新Output綁上去后一直拿不到幀緩沖區(qū)于是黑屏。我試過(guò)切換前把XComponent隱藏切換后顯示但這只是視覺(jué)手段不解決根本問(wèn)題。更穩(wěn)的做法是每次切換都重新為XComponent獲取一個(gè)新的surfaceId即先銷(xiāo)毀XComponent再重建。比如在ArkUI里給XComponent加一個(gè)key值切換時(shí)先this.needReCreateSurface true然后通過(guò)條件渲染重建組件讓新onCreated回調(diào)拿到全新Surface句柄。代價(jià)是重建XComponent會(huì)有幾十毫秒到一百毫秒的空白期但相比黑屏這點(diǎn)代價(jià)值得。如果你不希望頻繁銷(xiāo)毀Surface也可以嘗試復(fù)用surfaceId但保證舊PreviewOutput釋放完成。previewOutput.release()返回Promiseawait它之后還需要再等一幀。我是這樣做的release后用await new Promise(resolve setTimeout(resolve, 50))強(qiáng)制等50ms再重建Output。實(shí)測(cè)大部分機(jī)型能解決但有個(gè)別平板仍然黑屏。最終我在兼容層選擇了重建XComponent方案切換耗時(shí)大約增加20ms換來(lái)的是穩(wěn)定。5.3 畫(huà)面方向漂移的根源與修正前后切換后畫(huà)面橫豎屏方向錯(cuò)亂也是常見(jiàn)問(wèn)題。攝像頭傳感器有一個(gè)固定的安裝方向通常后置是90度前置是270度這在SensorOrientation屬性里可以看到。鴻蒙的PreviewOutput會(huì)根據(jù)Display的方向自動(dòng)旋轉(zhuǎn)不能說(shuō)自動(dòng)實(shí)際上是開(kāi)發(fā)者要讓Session知道預(yù)覽的預(yù)期方向。在不同華為設(shè)備上不做direction處理橫屏進(jìn)入頁(yè)面后切到前置預(yù)覽畫(huà)面會(huì)歪著。我的處理方案很粗暴首先在項(xiàng)目里鎖定豎屏然后在每次創(chuàng)建Session后調(diào)用session.setPreviewOrientation(90)。如果你要兼容橫豎屏切換需要監(jiān)聽(tīng)屏幕旋轉(zhuǎn)把映射表做出來(lái)。我簡(jiǎn)單給出豎屏場(chǎng)景的代碼private async configureOrientation() { if (this.captureSession) { try { this.captureSession.setPreviewOrientation(90); // 豎屏固定90 } catch (e) { console.warn(setPreviewOrientation not supported); } } }這個(gè)調(diào)用時(shí)機(jī)在commitConfig之后、start之前或之后都可以不同設(shè)備表現(xiàn)略有差異。我把它放在commitConfig后立刻執(zhí)行再start。如果你看到切換后畫(huà)面內(nèi)容旋轉(zhuǎn)90度優(yōu)先檢查這個(gè)值如果方向?qū)ΦA(yù)覽被拉伸去看3.2里的profile選擇是否正確。方向問(wèn)題不會(huì)讓App崩潰但用戶立刻能感知屬于不崩潰但很傷逼格的坑。5.4 我壓到120ms的三個(gè)關(guān)鍵優(yōu)化最后說(shuō)說(shuō)性能。最開(kāi)始我的完整切換流程耗時(shí)200-400ms切換時(shí)黑屏明顯。做了三個(gè)優(yōu)化后在Mate系列機(jī)型上穩(wěn)定壓到120ms左右體感已經(jīng)接近系統(tǒng)相機(jī)。第一個(gè)優(yōu)化是緩存Profile。每次查詢getSupportedOutputCapability雖然不貴但加上對(duì)象分配和設(shè)備IO在低端機(jī)上也要幾毫秒到幾十毫秒。我在頁(yè)面加載時(shí)把前置和后置各自的previewProfile都查好切換時(shí)直接取用。注意如果設(shè)備支持隨時(shí)熱拔插這種緩存會(huì)失效需要監(jiān)聽(tīng)onCameraStatusChanged事件做失效處理。B端固定設(shè)備很少拔插所以我直接緩存。第二個(gè)優(yōu)化是重建XComponent和重建Session并行化。釋放和創(chuàng)建之間存在硬依賴但創(chuàng)建XComponent的time和會(huì)話重建可以并行。我在切換前先申請(qǐng)新的surfaceId并把initCamera中的createSession和createPreviewOutput并行執(zhí)行用Promise.all因?yàn)閮烧邲](méi)有依賴關(guān)系。實(shí)際收益大約30-50ms。第三個(gè)優(yōu)化是減少無(wú)謂的全局狀態(tài)同步。切換完切換restoreCameraState放到start()之后并和UI更新錯(cuò)開(kāi)。第一次寫(xiě)時(shí)我們把狀態(tài)恢復(fù)邏輯放在start之前結(jié)果前幾幀調(diào)用部分API被拒白白浪費(fèi)時(shí)間。放到start之后一次秒級(jí)操作就能全部同步。這三點(diǎn)做完切換依然有可感知的停頓但用戶不會(huì)覺(jué)得卡——他們會(huì)覺(jué)得“有個(gè)切換過(guò)程”但不至于煩。如果你的業(yè)務(wù)對(duì)切換流暢度要求更高可以再?gòu)牡讓覵urface預(yù)分配和雙會(huì)話輪轉(zhuǎn)方向做文章但那樣復(fù)雜度會(huì)成倍增長(zhǎng)個(gè)人覺(jué)得除非做消費(fèi)級(jí)相機(jī)App否則不值當(dāng)。做完整套切換后我最深刻的體會(huì)是自定義相機(jī)里的前后切換難點(diǎn)從來(lái)不是切換這個(gè)動(dòng)作本身而是切換前后的一整套狀態(tài)維護(hù)、資源順序和異常兜底。如果你在鴻蒙自定義相機(jī)上遇到了切換后黑屏、崩潰、照片鏡像反了之類(lèi)的問(wèn)題先不要懷疑API從頭檢查一遍會(huì)話釋放順序是否正確目標(biāo)攝像頭的Profile有沒(méi)有重新取前置Mirror是不是只設(shè)了一半。把這些基礎(chǔ)細(xì)節(jié)打磨好切換模塊比任何花哨優(yōu)化都更值得投入。