☰
Android UVC Camera实战:USB摄像头采集、JNI与YUV转码全解析
2026/10/19 7:58:38 网站建设 项目流程

简介:Android-UVC-Camera是一套面向Android开发者的USB摄像头接入示例工程,解决Android 4.0.4及以上设备通过OTG连接UVC摄像头并实时取流的问题,同时覆盖Micro USB与Type-C接口。工程基于Android Studio构建,包含Java上层调用与C/C++底层驱动适配,并集成LibUsb以提升标准USB驱动性能,适合需要做外接摄像头兼容性调试或硬件交互开发的中高级开发者参考;对部分OTG线缆供电不足导致识别失败的情况也给出了排查方向。压缩包共1447个文件、约9.67MB,主要涵盖c/h/cpp源码、java代码、xml配置文件、so库及gradle构建脚本,同时附带部分示例文档与脚本,便于快速梳理工程结构。整个包内目录层次分明,源码与构建配置分离,便于直接导入Android Studio进行二次开发。目前已有448人学习,通过该项目可了解UVC协议枚举、同步视频流处理、OTG接线异常排查等关键思路,是一份紧凑但完整的移植与调试参考。

1. 老手机还能这么用:Android-UVC-Camera 到底解决什么问题

你手上有一台吃灰的旧安卓手机,想把它变成一台能接 USB 摄像头做扫码、做检测、做远程画面的设备,结果发现安卓原生对 UVC(USB Video Class)的支持藏得很深,普通 App 根本拿不到摄像头数据。Android-UVC-Camera 这类工程就是为这个场景生的:用 C++ 在 native 层直接操作 USB 设备节点,绕开系统相机框架,把 UVC 摄像头输出的 YUV 帧拉回来,再转成能显示、能送进图像处理算法的 RGB 数据。它适合两类人:一是要快速搭一条 USB 摄像头数据通路的嵌入式或 Android 应用工程师,二是在做毕业设计或课程项目的学生,需要一套能跑起来的采集链路做二次开发。这套资源的核心价值不在 UI,而在那条从 USB 设备到应用层回调的 C++ 数据通道。

2. UVC 的前置逻辑:USB 权限、File Descriptor 与 JNI 桥接

2.1 为什么非要用 C++,而不是直接调 Java 层的 Camera2

安卓的 Camera2 API 只负责管理设备自带的摄像头模组,对 UVC 外接摄像头视而不见。Android 从 3.1 开始支持 USB Host 模式,但系统没有把 UVC 摄像头暴露成标准 Camera 设备,而是挂在 /dev/video 节点上。普通应用连这个节点的打开权限都拿不到,更别提控制传输、批量传输和等时传输这些 USB 协议细节。

解法的核心就是:用 UsbManager 完成设备授权,拿到 FileDescriptor,通过 JNI 传给 C++ 层,然后在 native 代码里基于 libuvc 或 V4L2 做帧采集。YUV 帧从 USB 端点进到内核缓冲区,再由 libuvc 的传输回调拷贝到 C++ 分配的内存块里,最后把内存地址和 stride 传给 Java/Kotlin 层做渲染。这套链路里 C++ 的价值体现在三点:

  • 内存零拷贝:帧缓冲在 native 层申请,直接传给 SurfaceTexture 或 Bitmap,不走 Java 层的 byte[] 拷贝。
  • 大批量数据吞吐:UVC 的 isochronous 传输每帧可能达到几 MB,Java 层的数组操作在性能上撑不住。
  • 复用 libuvc 生态:协议解析、视频格式协商、各类控制单元(PU/CT)的查询都已有实现,不用从头写。

常见的做法是 clone libuvc 源码到工程里,用 CMake 组织 ndk-build,在 JNI_OnLoad 里初始化上下文。我一般会建议把 libuvc 编成静态库,方便后续裁剪,只保留 streaming 和 control 两部分。

2.2 JNI 桥的搭建:从 UsbDeviceConnection 拿到 fd 开始

在 Java 层,你需要先请求宿主 USB 权限,这一步是后续一切操作的前提。下面这段示例展示的是最小权限流程:

UsbManager usbManager = (UsbManager) getSystemService(Context.USB_SERVICE); HashMap<String, UsbDevice> deviceList = usbManager.getDeviceList(); UsbDevice device = null; for (UsbDevice d : deviceList.values()) { if (d.getVendorId() == 0x1234 && d.getProductId() == 0x5678) { device = d; break; } } if (device != null && !usbManager.hasPermission(device)) { PendingIntent pi = PendingIntent.getBroadcast(this, 0, new Intent("com.example.USB_PERMISSION"), PendingIntent.FLAG_MUTABLE | PendingIntent.FLAG_UPDATE_CURRENT); usbManager.requestPermission(device, pi); }

