
服务端在 Android 设备上接入 USB 外接摄像头听起来像是个冷门需求但真的做起来才发现这玩意儿在工业平板、自助终端、车机、医疗设备、便携检测仪上到处都是。这两年扫码枪、热成像仪、内窥镜、USB 显微镜轮番上阵客户一个比一个着急开口就是“能不能用 CameraX 直接调”结果 CameraX 压根不认识 UVC 设备。这篇文章我就把我在实战里踩出来的路子讲清楚为什么 CameraX 默认搞不定 USB 摄像头、两条主流技术路线怎么选、UVC 直连方案从权限申请到帧数据转换的完整代码怎么写、性能怎么调优、以及常见的坑都在哪。这篇文章适合已经在用 CameraX 做过内置摄像头开发、突然被“USB 摄像头”需求扣住的人也适合从零开始、想搞懂 Android 上外接摄像头原理的初学者——我会把不需要前置知识的地方也讲明白尽量让你照着抄就能跑起来。1. 方案选型与核心思路拆解1.1 为什么 CameraX 直接访问不了 USB 摄像头CameraX 是 Google 在 Jetpack 里推的相机抽象层它封装的是 Android 系统里的 Camera2 API而 Camera2 只认识设备自带的摄像头也就是 /dev/video 那套 HAL 层已经注册好的 CameraDevice。USB 外接摄像头走的是 UVC 协议USB Video Class对应的是 Linux 内核里的 uvcvideo 驱动它并没有被 Android 的 Camera HAL 接管所以 Camera2 也好、CameraX 也好通过正常的 openCamera 流程根本枚举不到它。打个比方Camera2 好比是物业统一管理的公寓房间每个房间都有编号你要开门拿钥匙得找物业UVC 摄像头则是你自己带来的折叠床它压根不在物业的登记簿里你只能自己打开仓库门把它搬进去。所以任何声称“用 CameraX 调用 UVC 摄像头”的方案本质上都不是 CameraX 在直接驱动 UVC而是走了另外两条路要么绕过 CameraX直接用 USB 层的库去读 UVC 流要么把 UVC 画面转成一种 CameraX 能接受的数据源再喂给它。理解了这一点后面所有的代码逻辑就都顺了。1.2 两条主流技术路线的取舍我在项目里实际尝试过的方案主要有两条各有各的适用场景。路线一UVC 库直连核心方案用开源的 UVCCamera 库比如 saki4510t 的 UVCCamera底层是 libuvc通过 USB 权限拿到设备后直接读取 UVC 传输的帧数据再渲染到 SurfaceView/TextureView 上或者塞给 OpenGL 做二次处理。优点延迟低、不依赖系统相机管线可以拿到原始 YUV 数据适合做图像分析、扫码、机器视觉。缺点要自己管理 USB 权限、线程、帧格式转换部分国产 ROM 对 USB 权限弹窗和 OTG 支持有奇怪的问题。路线二WiFi/ADB 转发调试备选方案如果你只是想临时看看 USB 摄像头的画面或者调试内置摄像头效果可以把内置摄像头的预览流通过 ADB 或局域网转发到电脑上显示。但这个方案和“USB 外接摄像头”本身没什么关系只是调试辅助手段不展开讲。在这篇文章里我主要讲路线一。这也是“Android 设备上真正用起来 USB 外接摄像头”最可靠的做法而且代码可以在各种 Android 版本、各种 UVC 摄像头上复用。2. 硬件准备与项目环境搭建2.1 摄像头与 OTG 硬件的选择不是所有 USB 摄像头都能在 Android 上正常工作我建议直接选支持 UVC 协议、MJPEG 或 H.264 输出的摄像头这类摄像头兼容性最好。常见的免驱 USB 摄像头USB Camera不是手机自带的 Camera很多都支持 UVC但要注意以下几点分辨率与帧率尽量选 720p30fps 或 1080p30fps 的型号太高的分辨率4K在 Android 端解码和传输压力很大。供电问题部分 USB 摄像头功耗较高直接插手机 OTG 口可能带不动会出现画面卡顿、设备反复断开的情况。这时候需要带供电的 USB HUBOTG 转接 外接电源。接口类型Type-C 接口直插最方便Micro USB 接口的旧设备需要 OTG 转接头。Android 设备兼容性个别 Android 设备的 USB Host 模式支持不完整会出现插入无反应的问题。我在代码里建议先做一个设备检测逻辑后面会讲到。简单说硬件选型不能图便宜网上 9.9 包的摄像头多半在 Android 上掉链子实测下来兼容性排名大约是“罗技、宏碁、海康威视的大厂 UVC 摄像头 杂牌免驱摄像头”。2.2 Android 项目依赖与权限配置这里假定你已经安装了最新稳定版 Android Studio不需要折腾汉化保持默认英文界面即可网上很多汉化教程反而引入不必要的麻烦新建一个空 Activity 项目即可。在app/build.gradle里加入 CameraX 依赖用于配套使用内置摄像头如果你完全不用内置摄像头CameraX 可以不加但多数场景是“内置 外接”双摄方案android { // 如果使用 Java 8 以上特性需要开启 compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } } dependencies { // CameraX 基础库写这篇文章时最新稳定版是 1.3.x def camerax_version 1.3.4 implementation androidx.camera:camera-core:${camerax_version} implementation androidx.camera:camera-camera2:${camerax_version} implementation androidx.camera:camera-lifecycle:${camerax_version} implementation androidx.camera:camera-view:${camerax_version} // UVC 摄像头库底层基于 libuvc implementation com.github.saki4510t:UVCCamera:1.0.12 }Manifest里需要声明 USB 设备权限和 OTG 相关权限uses-feature android:nameandroid.hardware.usb.host android:requiredtrue / uses-permission android:nameandroid.permission.CAMERA / uses-permission android:nameandroid.permission.INTERNET /CAMERA 权限是给内置 CameraX 用的如果只做外接 UVC也需要保留因为很多设备会要求相册/相机权限来控制曝光参数。最后一个关键点如果你需要把拍照或录制的文件保存到外部存储Android 10 以后强烈建议用 MediaStore或者直接访问应用专属目录getExternalFilesDir不要直接用 FileProvider 拼接content://com.tencent.wework.fileprovider/...那种路径那样会在不同应用之间出现权限错乱。安全合规的做法是拍照保存到自己应用的external_path/android/data/包名/下再通过 MediaStore 插入系统媒体库。3. 核心实现UVC 摄像头直连方案这一部分是整篇文章的重头戏。我会用 Kotlin 写一个可运行的最小实现从 USB 设备授权到画面显示全部覆盖。3.1 USB 设备发现与权限申请Android 中要访问 USB 外设首先要通过UsbManager拿到设备实例。UVC 摄像头的vendorId和productId不固定所以我采取“扫描所有设备挑选接口类型包含 Video”的策略class UvcCameraHelper(private val context: Context) { private val usbManager: UsbManager context.getSystemService(Context.USB_SERVICE) as UsbManager fun findUvcCamera(): UsbDevice? { val devices usbManager.deviceList.values for (device in devices) { // 判断是否为 UVC 设备通常有一个接口类是 0x0EVideo val interfaceCount device.interfaceCount for (i in 0 until interfaceCount) { val usbInterface device.getInterface(i) if (usbInterface.interfaceClass UsbConstants.USB_CLASS_VIDEO) { return device } } } return null } fun requestPermission(device: UsbDevice, callback: (Boolean) - Unit) { if (usbManager.hasPermission(device)) { callback(true) return } val intent Intent(context, UsbPermissionReceiver::class.java) // 这里使用 BroadcastReceiver 回调 PendingIntent.getBroadcast( context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ).let { pendingIntent - usbManager.requestPermission(device, pendingIntent) } } }在UsbPermissionReceiver里你会在ACTION_USB_PERMISSION回调中拿到授权结果。这里有个坑部分设备在授权后需要重新获取一次UsbDevice实例因为旧实例可能已经失效。实际操作中我建议在onResume时重新扫描设备并且在 USB 拔插时监听ACTION_USB_DEVICE_ATTACHED和ACTION_USB_DEVICE_DETACHED这样插拔体验才会自然。3.2 打开设备与启动预览拿到 USB 权限以后就要打开 UVC 设备了。这里我推荐用开源的UVCCamera库它封装了 libuvc 的复杂逻辑class UvcCameraController( private val context: Context, private val surface: Surface ) { private var camera: UVCCamera? null fun open(usbDevice: UsbDevice) { // 拿到 UsbDeviceConnection val usbManager context.getSystemService(Context.USB_SERVICE) as UsbManager val connection usbManager.openDevice(usbDevice) ?: throw IllegalStateException(无法打开 USB 设备) camera UVCCamera() camera?.open(connection) camera?.setPreviewSize(1280, 720, UVCCamera.FRAME_FORMAT_MJPEG) camera?.setPreviewDisplay(surface) camera?.startPreview() } fun release() { camera?.stopPreview() camera?.destroy() camera null } }关键在于setPreviewSize的三个参数第一个是宽度第二个是高度。我实测 1280x720 是兼容性和性能最均衡的选择如果你想做 4K 输出很多 UVC 摄像头根本不支持会直接 throwRuntimeException。所以最好先枚举设备支持的分辨率再选择最接近目标值的。第三个是帧格式。FRAME_FORMAT_MJPEG是最通用的因为大部分 UVC 摄像头都输出 MJPEG如果摄像头支持 YUV420也可以选FRAME_FORMAT_YUYV但帧率会低不少。3.3 帧回调与数据转换如果只是显示画面直接用setPreviewDisplay就够了。但做扫码、图像识别、OpenCV 分析你就需要拿到每一帧原始数据。UVCCamera 提供了帧回调接口camera?.setFrameCallback { frame - val bytes frame.cloneBytes() // 这里 bytes 就是 MJPEG 或 YUV 数据 // 如果库里设置的是 MJPEG需要先解码成 Bitmap val bitmap BitmapFactory.decodeByteArray(bytes, 0, bytes.size) // 传给分析线程 analyzeBitmap(bitmap) }, UVCCamera.PIXEL_FORMAT_MJPEG这里有一个很容易踩的坑UVCCamera.PIXEL_FORMAT_MJPEG表示回调数据是 MJPEG 编码而PIXEL_FORMAT_YUV420SP表示 NV21 原始数据。如果你用 MJPEG那decodeByteArray可以直接得到 Bitmap如果你用 NV21就得手动做YuvImage转 JPEG 再解码或者直接用 RenderScript/OpenGL 转换。实测下来在性能敏感的场景比如实时二维码识别我建议用 MJPEG BitmapFactory它比 YUV 转换要快。代价是 MJPEG 压缩率较低带宽占用大但 720p 的 MJPEG 在 USB 2.0 上传带宽内完全没有问题。帧回调是在单独的线程里执行的如果你要做耗时操作记得自己开线程池不要在回调里直接做网络上传或深度学习推理否则掉帧会非常严重。3.4 显示方案的选择与实现UVC 画面可以显示在SurfaceView或TextureView上。我的经验是如果对实时性要求高比如视频通话、直播、扫码用SurfaceView它独享一个 Surface有独立的绘制线程性能最好。如果需要对画面做缩放、平移、加滤镜、和 UI 混合展示用TextureView它本质上是普通 View可以参与动画、透明度处理但性能略低于 SurfaceView。一个复杂的现实是CameraX 的内置摄像头预览通常用PreviewView内部基于 TextureView 或 SurfaceView而 UVC 摄像头如果你也想用TextureView就存在双路 TextureView 的叠加问题。我会在下一章专门讲双摄架构怎么设计。4. CameraX 在内置摄像头场景的配合使用4.1 CameraX 预览与录像的基础实现与 UVC 互补很多实际项目是“外接 UVC 摄像头 内置摄像头”同时存在比如一台巡检终端既要用前置摄像头拍操作员又要用 USB 内窥镜拍设备内部。这时候 CameraX 依然负责内置摄像头的预览、录像、拍照而 UVC 摄像头负责外接输入两者并行工作。CameraX 侧的基础代码和正常项目没什么区别val processCameraProvider context.getSystemService(ProcessCameraProvider::class.java) val preview Preview.Builder() .setResolutionSelector( ResolutionSelector.Builder() .setResolutionStrategy( ResolutionStrategy( Size(1280, 720), ResolutionStrategy.FALLBACK_RULE_CLOSEST_HIGHER_THEN_LOWER ) ).build() ) .build() preview.setSurfaceProvider(previewView.surfaceProvider) processCameraProvider.bindToLifecycle( lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview )需要注意如果内置摄像头和后置摄像头都被某个模块占用再想去 bind 另一个摄像头系统可能会抛CameraException因为同一时刻一个物理摄像头只能被一个客户端占用。而 UVC 摄像头走的是独立 USB 链路和 Camera2 不冲突可以随意和内置摄像头并行这刚好是 UVC 方案最大的优势。4.2 双路相机协同架构同时管理内置 CameraX 和外接 UVC我建议在项目里抽象出两个控制器不要混在一坨sealed class CameraSource { data object BuiltIn : CameraSource() data class External(val device: UsbDevice) : CameraSource() } class CameraManager(private val context: Context) { private val builtInController CameraXController(context) private val externalController UvcCameraController(context) fun start(source: CameraSource, surface: Surface) { when (source) { is CameraSource.BuiltIn - builtInController.start() is CameraSource.External - externalController.open(source.device, surface) } } }界面层用一个主画面显示 UVC一个 PiP 小窗显示内置 CameraX或者反过来。总之双路画面在界面层是纯 View 的叠加不存在数据竞争的问题。我实际完成过一个项目一台带 8 寸屏幕的平板前置摄像头拍人脸用于活体检测同时 USB 摄像头拍摄纸质文档用于 OCR。两者的帧率都维持在 25fps 以上关键是内置摄像头用 CameraX 的ImageAnalysis做活体检测分析线程和预览线程分离。UVC 摄像头只用setFrameCallback取帧不显示内置预览减少 GPU 合成压力。两个摄像头共用同一个生命周期通过ProcessLifecycleOwner控制资源释放。也就是说CameraX 和 UVC 不存在谁替代谁的问题它不是用来驱动外接摄像头的而是用来管理和内置摄像头相关的上层能力的。两者各司其职在整个系统里扮演不同的角色。5. 性能、兼容性与体验优化5.1 分辨率、帧率与格式的平衡UVC 直连方案最大的性能瓶颈在帧数据搬运和编解码。拿 1080p MJPEG 来说每帧大小可能 200KB 到 500KB30fps 就是 6MB/s 到 15MB/s 的数据量。虽然 USB 2.0 的理论带宽是 480Mbps但 Android 设备上实际可用带宽往往打七折而且同一时间 USB 总线还要管鼠标键盘等设备所以我的建议扫码、文档扫描、视觉定位720p15fps MJPEG足够CPU 占用低。视频通话、直播720p30fps MJPEG如果可以选 H.264 更好但 UVCCamera 对 H.264 支持不稳定。工业检测、AI 识别1080p15fps MJPEG或YUV42030fps视识别模型输入尺寸而定。帧率的测试方法也简单在setFrameCallback里统计每秒回调次数打印出来连续观察一分钟。这里再说一个参数选择技巧不要一上来就setPreviewSize(1920, 1080)先用 PictureSize 枚举接口把摄像头支持的所有分辨率打出来选一个和你目标分辨率最接近的。强制上传摄像头不支持的分辨率轻则打不开重则花屏卡死。5.2 NDK 层处理与渲染优化思路到了性能调优阶段你会发现纯 Java/Kotlin 做逐帧处理很吃力。比如你要同时做人脸检测 图像增强Kotlin 层的 Bitmap 操作撑不住这时候有两个优化方向OpenGL 纹理上传把 UVC 帧直接上传到 GPU 纹理用 OpenGL Shader 做格式转换、滤镜、裁剪。UVCCamera 官方 Demo 里就有onFrameAvailable结合 GLSurfaceView 的做法省掉 CPU 到 GPU 的拷贝。NDK OpenCVC/C 里处理 YUV 数据、跑检测算法只把结果传回 Java。Android 上做图像分析的成熟方案基本都是这个路线。如果你只是想快速上线不追求极限性能Kotlin 层 BitmapFactory.decodeByteArray在 720p 下其实也能跑到 20fps 左右关键在于分析逻辑不要在主线程也不要在帧回调里同步等待。5.3 界面方向与画面比例适配UVC 摄像头画面普遍存在方向问题传感器默认的坐标系和 Android 竖屏不一致画面经常是转过 90 度的。解决方法是给TextureView做矩阵变换fun adjustOrientation(textureView: TextureView, angle: Float) { val matrix Matrix() val viewWidth textureView.width.toFloat() val viewHeight textureView.height.toFloat() matrix.postRotate(angle, viewWidth / 2f, viewHeight / 2f) textureView.setTransform(matrix) }但注意TextureView.setTransform只改变显示方向不会改变帧回调里的数据方向。如果你的算法是检测人脸角度、车牌方向等必须额外记录一个旋转角传给分析模块一起纠正。另外一个很常见的坑是动态切换横竖屏时TextureView会重新创建Surface 会失效UVC 关闭再重启会导致画面短暂黑屏。稳妥的做法是在SurfaceTextureListener.onSurfaceTextureDestroyed时只暂停预览不销毁 UVC 设备等 Surface 重新创建后再setPreviewDisplay并startPreview这样重新出画的速度会快很多。6. 常见问题排查与经验总结6.1 典型问题速查表我在多台设备和十几个 UVC 摄像头上实测整理了一张排查表可以帮你快速定位问题现象可能原因解决方案插入 USB 无反应设备不支持 UVCOTG 线质量问题换大厂 UVC 摄像头用带供电 HUB 测试授权弹窗不出现部分 ROM 对 USB 权限弹窗有特殊策略手动到系统设置里搜索“USB 权限”或直接用adb shell pm grant预授权授权后崩溃SecurityExceptionUsbDevice 实例失效在授权回调中重新usbManager.deviceList获取设备预览黑屏setPreviewDisplay传的 Surface 无效分辨率不受支持检查 TextureView Surface 是否已创建枚举分辨率后选择一个支持的画面花屏/偏色帧格式不匹配改为 MJPEG或确保回调格式和设置一致帧率低MJPEG 数据量太大分析线程阻塞降低分辨率将分析逻辑移到线程池拔掉摄像头后应用崩溃未在onDestroy停止预览并释放注册ACTION_USB_DEVICE_DETACHED收到广播就release()部分分辨率设置后无响应摄像头硬件不支持该模式使用getSupportedSize()枚举后选择拍照保存到相册后看不到Scoped Storage 路径问题使用 MediaStore 插入相册避免跨应用 FileProvider 路径拼接6.2 写在最后的私有经验上面这些内容都是我从一个个项目里“趟”出来的最后再分享几条不太会写在官方文档里的心得。第一USB 权限回调一定要放在 Activity/Fragment 的onResume之前完成注册。registerReceiver和unregisterReceiver配对使用时如果时机不对会出现“已经授权过但回调永远收不到”的诡异现象。安全的做法是广播接收器在 Application 或进程级注册不随 Activity 销毁。第二对 Android 12 及以上系统PendingIntent 要显式指定 FLAG_IMMUTABLE。如果不指定系统在创建通知或定位时可能因为可变性检查直接异常。第三尽可能把 UVC 的打开过程放到后台线程。UVCCamera.open(connection)内部会做 USB 控制传输部分设备耗时会到几百毫秒甚至一秒如果你在主线程里运行用户会看到明显卡顿甚至 ANR。我通常配合CoroutineScope(Dispatchers.IO)来做。第四不要过度信任 CameraX 的自动白平衡和曝光。外接 UVC 摄像头的曝光和白平衡是独立于 Android 系统的CameraX 的CameraControl管不到它。如果你的应用需要控制曝光、增益必须走 UVCCamera 自己的控制接口比如UVCCamera.setExposureMode()、UVCCamera.setWhiteBalance()等。第五把 UVC 库版本锁死。这类开源库更新频率低但一旦系统升级旧库可能在 Android 14 上出现 USB 新权限模型的问题。实测在 Android 13/14 上saki4510t/UVCCamera 1.0.12 版本可以正常工作但未来如果遇到问题要优先查UsbManager的权限模型变化而不是盲目升库版本。6.3 更进一步的扩展思路如果你觉得自己写 UVC 底层太麻烦市面上也有些商业库比如某些工业相机 SDK支持 Android ARM 平台底层封装好了 UVC 解码和控制但价格不便宜而且会捆绑定制的硬件。好处是稳定、文档全、技术支持快坏处是项目会绑定厂商生态。能接受的话在预算充足的项目里也是一个选项。不过对绝大多数场景开源的 UVCCamera CameraX 这套组合已经足够了。我用它做过扫码终端、内窥镜 App、USB 显微镜查看器、停车场道闸辅助识别稳定运行几周都很正常。核心还是那一句话CameraX 管内置UVC 库管外接两者各自发挥强项最后在界面层合流。如果后续你想做更复杂的多路摄像头拼接、AI 模型实时推理也可以从这一篇的基础架构继续延伸。比如把 UVC 帧数据送入 TensorFlow Lite 或 ONNX Runtime先做好格式转换和线程模型基本上就能跑通端侧识别。关于帧数据如何高效送进 TFLite以及如何避免内存抖动等下次有机会我再单独写一篇实战记录。