去年给客户做桌面扫码机,硬件平台是一台 Android 工控机,摄像头选了市面上最常见的USB免驱摄像头。客户拍着胸脯说:“USB摄像头嘛,插上就能用。”结果插上去以后,预览画面里还是机身自带的那颗内置摄像头。我把摄像头插了拔、拔了插,系统日志里一点波澜都没有,那一刻才意识到:在Android上让USB外接摄像头出画面,和你在手机上打开相机完全是两码事。
折腾了几天,最后我是靠 CameraX 把这套链路走通的。这篇文章就把我踩过的坑、验证过的代码、排错的思路完整记录下来。如果你正准备把USB摄像头接到 Android 开发板、安卓盒子、工控机或者定制设备上做扫码、工业视觉、直播推流,这篇文章应该能帮你少走一大半弯路。
1. 想清楚再动手:USB摄像头在Android上到底走的是哪条链路
在写第一行代码之前,必须先搞明白USB摄像头的数据是怎么进到系统里的。很多人上来就在CameraManager里找摄像头列表,找不到就怀疑代码写错了,其实问题往往出在底层链路。
1.1 USB摄像头的本质:UVC设备与USB Host
USB摄像头在硬件协议层属于 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 9(API 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") } } }在这段日志里,你会看到两类关键信息:
facing=1代表前置摄像头,facing=0代表后置摄像头,facing=2就是外置摄像头(不同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",有的叫"device@1.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摄像头" return@addListener } 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 camera:CameraX根据你的 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 = mutableListOf<Size>() 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摄像头最高也就 640x480@30fps,硬上高分辨率只会让帧率跌到不可用的程度。
4.3 画面方向和镜像
USB摄像头不像手机内置摄像头那样有固定的传感器方向和屏幕方向关系。它的SensorOrientation经常是 0 度,也就是说画面横着放,或者在某些开发板上是倒着的。这时候不能指望 CameraX 自动帮你转好。
最省事的办法是旋转PreviewView:
binding.previewView.rotation = 90f如果你的设备需要镜像画面(比如摄像头照着被测物,画面反了看得难受),可以设置:
binding.previewView.scaleX = -1f镜像和旋转加在一起,显示效果基本能满足大部分需求。如果你还要用ImageAnalysis去逐帧处理图像,请记住:ImageProxy拿到的字节流方向和PreviewView上看到的方向是两回事,做图像识别之前手动旋转帧数据,否则检测结果会对不上。
4.4 多路USB摄像头与带宽
有些项目要接两个USB摄像头,比如视野范围不够,需要两个角度同时采集。这个在 CameraX 里可以实现,但有几个约束你必须知道:
- USB 2.0 的等时传输带宽是有限的,一路 720P@30fps 的 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摄像头问题最常用的第一动作。