拿到权限后,调用usbManager.openDevice(device)得到 UsbDeviceConnection,再通过connection.getFileDescriptor()拿到 native fd。注意:Android 10 及以上版本建议用connection.getNativeContext()或封装好的 ParcelFileDescriptor 方式传 fd,因为直接用 int fd 在某些 ROM 上会因为权限校验失败而无法 open 底层节点。传 fd 到 native 层的 JNI 签名如下:

JNIEXPORT jlong JNICALL Java_com_example_uvc_UVCNative_nativeOpen(JNIEnv *env, jobject thiz, jint fd) { int uvc_fd = (int) fd; uvc_context_t *ctx; uvc_device_t *dev; uvc_error_t ret = uvc_init(&ctx, NULL, NULL); if (ret != UVC_SUCCESS) return (jlong) 0; ret = uvc_wrap_fd(ctx, uvc_fd, &dev); if (ret != UVC_SUCCESS) { uvc_exit(ctx); return (jlong) 0; } uvc_device_handle_t *devh; ret = uvc_open(dev, &devh); if (ret != UVC_SUCCESS) { uvc_unref_device(dev); uvc_exit(ctx); return (jlong) 0; } return (jlong) devh; }

uvc_wrap_fd的关键作用是把已经 open 过的 fd 包装成 libuvc 内部设备对象,避免再走一遍 USB 枚举。这里有一个隐藏坑:如果 Java 层只传了 fd,而没有保持 UsbDeviceConnection 对象存活,C++ 层会用着用着突然丢帧。原因是底层 UsbDeviceConnection 被 GC 回收时会把 fd 一并关闭。

2.3 数据通道的配置:设备描述符与最佳分辨率枚举

libuvc 初始化完成后,要拿到设备当前的流接口描述。USB 摄像头通常提供多个配置描述符,每个描述符里又包含多个接口,接口里又有多个端点。最常见的坑是:摄像头默认的配置可能是 0x01,但里面不一定包含你要的分辨率。所以要先轮询设备支持的所有格式:

const uvc_format_desc_t *fmt_desc = uvc_get_format_descs(devh); while (fmt_desc) { ALOGD("format: %s", fmt_desc->name); const uvc_frame_desc_t *frame_desc = fmt_desc->frame_descs; while (frame_desc) { ALOGD(" frame: %dx%d", frame_desc->wWidth, frame_desc->wHeight); frame_desc = frame_desc->next; } fmt_desc = fmt_desc->next; }

实际项目中,很多廉价 UVC 模组会虚报支持 4K,但受限于 USB 2.0 带宽,实际等时传输只能扛住 1080p@30fps 或 720p@60fps。枚举结果只能作为参考,最终要结合带宽算一下:USB 2.0 高速模式的等时带宽上限约 480 Mbps,扣掉协议开销实际可用约 400 Mbps,一条 YUV422 1080p@30 的裸流带宽约为 1920×1080×2 字节×30 fps,已经接近 100 Mbps,加上可分帧头,两条并发就容易翻车。所以我的习惯是:能枚举出来是一回事,跑起来稳定是另一回事,最终选型优先考虑 MJPEG 格式做 1080p 以上分辨率,YUV422 留给 720p 以内。

3. 把 C++ 工程跑起来:四步集成与核心参数对照

3.1 目录结构与 CMake 配置的最小落地版

一个可复现的 Android-UVC-Camera 项目,建议按下面这个目录组织:

app/src/main/ ├── cpp/ │ ├── CMakeLists.txt │ ├── uvc_native.cpp │ ├── uvc_native.h │ └── libuvc/ # 第三方库源码或预编译静态库 │ ├── src/ │ ├── include/ │ └── cmake/ └── java/com/example/uvc/ ├── UvcCamera.java ├── UvcGLSurfaceView.java └── MainActivity.java

CMakeLists.txt 里关键配置只有三处:指定 C++17、链接 libuvc 静态库、设置-fexceptions -frtti。前者保证 STL 容器和 lambda 可用,后者是因为部分 libuvc 版本在异常处理上有依赖。注意不要在 CMake 里盲目打开-O3,我踩过坑:某些 ARM 设备上 -O3 会让 NEON 优化代码在边界处理上行为异常,建议 release 用-O2。

3.2 初始化与开始推流的最小 Kotlin 调用链

