两个 SDK 抢一个摄像头:Android 设备上的相机仲裁设计与踩坑实录

两个 SDK 抢一个摄像头:Android 设备上的相机仲裁设计与踩坑实录

虹膜模组要独占相机,人脸 SDK 也要独占相机,页面生命周期还时不时插一脚把相机杀掉------当「单资源多消费者」遇上异步回调和生命周期交错,一把 synchronized 锁是远远不够的。这篇文章记录我们在一台 RK 主板、2GB 内存的生物识别终端上,设计相机仲裁层(CameraCoordinator)的完整过程。

一、背景:这个场景为什么值得讲

我们做的是一台智能柜识别终端:RK 主板、Android 系统、虹膜 + 人脸双模态识别、NFC 刷卡、485 串口控制锁板。听起来是个普通的物联网设备,但摄像头这块有个和很多手机 App 不一样的现实:

设备上的相机资源是独占且昂贵的。

  • 虹膜模组是一路独立的 UVC 相机,打开要几百毫秒,打开后还要等数据稳定(我们实测需要约 300ms 的 IRIS_CAMERA_DATA_READY_DELAY)才能出有效帧;
  • 人脸 SDK 用的是另一路 UVC 相机,同样存在打开耗时长、缓冲区被 SDK 复用等问题;
  • 厂商 SDK 的 openCamera() / closeCamera() 都是异步 的------你调用了 close,相机不是立刻关的,USB 设备断开回调、预览停止回调会陆续回来。

在手机上,大多数 App 只有自己一个消费者用相机,冲突顶多发生在「我的 App 和别的 App」之间,系统帮你仲裁了。而在这种专用终端上,冲突发生在同一个进程内部 :生物识别模块要用、扫码模块要用、主页待机的人脸预热要用、页面 onStop 时厂商层还会自动 stopCamera()------大家抢的是同一批物理设备节点。

这篇文章讲清楚三件事:

  1. 为什么简单的互斥锁解决不了这个问题;
  2. 我们最终落地的 acquire(tag) / release(tag) 令牌式仲裁设计;
  3. 这套「单资源多消费者」的仲裁思路,怎么迁移到蓝牙、串口、音频焦点等场景。

二、问题现象:三个真实的翻车现场

先说症状,再说根因。以下三个问题都是我们在真机上踩出来的。

现场 1:相机打开失败,偶发且无法复现

识别流程偶尔报「相机被占用」,日志里看到虹膜相机 openCamera 返回失败。排查后发现:上一个流程的 closeCamera 刚调完,底层 USB 释放还没完成,下一个流程的 openCamera 就进来了。调用方的「释放」和硬件的「真正可用」之间有一个不确定的时间窗。

现场 2:退回首页后识别页「幽灵复活」

这是最有意思的一个 bug。用户在识别页双击进入开发者登录页,按返回后,识别页突然自己弹了出来。根因有两层(见我们的改动日志 2026-09-02):

  • 退出识别页时我们调度了一个 450ms 后的相机预热,这个延迟任务往往在主页还前台时执行,自动监控逻辑凭相机里仍在的人脸,立刻把识别页重新拉了起来,盖在开发者登录页底下;
  • 更隐蔽的是:厂商层 stopCamera()不清空人脸缓存 mFaceInfo,返回瞬间一帧陈旧的人脸数据就足以触发「检测到人脸 → 自动拉起识别页」。

相机关了,但相机「留下的数据」还在驱动业务逻辑------这是典型的资源释放与状态清理不同步。

现场 3:返回后画面卡死

主页被识别页覆盖时走 onStop,厂商层在 onStopstopCamera() 杀掉相机;但识别 Fragment 没有覆写 onStopisPreviewReadyisAuthenticationActive 这些标记不失效。返回主页后,相机预热逻辑一看 isAuthenticationActive == true,认为「识别还在进行中」,直接跳过了相机重启------没人再负责把相机打开,画面就永远卡住了。

三个现场的共同模式

把三个问题抽象一下,会发现它们根本不是「并发竞争」,而是:

资源的占用/释放是一个异步过程,而调用方把它当成了同步过程;资源状态和业务状态(标记位、缓存数据)分离,且各自的生命周期不一致。

