更新记录

1.0.0(2026-06-23)

专注解决 MTK 设备黑屏问题,支持多摄像头预览。


平台兼容性

uni-app(3.7.12)

Vue2 Vue2插件版本 Vue3 Vue3插件版本 Chrome Safari app-vue app-nvue Android iOS 鸿蒙
1.0.0 1.0.0 × × × × × × ×
微信小程序 支付宝小程序 抖音小程序 百度小程序 快手小程序 京东小程序 鸿蒙元服务 QQ小程序 飞书小程序 小红书小程序 快应用-华为 快应用-联盟
× × × × × × × × × × × ×

uni-app x(3.7.12)

Chrome Safari Android iOS 鸿蒙 微信小程序
× × 5.0 × × ×

zy-uvccamera

📺 演示视频

基于 UVC 协议的 USB 摄像头插件,支持在 Android 设备上连接与使用外接 USB 摄像头,提供相机预览、拍照、录像、参数调节等功能。

特性

  • USB 摄像头即插即用(需设备支持 USB Host/OTG)
  • 已解决MTK设备预览不正常问题
  • 支持预览画面旋转与镜像
  • 支持拍照(JPEG 格式)
  • 支持录像(MP4 格式,可选音频)
  • 支持动态切换分辨率
  • 支持调节 UVC 参数(亮度、对比度、饱和度、 hue、白平衡、锐度、增益、曝光、焦距、缩放、背光补偿、Gamma)
  • 支持多摄像头设备枚举
  • 支持多摄像头同时预览

平台支持

平台 支持
Android
iOS
H5
小程序

最低 Android SDK 版本: 21 (Android 5.0)

快速开始

引入组件

在页面 template 中使用组件标签:

<zy-uvccamera
  ref="uvcCamera"
  class="camera"
  @onEvent="onEvent"
  @onEventMethod="onEventMethod">
</zy-uvccamera>

打开摄像头

// 自动选择第一个 USB 摄像头
this.$refs.uvcCamera.startCamera()

// 按设备名指定
this.$refs.uvcCamera.startCamera({ deviceName: '/dev/bus/usb/001/002' })

拍照

this.$refs.uvcCamera.takePicture()
// 照片保存至: {app-files-dir}/uvcCamera/uvc_{timestamp}.jpg

录像

// 开始录像(默认采集音频)
this.$refs.uvcCamera.startRecord()

// 关闭音频
this.$refs.uvcCamera.startRecord({ audio: false })

// 停止录像
this.$refs.uvcCamera.stopRecord()
// 视频保存至: {app-files-dir}/uvcCamera/uvc_{timestamp}.mp4

API

组件属性

无外部 props,所有操作通过方法调用与事件监听完成。

事件

@onEvent

处理拍照、录像等异步操作的回调。

onEvent(e) {
  const { type, data } = e.detail
  // data: { message, code, data }
  if (type === 'takePicture' && data.code === 0) {
    console.log('照片已保存:', data.data.uri)
  }
}

事件 type 取值:

type 说明
takePicture 拍照结果
startRecord 开始录像
stopRecord 停止录像结果

@onEventMethod

处理方法调用返回结果。

onEventMethod(e) {
  const { type, data } = e.detail
  // data: { code, msg, data }
  if (type === 'startCamera' && data.code === 0) {
    console.log('摄像头已就绪')
  }
}

事件 type 取值:

type 说明
startCamera 打开摄像头结果
stopCamera 关闭摄像头结果
getAllUvcCameras 设备列表
getSupportedSizeList 支持的分辨率列表
getPreviewSize 当前预览分辨率
getUVCControlParam UVC 参数值
setUVCControlParam 设置 UVC 参数结果
resetAllControlParams 重置参数结果
setResolution 切换分辨率结果
setPreviewRotationAndMirror 旋转/镜像设置结果
isCameraOpen 摄像头状态

方法

startCamera(param?: CameraOptions)

打开 USB 摄像头。

type CameraOptions = {
  deviceName?: string   // 按 USB 设备名选择
  productName?: string  // 按产品名选择
  size?: number[]       // 指定分辨率 [width, height]
}

stopCamera()

关闭摄像头并释放资源。

takePicture()

拍照。成功回调携带 { uri: string }

startRecord(param?: RecordOptions)

开始录像。

type RecordOptions = {
  audio?: boolean  // 是否录制音频,默认 true
}

stopRecord()

停止录像。成功回调携带 { uri: string, path: string }

getSupportedSizeList()

获取所有支持的分辨率列表。

getPreviewSize()

获取当前预览分辨率。