Java/Kotlin 侧职责很薄,只做三件事:申请 USB 权限、把 fd 交给 native、把 native 返回的 surface 或 buffer 地址接进来。下面的 Kotlin 代码演示了最简调用序列:

class UvcCamera(private val connection: UsbDeviceConnection) { private val nativeHandle: Long init { System.loadLibrary("uvc_native") val fd = connection.fileDescriptor nativeHandle = nativeOpen(fd) if (nativeHandle == 0L) { throw IllegalStateException("native open failed") } } fun startPreview(surface: Surface, width: Int, height: Int) { val format = 0x12 // 0x12 = YUYV, 0x4d = MOTION-JPEG val fps = 30 nativeStart(nativeHandle, width, height, format, fps, surface) } fun stopPreview() { nativeStop(nativeHandle) nativeClose(nativeHandle) } private external fun nativeOpen(fd: Int): Long private external fun nativeStart( handle: Long, width: Int, height: Int, format: Int, fps: Int, surface: Surface ): Boolean private external fun nativeStop(handle: Long) private external fun nativeClose(handle: Long) }

外部函数声明里的最后一个参数直接传 Surface,对应的 JNI 实现里可以用ANativeWindow_fromSurface把 Java Surface 转成 native 窗口,然后让 libuvc 的回调直接把 YUV 通过ANativeWindow_acquire、ANativeWindow_lock写入 buffer。这样做的性能风险在于:每帧都要 lock/unlock 的耗时,在低端设备上有三五毫秒的波动。如果检测到掉帧率超过 5%,建议改走 OpenGL 纹理或直接导出 buffer 让上层 RenderScript 处理。

3.3 推流格式与参数对照表

下面这张表覆盖了我在多个模拟项目里验证过的参数对应关系,硬件平台为常见 USB 摄像头模组,宿主为市面主流中端 Android 手机:

摄像头类型推荐格式分辨率档位实际帧率表现注意事项
USB 2.0 UVC 720p 模组YUYV1280x72028-30 fps稳定,带宽余量充足
USB 2.0 UVC 1080p 模组MJPEG1920x108024-30 fpsYUYV 下 1080p 必花屏
免驱 4K 摄像头MJPEG2560x144010-15 fpsisochronous 带宽成瓶颈
低成本 VGA 模组YUYV640x48030 fpsCPU 占用最低,适合做算法验证

关键参数是uvc_stream_ctrl_t里的bmHint、dwFrameInterval和dwMaxVideoFrameSize。libuvc 的uvc_get_video_mode函数会自动填充,但部分设备返回的dwMaxVideoFrameSize偏小,会截断帧尾导致颜色条纹。我一般会在这个值上再加+ 64字节的余量,原因是有些设备的 frame payload header 占 12 字节,描述符头占 52 字节,加上可能的填充字节,默认值不够用。

4. 字节流到显示帧:YUV 转码、帧同步与 ANativeWindow 输出

4.1 libuvc 回调里做转码,还是外面另开线程

uvc_start_streaming注册的回调函数是跑在采集线程里的,这个线程不能做重活,否则下一次 USB 等时传输的数据会被内核丢弃,表现为偶发花屏。所以标准做法是双缓冲:

static void uvc_frame_callback(uvc_frame_t *frame, void *user_ptr) { // 快速逻辑:把帧数据拷贝到环形缓冲,标记 available FrameBuffer *fb = static_cast<FrameBuffer*>(user_ptr); memcpy(fb->buffers[fb->write_index], frame->data, frame->data_bytes); fb->write_index = (fb->write_index + 1) % kBufferCount; // 唤醒转码线程 fb->frame_ready.signal(); }

转码线程独立运行,一旦收到frame_ready信号就取出缓冲,执行 YUV422 到 RGB565 或 RGBA8888 的转换。这里为什么不一帧一拷贝?因为直接 memcpy 一块大小为 1920×1080×2 的 buffer 耗时约 3-5ms,看起来不多,但叠加在摄像头本身的采集抖动上,会让平均帧间隔从 33ms 跳到 40ms,肉眼能感觉到卡顿。双缓冲的方式用时间换空间,把拷贝耗时挪到与转码并行的阶段,实效更好。

4.2 YUV422 转 RGBA 的 NEON 加速写法

转码是整个链路里 CPU 消耗最大的部分。如果不对齐处理,一个 720p YUYV 帧每像素两次查表,在骁龙 6 系列上能吃掉 40% 的 CPU。真实工程里我习惯直接上 ARM NEON,下面是 16 字节对齐的块内 YUYV 转 RGBA 关键代码:

#include <arm_neon.h> void yuyv_to_rgba_neon(const uint8_t* yuyv, uint8_t* rgba, int width, int height) { int total_pixels = width * height / 8; for (int i = 0; i < total_pixels; i++) { uint8x8x2_t yuyv_pixels = vld2_u8(yuyv); uint8x8_t y = yuyv_pixels.val[0]; uint8x8_t uv = yuyv_pixels.val[1]; int16x8_t y16 = vreinterpretq_s16_u16(vmovl_u8(y)); int16x8_t uv16 = vreinterpretq_s16_u16(vmovl_u8(uv)); int16x8_t r = vaddq_s16(y16, vshrq_n_s16(vmulq_n_s16(uv16, 1402), 10)); int16x8_t g = vsubq_s16(vsubq_s16(y16, vshrq_n_s16(vmulq_n_s16(uv16, 344), 10)), vshrq_n_s16(vmulq_n_s16(uv16, 714), 10)); int16x8_t b = vaddq_s16(y16, vshrq_n_s16(vmulq_n_s16(uv16, 1772), 10)); uint8x8_t r_u8 = vqmovun_s16(r); uint8x8_t g_u8 = vqmovun_s16(g); uint8x8_t b_u8 = vqmovun_s16(b); uint8x8x4_t rgba_pixels = { r_u8, g_u8, b_u8, vdup_n_u8(255) }; vst4_u8(rgba, rgba_pixels); yuyv += 16; rgba += 32; } }

这段代码做了个折中:没有按标准 BT.601 全展开系数处理,而是用移位近似替代乘法,牺牲一点色彩精度换吞吐量。实测视觉上差别不大,适合预览场景。如果将来要送进机器视觉算法,建议改成查表法配合vld4_u8的 RGBA 连续写,避免相邻行像素错位。注意 NEON 版本只能处理宽度为 16 的倍数,最后不足 16 像素的尾巴用普通 C 循环补齐,否则会越界访问。

4.3 帧同步上常见的两个翻车点

很多同学在接入 Surface 输出后,反馈画面延迟越来越高。问题往往是 latency 累积:UVC 摄像头自带 3-5 帧缓冲,Android Surface 又带缓冲队列,如果转码线程没有丢帧策略,队列就会积压。我一般会在转码线程入口加一个时间戳判断:if (now - fb->last_present_time > 66ms) { drop current frame; },让延迟不超过两帧。另一个翻车点是设备横竖屏切换后,Surface 尺寸变了,但 native 层还在按旧宽高锁 buffer,导致花屏或者闪退。最简单处理是停止推流,重新申请 surface,再重新 start。

5. 避坑清单:分辨率、带宽与设备兼容性的排查路径

5.1 摄像头枚举到了却打不开设备节点

现象:Java 层usbManager.getDeviceList()能看到设备,但nativeOpen返回 0,logcat 报Permission Denied。

原因:最常见的是 UsbDeviceConnection 被 GC 回收。另一个常见原因是设备在 Android 12 以上需要UsbManager.getDeviceList()返回的 device 对象和 connection 完全匹配,部分国产 ROM 对 USB 设备的劫持策略会在 openDevice 后立刻关闭 fd。

解决:首先确认 Java 层保存了 connection 引用,并且定义在 Activity 字段而非局部变量。其次,在 native 层 open 之前用uvc_unref_device清理掉旧设备引用。如果定位到是 ROM 限制,换用 OTG 扩展口或改走adb shell dumpsys usb查看设备状态。这类问题不是写代码能解决的,属于硬件权限的边界。

5.2 请求 1080p 只出 720p,或者直接超时

现象:start 传入 1920x1080,摄像头实际输出 1280x720,或者干脆返回 UVC_ERROR_INVALID_MODE。

原因:部分摄像头固件对dwMaxVideoFrameSize的协商逻辑有 bug,libuvc 用标准 UVC 协商请求时,设备返回的dwFrameInterval不符合预期。另一个原因是带宽估算差异:某些板载 USB Hub 实际可用带宽打七折,1080p YUYV 在带宽不足时设备会静默降级。

解决:在枚举格式的日志里看dwFrameInterval是否真的匹配 30fps,然后用uvc_set_video_mode强制设置。如果仍然失败,改为 MJPEG 格式推 1080p。MJPEG 帧是 JPEG 压缩流,带宽占用比 YUYV 低一半以上,代价是解码 CPU 开销上升,综合收益仍可观。

5.3 画面色彩偏绿,颜色整体不对

现象:帧能推出来,但画面像蒙了一层绿纱,或者红绿互换。

原因:摄像头默认输出格式很可能不是 YUYV,而是 NV12 或 NV21 或 BMP 格式。不少免驱摄像头在 Windows 下是 YUYV,但在 Android 上协商出的格式却是 NV12。libuvc 返回的frame->frame_format字段才是真实格式,而不是你传入格式。

解决:在回调里先判断frame->frame_format,如果是UVC_FRAME_FORMAT_NV12,走 NV12 转 RGBA 的专用函数。我还会把回调收到的第一帧原始数据 dump 前 64 字节,检查头部是否带 JPEG 起始标记0xFFD8,有就是 MJPEG 流,没有再看 YUV 排列。这一步排查花 5 分钟,能省下一整天玄学调试。

5.4 手机一插摄像头就崩溃重启

现象:应用启动后,插入摄像头,系统直接软重启或 App 闪退,日志里只有Fatal signal 11 (SIGSEGV)。

原因:多数情况是纹理尺寸未对齐。Android Surface 的 buffer 在 hardware renderer 下需要宽高为 2 的幂次或对齐到 16 的倍数,libuvc 返回的宽高本来可能是偶数,但转码输出到 RGBA 时行宽没有按ANativeWindow_Buffer.stride填,导致写了越界内存。

解决:在ANativeWindow_lock拿到 buffer 后,用buffer->stride计算每行实际写入宽度,而不是用传入的 width。另外,固定使用 RGBA8888 格式,避免 RGB565 因为字节对齐不一致导致的偶发崩溃。

5.5 后台黑屏,前台正常,切回来要重插

现象:App 切到后台再切回来,画面停在最后一帧,必须拔插摄像头才能恢复。

原因:Android 系统在应用退到后台会回收 Surface 对应的 buffer,ANativeWindow 失去渲染目标。libuvc 线程没有感知,继续往旧 Surface 写数据,写也不报错,但画面已经没了。

解决:正确做法是在 onPause 时 stopPreview,onResume 时重新 start。非要后台保持推流的话,用 TextureView + OpenGL FBO 离屏缓冲来代替 Surface 直接输出,这样后台渲染到 FBO 再贴回 TextureView,绕开系统 Surface 回收机制。不过这个方案对生命周期处理要求较高,我一般建议除非有硬需求,否则就按前一种方案处理。

6. 验证到位的三条路:帧率计算、CPU 侧写与热插拔恢复

资源跑起来还不算完,至少要验证三个指标:真实帧率、CPU 占用率、热插拔稳定性。真实帧率不能靠Thread.sleep估算,我一般用系统时间戳累计:每 100 帧记录一次耗时,算出滑动平均 FPS。如果 FPS 低于设定的 80%,优先检查转码线程是否成了瓶颈——用systrace抓取采集线程、转码线程和 UI 线程的 CPU 分布,正常情况下转码线程应占30%-45%,再高就要考虑 NEON 优化或降分辨率。

CPU 侧写用perf工具或者 Android Studio 内置的 CPU profiler 都可以。看两个函数:一是uvc_handle_events,它控制 USB 事件分发,如果耗时高说明带宽不够,内核在丢 urb;二是yuyv_to_rgba_neon,如果耗时超过 15ms,就要把 720p 降到 640x480,或者改用硬件编码的 MJPEG 格式。我自己的验收标准是:720p@30fps 下转码线程 CPU 占用不超过 35%,整体 App 内存占用不超过 250MB。

热插拔验证是个体力活。我习惯写一个 10 分钟的自动化脚本,每 30 秒执行一次adb shell dumpsys usb检查设备状态,再模拟拔插 50 次,统计恢复成功率。关键点在 native 层注册usb_device_attach/detach广播监听,detach 时立刻停止流线程并释放 buffer,attach 时重新枚举设备并自动恢复到最后使用的分辨率。如果你拿到的是只做了采集功能的版本,注意看它有没有处理uvc_close后的 fd 释放。有的项目会在nativeStop里直接调 close,但如果 Java 层 UsbDeviceConnection 还握着 fd,第二次 open 会失败。正确顺序是先通知 native 释放 fd,再让 Java 层关 connection。

从那以后,我每次接一个新摄像头模组,都会强制走一遍枚举格式、带宽预算、压测 30 分钟这三步流程,确认没有隐藏风险再交到上层。这套动作看着啰嗦,但能帮你把大部分 USB 摄像头的脾气摸清楚,后面接任何厂家的模组都有底气。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询