更新记录
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 | 270mirror: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>

收藏人数:
购买源码授权版(
试用
赞赏(0)
下载 2
赞赏 0
下载 12306584
赞赏 1923
赞赏
京公网安备:11010802035340号