setResolution(size: UvcSize)

切换预览分辨率。

type UvcSize = {
  width: number
  height: number
  type?: number      // 7 = MJPEG
  fps?: number
  fpsList?: number[]
}

setPreviewRotationAndMirror(rotation: number, mirror: number)

设置预览旋转与镜像。

  • rotation: 0 | 90 | 180 | 270
  • mirror: 0=关闭, 1=水平, 2=垂直

isCameraOpen()

查询摄像头是否已打开。

getAllUvcCameras()

枚举所有已连接的 USB 摄像头设备。

type UvcDeviceInfo = {
  deviceName: string
  deviceId: number
  vendorId: number
  productId: number
  manufacturerName: string | null
  productName: string | null
  serialNumber: string | null
  interfaceCount: number
}

getUVCControlParam()

读取当前 UVC 控制参数(含 min/max 范围)。

setUVCControlParam(action: string, value: number)

设置 UVC 控制参数。

action 值 参数
setBrightness 亮度
setContrast 对比度
setSaturation 饱和度
setHue 色调
setWhiteBalance 白平衡
setSharpness 锐度
gain 增益
exposureTime 曝光时间
focus 焦距
zoom 缩放
backlightComp 背光补偿
gamma Gamma

resetAllControlParams()

重置所有 UVC 参数到硬件默认值。

错误码

错误码 说明
9010001 设备未连接
9010002 打开摄像头失败
9010003 拍摄失败
9010004 录制失败
9010005 操作超时

权限与要求

Android 权限

<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />

硬件要求

  • 设备须支持 USB Host 模式(OTG)
  • Android 5.0 (API 21) 及以上

完整示例