三、根因分析:为什么 synchronized 救不了你

遇到「多个模块抢一个资源」,第一反应是加锁:

kotlin 复制代码
val cameraLock = Any()

fun openCamera() {
    synchronized(cameraLock) {
        // 打开相机
    }
}

这个方案在我们的场景里会死在四个地方:

1. 异步回调让锁的边界失效。 openCamera() 是发起请求,真正的「相机就绪」是几百毫秒后 USB 层的 onPreview() 回调。锁只能保护「发起」这个动作,保护不了整个「打开 → 使用 → 关闭」的会话周期。

2. 生命周期事件不可控。 onStop 触发 stopCamera() 是框架行为,它不会先排队等你的锁;识别页、主页、开发者登录页之间的跳转时序由用户操作决定,完全交错。

3. 锁没有「持有者身份」。 关键问题不是「现在有没有人用相机」,而是「在用」。主页待机的人脸预热和正式认证用的是同一颗人脸相机,但优先级完全不同------认证进行中时,待机预热应该静默让路,而不是报错。互斥锁无法表达这种语义。

4. 释放可能不匹配。 异步世界里,「A 申请、B 释放」的错位很容易发生(比如页面销毁时代理释放了别的模块的申请)。没有持有者校验的 release() 是危险的。

所以正确的抽象不是「锁」,而是「带身份的占用令牌」:申请时报上名号,占用期间其他人看得到是谁在用,释放时校验身份,紧急情况下允许管理员强制回收。

四、最终实现:CameraCoordinator

4.1 核心实现

落地代码很短,整个类不到 70 行(sdk/CameraCoordinator.kt),精简后如下:

kotlin 复制代码
object CameraCoordinator {

    enum class Holder {
        NONE,
        BIOMETRIC,   // 生物识别(人脸/虹膜)
        OTHER        // 其他用途(如扫码)
    }

    @Volatile
    private var currentHolder: Holder = Holder.NONE

    @Synchronized
    fun acquire(requester: Holder): Boolean {
        if (currentHolder != Holder.NONE && currentHolder != requester) {
            LogUtil.w(TAG, "acquire failed - held by $currentHolder, requester=$requester")
            return false
        }
        currentHolder = requester
        return true
    }

    @Synchronized
    fun release(requester: Holder) {
        if (currentHolder == requester) {
            currentHolder = Holder.NONE
        } else {
            // 释放者与持有者对不上:只记日志,不误放别人的资源
            LogUtil.w(TAG, "release mismatch - current=$currentHolder, requester=$requester")
        }
    }

    @Synchronized
    fun forceRelease() {
        // 紧急回收:如进程内异常后状态不确定时
        currentHolder = Holder.NONE
    }

    fun isFree(): Boolean = currentHolder == Holder.NONE
    fun currentHolder(): Holder = currentHolder
}

几个设计决策值得单独说:

1. acquire 返回布尔值,而不是阻塞等待。 阻塞在 Android 主线程模型下是灾难。申请失败时由调用方决定策略:认证流程可以提示「相机被占用」走 onError(CameraOccupied) 分支;待机预热则直接静默放弃,等下一次机会。

2. 同一持有者重复 acquire 幂等成功。 currentHolder != requester 才失败,意味着 BIOMETRIC 内部的人脸、虹膜子流程可以反复确认占用状态,而不需要精确配对计数。在生命周期交错的场景下,「幂等」比「精确」可靠得多。

3. release 校验持有者身份,不匹配时拒绝释放。 这直接堵死了「页面销毁时代理释放了别人资源」的错位场景。宁可漏放(后面有 forceRelease 兜底),不能误放。

4. 保留 forceRelease() 作为逃生门。 所有「优雅」的配对协议都可能被异常路径打破(崩溃恢复、厂商 SDK 内部状态错乱)。留一个不校验身份的强制回收口,配合启动时的状态重置,保证系统总能回到已知状态。

5. 全程打日志。 acquire/release/forceRelease 每个分支都有带持有者信息的日志。这种仲裁类 bug 在现场几乎不可复现,日志是唯一的破案线索。我们日志里甚至会带上 reason 字符串(如 hideIrisAuthOverlay("host stopped")),事后能还原完整时序。

