简介:这是一套面向Android设备接入UVC摄像头场景的C++/Java混合开发工程,专为需要通过OTG连接外部USB摄像头并同步采集视频流的开发者设计,支持Android 4.0.4以上及部分MTK等非典型设备。压缩包共1447个文件、大小9.67MB,代码结构以C/C++源码、Java业务逻辑、XML配置与布局、PNG资源及JNI/汇编实现为主,同时包含构建脚本、工具组件和文档说明,并引入LibUsb优化USB驱动性能,整体目录划分清晰,适合中高级嵌入式或Android系统开发人员二次改造。已有448人浏览学习。借助该项目可梳理USB摄像头驱动的加载逻辑、OTG链路建立及视频流同步方案;包内附带的libjpeg等源码,还能帮助理解图像编码输出环节。项目虽标注仍在开发中,但多设备适配思路、LibUsb集成方式以及完整目录结构,对研究Android外设接入和底层图像传输仍有实用参考价值。
1. Android-UVC-Camera 到底解决什么问题:给 Android 插上一颗 USB 摄像头
Android-UVC-Camera 这个项目,说白了就是让 Android 设备能直接用 USB 免驱摄像头出画面。做行业应用开发的应该都有体会:产品经理丢过来一个 USB 摄像头,要求在安卓平板上实时预览,你打开 Camera2 API 一查,设备列表里空空如也。原因在于 Android 的 Camera HAL 只暴露了板载摄像头,外部 USB 摄像头在 Linux 内核里虽然存在驱动,但普通应用根本够不着那条链路。这个项目用 C++ 把 UVC 协议栈搬进 Android,在 native 层直接操作 usbfs,把描述符解析、格式协商、等时传输全部接了起来,再把 YUV 帧通过 JNI 抛给 Java 层。简单讲,它是 Android 上接入 UVC 摄像头的一套完整可复用工程脚手架,适合两类人:一类是行业应用开发,要接各种 USB 工业相机、会议摄像头;另一类是想搞懂 UVC 协议和 JNI 数据通路怎么配合的人。
2. 工程结构与技术选型:C++ 干粗活、Java 管权限、JNI 当桥梁
2.1 三个模块的分工:app、usbCamera、libuvc 各管一段
整个工程按三层来切,目录结构大致长这样:
Android-UVC-Camera/ ├── app/ # 示例应用:预览、拍照、录像 ├── usbCamera/ # Java 封装层:设备发现、权限申请、帧回调 │ ├── src/main/java/ # UvcCameraManager、IFrameCallback 等 │ └── src/main/jni/ # C++ 代码:JNI 桥 + libuvc 移植 ├── libuvc/ # UVC 协议栈 C 源码,含 libusb 依赖 │ ├── src/ # uvc.c、ctrl.c、stream.c、frame.c │ ├── include/ # libuvc 头文件 │ └── android/ # libusb 的 Android 后端适配 └── CMakeLists.txt # native 构建脚本app 模块只做一件事:调用 usbCamera 暴露的接口,把 SurfaceView 或 TextureView 的 Surface 传下去,然后接收预览帧。它不关心 UVC 协议细节,哪怕后面把底层换成别的采集方案,app 层可以完全不动。usbCamera 是 Java 和 C++ 的边界,负责所有和 Android 系统相关的脏活——UsbManager 权限、设备连接、帧数据回传。真正的协议解析全在 libuvc 里,这个模块是独立的 C 库,不依赖 Android 的任何 API,所以拉到 PC 上也能编。
这样分层的好处很实际:libuvc 是上游社区维护的,升级只需要替换源码目录;JNI 层只做翻译,不掺业务逻辑;app 层面向接口编程。如果你要二次开发,大部分需求都落在 usbCamera 这一层,不需要动 C++。
2.2 为什么底层必须用 C++:UVC 的视频流不走普通 USB 通道
很多第一次接触 UVC 的人会问:Java 层不是有 UsbDeviceConnection 吗,为什么不能直接拿它读数据?这里有个关键限制——UVC 视频流走的是 isochronous 等时传输端点,这种端点的特点是带宽预留、数据按帧周期性到达,适合视频这种对延迟敏感但对丢包容忍度高的场景。Android 的 Java API 对等时传输的支持非常有限,UsbDeviceConnection 暴露的 bulkTransfer、controlTransfer 都碰不到 isochronous 的完整能力。而 libusb 在 native 层把这些事都做完了,它会直接操作 Android 内核的 usbfs 接口,在设备描述符里解析出端点和备用设置,把等时传输包一个一个收回来。
另一个选择 C++ 的原因是性能。一帧 720p 的 YUYV 原始数据大约是 1.4MB,30 帧每秒就是 42MB 的吞吐。如果每一帧都在 Java 层 new 一个 byte[] 再 System.arraycopy,GC 压力会非常大,掉帧是必然的。C++ 层可以预分配好内存,用 DirectByteBuffer 直接把数据写到 Java 侧能访问的堆外内存里,全程零复制。这个项目从诞生起就把 C++ 作为核心,不是炫技,是 UVC 协议的传输特性决定的。
libuvc 这个库解决的是协议解析问题。UVC 1.1 规范有上千页,里面定义了视频控制接口、视频流接口、各种描述符和请求的格式。自己从零实现一遍要按周计算工作量,而 libuvc 已经实现了设备枚举、格式协商、流控制、帧数据重组这些核心功能,同时它本身就是基于 libusb 写的,和 Android 的 native 层天然契合。项目里要做的不是重写,而是把 libusb 的 Android 后端接进来,再包一层 JNI。
2.3 JNI 方法对照表:Java 和 C++ 的接口边界定在哪
JNI 接口是整个工程的命门,接口定得好不好,直接决定上层能不能顺畅地二次开发。这个项目把 native 方法收敛成了四个:
| Java 方法 | Native 实现 | 职责 |
|---|---|---|
| nativeConnect(fd, vid, pid) | nativeConnect | 用 fd 接管设备,初始化 libusb 和 libuvc |
| nativeStartStream(width, height, fps) | nativeStartStream | 协商视频格式,启动拉流和帧回调 |
| nativeStopStream() | nativeStopStream | 停止拉流,释放流相关资源 |
| nativeRelease() | nativeRelease | 关闭设备,释放所有 native 资源 |
接口尽量少,是因为 C++ 和 Java 之间每次调用都要跨 JNI 边界,参数越多、签名越复杂,出错的概率越大。fd、vid、pid 是定位一个 USB 设备的最小信息集;width、height、fps 是启动一条视频流的完整参数。剩下的所有细节,全被封装在 C++ 内部。
这里有个重要的约定:帧回调发生在 native 线程,不是主线程。Java 侧收到 onFrame 回调时,不能直接碰任何 View 或者 Surface,必须通过 Handler 切到主线程再做 UI 操作。这个边界在项目的 IFrameCallback 接口注释里有醒目标记,二次开发时最容易翻车的也是这一点。
3. 核心链路拆解:从 USB 权限申请到第一帧 NV21 画面
3.1 Java 层拿下 USB 权限,把设备 fd 交给 native
所有 USB 外设在 Android 上都要先过权限这一关。UsbManager 会在设备插上时广播 ACTION_USB_DEVICE_ATTACHED,但真正能收发数据前,必须拿到用户的授权。这个授权是异步的,通过 PendingIntent 回调回来。关键代码如下:
public void attach(UsbDevice device) { UsbManager usbManager = (UsbManager) context.getSystemService(Context.USB_SERVICE); if (device == null || !usbManager.hasPermission(device)) { PendingIntent pi = PendingIntent.getBroadcast(context, 0, new Intent(ACTION_USB_PERMISSION), PendingIntent.FLAG_MUTABLE); usbManager.requestPermission(device, pi); return; } UsbDeviceConnection connection = usbManager.openDevice(device); if (connection == null) { Log.e(TAG, "openDevice failed, check USB host mode"); return; } int fd = connection.getFileDescriptor(); int ret = nativeConnect(fd, device.getVendorId(), device.getProductId()); if (ret != 0) { connection.close(); } }UsbDeviceConnection.getFileDescriptor() 返回的是内核 usbfs 设备节点的文件描述符。按常规思路,native 层拿到这个 fd 后,所有 USB 操作都可以基于它进行,Java 层的 connection 必须一直持有,不能提前关闭。很多人在这里犯过一个错误:nativeConnect 成功后觉得 Java 侧没用了,把 connection 置空,结果下一次操作时设备已经失效。正确的做法是,connection 的生命周期和整个摄像头会话保持一致,直到 nativeRelease 之后才 close。
3.2 C++ 层用 libusb 接管设备,挂上 UVC 上下文
JNI 这一侧的核心逻辑,是把 Java 传下来的 fd 包装成 libusb 能理解的设备句柄,再交给 libuvc。这里有个常见的坑需要避开:原生 libusb 在桌面平台上会自己扫描 /dev/bus/usb 下的设备,但 Android 应用没有权限访问那个目录,所以项目里必须禁用 libusb 的设备发现机制,改用 libusb_wrap_sys_device 直接包住已有 fd。
extern "C" JNIEXPORT jint JNICALL Java_com_demo_uvc_UvcCameraManager_nativeConnect( JNIEnv *env, jobject thiz, jint fd, jint vid, jint pid) { // 1. 初始化 libusb,关闭设备发现,设备由上层塞进来 libusb_context *usb_ctx = NULL; if (libusb_init(&usb_ctx) != LIBUSB_SUCCESS) return -1; libusb_set_option(usb_ctx, LIBUSB_OPTION_NO_DEVICE_DISCOVERY, NULL); // 2. 把 Java 层的文件描述符包装成 libusb 设备句柄 libusb_device_handle *dev_handle = NULL; if (libusb_wrap_sys_device(usb_ctx, fd, &dev_handle) != LIBUSB_SUCCESS) { libusb_exit(usb_ctx); return -2; } // 3. 初始化 UVC 上下文,复用同一个 libusb context uvc_context_t *uvc_ctx = NULL; if (uvc_init(&uvc_ctx, usb_ctx, NULL) != UVC_SUCCESS) { libusb_close(dev_handle); libusb_exit(usb_ctx); return -3; } // 4. 按 VID/PID 找到 UVC 设备,后续格式协商都基于它 uvc_device_t *uvc_dev = NULL; if (uvc_get_device(uvc_ctx, vid, pid, &uvc_dev) != UVC_SUCCESS) { uvc_exit(uvc_ctx); return -4; } return 0; }LIBUSB_OPTION_NO_DEVICE_DISCOVERY 这个选项是关键,禁用设备发现后,libusb 不会启动 hotplug 线程,也不会自己去扫描 USB 总线。这样做完全是因为设备发现、权限申请这种 Android 系统层面的逻辑,Java 的 UsbManager 已经做完了,native 层重复做反而会和系统服务抢设备,导致权限冲突。uvc_init 的第二个参数传的就是刚才创建的 libusb context,这样 libuvc 内部就不会再自己初始化一套 libusb,而是复用我们已经配置好的实例。这一步接对之后,后面所有 UVC 操作都顺理成章。
3.3 格式协商、帧回调和 YUYV 到 NV21 的转换
连接建立后,需要从设备支持的格式里挑一组参数。libuvc 提供了一个叫 uvc_get_stream_ctrl_format_size 的接口,负责把用户想要的分辨率、帧率、像素格式翻译成 UVC 协议里的格式索引和帧索引:
uvc_stream_ctrl_t ctrl; memset(&ctrl, 0, sizeof(ctrl)); int ret = uvc_get_stream_ctrl_format_size( uvc_dev, &ctrl, UVC_FRAME_FORMAT_YUYV, // 请求的像素格式 1280, 720, // 宽度、高度 30 // 帧率 ); if (ret != UVC_SUCCESS) { // 设备不一定支持这个组合,需要降级重试 }uvc_stream_ctrl_t 比较重要的字段是 bFormatIndex、bFrameIndex 和 dwFrameInterval。前两个决定了设备固件用哪套描述符来解释数据流,dwFrameInterval 的单位是 100ns,30 帧每秒对应的值就是 333333。设备并不一定支持你请求的任意组合,比如某些摄像头 YUYV 格式下最大只支持 640x480,1920x1080 就要切到 MJPEG。所以拿到返回值后,最好把兼容的参数列表完整打印出来,手动选一组再往下走。
启流之后,每一帧数据都会从设备的等时端点到达,libuvc 重新组帧完成后会回调我们注册的函数。摄像头原生输出的 YUYV 格式,Android 的预览和编码器一般不能直接用,常见的做法是现场转成 NV21。转换逻辑每帧都要跑,算法要尽量简洁:
void yuyv_to_nv21(const uint8_t *yuyv, uint8_t *nv21, int width, int height) { int frame_size = width * height; uint8_t *y_plane = nv21; // Y 平面在前 uint8_t *uv_plane = nv21 + frame_size; // UV 交错平面在后 for (int k = 0; k < frame_size / 2; k++) { int src_idx = k * 4; // 每组 4 字节包含两个像素的 Y 和一个 U、一个 V y_plane[k * 2] = yuyv[src_idx]; // Y0 y_plane[k * 2 + 1] = yuyv[src_idx + 2]; // Y1 // NV21 的 UV 平面是 V 在前、U 在后 uv_plane[k * 2] = yuyv[src_idx + 3]; // V uv_plane[k * 2 + 1] = yuyv[src_idx + 1]; // U } }YUYV 是 4:2:2 采样,每两个像素共享一组 UV 数据,所以每处理一组 YUYV 的 4 字节,就能产出 NV21 的 4 字节。转换结束后,通过 JNI 把数据传给 Java 层。这里生产环境的做法是预分配一块 DirectByteBuffer,拿到它的 native 地址后直接在 C++ 里 memcpy,Java 侧零拷贝就能拿到新数据,不要每帧都 NewDirectByteBuffer,那和每帧 new byte[] 在性能上没有任何区别。
4. 踩坑记录:UVC 摄像头接入安卓最容易翻车的五个场景
4.1 权限拿到了、设备打开了,回调却一帧都不来
现象:Java 层 onFrame 一次都没被调用,logcat 里看不到任何报错,USB 连接也没断。原因:格式协商那一关其实失败了,但代码里只打印了返回值没有中断流程,libuvc 内部没有成功建立流通道。常见的情况是请求了 1280x720@30 的 YUYV,而设备只在 MJPEG 模式下才支持这个分辨率。解决:协商失败后不要直接 return,先把设备支持的所有格式打印出来,按优先级逐个尝试。我一般会写一个自动降级链:先试 YUYV 高分,再试 YUYV 低分,最后试 MJPEG。
4.2 第一次能预览,第二次打开直接黑屏
现象:退出预览界面再进来,画面全黑,运气差一点整个 App 崩溃,甚至 USB 断开重连。原因:上一次会话的资源没有按顺序释放。C++ 层申请了一大堆东西,libuvc 的流上下文、libusb 的设备句柄、Java 层的 UsbDeviceConnection,任何一个没释放,第二次打开时内核会认为设备还在被占用。解决:释放顺序必须和创建顺序完全相反,先 uvc_stop_streaming,再 uvc_exit,再 libusb_close,最后 connection.close()。缺一不可,而且不能依赖 GC,要显式调用。
4.3 Java 层频繁分配数组,画面卡到没法看
现象:帧率只有个位数,logcat 里 GC 日志刷屏,Android Studio 的 Memory Profiler 里内存曲线像锯齿。原因:帧回调里每帧都 new byte[] 接收数据,然后 Android 的垃圾回收器为了回收这些短命对象频繁触发 GC,每次 GC 都会暂停所有线程,直接把视频帧间隔拉长。解决:用 DirectByteBuffer 预分配一块和帧大小匹配的堆外内存,C++ 层直接把数据写进去,Java 层只读这块缓冲。如果担心撕裂,就用双缓冲,两个 DirectByteBuffer 轮流写和读。
4.4 YUYV 协商成功但画面花屏/绿屏
现象:有数据流、有帧率,但画面是花的,或者颜色完全不对,整体发绿。原因:摄像头实际输出的不是标准 YUYV 排列,有的低成本模组在 YUYV 模式下 UV 顺序是反的,有的实际是 UYVY,只是描述符里写成了 YUYV。解决:先做一个一帧抽样的工具,把原始数据存下来用工具分析。针对 UV 顺序相反的情况,把转换函数里的 U、V 赋值位置对调即可;如果实际是 UYVY,就调整源数据索引。这种问题没法从协议层面根治,只能针对具体摄像头做映射表。
4.5 设置亮度、曝光等控制命令返回超时
现象:通过 UVC 控制接口设置亮度、对比度、曝光时,要么返回 LIBUSB_ERROR_TIMEOUT,要么返回 LIBUSB_ERROR_PIPE,画面没有任何变化。原因:极少数摄像头对 UVC 的标准控制请求支持不完整,或者要求先发送一个特定长度的 GET_LEN 请求,固件才会响应后续的 SET_CUR 请求。解决:先把控制请求单独拆出来,不要阻塞主流程,即使失败也继续拉流。调试时在 native 层打印请求的 bmRequestType、bRequest、wValue、wIndex,对照 UVC 1.5 规范手动构造原始控制传输,能定位出设备到底卡在哪一步。
5. 二次开发实战:把预览流接进 MediaCodec 做 H.264 硬编
5.1 改造关键步骤:先拿到 NV21,再喂编码器
拿到 NV21 帧之后,最简单的提效改造是接 MediaCodec 做 H.264 硬编码,输出可以存 MP4 也可以推流。改造思路是:Java 层在界面初始化时建好编码器,配置成 NV21 输入格式,然后在 onFrame 回调里把数据直接塞给编码器的 input buffer。关键配置如下:
val format = MediaFormat.createVideoFormat( MediaFormat.MIMETYPE_VIDEO_AVC, width, height) format.setInteger(MediaFormat.KEY_COLOR_FORMAT, CodecCapabilities.COLOR_FormatYUV420SemiPlanar) format.setInteger(MediaFormat.KEY_BIT_RATE, 4_000_000) format.setInteger(MediaFormat.KEY_FRAME_RATE, fps) format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 2) val encoder = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_VIDEO_AVC) encoder.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) encoder.start()COLOR_FormatYUV420SemiPlanar 对应的就是 NV21 的内存布局,V 在前 U 在后,和 C++ 层转换出来的数据完全一致。每帧到达时,从编码器取一个空闲 input buffer,把 NV21 数据拷贝进去,再 queueInputBuffer 交给编码器,dequeueOutputBuffer 拿编码后的 H.264 数据。这里要注意的是编码器的 input buffer 大小可能小于一帧 NV21 的总大小,按常规经验先查 MediaCodecInfo 里声明的 buffer 大小,再做适配。
进一步改造可以把编码后的数据接 RTMP 推流,或者加一个前置的美颜步骤——把 NV21 再转成 RGBA 丢给滤镜库处理,这在 C++ 层都是顺路的事。做这个改造时我对帧数据生命周期有了更深的认识:内存复用是 UVC 采集项目的核心命题,数据从 USB 端点进入内核,再经过 C++ 转换、JNI 传递、MediaCodec 编码,每一层都在和同一块内存打交道。从那以后我每次接新摄像头,都强制自己先跑一遍枚举格式的调试开关,把设备真实的分辨率、帧率、格式打印出来再动 start,这个习惯帮我省掉了大量排查时间。希望帮到你。
本文还有配套的精品资源,点击获取