寫 WebRTC 的人都知道那句老話:Demo 跑通官方範例只要兩小時,上線除錯要花整整兩年。
尤其當你的載體是一塊算力被閹割的高通 Snapdragon QM215(4 顆 A53、2GB RAM)時,系統既要維持 7×24 小時雙鏡頭活體人臉辨識,老闆又硬塞了一個「訪客按鈴即時視訊對講」需求。
在門禁機的日常狀態下,相機是一條瘋狂運轉的高速輸送帶:RGB 與 ToF 鏡頭以 30fps 採集畫面,C++ JNI 生成 3D 點雲,OpenGL ES 繪製預覽,同時推入 AI 管線進行特徵比對。
系統本來跑得好好的,直到對講需求砸下來。
底層立刻引爆了致命衝突:Android 的 cameraserver 是絕對排他性的,同一個 Camera ID 在同一瞬間只能被一個 Client 打開。
AI 辨識霸佔相機不放,WebRTC 又急著搶相機,兩個互不相識的模組在底層硬碰硬。
最初的爛解法:sleep 5 秒鴕鳥術
最初接手的工程師為了趕上線,採用了工程界最經典的「鴕鳥策略」:要對講就把 AI 相機徹底關掉;要回主畫面再把 WebRTC 相機徹底關掉。
於是在程式碼裡留下了這段令人哭笑不得的歷史遺產:
// RecognitionFragment.java
DetectService.instance().stopDetection();
CameraProxy.getInstance().setCameraDeviceStateCallback(new ICameraInstance.CameraStateCallback() {
@Override
public void onClosed(int cameraId) {
if (cameraId == ICameraInstance.ALL) {
// 收到關閉通知後,在 UI 執行緒硬等 5 秒!
ThreadPoolManager.getInstance().runOnUiThreadDelay(() -> {
iSwitcher.switchTo(Const.MAIN_FRAGMENT, WebRTCFragment.class.getName());
}, 5000);
}
}
});
ThreadPoolManager.getInstance().execute(() -> {
CameraProxy.getInstance().close();
});
這段程式碼就是標準的「能跑就不要動」,但也埋下了兩顆地雷:
1. 去程 5 秒黑屏與 onClosed 的假象欺騙(專案真實 Git 鐵證)
翻開我們專案庫的提交歷史,赫然躺著這條血淚記錄:
Commit 809de5ff:CHANGE: enter webRTC use RunOnUiThread delay 5sec. for let rgb tof onClosed
訪客按門鈴後,為什麼不敢設 500ms 而要硬等 5 秒?底層 CameraInstance.java 裡的 onClosed(ALL) 根本是個假訊號:
// CameraInstance.java
mRgbCamera.stopCameraSession();
mRgbCamera.close();
if (stateCallback != null) {
stateCallback.onClosed(ALL); // 剛發出關閉指令就立刻同步回呼!
}
onClosed 只代表「Java 層發出了關閉請求」,底層高通 Camera HAL3 cameraserver 在釋放雙鏡頭 Session 時需要時間排空硬體緩衝區。Linux 核心裝置節點根本還在釋放 DMA 記憶體。如果 WebRTC 迫不及待去搶開相機,會當場噴出 CAMERA_IN_USE (4) 致命異常導致 App 崩潰!
2. 回程 0 毫秒搶相機引發崩潰死鎖與硬體重啟
更精彩的是掛斷通話時:
// WebRTCFragment.java
private void hangup() {
face3DWebRTC.hangup();
face3DWebRTC.release(); // 內部透過背景 CameraThread 非同步關相機
face3DWebRTC = null;
// 0 毫秒瞬間切回主畫面!
iSwitcher.switchTo(WebRTCFragment.class.getName(), Const.MAIN_FRAGMENT);
}
主執行緒 0ms 切回主畫面,RecognitionFragment.onResume() 秒呼 CameraProxy.open()。
但 WebRTC 的背景執行緒甚至還沒向相機驅動說完再見,主畫面就一腳踹開大門進來搶相機。結果 cameraserver 當場噴出 CAMERA_IN_USE 異常,相機直接死鎖黑屏!
更有甚者,快速開關相機還引發了更恐怖的硬體災難:
Commit 1181:FIX: webRTC close and start quickly will cause tof reboot.
高頻快速開關相機造成 MIPI/I2C 匯流排供電脈衝異常,Sony iToF 模組直接硬體斷電重開機!
為什麼教科書教的「多行程隔離」是象牙塔空談?
常有人問:「為什麼不乾脆開兩個行程,一個跑門禁辨識,一個跑 WebRTC 對講?」
在 2GB QM215 板子上,這想法必死無疑:
- 底層硬體排他鎖:Camera HAL3 只認一個 Client。門禁核心行程 7×24 咬死相機,獨立對講行程根本打不開相機。
- 跨行程搬運必死:若由主行程 open 相機再透過 Binder / Ashmem 每秒跨行程搬移 30 幀 1080p 資料,在弱處理器上會引發昂貴的 IPC 複製與排程延遲,直接把系統拖垮。
終極架構:永遠不要關閉相機(單行程影格分發與被動餵入)
既然開關相機是一場豪賭,那我們老鳥最擅長的招數就來了:永遠不要關相機,假裝無事發生,在同一個行程內透過被動影格分發管線(ExternalVideoCapturer)將畫面分流!