【配图建议:一张状态转换图,NONE → BIOMETRIC/OTHER 的 acquire/release 迁移,标注 mismatch 和 forceRelease 两条异常路径】

4.2 令牌不够,还要配「守卫」

必须诚实地说:CameraCoordinator 解决了「谁有资格用」,但解决不了「生命周期把状态搞乱」。第二、三节里的现场 2 和现场 3,最终靠的是另一组配合机制:

a. 抑制标记(suppression flag)。 从识别页跳开发者登录这种「主动打断」场景,跳转前置位 autoAuthSuppressed,所有「检测到人脸就自动拉起识别」的入口都先检查这个标记,onResume 后延迟 3 秒自动解除:

kotlin 复制代码
// MainActivity(简化)
private var autoAuthSuppressed = false

fun canStartAutoAuthentication(): Boolean =
    isMainResumed && !isHomeInteractionBusy() && !autoAuthSuppressed

b. 关闭资源时同步清理派生状态。 厂商层 stopCamera() 里补上 mFaceInfo = null,杜绝陈旧人脸缓存在下一帧驱动业务逻辑。原则:资源句柄和资源的「产出物缓存」必须同生共死。

c. 生命周期钩子对称复位。 识别 Fragment 覆写 onStop,把 isPreviewReadyisCameraOpenRequested 等标记全部复位------相机既然被厂商层杀了,标记就不允许存活。

d. 延迟任务要可取消、带场景参数。 退出识别页时 450ms 后的预热调度,在「跳转开发者登录」「宿主被覆盖」场景下传 scheduleWarmup = false 直接不调度。凡是 postDelayed 出去会改变资源状态的任务,都要问自己一句:执行那一刻,世界还是发起时的样子吗?

仲裁器管「准入」,守卫管「时序」,两者缺一不可。

五、让上层无感知:Provider 模式 + 状态机回调

5.1 SDK 抽象层

CameraCoordinator 这类仲裁逻辑不应该散落在每个 Activity 里。我们的结构是:

markdown 复制代码
Activity / ViewModel
        │
   SdkFactory(单例工厂)
        │
  ISdkProvider(设备相关实现)
        │
 ┌──────┴───────┐
IBiometricManager   INfcManager
        │
  CameraCoordinator(内部仲裁)
kotlin 复制代码
object SdkFactory {
    fun getBiometricManager(context: Context): IBiometricManager {
        return biometricManager ?: synchronized(this) {
            biometricManager ?: provider.createBiometricManager(
                context.applicationContext
            ).also { biometricManager = it }
        }
    }
}

上层(MainActivityIrisAuthActivity)只面对 IBiometricManagerstartRecognition() / stopRecognition(),完全不知道相机仲裁的存在------申请、占用、释放全部封装在实现层内部。这里有一个容易忽略的细节:getBiometricManager 传的是 applicationContext 并做单例缓存,而 NFC 管理器故意不缓存 ------因为串口读卡 SDK 要求传当前 Activity,复用已暂停页面的 Context 会出事。单例与否,取决于实现内部持有的是什么级别的 Context,这是个真实的坑。

5.2 回调状态机与相机生命周期的对齐

异步资源的生命周期天然是个状态机,回调接口也要按状态机设计。我们的 BiometricCallback

kotlin 复制代码
interface BiometricCallback {
    fun onStart()                    // 相机已打开,识别流程开始
    fun onDetecting()                // 检测到活体/人脸,提示用户保持姿势
    fun onSuccess(result: AuthResult)
    fun onFailed(reason: FailReason) // 业务失败:比对不过、超时、未检测到......
    fun onError(error: SdkError)     // SDK 异常:相机被占用、授权失败......
    fun onComplete()                 // 无论如何都会回调,用于收尾
}

设计要点:

1. onStart 的语义是「相机已就绪」而不是「startRecognition 已调用」。 中间隔着几百毫秒的异步打开过程,UI 只有收到 onStart 才显示预览、允许用户交互。这条边界划错了,用户就会在黑屏期乱点(我们为此专门加过「摄像头校准中」的预显示遮罩 + 90 秒兜底超时,见改动日志 2026-09-02)。

2. 区分 onFailedonError 「比对不通过」是业务结果,「相机被占用」是资源异常,两者的 UI 提示、重试策略、埋点口径完全不同,不能合并成一个 onFailure(code)

3. onComplete 是唯一的收尾保证。 无论成功、失败、异常、取消,onComplete 必定回调------这正是 try/finally 在异步世界的等价物。释放 UI 状态、归还相机令牌(CameraCoordinator.release)这类操作只放在这里,就不会因为某条异常路径忘记写 release 而永久占用资源。

【配图建议:BiometricCallback 状态机图:Idle →(acquire 成功)→ Starting →(onStart)→ Detecting → Success/Failed/Error →(onComplete)→ Idle;acquire 失败直接走 onError(CameraOccupied)】

六、迁移价值:这套模式不只属于相机

「单资源、多消费者、异步占用、生命周期交错」这个组合在嵌入式/物联网 Android 开发里极其常见,同一套设计可以直接搬:

场景 资源 消费者 对应物
串口/485 总线 /dev/ttySx 独占句柄 锁板控制、NFC 读卡、固件升级 SerialCoordinator,Modbus 指令还要排队+CRC 校验
蓝牙 BLE 连接/GATT 会话 扫描、连接、OTA 连接令牌 + 操作队列
音频焦点 扬声器/麦克风 语音提示、报警音、语音识别 系统有 AudioFocusRequest,自研场景同理
电机/执行器 云台、门锁电机 人脸跟随、自动校准、手动调试 我们的电机控制同样做了「调试页命令优先于自动跟随」的持有者优先级

迁移时带走的四条原则:

  1. 申请带身份、释放验身份、失败不阻塞、紧急情况可强制回收------令牌四要素;
  2. 异步资源的「就绪」必须以回调为准,调用返回 ≠ 资源可用;
  3. 收尾逻辑集中在一个必定触达的回调里onComplete / finally 语义),释放动作不散落在各分支;
  4. 资源句柄与其派生状态(缓存、标记位)同生共死,过期数据比没有数据更危险。

七、总结

回到开头的三个翻车现场,它们没有一个是「并发没锁住」,全部是「把异步资源当同步资源用」的变体。最终的方案谈不上高深------一个 70 行的令牌仲裁器,加一组生命周期守卫,加一个状态机回调------但每一行背后都对应着真机上一个具体的、难以复现的 bug。

这套设计适合的场景:专用设备、进程内多个模块竞争独占外设、外设操作异步且昂贵。如果你的 App 只是在手机上拍拍照,系统相机 API 已经帮你处理了一切,不需要这套东西;但只要你在做 RK 主板、自助终端、工业平板这类「整机都是你的,外设也都是你的」的设备,早晚会遇到自己的「CameraCoordinator 时刻」。

最后留一个我们至今保留的习惯:所有仲裁路径的日志带上 reason 参数。线上设备出问题的那一刻,你会感谢这个决定。


相关推荐
Godikov2 小时前
摄像头电机自动校准实战:为什么我用「步数计数」替换了角度传感器绝对定位
android
Godikov2 小时前
2GB 内存的 Android 工业终端上,我们是怎么把 OOM 按在地上摩擦的
android
Godikov2 小时前
暗光下摄像头"见鬼"了:一个 2GB 内存 Android 终端的帧差唤醒踩坑记
android
mmsx2 小时前
Android 测绘开发实战 · 专栏导读:20 篇文章从 osmdroid 入门到 MapLibre 进阶
android·源码·地图·osmdroid·maplibre
AI_Cloud_推荐2 小时前
Android集成百度人脸离线SDK实战:从环境搭建到活体检测(附避坑清单)
android·人工智能·百度·云计算·视觉检测·智能硬件
hai_android3 小时前
Android JNI 示例详解:Java 与 C++ 互调演示
android·java
IT毕设实战小研3 小时前
基于大数据的跨国外派人员适应满意度与留存影响因素可视化分析
android·java·大数据·python·django·课程设计
没文化的阿浩4 小时前
【Linux系统】进程状态详解
android·linux·c++·c