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