1. 消除 Data Race:帶有釋放保護的物件池
WebRTC MediaCodec 硬體編碼是非同步的。如果不做物件池,相機 33ms 吐一幀,WebRTC 40ms 才編完,下一幀會直接在編碼器嘴巴裡塞進新資料,遠端警衛看到的就不只是訪客,而是二維維度打擊般的現代藝術綠屏。
透過 3 槽位物件池搭配 releaseCallback 實現精準管理:
public class SafeWebRtcFramePool {
private static final int POOL_SIZE = 3;
private final byte[][] buffers = new byte[POOL_SIZE][];
private final boolean[] inUse = new boolean[POOL_SIZE];
public synchronized byte[] acquire(int size) {
for (int i = 0; i < POOL_SIZE; i++) {
if (!inUse[i]) {
inUse[i] = true;
if (buffers[i] == null || buffers[i].length != size) {
buffers[i] = new byte[size];
}
return buffers[i];
}
}
return null; // 滿載時安全拋棄本幀,絕不踩踏
}
public synchronized void release(byte[] buffer) {
for (int i = 0; i < POOL_SIZE; i++) {
if (buffers[i] == buffer) {
inUse[i] = false;
break;
}
}
}
}
2. 被動式 WebRTC 外部採集器(ExternalVideoCapturer)
我們不再讓 WebRTC 去管 Camera 開關,而是建立一個被動接收影像幀的自訂採集器:
public class ExternalVideoCapturer implements VideoCapturer {
private CapturerObserver capturerObserver;
private volatile boolean isCapturing = false;
private final SafeWebRtcFramePool framePool = new SafeWebRtcFramePool();
public void feedRawFrame(byte[] nv21Data, int width, int height, int rotation) {
if (!isCapturing || capturerObserver == null || nv21Data == null) return;
byte[] safeBuffer = framePool.acquire(nv21Data.length);
if (safeBuffer == null) return;
System.arraycopy(nv21Data, 0, safeBuffer, 0, nv21Data.length);
long timestampNs = TimeUnit.MILLISECONDS.toNanos(SystemClock.elapsedRealtime());
// safeBuffer 綁定 releaseCallback:編碼完成後自動歸還物件池
NV21Buffer buffer = new NV21Buffer(safeBuffer, width, height, () -> {
framePool.release(safeBuffer);
});
VideoFrame videoFrame = new VideoFrame(buffer, rotation, timestampNs);
capturerObserver.onFrameCaptured(videoFrame);
videoFrame.release();
}
}
3. 一刀切換,0 秒黑屏
在主相機流水線中,我們只保留一個布林標誌:
// CameraRgbHelper.java
if (mIsIntercomActive) {
mExternalCapturer.feedRawFrame(nv21, width, height, rotation);
}
訪客按鈴切換對講時,耗時從原本的 5000ms 直接暴跌至 0ms!相機根本沒關過,硬體狀態紋風不動,只是多了一條分支把記憶體複製進 WebRTC 編碼佇列。
通話結束時把標誌設為 false,主畫面無縫繼續人臉辨識,徹底消滅 CAMERA_IN_USE 異常。
影像編碼硬體妥協與收工
- 不要相信非同步的 onClosed:硬體驅動的釋放往往遠落後於 Java 抽象層的通知,靠 sleep 賭運氣早晚要翻車。
- 化被動為主動:面對排他性硬體資源,永遠不要讓兩個上層模組搶著當主人。將硬體包裝成單一常駐源,用分發器向多個消費者分發影格。
- 有借有還的 Buffer Pool:高頻影像進入非同步編碼器時,必須用帶有生命週期回呼的緩衝池杜絕綠屏與記憶體洩漏。
感謝觀看,收工。

發佈留言