资讯动态

Android UVC摄像头接入实战:从USB权限到H.264硬编全解析

发布时间:2026/10/19 8:04:41 来源:尧图企业网站定制
简介这是一套面向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 为什么底层必须用 CUVC 的视频流不走普通 USB 通道很多第一次接触 UVC 的人会问Java 层不是有 UsbDeviceConnection 吗为什么不能直接拿它读数据这里有个关键限制——UVC 视频流走的是 isochronous 等时传输端点这种端点的特点是带宽预留、数据按帧周期性到达适合视频这种对延迟敏感但对丢包容忍度高的场景。Android 的 Java API 对等时传输的支持非常有限UsbDeviceConnection 暴露的 bulkTransfer、controlTransfer 都碰不到 isochronous 的完整能力。而 libusb 在 native 层把这些事都做完了它会直接操作 Android 内核的 usbfs 接口在设备描述符里解析出端点和备用设置把等时传输包一个一个收回来。另一个选择 C 的原因是性能。一帧 720p 的 YUYV 原始数据大约是 1.4MB30 帧每秒就是 42MB 的吞吐。如果每一帧都在 Java 层 new 一个 byte[] 再 System.arraycopyGC 压力会非常大掉帧是必然的。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 和 libuvcnativeStartStream(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 的单位是 100ns30 帧每秒对应的值就是 333333。设备并不一定支持你请求的任意组合比如某些摄像头 YUYV 格式下最大只支持 640x4801920x1080 就要切到 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 里 memcpyJava 侧零拷贝就能拿到新数据不要每帧都 NewDirectByteBuffer那和每帧 new byte[] 在性能上没有任何区别。4. 踩坑记录UVC 摄像头接入安卓最容易翻车的五个场景4.1 权限拿到了、设备打开了回调却一帧都不来现象Java 层 onFrame 一次都没被调用logcat 里看不到任何报错USB 连接也没断。原因格式协商那一关其实失败了但代码里只打印了返回值没有中断流程libuvc 内部没有成功建立流通道。常见的情况是请求了 1280x72030 的 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这个习惯帮我省掉了大量排查时间。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