UniApp 使用 live-pusher 实现人脸活体检测(附完整源码与踩坑记录)
不论你是因为什么场景用到 UniApp 来开发移动端 这里分享的都是我自己的实战经验;如果哪部分理解得不够准确 也欢迎各位大佬在评论区指点纠正 一起交流提升;
虽然 UniApp 官方提供了 camera 组件 但它目前对 App 端并不支持 只能在部分小程序平台正常使用;
我这次使用的是 Vue3 项目结构 + Vue2 语法(主要是当时刚上手 HBuilderX 推荐什么就跟着用了);如果你不强求新特性、Composition API 那些 其实 Vue2 的语法现在依然够稳、够成熟 踩坑概率也更低;
目前遇到的问题是:相机组件在 App 端渲染出来的 只能是方形预览框 我尝试了多种方式仍没找到可行的方案把它“强制”改成圆形;如果有大佬实现过 欢迎在评论区分享思路 我也想继续探索一下新的写法;
文末附带完整源码 供大家参考~
live-pusher是什么
live-pusher = 系统原生摄像头 API + 腾讯/自研推流引擎封装
UniApp 官方说明很明确
App 端 live-pusher 是使用原生能力实现的 不是 H5 所以h5上是不能渲染的
底层使用
-
Android:Camera、Camera2、MediaCodec、SurfaceView、OpenGL
-
iOS:AVFoundation(AVCaptureSession、AVCaptureDevice)
-
推流编码:硬件编码(H.264),有些版本是基于腾讯 TUIKit Lite 封装
在 App 上,它不是浏览器渲染,是直接调 Android/iOS 原生 API,所以使用稳定、延迟低、清晰度高;
live-pusher属性
<live-pusher
id="livePusher"
device-position="front"
mode="SD"
:enable-camera="true"
:auto-focus="true"
:muted="true"
style="width: 100%; height: 100%"
></live-pusher>
1. id="livePusher"
组件的唯一标识,后面用 JS 通过uni.createLivePusherContext('livePusher')
来控制它(开始推流、停止、快照…)。
2. device-position="front"
选择摄像头角度:
-
front= 前置摄像头(自拍) -
back= 后置摄像头
你做活体检测、身份验证,基本都是 front。
3. mode="SD"
推流模式(视频质量):
-
SD:标清(更稳定,耗流量小) -
HD:高清 -
FHD:全高清(清晰但更吃资源) -
RTC:实时模式(视频通话那种实时性)
如果你只是采集脸图,不推流,其实模式影响不大。
4. :enable-camera="true"
是否打开摄像头。
-
true:启用 -
false:禁用(不显示画面)
5. :auto-focus="true"
是否自动对焦。
-
true:摄像头自动调整清晰度(推荐) -
false:固定焦距(一般不用)
6. :muted="true"
是否关闭麦克风。
-
true:静音(只采集视频) -
false:同时采集声音
你做人脸识别,只需要视频,静音没问题。
7. style="width: 100%; height: 100%"
占满整个父容器,如果外层 view 是全屏,这里就是全屏摄像头画面。
总结
| 属性 | 作用 | 场景建议 |
|---|---|---|
| id | 用于 JS 控制 live-pusher | 必填 |
| device-position | 前后摄像头 | 活体、自拍用 front |
| mode | 推流质量 | 离线采集无差别,SD 更稳 |
| enable-camera | 是否启用摄像头 | 必填 true |
| auto-focus | 自动对焦 | 推荐 true |
| muted | 静音模式 | 人脸识别必须开 true |
| style | 宽高 | 自由控制 |
配置完上述属性还是不行 发现我们打开还是看不见人脸 那我们就要开始写js部分了
JavaScript部分
变量声明
data() {
return {
livePusher: null, //查看是否创建好了摄像头
isDetecting: false, //标记是否开始检测
isFaceExist: false, //人脸在不在框内
desc: '请正对摄像头,保持面部在方框内', //提示语
currentBg: '#ffffff', //当前背景颜色
actionList: ['眨眨眼', '张张嘴', '摇摇头'], //活体检测动作
colors: ['#ff4d4f', '#fadb14', '#1890ff', '#52c41a', '#722ed1'], //变换颜色
actionTimer: null, //计时器
detectTimer: null, //计时器
resourceUrl: ''
};
},
我这边是写了多个颜色和多个动作 最好是颜色和动作数量对齐 ;
使用onReady钩子
onReady() {
this.livePusher = uni.createLivePusherContext('livePusher', this);
this.livePusher.startPreview({
success: () => console.log('摄像头预览启动成功'),
fail: (err) => console.error('摄像头预览失败', err)
});
this.switchCamera(); //调用摄像头
this.startDetect();
},
this.livePusher = uni.createLivePusherContext('livePusher', this);
这里第一个参数 livePusher 就是我们开始在组件内部声明的id;
第二个参数this就是把 context 绑定到当前 Vue 实例;
this.livePusher.startPreview({
success: () => console.log('摄像头预览启动成功'),
fail: (err) => console.error('摄像头预览失败', err)
});
this.livePusher
就是你用 uni.createLivePusherContext() 创建的控制器;
startPreview()
告诉系统:
“打开摄像头,把画面展示出来,但我现在还不推流。
其他方法和区别
| 方法 | 作用 |
|---|---|
startPreview() |
只显示摄像头画面,不推流(你做人脸识别就是用这个) |
start() |
开始真正的推流(直播、通话场景) |
(tips:如打开调试发现还是前置摄像头的话可以使用这个方法调转在onReady钩子内
this.switchCamera()
switchCamera() {
if (!this.livePusher) return;
this.livePusher.switchCamera({
success: () => {},
fail: (err) => {}
});
},
在前置 / 后置摄像头之间切换
this.startDetect()
startDetect() {
if (this.isDetecting) return;
this.isDetecting = true;
this.desc = '正在检测人脸...';
this.detectLoop();
},
这里就在判断是否开始了人脸检测 ;变量声明是默认直接为false未开始 进入这个函数后直接开始;
this.detectLoop();
detectLoop() {
this.detectTimer = setInterval(async () => {
const faceDetected = await this.detectFace();
if (!faceDetected) {
// 没检测到人脸
this.isFaceExist = false;
this.desc = '请正对摄像头,保持面部在方框内';
this.stopActionDetect();
this.randomBg('#ffffff');
return;
}
// 检测到人脸
if (!this.isFaceExist) {
this.isFaceExist = true;
this.desc = '检测到人脸,准备开始动作识别...';
setTimeout(() => this.startActionDetect(), 1500);
}
}, 2000);
},
每2秒执行一次 detectFace()
↓
判断是否有人脸
↓
有脸?─────────────┐
↓ 是 否 ↓
检查 isFaceExist 停止动作检测
是否从 false→true 提示“请对准摄像头”
↓
第一次检测到脸?
↓ 是
更新提示 → 1.5秒后启动动作检测
this.getRandomColors();
// 随机选择三个不重复的颜色
getRandomColors() {
let copy = [...this.colors];
let selected = [];
for (let i = 0; i < 3; i++) {
const idx = Math.floor(Math.random() * copy.length);
selected.push(copy[idx]);
copy.splice(idx, 1);
}
return selected;
},
this.startActionDetect()
startActionDetect() {
if (this.actionTimer) clearInterval(this.actionTimer);
const colors = this.getRandomColors();
const shuffledActions = this.actionList.sort(() => Math.random() - 0.5);
let step = 0;
this.actionTimer = setInterval(() => {
if (!this.isFaceExist) {
this.stopActionDetect();
return;
}
if (step < shuffledActions.length) {
this.desc = shuffledActions[step];
this.currentBg = colors[step];
step++;
} else {
this.desc = '动作检测完成,开始识别中...';
clearInterval(this.actionTimer);
this.actionTimer = null;
setTimeout(() => this.verifyFace(), 1500);
}
}, 3000);
},
按照随机顺序让用户依次做动作(眨眼、摇头、点头…),每个动作给 3 秒,全部完成后自动进入 verifyFace() 做最终活体校验
随机动作排列 + 随机背景
↓
每 3 秒执行一次动作循环
↓
画面中无人脸?
↓ 是
停止动作检测 ←─────────┐
↓ 否 │ │
当前动作还未做完? │
↓ 是 │ │
显示动作提示 + 换背景 │
step++ │ │
│ │ │
└───────继续下一次循环
↓ 否(动作都做完)
显示“完成检测”
1.5秒后 this.verifyFace()
this.verifyFace();
verifyFace() {
this.desc = '人脸识别中...';
setTimeout(() => {
this.desc = '识别成功';
this.randomBg('#ffffff');
this.isDetecting = false;
clearInterval(this.detectTimer);
this.goTo(this.resourceUrl);
}, 2000);
}
this.stopActionDetect()
stopActionDetect() {
clearInterval(this.actionTimer);
this.actionTimer = null;
},
① 停止动作检测的定时器
this.actionTimer 是 startActionDetect() 里创建的循环动作(比如:抬头、低头、左右转等)。
一旦:
-
人脸丢失
-
或者识别流程被打断
-
或者你手动调用停止
就执行:
clearInterval(this.actionTimer);
意味着:动作指令不再继续播放。
② 清空状态
this.actionTimer = null;
把定时器变量置空,避免:
-
重复创建定时器
-
内存泄漏
-
多个 actionTimer 同时触发导致动作乱跳
以上就是本次人脸校验渲染核心代码解释 接下来附上源码
附上源码
<template>
<view :style="{ backgroundColor: currentBg, width: '100vw', height: '100vh', transition: 'background-color 1.5s ease' }">
<view class="header">
<last-step :active="true" title="人脸活体检测" />
</view>
<view class="body">
<!-- 不是白色背景的时候全部变成其他的颜色 -->
<view class="camera-box" :class="{ active: currentBg !== '#ffffff' }">
<live-pusher
id="livePusher"
device-position="front"
mode="SD"
:enable-camera="true"
:auto-focus="true"
:muted="true"
style="width: 100%; height: 100%"
></live-pusher>
</view>
<view class="other">
<view class="desc" :class="{ active: currentBg !== '#ffffff' }">{{ desc }}</view>
<!-- <view class="btn" @click="startDetect">开始识别</view> -->
</view>
</view>
</view>
</template>
<script>
export default {
data() {
return {
livePusher: null, //查看是否创建好了摄像头
isDetecting: false, //标记是否开始检测
isFaceExist: false, //人脸在不在框内
desc: '请正对摄像头,保持面部在方框内', //提示语
currentBg: '#ffffff', //当前背景颜色
actionList: ['眨眨眼', '张张嘴', '摇摇头'], //活体检测动作
colors: ['#ff4d4f', '#fadb14', '#1890ff', '#52c41a', '#722ed1'], //变换颜色
actionTimer: null, //计时器
detectTimer: null, //计时器
};
},
onReady() {
this.livePusher = uni.createLivePusherContext('livePusher', this);
this.livePusher.startPreview({
success: () => console.log('摄像头预览启动成功'),
fail: (err) => console.error('摄像头预览失败', err)
});
this.switchCamera(); //调用摄像头
this.startDetect();
},
onUnload() {
clearInterval(this.detectTimer);
clearInterval(this.actionTimer);
if (this.livePusher) this.livePusher.stopPreview();
},
methods: {
startDetect() {
if (this.isDetecting) return;
this.isDetecting = true;
this.desc = '正在检测人脸...';
this.detectLoop();
},
// 模拟或调用人脸检测接口
async detectFace() {
// const res = await uni.request({
// url: '识别接口',
// method: 'POST',
// data: { base64: 当前帧截图 }
// });
// return res.data?.faceDetected;
// 模拟检测: 随机返回是否检测到人脸
// return Math.random() > 0.3; // 70% 概率检测到
return true;//这是用于测试 返回永远拿到人脸
},
detectLoop() {
this.detectTimer = setInterval(async () => {
const faceDetected = await this.detectFace();
if (!faceDetected) {
// 没检测到人脸
this.isFaceExist = false;
this.desc = '请正对摄像头,保持面部在方框内';
this.stopActionDetect();
this.randomBg('#ffffff');
return;
}
// 检测到人脸
if (!this.isFaceExist) {
this.isFaceExist = true;
this.desc = '检测到人脸,准备开始动作识别...';
setTimeout(() => this.startActionDetect(), 1500);
}
}, 2000);
},
// 随机选择三个不重复的颜色
getRandomColors() {
let copy = [...this.colors];
let selected = [];
for (let i = 0; i < 3; i++) {
const idx = Math.floor(Math.random() * copy.length);
selected.push(copy[idx]);
copy.splice(idx, 1);
}
return selected;
},
startActionDetect() {
if (this.actionTimer) clearInterval(this.actionTimer);
const colors = this.getRandomColors();
const shuffledActions = this.actionList.sort(() => Math.random() - 0.5);
let step = 0;
this.actionTimer = setInterval(() => {
if (!this.isFaceExist) {
this.stopActionDetect();
return;
}
if (step < shuffledActions.length) {
this.desc = shuffledActions[step];
this.currentBg = colors[step];
step++;
} else {
this.desc = '动作检测完成,开始识别中...';
clearInterval(this.actionTimer);
this.actionTimer = null;
setTimeout(() => this.verifyFace(), 1500);
}
}, 3000);
},
stopActionDetect() {
clearInterval(this.actionTimer);
this.actionTimer = null;
},
randomBg(color) {
this.currentBg = color;
},
switchCamera() {
if (!this.livePusher) return;
this.livePusher.switchCamera({
success: () => {},
fail: (err) => {}
});
},
verifyFace() {
this.desc = '人脸识别中...';
setTimeout(() => {
this.desc = '识别成功';
this.randomBg('#ffffff');
this.isDetecting = false;
clearInterval(this.detectTimer);
}, 2000);
}
}
};
</script>
<style lang="scss" scoped>
@import '@/uni.scss';//外部我自己的样式
.body {
margin-top: 20vh;
}
.btn {
padding: 0.5vh 2vw;
width: 20vw;
font-size: 26rpx;
border-radius: 20rpx;
text-align: center;
margin: 0 auto;
background: $main-font-color;
color: #fff;
}
.desc {
font-size: 26rpx;
color: #585858;
text-align: center;
margin-bottom: 1vh;
font-weight: 500;
font-weight: bold;
transition: color 1.5s ease;
&.active {
color: #fff !important;
transition: color 1.5s ease;
}
}
.camera-box {
margin: 10vh 0 5vw 30vw;
width: 40vw;
height: 40vw;
border-radius: 20rpx;
overflow: hidden;
border: 4rpx solid #2196f3;
box-shadow: 0 0 20rpx rgba(33, 150, 243, 0.8);
animation: glow 2s infinite alternate;
&.active {
box-shadow: 0 0 20rpx rgba(255, 252, 253, 0.8);
border: 4rpx solid #fff;
animation: glow_active 2s infinite alternate;
}
}
@keyframes glow_active {
from {
box-shadow: 0 0 10rpx rgba(255, 255, 255, 0.3);
}
to {
box-shadow: 0 0 30rpx rgba(255, 255, 255, 1);
}
}
@keyframes glow {
from {
box-shadow: 0 0 10rpx rgba(33, 150, 243, 0.3);
}
to {
box-shadow: 0 0 30rpx rgba(33, 150, 243, 1);
}
}
</style>
结尾
以上分享完全基于个人在项目中的一些实战体会 不一定是最优解 但都是踩过坑之后沉下来的干货;如果你有更高效、更优雅、更稳妥的实现方式 真心欢迎在评论区一起交流讨论 让我们都能少走点弯路、学到更多东西;如果哪一段我讲得不够准确 或者有理解偏差的地方 也希望各位大佬不吝指正 我这边一定虚心接受、及时修正;技术就是这样 互相交流、互相补位 一起成长才是最爽的!
更多推荐
所有评论(0)