<template>
    <view class="page">
        <scroll-view scroll-y class="scroll-content">
            <view class="preview-wrap">
                <zy-uvccamera ref="uvcCamera" class="camera" @onEvent="onEvent" @onEventMethod="onEventMethod">
                </zy-uvccamera>
            </view>

            <view class="status-bar">
                <view class="status-dot" :class="cameraReady ? 'dot-online' : 'dot-offline'"></view>
                <text class="status-text">{{ statusText }}</text>
            </view>

            <view class="section">
                <text class="section-title">设备</text>
                <view class="btn-row">
                    <button class="btn" :class="cameraReady ? 'btn-disabled' : ''" @click="startCamera(null)"
                        :disabled="cameraReady">打开摄像头</button>
                    <button class="btn" :class="!cameraReady ? 'btn-disabled' : ''" @click="stopCamera"
                        :disabled="!cameraReady">关闭</button>
                    <button class="btn" @click="refreshDevices">刷新</button>
                </view>
                <view v-if="deviceList.length" class="device-list">
                <view class="device-item" v-for="(d, i) in deviceList" :key="i" @click="startCamera(d.deviceName)">
                        <text>{{ d.deviceName || ('设备' + i) }}</text>
                        <text>VID:{{ d.vendorId }} PID:{{ d.productId }}</text>
                    </view>
                </view>
                <text class="hint" v-else>未检测到 USB 摄像头设备</text>
            </view>

            <view class="section">
                <text class="section-title">捕获</text>
                <view class="btn-row">
                    <button class="btn btn-capture" :class="!cameraReady ? 'btn-disabled' : ''" @click="takePicture"
                        :disabled="!cameraReady">拍照</button>
                </view>
                <view class="file-result" v-if="lastFilePath">
                    <text>已保存: {{ lastFileName }}</text>
                    <image :src="previewPath" class="preview-img" mode="aspectFit" v-if="previewPath"></image>
                </view>
            </view>

            <view class="section">
                <text class="section-title">录像</text>
                <view class="btn-row">
                    <button class="btn btn-capture" :class="!cameraReady || isRecording ? 'btn-disabled' : ''"
                        @click="startRecord" :disabled="!cameraReady || isRecording">开始录像</button>
                    <button class="btn btn-capture" :class="!isRecording ? 'btn-disabled' : ''"
                        @click="stopRecord" :disabled="!isRecording" style="background:#ff3b30;color:#fff;border-color:#ff3b30">停止录像</button>
                </view>
                <view class="file-result" v-if="lastVideoPath">
                    <text>已保存: {{ lastVideoName }}</text>
                </view>
                <text class="hint" v-if="recordingStatus" style="color:#ff3b30">● {{ recordingStatus }}</text>
            </view>

            <view class="section">
                <text class="section-title">分辨率</text>
                <view class="btn-row">
                    <button class="btn" :class="!cameraReady ? 'btn-disabled' : ''" @click="loadSupportedSizes"
                        :disabled="!cameraReady">扫描</button>
                    <text class="hint" v-if="supportedSizes.length" style="margin-left:12rpx">共 {{ supportedSizes.length }} 种</text>
                </view>
                <text class="hint" v-if="currentPreviewSize">
                    当前: {{ currentPreviewSize.width }} × {{ currentPreviewSize.height }}
                </text>
                <scroll-view scroll-x class="size-scroll" v-if="supportedSizes.length">
                    <view class="size-chip" v-for="(s, i) in supportedSizes" :key="i"
                        @click="applyResolution(s)"
                        :class="currentPreviewSize && currentPreviewSize.width == s.width && currentPreviewSize.height == s.height ? 'size-chip-active' : ''">
                        <text>{{ s.width }}×{{ s.height }}</text>
                        <text class="size-type">{{ s.type == 7 ? 'MJPEG' : 'YUYV' }}</text>
                        <text class="size-fps" v-if="s.fps">{{ s.fps }}fps</text>
                    </view>
                </scroll-view>
            </view>

            <view class="section">
                <text class="section-title">UVC 参数</text>
                <view class="btn-row">
                    <button class="btn" :class="!cameraReady ? 'btn-disabled' : ''" @click="loadUVCParams">读取</button>
                    <button class="btn" :class="!cameraReady ? 'btn-disabled' : ''"
                        @click="resetControlParams">重置</button>
                </view>
                <view v-if="!Object.keys(controlParams).length" class="hint">点击"读取"获取当前值</view>
                <view class="control-group" v-for="(ctrl, key) in controlParams" :key="key">
                    <view class="control-header">
                        <text>{{ ctrl.label }} {{ ctrl.current }}</text>
                    </view>
                    <slider :min="ctrl.min" :max="ctrl.max" :value="ctrl.current"
                        @changing="onSliderChanging(key, $event)" @change="onSliderChange(key, $event)" />
                </view>
            </view>

            <view class="section">
                <text class="section-title">预览配置</text>
                <view class="config-row">
                    <text class="config-label">旋转</text>
                    <view class="btn-row">
                        <button class="btn btn-xs" :class="rotation == 0 ? 'btn-active' : ''"
                            @click="setRotation(0)">0°</button>
                        <button class="btn btn-xs" :class="rotation == 90 ? 'btn-active' : ''"
                            @click="setRotation(90)">90°</button>
                        <button class="btn btn-xs" :class="rotation == 180 ? 'btn-active' : ''"
                            @click="setRotation(180)">180°</button>
                        <button class="btn btn-xs" :class="rotation == 270 ? 'btn-active' : ''"
                            @click="setRotation(270)">270°</button>
                    </view>
                </view>
                <view class="config-row">
                    <text class="config-label">镜像</text>
                    <view class="btn-row">
                        <button class="btn btn-xs" :class="mirror == 0 ? 'btn-active' : ''"
                            @click="setMirror(0)">关</button>
                        <button class="btn btn-xs" :class="mirror == 1 ? 'btn-active' : ''"
                            @click="setMirror(1)">水平</button>
                        <button class="btn btn-xs" :class="mirror == 2 ? 'btn-active' : ''"
                            @click="setMirror(2)">垂直</button>
                    </view>
                </view>
            </view>
        </scroll-view>
    </view>
</template>

<script>
    function unwrapUTS(obj) {
        if (!obj || typeof obj !== 'object') return obj
        if (obj.dynamicJSONFields) return unwrapUTS(obj.dynamicJSONFields)
        if (Array.isArray(obj)) return obj.map(unwrapUTS)
        let result = {}
        for (let key of Object.keys(obj)) {
            if (key === 'jSONArray') continue
            result[key] = unwrapUTS(obj[key])
        }
        return result
    }

    export default {
        data() {
            return {
                cameraReady: false,
                statusText: '等待 USB 摄像头...',
                lastFilePath: '',
                lastFileName: '',
                previewPath: '',
                deviceList: [],
                supportedSizes: [],
                currentPreviewSize: null,
                rotation: 0,
                mirror: 0,
                controlParams: {},
                isRecording: false,
                recordingStatus: '',
                lastVideoPath: '',
                lastVideoName: ''
            }
        },
        methods: {
            onEvent(e) {
                let data = unwrapUTS(e.detail)
                console.log("onEvent", data)
                if (!data) return
                let payload = data.data
                if (data.type == 'takePicture' && payload.code == 0) {
                    uni.hideLoading()
                    let d = payload.data
                    this.lastFileName = d.uri ? d.uri.substring(d.uri.lastIndexOf('/') + 1) : ''
                    this.lastFilePath = d.uri || ''
                    this.previewPath = d.uri || ''
                } else if (data.type == 'startRecord' && payload.code == 0) {
                    this.isRecording = true
                    this.recordingStatus = '录制中...'
                } else if (data.type == 'stopRecord' && payload.code == 0) {
                    this.isRecording = false
                    this.recordingStatus = ''
                    let d = payload.data
                    this.lastVideoName = d.uri ? d.uri.substring(d.uri.lastIndexOf('/') + 1) : ''
                    this.lastVideoPath = d.uri || ''
                    this.statusText = '录制完成'
                } else if (data.type == 'stopRecord' && payload.code != 0) {
                    this.isRecording = false
                    this.recordingStatus = ''
                    this.statusText = payload.message || '录制失败'
                } else if (payload.code != 0) {
                    this.statusText = payload.message || ''
                }
            },
            onEventMethod(e) {
                let data = unwrapUTS(e.detail)
                console.log("onEventMethod", data)
                if (!data) return
                let payload = data.data
                this.statusText = payload.msg || ''
                if (data.type == 'getAllUvcCameras' && payload.code == 0) {
                    this.deviceList = payload.data.devices || []
                } else if (data.type == 'startCamera' && payload.code == 0) {
                    this.cameraReady = true
                    this.statusText = '摄像头已就绪'
                } else if (data.type == 'stopCamera' && payload.code == 0) {
                    this.cameraReady = false
                } else if (data.type == 'takePicture' && payload.code == 0) {
                    uni.hideLoading()
                    let d = payload.data
                    this.lastFileName = d.uri ? d.uri.substring(d.uri.lastIndexOf('/') + 1) : ''
                    this.lastFilePath = d.uri || ''
                    this.previewPath = d.uri || ''
                } else if (data.type == 'startRecord' && payload.code == 0) {
                    this.isRecording = true
                    this.recordingStatus = '录制中...'
                } else if (data.type == 'stopRecord' && payload.code == 0) {
                    this.isRecording = false
                    this.recordingStatus = ''
                } else if (data.type == 'getSupportedSizeList' && payload.code == 0) {
                    this.supportedSizes = payload.data.sizes || []
                    this.statusText = '获取到 ' + this.supportedSizes.length + ' 种分辨率'
                } else if (data.type == 'getPreviewSize' && payload.code == 0) {
                    this.currentPreviewSize = payload.data
                } else if (data.type == 'startRecord' && payload.code != 0) {
                    this.statusText = payload.msg || '启动录像失败'
                } else if (data.type == 'stopRecord' && payload.code != 0) {
                    this.isRecording = false
                    this.recordingStatus = ''
                    this.statusText = payload.msg || '停止录像失败'
                } else if (data.type == 'getUVCControlParam' && payload.code == 0) {
                    let raw = payload.data
                    let params = {}
                    Object.keys(raw).forEach(key => {
                        let item = raw[key]
                        params[key] = {
                            current: item.current,
                            min: item.limit.min,
                            max: item.limit.max,
                            label: { brightness: '亮度', contrast: '对比度', saturation: '饱和度', hue: '色调', whiteBalance: '白平衡', sharpness: '锐度' }[key] || key
                        }
                    })
                    this.controlParams = params
                } else if (payload.code != 0 && payload.msg) {
                    uni.hideLoading()
                }
            },
            refreshDevices() {
                this.$refs.uvcCamera.getAllUvcCameras()
            },
            startCamera(deviceName) {
                this.statusText = '正在打开摄像头...'
                this.$refs.uvcCamera.startCamera({deviceName:deviceName})
            },
            stopCamera() {
                this.cameraReady = false
                this.statusText = '正在关闭...'
                this.$refs.uvcCamera.stopCamera()
            },
            takePicture() {
                this.statusText = '拍照中...'
                uni.showLoading({ title: '拍照中...', mask: true })
                try {
                    this.$refs.uvcCamera.takePicture()
                } catch (e) {
                    uni.hideLoading()
                    this.statusText = '拍照失败: ' + e.message
                }
            },
            startRecord() {
                this.recordingStatus = '启动中...'
                try {
                    this.$refs.uvcCamera.startRecord()
                } catch (e) {
                    this.recordingStatus = ''
                    this.statusText = '启动录像失败: ' + e.message
                }
            },
            stopRecord() {
                try {
                    this.$refs.uvcCamera.stopRecord()
                } catch (e) {
                    this.statusText = '停止录像失败: ' + e.message
                }
            },
            loadSupportedSizes() {
                this.$refs.uvcCamera.getSupportedSizeList()
                this.$refs.uvcCamera.getPreviewSize()
            },
            applyResolution(size) {
                this.statusText = '切换中...'
                this.$refs.uvcCamera.setResolution(size)
            },
            loadUVCParams() {
                this.$refs.uvcCamera.getUVCControlParam()
            },
            onSliderChanging(key, e) {
                if (this.controlParams[key]) {
                    this.controlParams[key].current = e.detail.value
                }
            },
            onSliderChange(key, e) {
                let val = parseInt(e.detail.value)
                this.$refs.uvcCamera.setUVCControlParam('set' + key.charAt(0).toUpperCase() + key.slice(1), val)
            },
            resetControlParams() {
                this.statusText = '重置中...'
                this.$refs.uvcCamera.resetAllControlParams()
            },
            setRotation(deg) {
                this.rotation = deg
                this.$refs.uvcCamera.setPreviewRotationAndMirror(deg, this.mirror)
            },
            setMirror(mode) {
                this.mirror = mode
                this.$refs.uvcCamera.setPreviewRotationAndMirror(this.rotation, mode)
            }
        }
    }
</script>

<style>
    .page { flex: 1; background: #fff; }
    .scroll-content { flex: 1; }
    .preview-wrap { width: 750rpx; height: 360px; background: #000; }
    .camera { width: 750rpx; height: 360px; }
    .status-bar { flex-direction: row; align-items: center; padding: 10rpx 24rpx; background: #f5f5f5; border-bottom: 1rpx solid #e0e0e0; }
    .status-dot { width: 10rpx; height: 10rpx; border-radius: 5rpx; margin-right: 10rpx; }
    .dot-online { background: #22c55e; }
    .dot-offline { background: #ccc; }
    .status-text { font-size: 24rpx; color: #666; }
    .section { padding: 20rpx 24rpx; border-bottom: 1rpx solid #f0f0f0; }
    .section-title { font-size: 28rpx; color: #333; font-weight: 600; margin-bottom: 14rpx; }
    .btn-row { flex-direction: row; flex-wrap: wrap; align-items: center; }
    .btn { padding: 14rpx 28rpx; border-radius: 6rpx; font-size: 26rpx; color: #333; background: #fff; border: 1rpx solid #ddd; text-align: center; margin-right: 12rpx; margin-bottom: 10rpx; }
    .btn-xs { padding: 8rpx 20rpx; font-size: 22rpx; width: 80rpx; }
    .btn-active { background: #007aff; color: #fff; border-color: #007aff; }
    .btn-disabled { opacity: 0.4; }
    .btn-capture { padding: 18rpx 48rpx; font-size: 28rpx; }
    .device-list { margin-top: 6rpx; }
    .device-item { padding: 10rpx 0; font-size: 24rpx; color: #333; }
    .file-result { margin-top: 12rpx; padding: 12rpx; background: #f9f9f9; border-radius: 6rpx; font-size: 22rpx; color: #999; }
    .preview-img { width: 200rpx; height: 200rpx; margin-top: 10rpx; border-radius: 6rpx; border: 1rpx solid #ddd; }
    .size-scroll { flex-direction: row; }
    .size-chip { padding: 8rpx 20rpx; background: #f5f5f5; border-radius: 6rpx; margin-right: 10rpx; border: 1rpx solid #e0e0e0; flex-direction: column; align-items: center; font-size: 24rpx; color: #333; }
    .size-chip-active { background: #007aff; border-color: #007aff; }
    .size-type { font-size: 18rpx; color: #999; margin-top: 2rpx; }
    .size-fps { font-size: 18rpx; color: #22c55e; margin-top: 2rpx; }
    .control-group { margin-top: 10rpx; padding: 12rpx; background: #f9f9f9; border-radius: 6rpx; }
    .control-header { margin-bottom: 4rpx; font-size: 24rpx; color: #333; }
    .config-row { flex-direction: row; align-items: center; margin-bottom: 10rpx; }
    .config-label { font-size: 24rpx; color: #666; width: 80rpx; }
    .hint { font-size: 22rpx; color: #999; }
</style>

隐私、权限声明

1. 本插件需要申请的系统权限列表:

"android.permission.CAMERA", "android.permission.WRITE_EXTERNAL_STORAGE", "android.permission.RECORD_AUDIO"

2. 本插件采集的数据、发送的服务器地址、以及数据用途说明:

3. 本插件是否包含广告,如包含需详细说明广告表达方式、展示频率:

暂无用户评论。