
去年给客户做桌面扫码机硬件平台是一台 Android 工控机摄像头选了市面上最常见的USB免驱摄像头。客户拍着胸脯说“USB摄像头嘛插上就能用。”结果插上去以后预览画面里还是机身自带的那颗内置摄像头。我把摄像头插了拔、拔了插系统日志里一点波澜都没有那一刻才意识到在Android上让USB外接摄像头出画面和你在手机上打开相机完全是两码事。折腾了几天最后我是靠 CameraX 把这套链路走通的。这篇文章就把我踩过的坑、验证过的代码、排错的思路完整记录下来。如果你正准备把USB摄像头接到 Android 开发板、安卓盒子、工控机或者定制设备上做扫码、工业视觉、直播推流这篇文章应该能帮你少走一大半弯路。1. 想清楚再动手USB摄像头在Android上到底走的是哪条链路在写第一行代码之前必须先搞明白USB摄像头的数据是怎么进到系统里的。很多人上来就在CameraManager里找摄像头列表找不到就怀疑代码写错了其实问题往往出在底层链路。1.1 USB摄像头的本质UVC设备与USB HostUSB摄像头在硬件协议层属于 UVC 设备USB Video Class。它通过USB接口把图像数据发出来传输方式大部分走 isochronous等时传输少部分走 bulk 传输。也就是说从USB协议的角度看它并不是“摄像头”而是一个符合UVC规范的外设。Android设备想接收这些数据先决条件是硬件的USB口支持 Host 模式。现在的手机、平板、盒子、开发板基本都支持OTG所以这一个条件绝大多数情况下是满足的。但请注意USB Host只能解决“物理连通”不解决“数据成为摄像头”。USB摄像头插上去USB内核会枚举到设备但这只是 USB 层的事离 Android 的相机框架还差得很远。系统必须有一套驱动把UVC流转换成上层能直接使用的视频帧才能被 Camera2/CameraX 调用。1.2 系统把UVC设备变成Camera的二层桥接从 Android 9API 28开始系统相机服务里多了一个 External Camera Provider专门负责把标准UVC摄像头桥接成一个虚拟的 Camera2 外置摄像头设备。你在CameraManager.cameraIdList里看到的“多出来的那个ID”本质上就是它虚拟出来的。你不需要在应用层去读UVC协议不需要自己写USB传输代码只要用标准 Camera2 API 或 CameraX 打开那个外置摄像头ID就行。画一条逻辑链路就是这样USB摄像头 → USB Host控制器 → 系统UVC驱动 → External Camera Provider → 虚拟Camera2设备 → CameraX → PreviewView所以用 CameraX 走通USB摄像头这件事核心前提是系统层把UVC桥接成了虚拟Camera2设备。应用层要做的其实是“拿到外置摄像头的ID在合适的时机绑定”。1.3 你手里的设备为什么“插上没反应”我一开始接到普通手机上测试插上USB摄像头后cameraIdList数量完全没变化。原因很简单手机厂商的ROM默认没有启用 External Camera 支持或者固件里根本就没编译这个模块。这不是应用层能解决的事我劝大家别在App代码里死磕先换一台支持外部摄像头的设备。实际测试下来以下几类设备更容易支持这条链路基于瑞芯微、全志、Amlogic等方案做的 Android 开发板/工控机部分国产 Android 盒子、收银机、人脸识别终端少数原生Android系统或接近AOSP的定制ROM普通手机能不能用基本看运气。判断方法不复杂插上USB摄像头后用adb shell dumpsys media.camera看摄像头服务列表里有没有多出外置摄像头或者直接在App里把cameraIdList打出来对照数量增加了就说明系统认了。注意OTG供电不足是另一种“插上没反应”的原因。部分功耗大一点的USB摄像头插在手机的OTG口上会被识别成“供电异常”直接不枚举。遇到这种情况用一个带外部供电的USB Hub就能解决这是硬件排查的第一步别急着改App。2. 设备体检把“能不能走CameraX这条路”提前测出来代码写再多设备不支持就等于白搭。所以我建议你先花十分钟做一个体检确认三件事系统API版本、是否是External Camera、硬件能力级别。2.1 三个前提条件缺一个都不行第一Android 系统版本要 API 28 及以上External Camera 就是这个版本才有的能力第二系统固件要支持外部摄像头桥接这个没法从代码突破第三UVC硬件本身工作正常能被系统枚举。把这些测出来其实代码量很少。核心就是用CameraManager把摄像头列表全部遍历一遍看有没有LENS_FACING_EXTERNAL这个标志。下面这段Kotlin可以直接跑fun dumpAllCameras() { val cameraManager getSystemService(Context.CAMERA_SERVICE) as CameraManager val executor ContextCompat.getMainExecutor(this) executor.execute { for (id in cameraManager.cameraIdList) { val characteristics cameraManager.getCameraCharacteristics(id) val facing characteristics.get(CameraCharacteristics.LENS_FACING) val hwLevel characteristics.get( CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL ) Log.d(CameraProbe, cameraId$id facing$facing hwLevel$hwLevel) } } }在这段日志里你会看到两类关键信息facing1代表前置摄像头facing0代表后置摄像头facing2就是外置摄像头不同Android版本常量的值可能会有差异最好直接和CameraCharacteristics.LENS_FACING_EXTERNAL比较。hwLevel表示硬件能力等级常见的是LIMITED、FULL、LEGACY。低端开发板上的外置摄像头经常是LEGACY意味着支持的特性比较基础像手动对焦、RAW输出这些就别指望了。2.2 识别外部摄像头的三种特征光看日志还不够代码里要准确判断“这个摄像头是不是USB外接的”建议组合下面三种方式特性标志CameraCharacteristics.LENS_FACING CameraCharacteristics.LENS_FACING_EXTERNAL硬件级别外置摄像头通常不会太高如果出现INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY大概率就是它摄像头ID变化插入USB前后对比cameraIdList多出来的那个ID基本就是外置摄像头建议不要只用摄像头ID去匹配因为不同系统给外置摄像头的命名完全不一样有的叫2有的叫device1.0/external/0。只有特性标志是稳定可靠的。2.3 检测不到EXTERNAL时的排查顺序如果你把上面的代码跑完发现一个外置摄像头都没有不要急着换方案按下面这个顺序排查adb shell lsusb或插上后看系统有没有输出 USB 枚举日志先确认USB设备本身被系统看到了用一个带独立供电的USB Hub排除供电不足导致的不枚举换一个不同品牌/主控的USB摄像头个别摄像头在兼容性上确实差一些检查固件版本有些开发板在旧固件里根本没有 External Camera 模块升级到官方最新固件后会多出来确认Android系统版本真的是API 28以上部分定制系统会把API level标得很高实际底层阉割了功能如果这些做完了还是不行说明你的设备大概率走不了 CameraX 这套方案。这时候就别死磕了改用UVC库直接读取USB视频流或者外接一个“USB转HDMI采集卡”再通过OTG方式接入那是另一条路子。但能用系统级External Camera还是最省事、最稳的。3. CameraX绑定USB摄像头的完整代码与踩坑设备确认支持之后就到了应用层开发。这一步我用的是 CameraX 的稳定路线代码不多但细节不少。CameraX 对 USB 外置摄像头的支持本质上是对 Camera2 能力的封装所以筛选外置摄像头时要用到Camera2CameraInfo和自定义CameraFilter。3.1 依赖版本稳定版就够别急着上experimental先说你最关心的版本。我目前用的稳定版是 CameraX 1.4.x你在Android Studio里新建工程时同步到的最新稳定版本都可以。只要不低于 1.2.0就支持后续我要用到的CameraFilter系列API。dependencies { implementation(androidx.camera:camera-core:1.4.2) implementation(androidx.camera:camera-camera2:1.4.2) implementation(androidx.camera:camera-lifecycle:1.4.2) implementation(androidx.camera:camera-view:1.4.2) }提示CameraX 新版本里出现过 Experimental 注解的 ExternalCameraManager 和 CameraEntry 相关API目的是更优雅地直接管理外部摄像头。但是按我的经验如果你的目标就是把USB摄像头跑通、稳定出画面用稳定版的滤镜方案就够了。Experimental API 在跨版本升级时接口变动很大很容易踩坑。3.2 核心代码用Camera2CameraInfo筛出外置摄像头CAMERA_X 的CameraSelector支持通过addCameraFilter自定义筛选逻辑。我们只需要在过滤条件里判断Camera2CameraInfo的cameraFacing是否为EXTERNAL。代码如下OptIn(ExperimentalCamera2CameraInfo::class) fun buildExternalCameraSelector(): CameraSelector { return CameraSelector.Builder() .addCameraFilter { cameras - cameras.filter { camera - val info camera.cameraInfo if (info is Camera2CameraInfo) { info.cameraFacing Camera2CameraInfo.Facing.EXTERNAL } else { false } } } .build() }这段代码的核心逻辑是在 CameraX 枚举到的所有相机里只保留cameraFacing EXTERNAL的那个。如果当前插入的是多个USB摄像头这里会保留多个CameraX 会按顺序选择第一个可用的。接下来是绑定预览。这里有一个非常重要的小细节bindToLifecycle之前最好先unbindAll()避免上次绑定还占着相机设备导致新的绑定失败或闪退。fun bindExternalCamera(lifecycleOwner: LifecycleOwner) { val providerFuture ProcessCameraProvider.getInstance(this) providerFuture.addListener({ val provider providerFuture.get() val selector buildExternalCameraSelector() val hasExternal cameraManager.cameraIdList.any { id - val ch cameraManager.getCameraCharacteristics(id) ch.get(CameraCharacteristics.LENS_FACING) CameraCharacteristics.LENS_FACING_EXTERNAL } if (!hasExternal) { binding.tvStatus.text 未检测到USB摄像头 returnaddListener } provider.unbindAll() val preview Preview.Builder() .setTargetResolution(Size(1280, 720)) .build() preview.setSurfaceProvider(binding.previewView.surfaceProvider) val imageCapture ImageCapture.Builder() .setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY) .build() provider.bindToLifecycle(lifecycleOwner, selector, preview, imageCapture) binding.tvStatus.text USB摄像头已绑定 }, ContextCompat.getMainExecutor(this)) }这段代码跑通后你已经能看到USB摄像头的画面了。它的意义不只是“出画面”而是证明了从UVC桥接、External Camera、CameraX绑定这一整条链路是通的后面所有功能都可以基于这个框架继续扩展。3.3 bindToLifecycle之后的三个常见报错绑定阶段我遇到过的报错基本逃不出下面这三类每条都有对应的原因和解决办法CameraAccessException: CAMERA_IN_USE_ERROR相机被其他应用或上一次绑定占用了最直接的解决方案是在重新绑定前执行provider.unbindAll()而且注意调用顺序别在子线程里执行。IllegalArgumentException: CameraSelector does not match any cameraCameraX根据你的 selector 找不到任何匹配的相机。最常见的原因是外部摄像头没有被系统识别或者你筛选EXTERNAL的方向不对。这时回到第2章的体检代码确认cameraIdList里确实有外置摄像头。SurfaceHolder相关的黑屏/白屏问题PreviewView的尺寸和摄像头输出分辨率不匹配时会出现。解决方案是给Preview设置合理的targetResolution并让PreviewView的布局宽高比和摄像头一致不要随便拉伸。这些坑在普通摄像头上不常见一旦换USB摄像头就特别容易触发因为外置摄像头的分辨率列表、帧率、输出格式经常和内置摄像头差异很大。4. 热拔插、分辨率与方向USB摄像头实战中的五个绕不开的坑USB摄像头和内置摄像头最大的区别就是它能随时被拔下来。这个特性给应用带来了一整类“内置摄像头时代几乎不存在”的问题。如果你只看官方文档这些问题大概率不会被讲到但我实际做下来每一个都值得提前处理。4.1 热插拔监听插上去没画面拔出来Crash用户把摄像头插上去应用得自动出画面把摄像头拔掉应用不能崩溃。实现方式不是轮询cameraIdList而是注册CameraManager.AvailabilityCallback。class SimpleCameraAvailabilityCallback( private val onChanged: () - Unit ) : CameraManager.AvailabilityCallback() { override fun onCameraAvailable(cameraId: String) { super.onCameraAvailable(cameraId) onChanged() } override fun onCameraUnavailable(cameraId: String) { super.onCameraUnavailable(cameraId) onChanged() } } fun watchExternalCamera(onChanged: () - Unit) { cameraManager.registerAvailabilityCallback( ContextCompat.getMainExecutor(this), SimpleCameraAvailabilityCallback(onChanged) ) }但直接在这个回调里调用bindExternalCamera是不保险的。因为onCameraUnavailable时摄像头可能已经被拔掉但你还没执行unbindAll于是会卡住。我建议在回调里统一走一个“刷新函数”先检查cameraIdList里还有没有外置摄像头fun refreshExternalCamera() { val hasExternal cameraManager.cameraIdList.any { id - val ch cameraManager.getCameraCharacteristics(id) ch.get(CameraCharacteristics.LENS_FACING) CameraCharacteristics.LENS_FACING_EXTERNAL } if (hasExternal) { bindExternalCamera(this) } else { ProcessCameraProvider.getInstance(this).get() .unbindAll() binding.tvStatus.text USB摄像头已拔出 } }这属于“不管谁触发、统一按状态处理”的思路。热插拔事件只是告诉你状态可能变了真正要不要重建还得看当前系统里到底还有没有外置摄像头。这个逻辑看起来简单但能避免掉大量边界问题。4.2 不要默认1080P先问摄像头支持什么USB摄像头在协议层面支持的格式五花八门有些只支持MJPEG有些支持NV12/ YUV422还有些输出H.264。CameraX帮你承担了一部分格式转换工作但你如果不管三七二十一直接setTargetResolution(Size(1920, 1080))很可能面对的是一块黑屏或者绑定失败。正确做法是先查询StreamConfigurationMap拿到这个摄像头真正支持的输出尺寸。一个比较通用的选择函数是这样fun pickOptimalSize(ch: CameraCharacteristics, targetWidth: Int 1280): Size { val scm ch.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP) ?: return Size(640, 480) val sizes mutableListOfSize() scm.getOutputSizes(ImageFormat.YUV_420_888)?.let { sizes.addAll(it) } scm.getOutputSizes(ImageFormat.JPEG)?.let { sizes.addAll(it) } return sizes .filter { it.width targetWidth it.height targetWidth * 0.75f } .maxByOrNull { it.width * it.height } ?: Size(640, 480) }这段代码的思路是先拿到所有支持尺寸过滤掉超过目标宽度的再选像素面积最大的一个。不建议直接硬编码 1080P很多廉价USB摄像头最高也就 640x48030fps硬上高分辨率只会让帧率跌到不可用的程度。4.3 画面方向和镜像USB摄像头不像手机内置摄像头那样有固定的传感器方向和屏幕方向关系。它的SensorOrientation经常是 0 度也就是说画面横着放或者在某些开发板上是倒着的。这时候不能指望 CameraX 自动帮你转好。最省事的办法是旋转PreviewViewbinding.previewView.rotation 90f如果你的设备需要镜像画面比如摄像头照着被测物画面反了看得难受可以设置binding.previewView.scaleX -1f镜像和旋转加在一起显示效果基本能满足大部分需求。如果你还要用ImageAnalysis去逐帧处理图像请记住ImageProxy拿到的字节流方向和PreviewView上看到的方向是两回事做图像识别之前手动旋转帧数据否则检测结果会对不上。4.4 多路USB摄像头与带宽有些项目要接两个USB摄像头比如视野范围不够需要两个角度同时采集。这个在 CameraX 里可以实现但有几个约束你必须知道USB 2.0 的等时传输带宽是有限的一路 720P30fps 的 MJPEG 已经会吃掉不少带宽同时开两路高分辨率会导致掉帧甚至枚举失败系统对同时打开多少个 Camera2 摄像头有一个上限有的设备是 2 个有的是 1 个外接多个USB摄像头时CameraManager.cameraIdList里会同时出现多个EXTERNAL你要通过摄像头ID的先后顺序或者用CameraCharacteristics里的其他字段去区分如果你真的需要多路同时处理建议降低分辨率和帧率优先用 MJPEG 格式再不行就用支持 USB 3.0 的摄像头和接口。实际项目中双路 720P 在 USB 3.0 下可用性会好很多。4.5 掉线重连系统休眠后USB摄像头会“消失”这个问题在工控机上非常典型系统休眠/锁屏再唤醒后USB摄像头可能没有被系统重新枚举你的应用里cameraIdList数量就变少了。解决方案是在onResume里重新走一遍refreshExternalCamera()。override fun onResume() { super.onResume() refreshExternalCamera() }同时建议在onPause里unbindAll()。这样做有两个好处一是释放相机资源休眠后摄像头更容易被系统重新识别二是避免摄像头设备还挂着但你的 Activity 已经不在了引发的内存泄漏或崩溃。5. 从Demo到能用把USB摄像头方案嵌入真实项目跑通预览只是第一步。接下来才是真正把USB摄像头变成生产工具的过程。这一节我会把实际项目里最常见的几个扩展方向说一下并提供一些我自己验证过的思路。5.1 一个完整的最小案例USB摄像头预览截图我最后交付给客户的那版核心功能就是“预览 拍照存档”。在上一节的绑定代码基础上加一个截图按钮就够用了fun takePhoto() { val imageCapture ImageCapture.Builder() .setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY) .build() val file File(getExternalFilesDir(null), usb_cam_${System.currentTimeMillis()}.jpg) val outputOptions ImageCapture.OutputFileOptions.Builder(file).build() imageCapture.takePicture( outputOptions, ContextCompat.getMainExecutor(this), object : ImageCapture.OnImageSavedCallback { override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) { Log.d(CameraUSB, saved: ${file.absolutePath}) } override fun onError(exception: ImageCaptureException) { Log.e(CameraUSB, capture failed, exception) } } ) }注意一个细节ImageCapture和Preview这两个 UseCase 要同时绑到同一个bindToLifecycle调用里不能先绑预览、再绑拍照否则第二次bindToLifecycle会把前面的UseCase顶掉。这也是新手最容易犯错的地方。5.2 扩展方向扫码、工业视觉与直播推流USB摄像头方案一旦走通能做的事情就非常多了。我只列三个我实测过的方向条码/二维码扫描用 CameraX 的ImageAnalysis结合 ML Kit 的条码扫描器比手机摄像头稳定得多尤其是工作距离和角度固定的时候USB工业相机在识读率和抗反光方面都强不少。工业视觉缺陷检测把ImageAnalysis每帧转成Bitmap或YUV_420_888数据交给 OpenCV 做边缘检测、模板匹配。USB摄像头色彩还原比很多内置摄像头好原始数据更干净。直播/远程画面转发从ImageAnalysis里拿到NV21字节流用自带的MediaCodec硬编码成 H.264再通过 RTSP/RTP 转发给局域网客户端。这个方案在工控机上CPU占用率可以控制在30%以下比录屏再转发靠谱得多。5.3 性能与稳定性调优最后这部分是我经过多轮压测之后总结出来的几条经验图像分析一定要用ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST丢帧比排队处理重要处理不过来宁可丢帧也不要让任务堆积把图像处理任务放到子线程ImageProxy不要直接传到主线程做更不能卡住analyze()方法不要追求高分辨率1080P 的实时分析在嵌入式设备上非常吃力720P 是性能和准确率的平衡点在某些设备上USB摄像头的曝光和自动白平衡响应很慢建议在Camera2Interop层手动设置一个固定曝光值否则图像会有明显的亮度跳变这些经验单独看都是小事合在一起就是项目能不能稳定跑起来的分水岭。我见过太多Demo在演示时没问题一放到产线上就掉链子基本都是栽在性能策略和热插拔处理上。最后再分享一个调试小技巧怀疑系统没识别USB摄像头时不要只靠Log。在电脑上执行adb shell dumpsys media.camera可以直接看到当前系统的所有 Camera Provider 和摄像头连接状态再配合adb shell dumpsys usb看USB枚举情况就能很快定位问题出在系统层还是应用层。这套组合拳是我排查USB摄像头问题最常用的第一动作。