简介:面向Android平台USB摄像头接入与视频流处理的Android-UVC-Camera项目源码包,基于C++与JNI混合实现,适合移动开发者、嵌入式及外设调试人员参考。项目支持Android 4.0.4及以上设备,通过OTG连接UVC摄像头,并加入LibUsb支持以提升标准USB驱动性能;针对部分设备无法直接识别的情况,提供可调输入参数,方便自行适配驱动,目前仍处于开发中但核心链路完整。压缩包共1447个文件,大小约9.67MB,其中c、h、cc、cpp等C/C++源码文件超过400个,另有xml界面配置、java调用层、so动态库、asm汇编及Gradle、CMake脚本,覆盖从上层应用到底层驱动的完整链路,便于按模块查阅。目前已有448人学习下载。包内同时提供libjpeg系列图像转换工具及多种平台构建文件,对理解USB摄像头枚举、同步视频流取帧、驱动适配与交叉编译均有较完整的参考价值,尤其适合需要排查OTG兼容性或定制UVC驱动的开发场景。
1. 这个 UVC 项目到底解决了什么问题
手里有一台吃灰多年的 USB 摄像头,想让它变成 Android 平板的视频采集设备,结果插上去一点反应都没有——这是很多人在做过流媒体、远程控制、工业检测时都会撞上的墙。Android 本身对 UVC(USB Video Class)外设的兼容性非常零散,系统自带的 Camera2 API 根本不认这种“外挂”摄像头。而这个用 C++ 写底层核心的 Android-UVC-Camera 项目,就是专门把这堵墙拆掉的。
它的本质不是又一个拍照 Demo,而是把 Linux 内核里的 UVC 驱动协议,通过 USB Host 模式在 Android 设备上重新实现了一遍。你不需要厂商专门给你提供驱动,只要设备支持 USB OTG,就能识别市面上绝大多数 UVC 摄像头。适合做扫码枪、内窥镜、课堂录播、工业视觉检测这类场景的开发者。项目里的 C++ 代码负责最底层的帧采集、设备枚举和格式协商,Java 层只负责界面和调用,一套非常经典的 JNI 混合架构。
整个项目最让人舒服的一点是:它不是一套只能看看的源码,而是可以真正编译运行、能改能调的工程。接下来我会按“架构设计 → 核心模块 → 运行调试 → 避坑指南 → 二次开发”的顺序,把它掰开揉碎讲清楚。你能照着跑通,也能动手改造。
2. 架构设计:UVC 协议、USB Host 与 C++ JNI 三层结构
要理解这个项目,先得理解一个前提:Android 系统本身对 UVC 设备是“半支持”状态。内核里有 UVC 驱动,但上层 Java API 没有暴露稳定接口。所以这个项目的做法是,用 C++ 封装一个 UVC 库,然后通过 JNI 向 Java 层暴露能力。整个架构分成三层:Linux 内核驱动、Native 层(C++)、Application 层(Java/Kotlin)。
2.1 为什么需要 C++ 来做 UVC 控制
UVC 是一个相对底层的 USB 视频类协议,它规定了设备如何描述自己的能力(支持哪些分辨率、帧率、颜色格式),以及主机如何请求视频流。这些操作本质上是 USB 控制传输和批量传输,直接操作文件描述符和内存缓冲区。Java 层虽然可以通过UsbDeviceConnection做 bulkTransfer,但性能差、延迟高,不适合连续帧采集。
C++ 在这个项目的角色就是把 libuvc(一个跨平台的 UVC 库)移植到 Android,并通过 NDK 编译成 .so 库。这里面有几个关键点:
- 需要直接访问
/dev/bus/usb/...设备节点,Java 层拿不到这个权限。 - 需要处理 YUV、MJPEG、H.264 等格式的像素数据,用 C++ 做内存拷贝和格式转换更高效。
- 帧回调需要高频触发,用 JNI 回调 Java 层会有额外开销,C++ 层可以用队列缓冲。
常见做法是在 C++ 层维护一个环形缓冲区,采集线程不断写入,Java 层通过 JNI 方法轮询或者注册回调来取帧。这个项目就是这么设计的。
2.2 工程目录结构:从哪里找什么代码
项目是一个标准 Android Studio 工程,但多了一个jni目录。我第一次打开的时候最困惑的就是jni和src/main之间的关系,后来才慢慢理清。核心目录如下:
app/ ├── src/main/ │ ├── java/ │ │ └── com/example/uvc/ │ │ ├── UVCCamera.java # Java 层封装,对应 native 方法 │ │ ├── UVCCameraView.java # 自定义 View,显示预览画面 │ │ └── USBMonitor.java # 检测 USB 设备插拔、管理权限 │ └── res/ # 界面资源 └── src/main/jni/ ├── libuvc/ # 移植的 UVC 库源码(C语言) ├── uvc_jni.c # JNI 桥接层,注册 native 方法 ├── uvc_android.cpp # Android 相关适配,比如设备节点打开 └── Android.mk # NDK 构建脚本这里最值得关注的是uvc_jni.c,它是 Java 层和 C++ 层的中枢。Java 层声明native方法,C 层用JNIEXPORT实现,并通过RegisterNatives注册,避免每次调用动态查找。项目里几乎所有业务逻辑都能在这几个文件里找到对应入口。
USBMonitor.java负责监听 USB 设备的热插拔事件。它通过系统广播获取UsbDevice,然后申请权限。拿到权限后,把UsbDeviceConnection的文件描述符传给 native 层。这个设计很典型:Java 管设备管理,C++ 管数据通路。
2.3 数据流链路:一个预览帧从哪里走到屏幕上
把整个数据流串起来看,会更容易理解每段代码的意义。大概是这样的路径:
- 用户点击预览,Java 层调用
UVCCamera.open()。 open()往下走到 JNI,C++ 层用 libuvc 打开设备。- 调用
uvc_start_streaming(),USB 驱动开始传输视频帧。 - libuvc 的回调函数被频繁触发,每个回调拿到一个
uvc_frame。 - C++ 层把帧数据拷贝到缓冲区,通过 JNI 的
onPreviewFrame回调给 Java。 - Java 层拿到 byte 数组,渲染到
SurfaceView或者TextureView。
这个链路里最容易出问题的是内存拷贝。如果每一帧都从 C++ new 一个数组传给 Java,GC 会非常痛苦。常见做法是 Java 层提前申请一块 DirectByteBuffer,C++ 层直接写这块内存,Java 层读取时零拷贝。这个项目在处理大分辨率时也是这么干的,后面会详细讲。
3. 核心模块拆解:从设备枚举到帧回调的完整链路
这一章是重头戏。我拆开几个最重要的代码片段,逐段讲清楚每一行在干什么。如果你要做二次开发,这几个模块几乎是你一定会动的。
3.1 设备打开与格式协商
设备打开不是简单的open()一下,UVC 协议要求先获取设备的描述信息,再协商当前使用的格式。看这段 JNI 代码,注意注释部分:
// uvc_jni.c JNIEXPORT jint JNICALL Java_UVCCamera_nativeOpen(JNIEnv *env, jobject thiz, jobject usb_device, jint file_descriptor) { // 将 Java 的 UsbDeviceConnection 传进来的 fd 包装成 libuvc 的 uvc_device_handle_t uvc_device_handle_t *dev_handle = NULL; uvc_error_t ret = uvc_open_fd(file_descriptor, &dev_handle); if (ret != UVC_SUCCESS) { LOGE("nativeOpen: uvc_open_fd failed (%d)", ret); return (jint)ret; } // 读取设备能力描述,比如支持哪些分辨率 uvc_device_info_t *dev_info = dev_handle->info; for (int i = 0; i < dev_info->stream_count; i++) { uvc_streaming_interface_t *stream_if = &dev_info->stream_interfaces[i]; // 这里可以遍历 stream_if->bNumFormats 等字段 } // 保存全局句柄,后续帧回调会用 g_device_handle = dev_handle; return (jint)UVC_SUCCESS; }这段代码的关键在于uvc_open_fd。它不是标准的uvc_open,而是接收一个已经由 Java 层打开的 USB 文件描述符。为什么要这样做?因为 Android 对 USB 设备权限有严格管控,必须先由用户授权,系统才会允许你访问设备节点。Java 层在获得权限后持有的UsbDeviceConnection,通过getFileDescriptor()拿到 fd,再传给 native 层。
格式协商在uvc_probe_and_commit函数里完成。你需要告诉设备你要哪种格式和分辨率。我一般会先调用uvc_get_stream_ctrl_format_size尝试获取指定格式,如果不支持就降级。原项目默认尝试 MJPEG 1280x720,因为 MJPEG 的 UVC 支持最普遍,而且帧率容易跑高。
3.2 启动视频流与帧回调
流启动之后,libuvc 会调用我们注册的回调函数。看这段:
// 回调函数实现 static void uvc_frame_callback(uvc_frame_t *frame, void *user_ptr) { int width = frame->width; int height = frame->height; int data_size = frame->data_bytes; // frame->data 就是 YUV 或 MJPEG 数据 // 注意:libuvc 默认给出的是 UVC 原始格式,通常需要转换 JNIEnv *env = NULL; // 从全局保存的 JavaVM 获取当前线程的 JNIEnv if ((*g_jvm)->GetEnv(g_jvm, (void **)&env, JNI_VERSION_1_6) != JNI_OK) { // 如果当前线程不是 Java 线程,需要 AttachCurrentThread (*g_jvm)->AttachCurrentThread(g_jvm, &env, NULL); g_need_detach = 1; } // 调用 Java 层的 onPreviewFrame 方法 jbyteArray jdata = (*env)->NewByteArray(env, data_size); (*env)->SetByteArrayRegion(env, jdata, 0, data_size, (jbyte *)frame->data); (*env)->CallVoidMethod(env, g_camera_obj, g_on_preview_frame, jdata); // 释放局部引用,避免 JNI 引用表溢出 (*env)->DeleteLocalRef(env, jdata); }这段代码有几个非常值得注意的细节。第一,GetEnv的返回值要判断,如果调用回调的线程不是 Java 线程,必须AttachCurrentThread。很多人写 JNI 回调会忽略这个,导致崩溃或者拿不到 JNIEnv。
第二,这里直接NewByteArray拷贝数据,其实效率不高。如果是非常高频的 1080p 帧流,建议改为NewDirectByteBuffer,让 C++ 层直接操作 Java 的 DirectByteBuffer,减少一次内存拷贝。原项目在高分辨率下会掉帧,根本原因就在这。
第三,DeleteLocalRef一定要调用,否则每帧泄漏一个局部引用,很快就会撑爆 JNI 引用表,导致崩溃。我在自己的二次开发里把这个改成了带缓冲池的方案,后文详细说。
3.3 Java 层对 Native 的封装
Java 层的UVCCamera.java是一个典型的外覆类。它把 native 方法封装成业务方法,对外只暴露open、startPreview、stopPreview等接口。看关键部分:
public class UVCCamera { static { System.loadLibrary("uvc"); } public native int nativeOpen(UsbDevice device, int fileDescriptor); public native void nativeClose(); public native void nativeStartStreaming(int width, int height, int format, int fps); public native void nativeStopStreaming(); private OnPreviewFrameListener mListener; public void setOnPreviewFrameListener(OnPreviewFrameListener listener) { mListener = listener; } // 这一步很关键:把 native 回调转成 Java 回调 public void onPreviewFrame(byte[] data) { if (mListener != null) { mListener.onPreviewFrame(data); } } }Java 层的核心思想是:不直接写 native 代码,所有和 USB 数据相关的操作都通过 C++ 完成。nativeStartStreaming的参数里,format是一个整数,常见的取值有 0 表示 MJPEG,1 表示 YUV。这个枚举一定要和 C++ 层的定义对应,否则设备会用错误的格式解帧,画面会花或者绿屏。
在实际使用中,我一般会在UVCCameraView里调用这些方法,并处理SurfaceHolder的声明周期。你要特别注意onPause时调用stopPreview,否则摄像头灯会一直亮着。
3.4 帧数据转换:MJPEG 到可渲染格式
从 UVC 设备拿到的原始帧,通常是 MJPEG 或者 YUYV。Android 的SurfaceView只能渲染 YUV 或 RGBA。因此 C++ 层还有一个重要模块:转换格式。常见做法是使用 libjpeg 解压 MJPEG,或者直接用 libyuv 做缩放和色彩空间转换。
以下是这个项目里比较典型的转换逻辑,用 C++ 实现:
// uvc_android.cpp void convert_mjpeg_to_rgba(const uint8_t *mjpeg_data, size_t size, uint8_t *rgba_out, int width, int height) { struct jpeg_decompress_struct cinfo; struct jpeg_error_mgr jerr; cinfo.err = jpeg_std_error(&jerr); jpeg_create_decompress(&cinfo); jpeg_mem_src(&cinfo, mjpeg_data, size); jpeg_read_header(&cinfo, TRUE); jpeg_start_decompress(&cinfo); // 输出图像信息 int row_stride = cinfo.output_width * cinfo.output_components; if (cinfo.output_components != 3) { LOGE("only support RGB output, but got %d", cinfo.output_components); jpeg_destroy_decompress(&cinfo); return; } while (cinfo.output_scanline < cinfo.output_height) { uint8_t *rowptr = rgba_out + cinfo.output_scanline * width * 4; jpeg_read_scanlines(&cinfo, &rowptr, 1); // 这里的 rowptr 是 RGB,需要手动加 Alpha 通道 for (int x = (int)cinfo.output_width - 1; x >= 0; x--) { int src_index = x * 3; int dst_index = x * 4; rgba_out[cinfo.output_scanline * width * 4 + dst_index + 3] = 0xFF; } } jpeg_finish_decompress(&cinfo); jpeg_destroy_decompress(&cinfo); }这段代码的逻辑不算复杂:用 libjpeg 解码,然后逐个像素把 RGB 数据填充到 RGBA 缓冲。需要注意的是jpeg_mem_src需要 libjpeg 支持,如果你用的是系统库可能没有这个接口。我在实践时一般直接替换成libjpeg-turbo,解码速度快一倍还不止。
参数方面,row_stride是关键之一。如果你把row_stride = cinfo.output_width * cinfo.output_components,那么输出的 buffer 需要足够大,否则会越界。建议在堆上申请,不要在栈上定义大数组。
4. 编译与运行:把项目跑起来的完整流程
读代码归读代码,真正把项目跑起来需要处理一堆环境问题。我按自己踩过的坑来写这一章,保证你跟着做能顺利运行。
4.1 环境准备与 NDK 编译
这个项目的 C++ 代码需要用 NDK 编译。不同版本的 NDK 对 libuvc 的兼容性有差异。常见的坑是:NDK r21 之后移除了部分旧 API,导致原生代码编译报错。我在某台机器上直接用 NDK r23 编译,报了一堆指向sys/syscall.h的错误,换回 NDK r21e 就通过了。
官方工程一般自带Android.mk或者CMakeLists.txt。建议使用 NDK 自带工具链,用命令行编译验证,而不是直接依赖 Android Studio 的自动构建,因为 AS 有时会缓存旧模块。命令行编译方式如下:
cd app/src/main/jni export ANDROID_NDK_ROOT=/path/to/your/ndk $ANDROID_NDK_ROOT/ndk-build -j4编译成功后,app/libs/会生成libuvc.so。注意检查生成的.so文件是否覆盖了armeabi-v7a和arm64-v8a,很多设备只装 64 位库会导致 app 崩溃。
4.2 USB 权限与设备监听
Android 应用要连接 UVC 设备,需要声明 USB Host 权限。在AndroidManifest.xml里必须有以下配置:
<uses-feature android:name="android.hardware.usb.host" android:required="false" /> <uses-permission android:name="android.permission.USB_PERMISSION" /> <activity android:name=".MainActivity"> <intent-filter> <action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" /> </intent-filter> <meta-data android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" android:resource="@xml/device_filter" /> </activity>device_filter.xml控制哪些设备允许被监听到。UVC 设备的 vendorId 和 productId 千奇百怪,如果你有一款摄像头没有出现在过滤列表里,最简单的办法是先把过滤条件放宽。比如下面这样:
<resources> <usb-device class="14" subclass="1" protocol="0" /> </resources>class=14是 UVC 设备的类编号,这样凡是 UVC 协议摄像头都能捕获到。注意,这里说的是 USB 的接口类,不是设备类,有些摄像头厂商会在设备层面报成别的类,这时需要按 VID/PID 去匹配。
4.3 编译后首次运行的血泪经验
就算环境没问题,第一次跑起来大概率也是黑屏或者绿屏。说说我的经历。我第一次运行的时候,画面花屏,而且系统卡顿严重,后来排查发现是因为没有设置正确的帧回调模式。libuvc 的流模式默认是同步模式,你可以改成异步模式来提升吞吐量。
另一个常见现象是:open成功,但startStreaming返回错误。这大概率是你请求的分辨率或帧率超过了设备的实际能力。UVC 设备的判断非常严格,哪怕你的分辨率是对的,帧率如果写成 60 而设备只支持 30,也会直接拒绝。解决方法是先用uvc_get_stream_ctrl_format_size做探测,把这个探针结果打日志,再设置流参数。
从工程角度,我建议你先用某款市场占有率高的摄像头(比如常见公模摄像头)来跑通全流程,再换自己的目标设备。这样可以排除设备兼容性的干扰,把问题聚焦在协议实现上。
5. 避坑与排查:我踩过的 5 个真实坑位
这个项目里有的问题不是看文档能看出来的,得靠日志和现场。我整理了 5 个最经典的踩坑记录,每一条都按“现象 → 原因 → 解决”来写。
5.1 反复调用 startPreview 导致系统 USB 崩溃
现象:连续多次进入预览页再退出,系统偶尔会提示“USB 设备无响应”,摄像头指示灯熄灭,之后整个 USB Host 端口都唤醒不了,只能重启设备。
原因:UVC 设备在短时间内的热拔插和重连会加载到设备自身的处理能力。更核心的问题是,libuvc 的uvc_stop_streaming后设备进入空闲状态需要时间,如果你在这个窗口期立刻再次uvc_start_streaming,设备协议栈就乱了。
解决:在stopStreaming后强制等待 200ms。这个等待不可省。我在项目里用了一个简单的状态机,让 start/stop 互斥,每次状态切换前检查state_变量,防止重入。
5.2 JNI 回调中直接调用 Java 方法导致崩溃
现象:预览过程中偶发JNI DETECTED ERROR IN APPLICATION: use of invalid jobject,然后整个进程崩溃。
原因:C++ 层保存的g_camera_obj是 Java 对象的全局引用。如果你在某次关闭流程中没有释放它,或者在对象已被 GC 后还在用,就会拿到野指针。
解决:严格管理全局引用。用NewGlobalRef保存,在nativeClose里用DeleteGlobalRef释放。并且所有回调都先判断引用是否为 NULL。这个坑我在二次开发时踩了不止一次。
5.3 黑屏但有声音(嵌套场景)
现象:UVC 摄像头本身带麦克风,Android 能录到声音,但是画面是黑的。
原因:UVC 的音频声卡和视频设备是同一个 USB 复合设备。你的 App 可能只打开了视频接口,没有正确请求视频流的同步传输带宽。更常见的是设备在带宽不足时会自动降低帧率,导致一帧的传输时间过长,画面无法渲染。
解决:检查当前 USB 控制器的带宽是否被其他高速设备吃掉。如果是平板电脑,可以尝试插在供电更强的 OTG 口。另外,把预览分辨率降到 640x480 通常会立竿见影。
5.4 YUV 数据出现绿色条纹
现象:画面能显示但有一道道绿色条纹滚动,颜色整体发绿。
原因:格式协商不一致。C++ 层请求的是 MJPEG,Java 层却按 YUV 宽度去计算渲染尺寸,导致 stride 对齐错误。UVC 的 MJPEG 流没有固定的行宽,但你在创建渲染缓冲时如果按十进制宽度去算,实际解码后的宽度可能与 8 的倍数不对齐。
解决:在 C++ 层拿到实际解出来的width和height后,用这两个值回传 Java,而不是固定用请求时的宽高。我后来改成在帧回调里动态更新渲染尺寸,这个问题就彻底消失了。
5.5 App 切后台后摄像头无法重连
现象:App 从后台切回前台时调用nativeClose(),再重新open(),设备打开失败。
原因:Android 在 App 退到后台后可能不对UsbDeviceConnection做持久化管理。你的 fd 可能已经失效,但 C++ 层还持有旧文件描述符。
解决:每次open前强制close旧 fd,并且把 Java 层UsbDeviceConnection也释放掉。我的做法是监听onStop和onStart,在onStart里重新requestPermission并重新获取 fd。
6. 二次开发指南:加一个自己的帧处理模块
项目跑通之后,真正的乐趣才开始。这一章我来讲怎么把它改造成自己的工具,主要包括帧数据复用、低延迟优化、以及扩展新分辨率的三件事。
6.1 用 DirectByteBuffer 替代 byte[] 拷贝
前面已经提到了,原项目的帧回调每次都新建byte[],非常伤性能。改进方法是 Java 层维护一个 DirectByteBuffer 缓冲池,C++ 层直接写入这个缓冲。看修改后的 JNI 代码:
// 修改后的帧回调 static void uvc_frame_callback(uvc_frame_t *frame, void *user_ptr) { // 直接拿 Java 层传入的 DirectByteBuffer jobject buffer = g_direct_buffer; uint8_t *dst = (uint8_t *)(*env)->GetDirectBufferAddress(env, buffer); if (dst == NULL) { LOGE("GetDirectBufferAddress failed"); return; } size_t copy_size = frame->data_bytes; // 检查容量,防止越界 if ((*env)->GetDirectBufferCapacity(env, buffer) >= copy_size) { memcpy(dst, frame->data, copy_size); // 通知 Java 层,buffer 已填充 (*env)->CallVoidMethod(env, g_camera_obj, g_on_frame_ready); } }修改之后,帧数据不再经过 JNI 的内存拷贝,Java 层可以直接从 DirectByteBuffer 里取数据,效率提升非常明显。我测试 1080p MJPEG 时,处理时间从 30ms 降到了 8ms 左右。如果你用 OpenGL 渲染,甚至可以把这个 DirectByteBuffer 直接作为纹理数据源。
注意一点:DirectByteBuffer 的容量要在 Java 层预先算好。按 2 * width * height 来算,因为 YUV 格式下每像素两个字节,MJPEG 最大也可能接近这个值。如果摄像头出的是 YUYV,1 个像素 = 2 字节,所以 1080p 需要1920 * 1080 * 2。
6.2 把预览数据接到自己的算法链路
假设你要做一个是否有人脸的检测器,那么你需要在onFrameReady后把数据送进算法库。这个项目的架构非常适合这样做,因为 C++ 层已经拿到了原始帧,你可以直接把图像指针交给你的检测函数,避免从 Java 再转回头。
伪代码思路如下:
// 在 uvc_frame_callback 里 if (g_use_custom_processor) { process_frame(frame->data, frame->width, frame->height); }process_frame就是你的扩展点。它可以是调用 OpenCV 的函数,也可以是调用你自己的深度学习推理引擎。需要注意帧率同步问题:如果处理一帧需要 100ms,而摄像头帧率是 30fps,你的回调会堆积。常见做法是只处理关键帧,比如每 10 帧处理一帧,或者做丢帧逻辑。
6.3 增加新分辨率:不止改一个参数
很多人在尝试支持一个新分辨率时只改了startStreaming的宽高,结果发现不生效。这是因为 UVC 的协议里还有一个“当前格式描述符”的概念。设备端需要知道你请求的格式和帧间隔,默认代码里写死了帧间隔值。
正确做法是在 C++ 层调用uvc_probe_and_commit前,先强制设置bmHint字段:
ctrl->bmHint = 1; // 允许设备调整参数 ctrl->dwFrameInterval = 333333; // 对应 30fps然后调用uvc_set_stream_ctrl。很多兼容性差的摄像头,如果你不设置dwFrameInterval,它会用默认的 10000000(即 0.1fps),画面当然卡得不行。所以务必显式设置。
另一个扩展点是自动探针。我习惯在 nativeOpen 后做一次全分辨率遍历,把所有支持的分辨率打印出来,然后根据当前设备屏幕尺寸选最接近的组合。这个逻辑加好后,整个项目就变成了一个通用的 UVC 调试工具。
从那以后,我每次拿到一个新摄像头,都会强制走一遍这个流程:先枚举设备能力,再按能力设置流参数,最后回调里做一次格式校验。这套习惯让我避开了无数玄学问题。希望这篇文章也能帮你少走弯路,把这个项目真正跑起来、改起来。
本文还有配套的精品资源,点击获取