Android 爛裝置想跑人臉辨識12 – 為了幾乎不會用到的功能需要大改特改的webrtc

寫 WebRTC 的人都知道那句老話:Demo 跑通官方範例只要兩小時,上線除錯要花兩年。但在這塊算力被閹割 Snapdragon QM215 上,既要維持 7×24 活體人臉辨識,又要支援訪客即時視訊對講

一句話,相機不夠分

在低能門禁機的日常狀態下,相機是一條瘋狂運轉的高速輸送帶:RGB 與 ToF Sensor 以 30fps 採集畫面,C++ JNI 生成 3D 點雲,OpenGL ES 著色器繪製預覽,同時推入 AI 管線進行特徵比對。

系統本來跑得好好的,直到對講需求砸下來。

底層立刻引爆了致命衝突:Android 的 cameraserver 是排他性的,同一個 Camera ID 在同一瞬間只能被一個 Client 打開。AI 辨識霸佔相機不放,WebRTC 又急著搶相機,兩個互不相識的模組在底層硬碰硬。

最初的爛解法:sleep 5 秒

最初的工程師為了趕上線,採用了工程界最經典的「鴕鳥策略」:要對講就把 AI 相機徹底關掉;要回主畫面再把 WebRTC 相機徹底關掉

於是在程式碼裡留下了這段令人哭笑不得的遺產:

// File: 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 的本質欺騙

訪客按鈴後螢幕黑屏整整 5 秒。為什麼不敢設 500ms?因為底層 CameraInstanceMK1.java 裡的 onClosed(ALL) 根本是個假訊號:

// File: CameraInstanceMK1.java
mRgbCamera.stopCameraSession();
mRgbCamera.close();

if (stateCallback != null) {
    stateCallback.onClosed(ALL); // 剛發出關閉指令就立刻同步回呼!
}

onClosed 只代表「Java 層發出請求」,底層 Linux Kernel 設備節點根本還在收拾爛攤子。開發者不知道底層到底何時放手,只好展現老工程師的浪漫——「我不知道它何時關完,但我賭 5 秒後它肯定下班了」。

2. 回程 0 毫秒搶相機引發崩潰死鎖

更精彩的是掛斷通話時:

// File: 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 異常,相機直接死鎖黑屏,整棟大樓的住戶站在門口跟你面面相覷,只能拔插頭重開機

二、不可能,大家都這樣寫,肯定有優美的解法,那就看看androidx 怎麼處理

既然開關相機是一場豪賭,那我們老鳥最擅長的招數就來了:永遠不要關相機,假裝無事發生,在記憶體裡偷天換日動態轉發影格。

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 外部採集器

public class ExternalVideoCapturer implements VideoCapturer {
    private CapturerObserver capturerObserver;
    private volatile boolean isCapturing = false;
    private final SafeWebRtcFramePool framePool = new SafeWebRtcFramePool();

    // ... 省略 initialize, startCapture, stopCapture 等 WebRTC 標準樣板代碼 ...

    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. 分發器路由與 ToF 發熱控制

public class CameraFrameDispatcher {
    public enum Mode { AI_DETECTION, WEBRTC_INTERCOM }

    private static final CameraFrameDispatcher INSTANCE = new CameraFrameDispatcher();
    public static CameraFrameDispatcher getInstance() { return INSTANCE; }

    private volatile Mode mode = Mode.AI_DETECTION;
    private volatile ExternalVideoCapturer webrtcCapturer;

    private CameraFrameDispatcher() {}

    public synchronized void switchToWebRTC(ExternalVideoCapturer capturer) {
        this.webrtcCapturer = capturer;
        this.mode = Mode.WEBRTC_INTERCOM;
        CameraInstanceMK1.getInstance().closeTofOnly(); // 關閉 ToF 抑制發熱
    }

    public synchronized void switchToAI() {
        this.mode = Mode.AI_DETECTION;
        this.webrtcCapturer = null;
        CameraInstanceMK1.getInstance().openTofOnly();  // 恢復 ToF 供辨識
    }

    public Mode getMode() { return mode; }

    public void dispatch(byte[] yuvData, int width, int height, int rotation) {
        if (mode == Mode.WEBRTC_INTERCOM && webrtcCapturer != null && yuvData != null) {
            webrtcCapturer.feedRawFrame(yuvData, width, height, rotation);
        }
    }
}

4. AI 管線短路與 BufferQueue 阻塞防護

在 AI 容器頂端短路,杜絕通話時的 CPU 浪費;離開畫面時掛載空轉監聽器,防止 Android GraphicBuffer 阻塞死鎖:

// File: CameraFrameContainerMK1.java (短路保護)
producer.setFrameAvailableCallback((byte[] color, byte[] raw, byte[] depth) -> {
    CameraFrameDispatcher dispatcher = CameraFrameDispatcher.getInstance();
    if (dispatcher.getMode() == CameraFrameDispatcher.Mode.WEBRTC_INTERCOM) {
        if (color != null) {
            dispatcher.dispatch(color, mCamera.getCroppedFrameWidth(), 
                               mCamera.getCroppedFrameHeight(), mCamera.getColorRotate());
        }
        return; // 跳過所有 ToF 點雲解析與 AI 比對
    }
    tofRawToPclIrIrz(raw);
    // ...
});
// File: CameraInstanceMK1.java (防止 BufferQueue 假死)
public void destroyViewOnly() {
    mView = null;
    if (mSurfaceTexture != null) {
        // 維持空轉監聽:持續消耗 GraphicBuffer,確保 Camera HAL 管線暢通不卡死
        mSurfaceTexture.setOnFrameAvailableListener(surfaceTexture -> {
            try { surfaceTexture.updateTexImage(); } catch (Exception ignored) {}
        });
    }
}

三、寫完覺得哪裡怪怪的 Frame Dispatcher 代價

架構圖在會議室投影幕上看起來都很風光,但回到座位只有自己知道背後的血淚:

1. 優點

  • 0 毫秒切換:相機從不關閉,進出對講皆為 0ms,消滅 CAMERA_IN_USE 併發死鎖;

2. 代價

  • CPU 記憶體拷貝開銷:相機 NV21 數據每次需經 System.arraycopy(約 460KB)灌入物件池,單次耗時 0.3ms,在閹割版晶片上多佔用微量頻寬;

沒有銀彈,只有妥協

業界現代標準(如 Android Jetpack CameraX 或 WebRTC 原生 rtc::VideoBroadcaster)本質上皆為單一相機源、多消費者分發體系。

軟體工程裡沒有銀彈,只有權衡:

  • 5 秒睡眠重啟 雖然丟人,卻用最低成本換來了模組隔離與快速交付;
  • 影格分發器 雖然優雅且徹底解決了延遲與死鎖,卻高度考驗工程師對 Android 底層 GraphicBuffer、EGL 與非同步記憶體管理的駕馭力。

參考文獻與源碼追蹤

  • Android Open Source Project: frameworks/av/services/camera/libcameraservice/
  • Google WebRTC Source Code: webrtc/src/media/base/videobroadcaster.h
  • Android Jetpack CameraX Architecture Guide: Multi-use-case Binding Guidelines
  • Linux Kernel V4L2 Subsystem: Single-opener vs Multi-streamer Drivers Specifications

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *