1. 为什么“两个 SDK 抢一个摄像头”不是 Bug,而是 Android 系统设计的必然结果
在 Android 开发中,当你的 App 集成了视频会议 SDK、扫码 SDK、AR 渲染 SDK 或智能硬件控制 SDK,突然发现前置摄像头打不开、预览黑屏、或者调用Camera.open()直接抛出RuntimeException: Fail to connect to camera service——很多人第一反应是“SDK 冲突了”“是不是哪个 SDK 没释放资源”,然后开始翻文档、查日志、逐个注释 SDK 测试。我做过不下 12 个带多路视觉能力的工业级 Android 项目,从车载 DMS 到 AGV 导航终端,再到医疗内窥镜辅助系统,几乎每个项目都踩过这个坑。但真相是:这不是某个 SDK 的质量问题,而是 Android Camera API 架构层面对“独占式硬件访问”的刚性约束所引发的必然仲裁问题。
Android 自 Camera1 API 时代起,就将摄像头设备抽象为系统级单例资源。无论你用的是android.hardware.Camera还是androidx.camera.core(CameraX),底层最终都要通过CameraService向 HAL 层申请设备句柄。而 HAL 层对物理摄像头(如/dev/video0)的访问控制是排他性的——同一时刻,只能有一个客户端持有该设备的 open fd。这就像一台打印机,不能同时被 Word 和 Excel 同时发送打印指令;摄像头也一样,不能同时被扫码 SDK 和美颜 SDK 同时初始化 sensor、配置 ISP 参数、启动 streaming pipeline。
更关键的是,绝大多数第三方 SDK 并不遵循 Android 官方推荐的“CameraX Lifecycle-aware”实践。它们往往在自己的 Service 或 Activity 中直接调用Camera.open(0),并在onDestroy()或onStop()中才调用release()。一旦 SDK 内部存在异常退出、线程卡死、或未正确监听生命周期,那个Camera实例就永远卡在“已打开未释放”状态。此时哪怕你的主 App 调用Camera.getNumberOfCameras()返回 2,Camera.open(0)依然会失败——因为设备句柄已被另一个 SDK “锁死”。
这也是为什么你在 Logcat 里常看到类似这样的报错:
E/CameraClient: Cannot connect to camera service E/Camera: Error 2 W/System.err: java.lang.RuntimeException: Fail to connect to camera service这里的 “Error 2” 对应CAMERA_ERROR_SERVER_DIED,它根本不是说相机服务崩溃了,而是指当前进程尝试获取的 camera device 已被其他客户端独占,且该客户端未按预期释放。这是 Android CameraService 在内核层返回的明确拒绝信号。
所以,“两个 SDK 抢一个摄像头”本质上是一场没有裁判的资源争夺战。而所谓“相机仲裁”,不是让 SDK 之间互相协商,而是由 App 主动建立一套中心化、可监控、可干预的资源调度机制——把原本散落在各 SDK 内部的相机生命周期,收归到一个统一的 Coordinator 手中。这不是锦上添花的优化,而是保障多视觉模块共存的基础设施。
提示:不要试图用
try-catch包裹Camera.open()来“绕过”错误。捕获异常只能掩盖问题,无法解决资源争抢的本质。真正的解法,是让所有相机使用者,都向同一个“调度中心”申请和归还资源。
2. CameraCoordinator 的核心职责不是“转发调用”,而是“状态建模与冲突消解”
很多团队在第一次设计相机仲裁时,会本能地写一个CameraManager类,里面封装open()、startPreview()、takePicture()等方法,再把参数原样透传给底层 SDK。这种做法看似简洁,实则埋下巨大隐患——它把仲裁逻辑降级成了“API 路由器”,完全忽略了相机资源最核心的三个维度:设备状态、使用意图、优先级时效。
真正健壮的CameraCoordinator必须是一个有状态的协调者,它要能回答以下五个关键问题:
- 当前摄像头设备(如
CAMERA_FACING_BACK)是否处于IDLE、OPENING、OPENED、STREAMING、ERROR中的哪一种状态? - 哪个模块(Module A / Module B / SDK X)正在使用它?它的使用场景是什么?(扫码需要高帧率低延迟,视频通话需要自动对焦+人脸追踪,AR 渲染需要精确时间戳)
- 如果 Module A 正在扫码,Module B 突然发起视频通话请求,是立即抢占?还是排队等待?还是降级为仅使用后置广角副摄?
- 当 Module A 因网络超时主动放弃扫码,它是否真的调用了
release()?如果没调用,Coordinator 是否能检测到并强制回收? - 如果系统触发了
onConfigurationChanged()(比如横竖屏切换)、或用户切到后台又切回,Coordinator 如何保证预览流不中断、不重建 Surface?
我见过太多 Coordinator 实现只做了第一层“加锁”:用synchronized包裹open()方法,认为“同一时间只有一个线程能进,就不会抢”。但这是典型的线程安全幻觉。Android 的 Camera 调用本身跨进程(App → CameraService → HAL),锁住 Java 层方法,完全无法阻止两个不同 SDK 分别在不同线程、不同进程里同时向 CameraService 发起 open 请求。真正的状态建模,必须基于 Binder 通信层的反馈与设备实际运行态。
我们目前在产线设备上稳定运行的CameraCoordinator架构,采用三层状态机设计:
| 层级 | 名称 | 职责 | 关键数据结构 |
|---|---|---|---|
| L1 | Device State Layer | 监听CameraDevice.StateCallback,实时同步物理设备真实状态(Opened / Closed / Disconnected) | AtomicReference<DeviceState>+ConcurrentHashMap<String, Long>(记录各模块 lastActiveTime) |
| L2 | Session Manager Layer | 为每个使用方创建CameraSession,绑定其生命周期、使用策略(如SCAN_ONLY,VIDEO_CALL,AR_TRACKING)、超时阈值(默认 30s) | CopyOnWriteArrayList<CameraSession>+PriorityBlockingQueue<CameraSession>(按 priority + timestamp 排序) |
| L3 | Policy Engine Layer | 定义仲裁规则:同类型 Session 可复用;高优先级 Session 可驱逐低优先级;驱逐前执行 graceful shutdown(如先 pause preview 再 release) | enum Policy { COEXIST, PREEMPT, DEGRADE }+PolicyRule[] rules(可动态加载) |
举个真实案例:某物流分拣终端需同时运行“条码扫描 SDK”和“AI 货物识别 SDK”。前者要求 60fps、无自动对焦;后者要求 30fps、开启 AF/AE。若两者同时请求后置主摄,Coordinator 不会简单拒绝第二个请求,而是启动DEGRADE策略:让扫码 SDK 继续使用主摄,同时为 AI SDK 分配副摄(OV2640)进行粗定位,待扫码完成释放主摄后,再无缝切换至主摄进行精识别。这种“分级响应”能力,远超简单的“谁先到谁用”。
注意:
CameraSession必须携带WeakReference<Activity>或LifecycleOwner,而非强引用。否则极易因 Activity 泄漏导致 Coordinator 持有已销毁页面的引用,进而阻塞资源回收。我们在 v2.3 版本中曾因此导致整机重启后摄像头永久不可用,排查耗时 3 天。
3. 从 Camera1 到 CameraX:不同 API 层级下的仲裁实现差异与兼容陷阱
Android 相机 API 的演进不是平滑升级,而是一次次推倒重来的架构重构。Camera1(API 1–20)、Camera2(API 21+)、CameraX(Jetpack,API 21+)三者底层调用链、生命周期管理、错误恢复机制完全不同。这意味着,你的CameraCoordinator如果只适配其中一种,就会在混合 SDK 场景下彻底失效。我整理了三者在仲裁设计中最关键的 5 个差异点,并附上实测验证过的兼容方案。
3.1 Camera1:最危险的“裸奔模式”
Camera1 是纯 Java 层 API,Camera.open()返回一个Camera对象,release()必须显式调用。它的致命缺陷在于:没有任何生命周期感知能力,也没有设备状态回调。SDK 只要拿到Camera实例,就可以随意调用startPreview()、setParameters(),甚至在SurfaceView销毁后继续往已释放的SurfaceHolder写帧——这会导致SIGSEGV崩溃。
更麻烦的是,Camera1 的open()是阻塞调用,且无超时机制。当设备被占用时,它会在 Binder 层无限等待,直到TransactionTooLargeException或 ANR。我们曾在一个车载项目中发现:扫码 SDK 卡在Camera.open()上长达 8 秒,导致整个导航界面卡死。
兼容方案:
必须为 Camera1 封装一层SafeCameraWrapper,内部使用HandlerThread+CountDownLatch实现超时控制:
public class SafeCameraWrapper { private static final long OPEN_TIMEOUT_MS = 3000; private final CountDownLatch latch = new CountDownLatch(1); private volatile Camera camera; public Camera open(int cameraId) throws TimeoutException { HandlerThread thread = new HandlerThread("CameraOpenThread"); thread.start(); new Handler(thread.getLooper()).post(() -> { try { camera = Camera.open(cameraId); } catch (RuntimeException e) { // 记录具体错误码,用于后续分析 Log.e("SafeCamera", "open failed: " + e.getMessage()); } finally { latch.countDown(); } }); if (!latch.await(OPEN_TIMEOUT_MS, TimeUnit.MILLISECONDS)) { thread.quitSafely(); throw new TimeoutException("Camera.open() timeout after " + OPEN_TIMEOUT_MS + "ms"); } return camera; } }同时,在 Coordinator 的onSessionReleased()中,必须调用camera.stopPreview(); camera.release();并置空引用。绝不能依赖 GC 回收——Camera 对象持有 native fd,GC 不会自动 close。
3.2 Camera2:状态驱动,但回调地狱
Camera2 引入了CameraDevice.StateCallback和CaptureCallback,理论上可以精准感知设备状态。但它的复杂度呈指数级上升:你需要管理CameraCharacteristics、CameraCaptureSession、CaptureRequest.Builder、TotalCaptureResult……稍有不慎,createCaptureSession()就会返回null,且无明确错误提示。
最大的陷阱在于:Camera2 的close()是异步操作,且没有回调通知。当你调用cameraDevice.close()后,设备并非立即释放,而是进入CLOSED状态,期间仍可能收到onClosed()回调。如果 Coordinator 在close()后立刻允许新 Session 获取设备,就会触发IllegalStateException: CameraDevice was already closed。
兼容方案:
在 Coordinator 中为 Camera2 设备维护一个AtomicBoolean isClosing标志位,并在onClosed()回调中清除所有 session 引用:
private final CameraDevice.StateCallback stateCallback = new CameraDevice.StateCallback() { @Override public void onOpened(@NonNull CameraDevice camera) { currentDevice = camera; isClosing.set(false); notifyStateChange(DeviceState.OPENED); } @Override public void onClosed(@NonNull CameraDevice camera) { currentDevice = null; isClosing.set(false); // 清理所有待处理的 CaptureRequest pendingRequests.clear(); notifyStateChange(DeviceState.IDLE); } @Override public void onClosed(@NonNull CameraDevice camera) { // 必须在此处清理 session,否则可能残留 activeSessions.removeIf(session -> session.getCameraId() == cameraId); } };3.3 CameraX:最友好,但“太友好”反而成坑
CameraX 的ProcessCameraProvider.bindToLifecycle()看似完美:自动绑定生命周期、自动释放、自动处理配置变更。但它的“自动”恰恰是多 SDK 场景下的最大风险源。因为bindToLifecycle()内部会创建独立的Camera实例和PreviewUseCase,不同 SDK 各自 bind,等于各自创建了一个 Camera 实例,系统层面仍是抢资源。
我们曾接入一个 AR SDK,它内部使用 CameraXPreviewView,而主 App 也用 CameraXImageAnalysis。结果是:AR SDK 的 PreviewView 黑屏,ImageAnalysis的onOutputSurface()回调永远不触发。Logcat 显示W/CameraX: Camera is busy—— 这正是 CameraX 在底层检测到设备被占用后的静默降级。
兼容方案:
必须禁用所有 SDK 内部的 CameraX 初始化,强制它们使用 Coordinator 提供的PreviewView和ImageAnalysis实例。具体做法是:
- 在
Application.onCreate()中提前初始化ProcessCameraProvider; - 通过
ContentProvider或Service将ProcessCameraProvider实例暴露给其他 SDK(注意跨进程需用 AIDL); - 要求 SDK 提供
setCameraProvider(ProcessCameraProvider)接口,由 Coordinator 统一注入; - 所有
UseCase(Preview/Analysis/Capture)均由 Coordinator 创建并管理生命周期。
实测心得:CameraX 的
enableTorch()在多 Session 场景下极易冲突。我们的解决方案是:只允许最高优先级 Session 控制闪光灯,其他 Session 的setFlashMode()调用会被 Coordinator 拦截并返回false,同时记录 warning 日志。这样既避免硬件冲突,又让调用方明确感知到权限被接管。
4. 真实踩坑全链路复盘:从 ANR 到热重启,一次完整的相机资源死锁排查
去年 Q3,我们为某智能巡检机器人交付固件,上线第三天,客户反馈“设备运行 4 小时后摄像头全部失效,重启也无法恢复”。现场抓取 logcat 后,发现核心线索只有两行:
E/CameraService: Disconnecting camera client (pid 1234) due to error -38 W/CameraClient: notifyError E38-38对应BAD_VALUE,是 HAL 层返回的通用错误码,毫无指向性。接下来 72 小时,我们完成了从表象到根因的完整排查链路,过程极具代表性,这里完整还原。
4.1 第一阶段:现象锁定与范围收缩
首先排除硬件故障。我们用adb shell dumpsys media.camera查看系统级相机状态:
adb shell dumpsys media.camera | grep -A 5 -B 5 "device"输出显示:
Device 0 (id=0): State: OPENED Client: com.xxx.robot (pid=1234) Stream configs: [PREVIEW: 1280x720@30, RECORD: 1920x1080@30] Device 1 (id=1): State: IDLE说明后置主摄(device 0)确实被com.xxx.robot占用,且状态为OPENED,但无任何 preview stream 正在运行。这很反常——OPENED状态却无流,意味着设备句柄被持有着,但无人消费帧数据。
接着检查该进程的线程栈:
adb shell kill -3 1234 # 触发 ANR trace adb shell cat /data/anr/traces.txt | grep -A 20 -B 5 "Camera"关键线索浮现:
"CameraThread" prio=5 tid=15 Runnable at android.hardware.Camera._startPreview(Native method) at android.hardware.Camera.startPreview(Camera.java:823) at com.xxx.sdk.ScannerCore.startCamera(ScannerCore.java:142) at com.xxx.sdk.ScannerCore$1.onOpened(ScannerCore.java:98)线程卡在startPreview(),且堆栈显示是扫码 SDK 的onOpened()回调里。这说明:SDK 成功open()了相机,但在startPreview()时卡死。而startPreview()是 JNI 调用,卡死原因只能是 HAL 层或驱动层。
4.2 第二阶段:HAL 层日志与驱动状态验证
普通logcat无法看到 HAL 层细节,需启用vendortag 日志:
adb shell setprop persist.vendor.camera.hal.debug 3 adb shell setprop persist.vendor.camera.hal.log 1 adb logcat -b vendor | grep -i "ov5647\|isp\|stream"日志中反复出现:
E/OV5647: stream_on failed: Device or resource busy (-16) E/ISP: Failed to start streaming for sensor 0-16是 Linux 的EBUSY错误,直指设备忙。但dumpsys media.camera显示只有com.xxx.robot在用 device 0。难道有隐藏的客户端?
我们用adb shell lsof | grep video查看/dev/video*的文件描述符占用:
adb shell lsof | grep video0 # 输出: # camera 1234 1234 12u CHR 81,0 0t0 123456 /dev/video0 # camera 5678 5678 15u CHR 81,0 0t0 123456 /dev/video0果然!PID 5678 也在占用/dev/video0。ps -p 5678查得它是com.yyy.ai(AI 识别 SDK)。但dumpsys media.camera没显示它——因为它用的是 Camera2 API,且在onDisconnected()后未正确关闭设备,导致 fd 泄漏。
4.3 第三阶段:根因确认与修复验证
我们写了一个最小复现脚本,模拟两个 SDK 的行为:
// SDK A (Camera1) Camera cam = Camera.open(0); cam.startPreview(); // 成功 // SDK B (Camera2) cameraManager.openCamera("0", new CameraDevice.StateCallback() { @Override public void onOpened(CameraDevice camera) { // 故意不调用 camera.close() } }, null);运行后,lsof确认/dev/video0被两个进程持有,startPreview()卡死。至此,根因明确:Camera2 SDK 未正确释放设备,导致 fd 泄漏,Camera1 SDK 在startPreview()时因设备忙而无限等待,最终触发 ANR,而 ANR 处理机制又不会主动 kill 卡死的 Camera 线程,形成死锁。
修复方案分三级:
- 紧急止血:在 Coordinator 的
onSessionAcquired()中,增加 fd 检查:
若检测到 busy,则拒绝新 Session,并触发private boolean isDeviceBusy(int cameraId) { String cmd = "lsof | grep video" + cameraId + " | wc -l"; int count = executeShell(cmd); // 执行 shell 命令 return count > 1; // 除自己外还有其他进程占用 }forceReleaseAll()。 - 中期加固:为 Camera2 SDK 注入
WeakReference<CameraDevice>,并在 Coordinator 的onTrimMemory()中遍历所有弱引用,对已销毁的CameraDevice强制close()。 - 长期治理:推动 SDK 厂商升级,要求其 Camera2 实现必须在
onClosed()回调中确保close()被调用,并提供isClosed()接口供 Coordinator 查询。
修复后,设备连续运行 72 小时无异常,lsof显示/dev/video0始终只有 1 个 fd。
踩坑总结:Android 相机死锁极少由单一 SDK 引起,往往是“一个 SDK 不释放 + 另一个 SDK 不超时 + 系统无兜底机制”三重叠加。排查时,必须跳出 App 层日志,深入到
lsof、dumpsys media.camera、vendor log三层,缺一不可。
5. 生产环境落地 checklist:从开发测试到 OTA 升级的 12 项硬性要求
CameraCoordinator不是写完就能上线的玩具组件,它直接关系到设备的核心功能可用性。我们在 5 个量产项目中沉淀出一份覆盖全生命周期的落地 checklist,每一条都来自血泪教训,必须逐项验证。
5.1 开发阶段:代码即契约
| 序号 | 检查项 | 为什么重要 | 验证方式 |
|---|---|---|---|
| 1 | 所有 SDK 的相机初始化入口,必须通过 Coordinator 的acquireCamera()获取CameraInstance,禁止直接调用Camera.open()或CameraManager.openCamera() | 防止绕过仲裁,造成资源竞争 | 静态代码扫描:grep -r "Camera.open|openCamera" --include="*.java" . | grep -v "CameraCoordinator" |
| 2 | CameraSession必须包含timeoutMs字段,且默认值 ≤ 30000ms(30秒),超时后 Coordinator 自动release()并回调onTimeout() | 避免某个 SDK 卡死导致整个相机系统瘫痪 | 单元测试:mock 一个永不返回的CameraDevice.StateCallback,验证超时后是否触发 release |
| 3 | Coordinator 必须实现androidx.lifecycle.DefaultLifecycleObserver,并在ON_DESTROY时调用forceReleaseAll() | 确保 Activity 销毁时资源彻底释放,防止内存泄漏 | LeakCanary 检测:启动 Camera 页面,快速旋转屏幕 10 次,观察是否有 Activity 泄漏 |
5.2 测试阶段:覆盖极端场景
| 序号 | 检查项 | 为什么重要 | 验证方式 |
|---|---|---|---|
| 4 | 模拟“扫码中切后台 → 启动视频通话 → 切回前台”全流程,验证预览流是否中断、是否自动恢复 | 多任务切换是 Android 最常见场景,也是状态同步最易出错的环节 | 使用adb shell input keyevent KEYCODE_HOME+adb shell am start -n com.xxx/.VideoCallActivity组合压测 |
| 5 | 同时启动 3 个不同 SDK(扫码/AR/录像),持续运行 2 小时,监控dumpsys media.camera中的Stream configs是否稳定 | 长时间运行会暴露内存泄漏、fd 泄漏、状态不同步等问题 | 自动化脚本:每 5 分钟抓取一次dumpsys,比对State和Stream configs是否变化 |
| 6 | 强制 kill 正在使用相机的 SDK 进程(adb shell kill -9 <pid>),验证 Coordinator 是否能在 5 秒内检测到并forceRelease() | 模拟 SDK 崩溃场景,检验 Coordinator 的容错能力 | adb shell ps | grep "com.xxx.scanner"→adb shell kill -9 <pid>→adb logcat | grep "forceRelease" |
5.3 发布与运维阶段:可监控、可回滚
| 序号 | 检查项 | 为什么重要 | 验证方式 |
|---|---|---|---|
| 7 | Coordinator 必须提供getActiveSessions()接口,返回 JSON 格式状态快照(含 module name、priority、duration、state),并通过adb shell dumpsys xxx.camera暴露 | 运维人员无需看代码,即可快速定位是哪个模块占着摄像头不放 | adb shell dumpsys xxx.camera应输出类似:{"sessions":[{"module":"scanner","priority":10,"durationMs":12450,"state":"STREAMING"}]} |
| 8 | 所有acquireCamera()失败必须记录EventLog,包含errorCode、callerPackage、stackTraceHash,并上报到远程监控平台 | 快速定位高频失败模块,为 SDK 升级提供数据支撑 | 查看监控后台:筛选camera_acquire_failed事件,按callerPackage分组统计 |
| 9 | OTA 升级包中,CameraCoordinator的版本号必须写入build.prop(如ro.camera.coordinator.version=2.4.1),且升级脚本需校验旧版本 ≥ 2.3.0 才允许覆盖 | 防止低版本 Coordinator 覆盖高版本,导致新特性丢失 | adb shell getprop ro.camera.coordinator.version在升级前后对比 |
5.4 硬件适配阶段:拒绝“一套代码走天下”
| 序号 | 检查项 | 为什么重要 | 验证方式 |
|---|---|---|---|
| 10 | 针对不同 SoC(高通/瑞芯微/全志),必须提供CameraHalAdapter,封装setParameters()中的 vendor-specific 参数(如qcom.hw.ae.target、rkisp.awb.mode) | 同一Camera.Parameters在不同 HAL 下含义不同,硬编码会导致预览偏色、对焦失效 | 在 RK3399 板子上运行,验证setFocusMode("continuous-video")是否生效;在骁龙 865 上验证setRecordingHint(true)是否降低功耗 |
| 11 | 对双摄/三摄设备,Coordinator 必须支持logicalCameraId,并能根据CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES_LOGICAL_MULTI_CAMERA自动选择主摄或融合模式 | 避免在多摄设备上错误使用副摄,导致扫码失败或画质下降 | adb shell dumpsys media.camera | grep logical,确认是否识别到 logical camera group |
| 12 | 所有Surface创建(SurfaceView.getHolder().getSurface()、ImageReader.getSurface())必须通过 Coordinator 的createSurface()统一创建,并添加Surface#isValid()检查 | 防止 SDK 使用已销毁的 Surface,导致IllegalArgumentException: surface is invalid | 在SurfaceView的onDetachedFromWindow()中调用Coordinator.destroySurface(surface),再触发acquireCamera(),验证是否抛出预期异常 |
这份 checklist 我们已固化为 Jenkins 流水线中的 gate check。任何一项不通过,CI 就会阻断发布。它不是束缚开发的枷锁,而是保护用户不被“摄像头突然失灵”困扰的最后一道防线。
最后分享一个小技巧:在
CameraCoordinator的onSessionAcquired()回调中,加入一行Debug.waitForDebugger()(仅 Debug 版本)。当某个 SDK 死活拿不到相机时,你可以 attach debugger,直接看到是哪个模块在acquire()时被阻塞,以及当前所有 active sessions 的完整状态——这比看 1000 行 log 有效得多。