简介面向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 本身对 UVCUSB 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()往下走到 JNIC 层用 libuvc 打开设备。调用uvc_start_streaming()USB 驱动开始传输视频帧。libuvc 的回调函数被频繁触发每个回调拿到一个uvc_frame。C 层把帧数据拷贝到缓冲区通过 JNI 的onPreviewFrame回调给 Java。Java 层拿到 byte 数组渲染到SurfaceView或者TextureView。这个链路里最容易出问题的是内存拷贝。如果每一帧都从 C new 一个数组传给 JavaGC 会非常痛苦。常见做法是 Java 层提前申请一块 DirectByteBufferC 层直接写这块内存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 表示 MJPEG1 表示 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:nameandroid.hardware.usb.host android:requiredfalse / uses-permission android:nameandroid.permission.USB_PERMISSION / activity android:name.MainActivity intent-filter action android:nameandroid.hardware.usb.action.USB_DEVICE_ATTACHED / /intent-filter meta-data android:nameandroid.hardware.usb.action.USB_DEVICE_ATTACHED android:resourcexml/device_filter / /activitydevice_filter.xml控制哪些设备允许被监听到。UVC 设备的 vendorId 和 productId 千奇百怪如果你有一款摄像头没有出现在过滤列表里最简单的办法是先把过滤条件放宽。比如下面这样resources usb-device class14 subclass1 protocol0 / /resourcesclass14是 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 层请求的是 MJPEGJava 层却按 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 最大也可能接近这个值。如果摄像头出的是 YUYV1 个像素 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 调试工具。从那以后我每次拿到一个新摄像头都会强制走一遍这个流程先枚举设备能力再按能力设置流参数最后回调里做一次格式校验。这套习惯让我避开了无数玄学问题。希望这篇文章也能帮你少走弯路把这个项目真正跑起来、改起来。本文还有配套的精品资源点击获取