
简介这是一套面向Android开发者的UVC摄像头接入项目基于Android Studio构建可让Android 4.0.4及以上版本的设备通过OTG线缆连接USB摄像头实现同步视频流预览兼容Micro USB与Type-C接口。压缩包共1447个文件大小约9.67MB主要包含C/C底层源码c/h/cc/cpp、Java层调用逻辑、Android资源配置xml、LibUsb库文件so/bin以及gradle等构建脚本覆盖从驱动适配到上层采集的完整链路其中C/C代码负责底层驱动与图像流处理Java代码负责应用层调用xml则描述界面与资源。当前已有448人学习浏览。项目特别加入了LibUsb支持可提升标准USB设备驱动性能针对部分Android设备无法自动识别摄像头的情况资源提供了手动配置驱动的思路并提示部分OTG线缆因供电不足无法工作建议使用外部供电的多口USB集线器。整套代码目录结构清晰、依赖完整还收录了libusb配套示例与TurboJPEG相关文件适合需要快速集成UVC摄像头、解决设备兼容性问题的Android开发者参考。1. Android UVC 摄像头绕不开 C系统 Camera2 在 USB 设备面前靠不住在质检工位、内窥镜和自助终端这几个场景里“把 USB 摄像头接到 Android 平板上看实时画面”几乎是绕不开的需求。可 Android 原生能力对这种需求基本无能为力Camera2 API 认不出 UVC 设备插上去只会显示“未知 USB 设备”。我最早试过厂商提供的预览方案也试过在 Camera2 的枚举列表里捞设备最后都翻车在同一个地方——UVC 摄像头根本不会被系统当成相机。真正能稳定出图的路只有一条用 C 在 native 层直接和 USB 设备的 UVC 协议对话自己控制枚举、协商格式、拿帧再把帧交给上层渲染。这份资源就是把这条链路完整拆解下来的采集模块从 libuvc 编译、JNI 封装、像素格式转换到渲染和避坑都按可复现的方式整理过适合正在做 Android USB 摄像头采集、又不想被厂商 SDK 绑死的开发者。2. UVC 协议接入 Android 的三条路线从 UsbManager 到 libuvc 的 C 接口2.1 三种接入方式的取舍为什么选 libuvc在 Android 上采集 UVC 摄像头常见的路子有三条。第一条是用厂商提供的“秒开预览”SDK。这类 SDK 一般把底层封装得很干净只需要把 USB 设备授权给它它就会通过回调把需要的视频帧送上来。优点是开发效率高缺点是绝大多数这类 SDK 的 Android 版本支持度参差不齐而且一旦摄像头固件或 UVC 描述符比较冷门就会出现“别人那能跑我这不能跑”的玄学问题遇到问题基本没法调试因为它是个黑匣子。第二条是纯 Java/Kotlin 层用 UsbManager 直接拿原始数据。UsbManager 确实能拿到 UsbDeviceConnectionJava 层可以自己发起 control transfer 和 bulk transfer。UVC 的 VideoStreaming 接口本质上是周期性的 bulk 传输看起来纯 Java 读数据是可行的。但问题在于UVC 类设备的控制逻辑标准请求太多纯手工实现协议栈非常容易出错再加上 Java 层为了性能还得开额外线程做阻塞式 read内存拷贝和 GC 压力在 1080P/30fps 下会马上顶上来帧率会抖得没法看。从我的实践来看第二条路更适合做协议研究而不是做产品。第三条路是走 C/C 层接 libuvc。libuvc 是 UVC 设备的 C 语言实现库封装了标准 UVC 控制命令、流接口协商、同步/异步帧读取。把它和 Android 的 UsbManager 组合起来很直接UsbManager 负责最外层的 USB 权限和设备句柄libuvc 负责 UVC 协议之间的所有细节。底层是 C 代码内存可以自己管帧拷贝次数也能压到最低。下面所有讨论都围绕这条路线展开。2.2 libuvc 会话初始化枚举设备、声明接口、协商流格式libuvc 的使用套路比较固定初始化上下文、查找设备、打开设备、协商流参数、开始采集。我一般在 JNI 层封装时会把 UVC 设备的 vendorId/productId 从 Java 层传进来。下面是一个精简后的初始化流程。// JNI 入口Java 层把 USB 设备授权后的 fd 传进来 public class UvcCamera { static { System.loadLibrary(uvc_camera); } // native 方法返回一个指针句柄 public native long nativeOpen(int fd, int vendorId, int productId); public native int nativeStartStream(long handle, int width, int height, int fps); public native void nativeClose(long handle); }// JNI: open 流程简化版 extern C JNIEXPORT jlong JNICALL Java_com_example_UvcCamera_nativeOpen(JNIEnv* env, jobject thiz, jint fd, jint vid, jint pid) { // Android 的 UsbDeviceConnection 拿到 fd 后可以直接交给 libuvc uvc_context_t* ctx nullptr; uvc_error_t ret uvc_init(ctx, nullptr); if (ret ! UVC_SUCCESS) { return 0; // 初始化失败返回 0Java 层判断空句柄 } uvc_device_t* dev nullptr; // uvc_find_device 的最后一个参数是后端传 nullptr 走默认后端 ret uvc_find_device(ctx, dev, vid, pid, nullptr); if (ret ! UVC_SUCCESS || dev nullptr) { uvc_exit(ctx); return 0; } uvc_device_handle_t* handle nullptr; ret uvc_open(dev, handle); if (ret ! UVC_SUCCESS) { uvc_unref_device(dev); uvc_exit(ctx); return 0; } // dev 可以释放引用真正持有的是 handle uvc_unref_device(dev); return reinterpret_castjlong(handle); }这里有一个容易被忽略的关键点uvc_init 成功之后libuvc 会自己创建后台线程做 USB 事件循环。如果你在 Java 层把 fd 传进去却没有把 fd 对应的 UsbDeviceConnection 保持打开线程一启动就会因为 fd 失效而退出。我一般会把 UsbDeviceConnection 作为一个全局对象保留到 nativeClose 之后再 close而不是 open 之后马上 close。流参数协商这块核心是从摄像头支持的格式列表里挑一个你需要的格式。libuvc 的对应函数是 uvc_get_stream_ctrl_format_size它会向摄像头发出 SET_CUR 请求把流格式、分辨率、帧率写进摄像头。注意MJPEG 格式的带宽占用低但 YUYV 格式在渲染时不需要解压CPU 占用更低。大多数免驱 USB 摄像头都同时支持这两种选哪个要看你的渲染链路。// 协商流参数 uvc_stream_ctrl_t ctrl; // 尝试 YUYV 格式 640x48030fps uvc_error_t res uvc_get_stream_ctrl_format_size( handle, ctrl, UVC_FRAME_FORMAT_YUYV, 640, 480, 30); if (res ! UVC_SUCCESS) { // 很多设备不主动上报 YUYV 的 30fps 支持可以回退到 MJPEG res uvc_get_stream_ctrl_format_size( handle, ctrl, UVC_FRAME_FORMAT_MJPEG, 640, 480, 30); } if (res ! UVC_SUCCESS) { // 协商失败注意不代表摄像头不支持只是这个组合不在描述符里 return -1; }这里有个真实教训有些摄像头描述符把分辨率和帧率分成两个独立配置项你在某一分辨率下申请 30fps 会失败但申请 25fps 反而能成功。我后来改成先把枚举列表里所有支持的组合拉出来再在列表里做匹配而不是直接按想要的分辨率硬试。2.3 帧回调线程模型C 回调与 Java 边界libuvc 拿帧是同步的典型用法是开一个独立线程在循环里调用 uvc_get_frame拿到帧之后调用 uvc_free_frame 释放。JNI 封装时这个线程要在 native 层管理不能在回调里频繁调用 Java 方法否则 JNI 跨线程的 FindClass/GetMethodID 会成为性能瓶颈。// 帧消费者线程native 层 void frame_loop(uvc_device_handle_t* handle) { JNIEnv* env nullptr; // 在 native 线程里 attach 到 Java VM JavaVM* vm get_jvm(); vm-AttachCurrentThread(env, nullptr); while (running) { uvc_frame_t* frame nullptr; uvc_error_t res uvc_get_frame(handle, frame, 3000); if (res ! UVC_SUCCESS) { continue; // 超时继续等 } // 只拷贝到自己的缓冲区避免直接持有内部指针 copy_frame_to_user(frame-data, frame-data_bytes); uvc_free_frame(frame); } vm-DetachCurrentThread(); }这里有三个必须注意的点。一是 Java 层回调对象要以全局引用保存否则 native 线程里拿不到有效的 jobject二是 uvc_get_frame 返回的 frame 是指向内部缓冲区的指针必须在下次调用前释放否则内部锁竞争会导致帧率下降三是 Android 的 USB bulk 传输在系统休眠时会超时所以 get_frame 的 timeout 参数不要设成无限大一般 3000ms 比较稳。提示UVC 摄像头的控制命令走 control transfer视频数据流走 bulk transfer两条路互不干扰。别在视频线程里发 control 请求会阻塞帧读取。3. 编译与封装NDK/CMake 交叉编译 libuvc 与 JNI 层设计3.1 CMake NDK 编译 libuvctoolchain 参数与全局静态库要把 libuvc 接进 Android 工程最省心的方式是用 CMake 直接集成源码编译。libuvc 的依赖很少主要有一个 libusb 的后端。在 Android 上我们不需要 libusb 自己管理设备枚举而是用 UsbManager 传进来的 fd所以通常把 libusb 也一并编进去。# CMakeLists.txt 核心片段 cmake_minimum_required(VERSION 3.22) project(uvc_camera) set(CMAKE_CXX_STANDARD 17) # 交叉编译工具链由 Android Studio 注入这里只需要指定 ABI add_library(uvc STATIC ${CMAKE_CURRENT_SOURCE_DIR}/libuvc/src/uvc.c ${CMAKE_CURRENT_SOURCE_DIR}/libusb/libusb/core.c ${CMAKE_CURRENT_SOURCE_DIR}/libusb/libusb/os/linux_usbfs.c ) target_include_directories(uvc PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/libuvc/include ${CMAKE_CURRENT_SOURCE_DIR}/libusb/libusb ) # Android 的 usbfs 需要 -D_GNU_SOURCE否则 linux_usbfs.c 里一些定义不可见 target_compile_definitions(uvc PUBLIC _GNU_SOURCE LIBUSB_NO_TIMEOUT_DEVICE_LIST ) # 链接我们自己写的 JNI 层 add_library(uvc_camera SHARED src/main/cpp/jni_bridge.cpp src/main/cpp/frame_processor.cpp ) target_link_libraries(uvc_camera uvc android log)编译 libuvc 时最常见的坑是 linux_usbfs.c 在 Android 上的 sysfs 路径和桌面 Linux 不一样。桌面 Linux 访问 /dev/bus/usbAndroid 上则必须通过 UsbDeviceConnection 拿到的 fd 来操作。libusb 在 Android 上有专门的 android 后端它的初始化方式是 libusb_set_option(ctx, LIBUSB_OPTION_WEAK_AUTHORITY)并且在设备枚举时用 AlreadyOpenFd。你在实际集成时不需要理解 libusb 全部源码只需要调通一个函数libusb_wrap_sys_device把 Android 的 UsbDeviceConnection 的 fd 包装进去。libuvc 源码本身没有直接暴露这个能力我一般是给 libuvc 打一个小补丁在 uvc_init 之后替换掉它的 usb_ctx再手动完成设备句柄挂载。这个补丁在资源包里是现成的改的是 libuvc 的 src/interface.c 和 src/device.c 两个文件。3.2 JNI 句柄设计native session 怎么安全地活着JNI 层最怕两件事句柄悬空和回调失效。句柄悬空指的是 Java 层用一个 long 保存 native 指针但 native 对象已经在某个异步路径里被释放。规避办法是做一个 session 对象把 context、device handle、线程状态、回调全局引用全部包在同一个结构体里面。// native session 结构伪代码 struct UvcSession { uvc_context_t* ctx; uvc_device_handle_t* dev_handle; std::thread* capture_thread; bool running; jobject java_callback; // 全局引用 uint8_t* frame_buffer; int buffer_size; std::mutex buffer_mutex; };Java 层拿到的 long就是 reinterpret_cast 之后的 UvcSession 指针。每次 nativeCall 进来第一步先校验 session 是否为空、是否还活着再往下走。我习惯在每个 native 方法入口加一个简单的守卫#define CHECK_SESSION(handle, ...) \ if ((handle) 0) return __VA_ARGS__; \ UvcSession* session reinterpret_castUvcSession*((handle)); \ if (session-ctx nullptr) return __VA_ARGS__; extern C JNIEXPORT jint JNICALL Java_com_example_UvcCamera_nativeStartStream(JNIEnv* env, jobject thiz, jlong handle, jint w, jint h, jint fps) { CHECK_SESSION(handle, -1); // 先停止旧的流再协商新的 if (session-dev_handle) { uvc_stop_streaming(session-dev_handle); } // 协商并启动 uvc_stream_ctrl_t ctrl; uvc_error_t res uvc_get_stream_ctrl_format_size( session-dev_handle, ctrl, UVC_FRAME_FORMAT_YUYV, w, h, fps); if (res ! UVC_SUCCESS) { return static_castjint(res); } res uvc_start_streaming(session-dev_handle, ctrl, frame_callback, session, 0); return static_castjint(res); }这里 frame_callback 是 uvc 内部回调它在 UVC 后端线程里被调用不应该在里面做耗时操作。我的做法是回调里只做一件事把 frame 数据 memcpy 到 session-frame_buffer然后给渲染线程发一个信号量。渲染线程再根据自己的节奏消费这块 buffer。这样采集和渲染完全解耦摄像头不会因为 UI 卡顿而丢帧。3.3 像素格式转换的 C 实现YUYV 转 NV21 的手写循环UVC 摄像头默认输出的像素格式大多是 YUYV一种 4:2:2 的 YUV 交错格式而 Android 的预览接口和 MediaCodec 输入通常要 NV21 或 YUV420P。所以 native 层一般要做一个像素格式转换。这个转换没有捷径就是逐像素搬运和计算 UV 分量。// YUYV - NV21假设输入宽度为偶数 void yuyv_to_nv21(const uint8_t* yuyv, uint8_t* y_plane, uint8_t* uv_plane, int width, int height) { const uint8_t* src yuyv; uint8_t* dstY y_plane; uint8_t* dstUV uv_plane; int frameSize width * height; for (int i 0; i frameSize / 2; i) { // YUYV 每 4 字节表示 2 个像素Y0 U Y1 V uint8_t y0 src[0]; uint8_t u src[1]; uint8_t y1 src[2]; uint8_t v src[3]; dstY[0] y0; dstY[1] y1; dstY 2; dstUV[0] v; dstUV[1] u; dstUV 2; src 4; } // NV21 的 UV 平面顺序是 V 在前U 在后 // 上面的写法已提前把 v 和 u 配对写入 }注意 NV21 的 UV 平面是交错存储而且顺序是 V 先 U 后和 YUV420SP 刚好相反。很多第一次写转换的人会直接照抄 YUV420SP 的代码导致颜色偏紫。另外如果要做 NEON 优化可以用 ARM 的 vld4/vst4 指令一次处理 8 个像素但这个函数作为兜底实现足够稳定我不会在主线程跑到它而是放在单独的低优先级线程里跑。4. 帧从 native 层走向屏幕ANativeWindow 推帧与 OpenGL 渲染4.1 用 ANativeWindow 实现最小化预览路径如果你的需求只是“把摄像头画面显示到屏幕上”不涉及水印、滤镜、特效那最简单的方式是用 ANativeWindow。它本质上是 Android Surface 的 native 接口可以直接把 RGBA 或 NV21 数据 push 上去不需要在 Java 层做任何拷贝。// 通过 ANativeWindow 推帧 void render_to_window(ANativeWindow* window, uint8_t* nv21_data, int width, int height) { ANativeWindow_setBuffersGeometry(window, width, height, WINDOW_FORMAT_RGBA_8888); ANativeWindow_Buffer buffer; if (ANativeWindow_lock(window, buffer, nullptr) ! 0) { return; } // 这里需要把 NV21 转成 RGBA或者直接改用 RGBA 格式的纹理 // 最省事的方式用 libyuv 的 NV21ToRGB565 转换 uint8_t* line reinterpret_castuint8_t*(buffer.bits); convert_nv21_to_rgba(nv21_data, line, width, height, buffer.stride); ANativeWindow_unlockAndPost(window); }这个方案的缺点是每次推帧都要做一次格式转换在 1080p 下 CPU 占用会比较高。但胜在简单适合快速验证采集链路是否通。如果你的业务是检测算法之类需要拿帧做分析的场景用这种方式把预览帧独立出来是很干净的架构。4.2 OpenGL 的 YUV 渲染管线Shader 里做颜色转换如果要叠加图形或者要高性能预览就得走 OpenGL。核心思路是把 YUYV 数据拆成 Y 平面和 UV 平面上传两个纹理在 Fragment Shader 里用 YUV 到 RGB 的矩阵做转换。这一步可以把颜色空间转换从 CPU 挪到 GPU。// 着色器核心代码GLSL #version 300 es precision mediump float; uniform sampler2D y_tex; uniform sampler2D uv_tex; in vec2 v_texCoord; out vec4 fragColor; void main() { float y texture(y_tex, v_texCoord).r; vec2 uv texture(uv_tex, v_texCoord).rg; // 标准 BT.601 颜色转换矩阵 float r y 1.402 * (uv.r - 0.5); float g y - 0.344 * (uv.g - 0.5) - 0.714 * (uv.r - 0.5); float b y 1.772 * (uv.g - 0.5); fragColor vec4(r, g, b, 1.0); }这里要注意纹理的像素格式选择。Android 的 GLES 扩展里有 GL_EXT_YUV_target但大多数设备不保证支持。稳妥做法还是把 Y 和 UV 拆成两个 R8/RG8 纹理靠 shader 做矩阵运算。上传纹理时PBOPixel Buffer Object是一个可用优化——先通过 glBufferData 把 CPU 数据拷进 PBO再在下一帧用 glTexSubImage2D 从 PBO 取避免纹理更新时的同步停顿。PBO 需要控制大小通常两到三个 buffer 轮转就够。4.3 缓冲池与丢帧策略稳住 30fps 的水位摄像头采集线程、格式转换线程、GL 渲染线程如果各干各的很容易出现内存抖动和帧积压。我一般维护一个固定长度的缓冲池容量是 3 帧采集线程往里写渲染线程往外取消费不过来就直接丢帧。// 缓冲池伪代码 class FramePool { public: int put(uint8_t* data, size_t len) { // 产出方 std::lock_guardstd::mutex lock(mtx_); if (read_index_ (write_index_ 1) % kPoolSize) { return -1; // 池已满丢帧 } memcpy(pool_[write_index_], data, len); write_index_ (write_index_ 1) % kPoolSize; return 0; } uint8_t* get() { // 消费方 std::lock_guardstd::mutex lock(mtx_); if (read_index_ write_index_) return nullptr; uint8_t* frame pool_[read_index_]; read_index_ (read_index_ 1) % kPoolSize; return frame; } private: static constexpr int kPoolSize 3; uint8_t pool_[kPoolSize][1920 * 1080 * 2]; // 按最大分辨率预分配 int read_index_ 0, write_index_ 0; std::mutex mtx_; };这里有一个很微妙的点池满了返回 -1 是丢帧策略但你不能把池子建得太大。池子里积压帧意味着预览延迟增大你看到画面会比实际慢不少。一般 1080p 下池容量 3 帧是比较平衡的既能把抖动的帧率抹平又不会把延迟拉到 100ms 以上。5. UVC 摄像头开发避坑指南枚举失败、花屏、拔线崩溃的五个现场5.1 插上没反应USB Permission 与设备过滤器现象设备插上后 Logcat 没有任何 UVC 相关输出调用 nativeOpen 一直返回 0但用lsusb能看到设备。原因Android 的 UsbManager 只有在应用声明了设备过滤规则并处于前台时才会把设备授权给应用。很多工程只加了USB_DEVICE_ATTACHED的 intent-filter但忘了在 xml 里声明具体的 vendor-id 和 product-id。另外如果设备没被 filter 命中UsbManager 不会自动弹授权框。解决在res/xml/device_filter.xml里显式声明摄像头的 VID/PID并在 Activity 里动态注册UsbManager.ACTION_USB_DEVICE_ATTACHED广播。拿到UsbDeviceConnection后调用claimInterface注意 UVC 摄像头一般有两个 interface一个 VideoControl通常 interface 0一个 VideoStreaming通常 interface 1。libuvc 默认 claim interface 是 0如果你的摄像头描述符里 streaming 在其它编号上需要在 native 层额外 claim。5.2 画面撕裂、花屏buffer 生命周期错位现象预览画面偶尔出现横向撕裂或者大范围花屏但是帧率并没有掉。原因最常见的是 uvc_get_frame 返回的帧指针被另一个线程释放了渲染线程还在读取这块内存。另一种是 libuvc 内部缓冲是环形复用帧数据在下一帧到来时被覆盖写入渲染线程拿到的是半新的数据。解决我在 2.3 节里已经强制要求memcpy到自己的 buffer这一步绝对不能省。如果你用的是 OpenGL 纹理上传还要注意glTexSubImage2D必须在渲染线程里执行不能在采集线程里直接上传纹理否则会引发 GL 上下文切换错误。排查花屏时先把颜色转换关掉直接渲染 Y 平面灰度图能快速判断是数据错位还是颜色矩阵写错。5.3 高分辨率卡顿带宽与格式选择的误区现象摄像头 640x48030fps 很流畅切到 1280x720 或 1080p 后帧率直接掉到 10fps 以下。原因这大概率不是摄像头性能问题而是 USB 带宽不够。YUYV 在 1080p30fps 下需要的带宽大约 1.2Gbps而普通 USB 2.0 实际有效带宽也就 300-400Mbps。UVC 摄像头在带宽不够时会频繁丢包导致帧率下降。解决切到 MJPEG 格式让它走硬件解压。一帧 1080p MJPEG 通常只有几百 KB带宽压力小一个数量级。代价是渲染前要解压但 libuvc 拿到 MJPEG 帧后可以直接交给系统硬件解码器或者用 libjpeg-turbo 在 native 层解成 YUV。我一般习惯先稳定跑通 MJPEG确认功能没问题后再回头优化 YUYV 的带宽预算。5.4 拔掉 USB 崩溃回调线程与设备句柄的竞争现象UVC 摄像头正常工作时拔出 USB应用直接 SIGSEGV重启后恢复复现率 100%。原因libuvc 的帧回调线程在设备断开时会进入错误分支但你的帧循环线程还在等下一帧此时访问已经失效的 dev_handle 就会崩溃。还有一种情况是 Java 层先调用了 nativeClose然后 native 层的 capture_thread 还在 uvc_get_frame 里阻塞两个线程同时操作同一个 handle。解决nativeClose 里必须先停线程再关设备。停线程不能用简单的pthread_join因为帧循环uvc_get_frame是阻塞调用设备毕竟还插着。正确顺序是先把 running 置 false再调uvc_stop_streaming让 get_frame 返回随后 join 线程最后uvc_close和uvc_exit。每层都加超时保护别让一个环节卡死导致整个 close 卡住主线程。5.5 部分手机枚举不到设备OTG 供电与厂商 USB 栈的差异现象同一个摄像头在开发机上能跑换某个品牌的手机后 USB 枚举不到设备或者偶尔能枚举但画面黑屏。原因一是部分手机的 USB OTG 供电能力弱带不动某些工控摄像头的高功耗。二是不同厂商的 USB 栈对 UVC 描述符解析的宽容度不一样某些摄像头允许描述符超时厂商栈会直接放弃。这个和 libuvc 无关是系统底层行为。解决先确认设备在对手头机器的 USB 枚举状态用UsbManager.getDeviceList()打印所有已挂载设备。如果列表里没有基本是供电或硬件兼容问题换一个带外部供电的 USB HUB 试试。如果列表里能出现但拿不到帧可以尝试把libuvc的UVC_STREAM_CTRL超时时间调大部分设备在启动流时响应慢默认超时 500ms 不够用。注意花屏类问题的排查顺序是 数据正确性 - 格式转换 - 渲染线程 - GL 纹理上传四个环节逐层验证别急着优化代码。6. 更进一步把采集能力封装成热插拔 CameraService这个资源做到能出画面只是第一步。真正在产线上用的时候你会发现设备拔插、切换摄像头、异常恢复才是最值钱的逻辑。我通常会在这份资源的基础上再做一层 CameraService 抽象把热插拔监听和自动重连加进去。热插拔的做法是在 Java 层监听ACTION_USB_DEVICE_ATTACHED和ACTION_USB_DEVICE_DETACHED收到广播后调用 native 层的nativeOpen重新枚举。自动重连要做“协商降级”而不是“硬重连”如果 1080p 协商失败不要直接报错尝试回退到 720p再失败回退到 640x480。很多 UVC 摄像头在拔插一次后描述符缓存会错乱宁可降级也别卡死。多摄像头切换的要点是 session 隔离。每一路摄像头一个独立的 UvcSession互不共享帧缓冲。Java 层用一个CameraManager维护 session 列表切换时先 stop 当前流再 start 目标流。这里不要每次都重新uvc_init和uvc_exit因为uvc_exit会清空整个上下文其他 session 会被连坐全部失效。正确做法是上下文复用设备 handle 各自管理。从那以后我每次做 UVC 采集模块都会强制走一遍“先验证 USB 枚举、再验证格式协商、最后才写渲染”的排查顺序。这套链路里的坑确实不少但把每一层的边界划清楚之后问题定位会快很多。希望帮到你。本文还有配套的精品资源点击获取