记录下两年前开发小程序答题监控功能的思路。因时间久远,同时删除了部分业务代码,没讲清楚的请多多包涵。
需求
- 用户进入考试须知页面,人脸识别成功后,点击开始考试进入答题页面
- 考试须知页面和答题页面控制考试总时长,控制每道题时长最多60秒
- 考试过程中启用摄像头对用户进行抓拍监控,比对人脸数据
方案选择
开始采用的方案是分别开发“考试须知”和“考试”两个独立页面,后因“考试”页面采用了tensorflow人脸识别,初始相机和加载模型耗时过长,遂将两个页面合并,让用户提前加载模型,同时两个页面是考试时长控制也统一起来了。其中最关键的视图容器就是page-container。
主要容器和组件
1.page-container
功能描述:
页面容器。
小程序如果在页面内进行复杂的界面设计(如在页面内弹出半屏的弹窗、在页面内加载一个全屏的子页面等),用户进行返回操作会直接离开当前页面,不符合用户预期,预期应为关闭当前弹出的组件。 为此提供“假页”容器组件,效果类似于
popup
弹出层,页面内存在该容器时,当用户进行返回操作,关闭该容器不关闭页面。返回操作包括三种情形,右滑手势、安卓物理返回键和调用navigateBack
接口。
根据官方文档描述,我们使用page-container来构建一个类似弹出层的假的“子页面”。它的特性可以用于禁止用户在考试过程中返回上一页,如果是两个独立页面,很难实现这一点。
2.camera
功能描述:
系统相机。扫码二维码功能,需升级微信客户端至6.7.3。需要用户授权
scope.camera
。 2.10.0起 initdone 事件返回 maxZoom,最大变焦范围,相关接口 CameraContext.setZoom。请注意原生组件使用限制
页面结构预览
1.页面原型
2.整体结构
<page-meta page-style="{{ popShow||containerShow ? 'overflow: hidden;' : '' }}">
<pageload id="pageload" bind:initPage="initPage" ...>
<view>
考试须知页面
</view>
</pageload>
<page-container show="{{containerShow}}" z-index="100" id="container"
duration="{{duration}}"
position="right"
bind:enter="pageenter"
bind:beforeleave="beforeleave"
bind:afterleave="afterleave"
>
<pageload bind:initPage="initExamPage" requesting="true" id="examPage">
<block wx:if="{{cameraShow}}">
<view class="camera-container-out">
<view class="camera-container-in">
<camera
device-position="front"
flash="off"
frame-size="small"
binderror="cameraError"
class="camera"
></camera>
<canvas id="mark-canvas" class="mark-canvas" type="2d"></canvas>
</view>
</view>
</block>
<view class="titlebar">
<view>
剩余答题时间:
</view>
</view>
<view class="pageBody pageBody_sty">
<view>
考试题目
</view>
<scroll-view scroll-y="true" scroll-top="{{top}}" style="height:{{viewHeight}}rpx;" show-scrollbar="false">
<view class="item_con" wx:for="{{curr_question.trunk.options}}">
试题选项
</view>
</scroll-view>
</view>
<view class="foot-nav-panel">
<view class="foot-nav-ul">
<view class="li">
<van-button type="primary" block custom-class="foot-btn"
bind:click="showPop">
答题板
</van-button>
</view>
<view class="li leftBorder">
<van-button type="primary" block custom-class="foot-btn"
data-id="{{curr_question.nextId}}"
bind:click="nextQuestion">
{{!curr_question.nextId?'交卷':'下一题'}}
</van-button>
</view>
</view>
</view>
<van-popup show="{{popShow}}" closeable position="bottom" custom-style="height: 60%" bind:close="hidPop">
<view class="serialNum">
答题板详情
</view>
</van-popup>
</pageload>
</page-container>
</page-meta>
3.局部分析
1)最外层组件page-meta
页面属性配置节点,用于指定页面的一些属性、监听页面事件。只能是页面内的第一个节点。page-style属性:页面根节点样式,页面根节点是所有页面节点的祖先节点,相当于 HTML 中的 body 节点。
<page-meta page-style="{{ popShow||containerShow ? 'overflow: hidden;' : '' }}">
当弹出层(答题板)或者子页面(答题页)显示时,父页面溢出隐藏,防止滑动操作时父页面滚动。
2)自定义组件pageload
功能描述
页面初始化。打开页面后执行initPage方法,通过回调来判断展示正确或错误的页面信息
wxml
<view>
<view wx:if="{{requesting===false}}">
<view wx:if="{{status===false}}" class="error">
<image class="error_image" src="{{errImg}}"></image>
<view class="error_text">{{errText?errText:'未知错误'}}</view>
<view class="error_btn">
<!-- <view bindtap="refresh" class="refresh">
<image class="icon" src="/assets/image/replay.png"/>
</view> -->
<button bindtap="refresh" class="btnDefault">刷新</button>
<button bindtap="goback" class="btnBack marginTop2">返回</button>
</view>
</view>
<view wx:if="{{status===true}}">
<slot>
</slot>
</view>
</view>
</view>
js
Component({
options: {
addGlobalClass: true
},
properties: {
requesting: {
type: Boolean,
value: false,
observer: 'requestingEnd',
},
status: {
type: Boolean,
value: false
},
errText: {
type: String,
value: ''
},
errImg: {
type: String,
value: '/assets/image/empty-image-error.png'
},
cancelLoading:{
type: Boolean,
value: false
}
},
data: {
},
methods: {
/**
* 监听 requesting 字段变化
*/
requestingEnd(newVal, oldVal) {
if(!this.data.cancelLoading){
if (oldVal === true && newVal === false) {
if(this.data.status===false&&this.data.errText==''){
//兼容request.js中拦截500和404响应,toast提示不能立马关闭
setTimeout(() => {
wx.hideLoading()
}, 2000);
}else{
wx.hideLoading()
}
} else {
wx.showLoading({
title: '加载中',
mask: true
})
}
}
},
init: function(){
//防止重复调用
if(this.data.requesting){
return;
}
//插件初始化
this.setData({
requesting : true,
status: false,
errText: ''
});
this.network().then(() => {
this.triggerEvent('initPage',{
callback: res=>{
this.setData(res);
}
});
}).catch(res => {
this.setData({
requesting : false,
status : false,
errText : res
});
})
},
refresh: function(){
this.init();
},
network: function() {
return new Promise((resolve, reject) => {
wx.getNetworkType({
success: res => {
if (res.networkType != 'none') {
resolve();
} else {
reject("网络异常");
}
},
})
})
},
goback: function(){
wx.navigateBack();
}
},
pageLifetimes: {
show: function() {
// 页面被展示
},
hide: function() {
// 页面被隐藏
}
},
lifetimes: {
attached: function() {
// 在组件实例进入页面节点树时执行
if(!this.data.requesting){
this.init();
}
},
}
})
/*
使用该组件的页面js中添加如下方法:
initPage(e){
let that = this;
let callback = e.detail.callback;
let url = app.globalData.url + "/...";
request.requestPostApi(url,{},this,succRes=>{
callback({
status : succRes.status,
errText : succRes.message
});
if(succRes.status){
doSuccess...
}
},failRes=>{
callback({
status : false,
errText : '未知错误'
});
},completeRes=>{
callback({requesting:false});
});
}
*/
3)倒计时组件van-count-down
本文使用的UI组件为Vant Weapp - 轻量、可靠的小程序 UI 组件库
<view>剩余时间:
<van-count-down use-slot
class="control-count-down"
time="{{ examInfo.remainingTime }}"
bind:change="remainingTimeChange"
bind:finish="remainingTimeFinish">
<text class="time" wx:if="{{vantRemainingTime.days>0}}">{{ vantRemainingTime.days }}天</text>
<text class="time" >{{ vantRemainingTime.hours>9?vantRemainingTime.hours:'0'+vantRemainingTime.hours }}时</text>
<text class="time" >{{ vantRemainingTime.minutes>9?vantRemainingTime.minutes:'0'+vantRemainingTime.minutes }}分</text>
<text class="time" >{{vantRemainingTime.seconds>9?vantRemainingTime.seconds:'0'+vantRemainingTime.seconds}}秒</text>
</van-count-down>
</view>
通过组件绑定事件remainingTimeChange来处理倒计时问题。除了计算整场考试剩余时间,可调用自定义函数去控制单个题目显示时长。
remainingTimeChange(e) {
this.setData({
'vantRemainingTime':e.detail
});
//自定义函数执行业务逻辑
this.timeGo(e.detail);
}
当小程序切换到后台,js定时器会停止,考试剩余时间就需要重新从服务端获取,可以在onShow中来触发。
onHide(){
this.setData({pageHide:true})
},
onShow(){
this.setData({pageHide:false});
//请求服务端更新考试剩余时间
this.updateRemainingTime();
}
4)父页面js
在onLoad中初始化子页面的滚动区域大小,在onReady中初始化tfjs插件(注:20230828,鸿蒙系统无法使用,暂未解决。该插件主要为在小程序端追踪人脸,实时提示用户保持头像居中,可整体移除。具体的考试监控由相机拍照上传至服务端后,异步执行)。tfjs插件使用教程可自行搜索。
const app = getApp();
const faces = require("../../../utils/face-storage.js");
const request = require("../../../utils/request.js");
const util = require("../../../utils/util.js");
const examApi = require("../../../utils/exam.js");
var fetchWechat = require('fetch-wechat');
var tf = require('@tensorflow/tfjs-core');
var webgl = require('@tensorflow/tfjs-backend-webgl');
var plugin = requirePlugin('tfjsPlugin');
const faceDetection = require('@tensorflow-models/face-detection');
const log = require('../../../utils/log.js')
onLoad(options) {
util.triggerEvent('resetIsClick');
this._cup2model = app.globalData.cup2model;
var that = this;
that.initViewHeight();
//防止 setTimeout 和 setInterval 在页面返回后继续执行
var taskId = setInterval(() => {
let timeout;
if(!util.isEmpty(that.data.examInfo)){
timeout = that.data.examInfo.timeout;
}
if(timeout!=1){
that.updateRemainingTime();
}else{
clearInterval(taskId);
}
}, 1000*60*10)
that.setData({taskId,taskId});
},
initViewHeight: function(){
var that = this;
wx.getSystemInfoAsync({
success: function(res) {
let {windowHeight,windowWidth} = res;
//页面高度减头部底部按钮高度,结合自身页面情况计算
let rpxHeight = Math.floor(750*(windowHeight-105)/(windowWidth || 375))-280;
that.setData({viewHeight:rpxHeight});
}
});
},
async onReady(){
if(this.data.useTfjs){
this.initTfjsPlugin();
this.loadmodel();
}
},
initTfjsPlugin(){
let config = {
// polyfill fetch function
fetchFunc: fetchWechat.fetchFunc(),
// inject tfjs runtime
tf,
// inject webgl backend
webgl,
// provide webgl canvas
canvas: wx.createOffscreenCanvas()
}
plugin.configPlugin(config);
},
async loadmodel(){
try {
let start = new Date();
const FILE_STORAGE_PATH = 'zjk_face_model';
const fileStorageHandler = plugin.fileStorageIO(FILE_STORAGE_PATH,wx.getFileSystemManager());
const model = faceDetection.SupportedModels.MediaPipeFaceDetector;
const detectorConfig = {
maxFaces: 2,
runtime: 'tfjs'
};
detectorConfig.detectorModelUrl = this._modelUrl;
this._model = await faceDetection.createDetector(model, detectorConfig);
let end = new Date();
log.info("下载模型成功,耗时:"+ (end-start)+" ms");
if(!this._cup2model){
this._model.estimateFaces(
{
data:new Uint8Array(),
width:1,
height:1
},
{flipHorizontal: false}
).then(e=>{
log.info("模型运行成功,耗时:"+ (new Date()-end)+" ms")
if(this._modelLoad){
log.info("成功后关闭等待框")
wx.hideLoading();
}
this._modelLoad = true;
this._cup2model = true;
app.globalData.cup2model = true;
console.log("第一次模型运行成功")
}).catch(e=>{
log.error("模型运行失败,耗时:"+ (new Date()-end)+" ms")
if(this._modelLoad){
log.error("失败后关闭等待框")
wx.hideLoading();
}
this._modelLoad = true;
console.log("模型运行失败",e)
})
}
} catch (error) {
log.error("捕捉到异常信息"+JSON.stringify(error))
if(this._modelLoad){
log.error("捕捉到异常信息后关闭等待框")
wx.hideLoading();
}
this._modelLoad = true;
}
//代码运行失败
/* try{
console.log("加载本地模型")
detectorConfig.detectorModelUrl = fileStorageHandler;
this._model = await faceDetection.createDetector(model, detectorConfig);
}catch(e){
console.log("加载本地模型失败",e)
detectorConfig.detectorModelUrl = this._modelUrl;
this._model = await faceDetection.createDetector(model, detectorConfig);
console.log("加载网络模型成功")
try{
this._model.detectorModel.save(fileStorageHandler);
}catch(e){
console.log("保存本地模型失败",e)
}
} */
}
onShow(){
this.setData({pageHide:false});
this.updateRemainingTime();
},
onHide(){
this.setData({pageHide:true})
},
onUnload(){
if(this.data.taskId){
clearInterval(this.data.taskId);
}
let countdown = this.selectComponent('.control-count-down');
if(countdown){
//返回上一页必须关闭定时器,否则会一直执行
countdown.pause();
}
},
/** 页面初始化,获取考试信息*/
initPage(e){
let that = this;
let callback = e.detail.callback;
let url = app.globalData.url + "/.../getExamInfo";
request.requestPostApi(url,{},this,succRes=>{
callback({
status : succRes.status,
errText : succRes.message
});
if(succRes.status){
that.setData({
examInfo : succRes.result
});
if(!that.data.examInfo.examing){//如果后台已经提交或者无考试,重置本地考试完成状态
examApi.finish(false);
}
that.setData({localFinish:examApi.finish()})
}
},failRes=>{
callback({
status : false,
errText : '未知错误'
});
},completeRes=>{
callback({requesting:false});
if(!this._cup2model&&!this._modelLoad){
this._modelLoad = true;
wx.showLoading({
title: '加载中',
mask: true
})
}
});
},
timeBeforeChange(e) {
this.setData({
timeBefore: e.detail
});
},
timeBeforeFinish(){
this.setData({
'examInfo.timeout':0
})
},
remainingTimeChange(e) {
this.setData({
'vantRemainingTime':e.detail
});
this.timeGo(e.detail);
},
remainingTimeFinish(){
this.setData({
'examInfo.timeout':1
})
if(this.data.examInfo.examing){
this.submitExam();
}
},
updateRemainingTime(){
var that = this;
if(!util.isEmpty(that.data.examInfo)&&that.data.examInfo.timeout!=1&&!(that.data.examInfo.answerCountCurrent==that.data.examInfo.answerCountMax&&!that.data.examInfo.examing&&that.data.examInfo.isSubmit)){
request.requestPostApi(app.globalData.url + "/.../validateRemainingTime",{},this,succRes=>{
if(succRes.status){
that.updateExamInfo(succRes.result);
}else{
console.log("刷新时间失败:"+succRes.message);
}
},failRes=>{
console.log("刷新时间失败");
});
}
}
5)子页面js
pageenter(){
//打开子页面,执行initPage绑定方法
let examPage = this.selectComponent("#examPage");
examPage.setData({requesting:false});
examPage.init();//执行的是initExamPage
},
beforeleave(){
//隐藏子页面,停止摄像头
this._listener&&this._listener.stop();
this.setData({
curr_question: {},
containerShow:false
})
},
afterleave(){
// 可在该事件内阻止用户返回
// if(!this.data.localFinish&&this.data.examInfo.timeout==0){
// this.setData({containerShow:true});
// setTimeout(() => {
// request.toast("请保持答题页面至提交试卷")
// }, 300);
// }
},
initExamPage(e){
let that = this;
that.setData({isLoadingExam:false});
let callback = e.detail.callback;
let{questions,answers,currQuestion} = examApi.getAll();
//中途退出,直接从本地缓存取题目
if(!util.isEmpty(questions)&&!util.isEmpty(currQuestion)){
console.log("从缓存中获取试题数据")
var count = 0;
for(let key in questions){
count++;
}
that.setData({
questions:questions,
listCount: count,
userAnswers: answers,
curr_question:questions[currQuestion.id]
})
//作用:显示剩余时间后再callback
if(!util.isEmpty(that.data.vantRemainingTime)){
that.timeGo(that.data.vantRemainingTime);
}
callback({
status : true,
errText : '',
requesting : false
});
//初始化相机
that.initCamera();
return;
}
let url = app.globalData.url + "/.../getExamQuestions";
request.requestPostApi(url,{},this,succRes=>{
callback({
status : succRes.status,
errText : succRes.message
});
if(succRes.status){
let qs = succRes.result;
let temp_questions = {};
for(var i=0;i<qs.length;i++){
temp_questions[qs[i].id]=qs[i];
}
that.setData({
questions:temp_questions,
listCount: qs.length
})
that.showQuestion(qs[0]["id"]);//显示第一题,在本地保存questions和curr_question
that.updateRemainingTime();
//初始化相机
that.initCamera();
}
},failRes=>{
callback({
status : false,
errText : '未知错误'
});
},completeRes=>{
callback({requesting:false});
});
}
initCamera(){
console.log("显示相机和画布");
this.setData({cameraShow:true});
setTimeout(() => {//必须setTimeout,等待页面渲染完成
try {
if(!this._camera){
console.log("创建相机")
this._camera = wx.createCameraContext();
this._camera.setZoom({
zoom:1
})
}else{
this._camera.setZoom({
zoom:1
})
}
if(this.data.useTfjs){//判断是否使用tfjs
this.initCanvas();//初始画布,用于显示人脸追踪框
}
this.addCameraListener();
this._listener.start();
} catch (error) {
console.log("exception:",error)
}
}, 1500);
},
initCanvas(){
if(!this._canvas){
console.log("创建画布")
const query = wx.createSelectorQuery();
query.select('#mark-canvas')
.fields({ node: true, size: true })
.exec((res) => {
this._canvas = res[0].node;
const canvas = res[0].node;
const canvasContext = canvas.getContext('2d')
const systemInfo = wx.getSystemInfoSync()
//设备像素比
const dpr = systemInfo.pixelRatio
//画布像素
canvas.width = res[0].width * dpr
this._canvasWidthPix = canvas.width;
canvas.height = res[0].height * dpr
this._canvasHeightPix = canvas.height
this._dpr = dpr;
canvasContext.lineWidth = 3
canvasContext.strokeStyle = 'red'
canvasContext.fillStyle = 'yellow'
this._canvasContext = canvasContext;
})
}
},
addCameraListener(){
if(!this._listener){
console.log("创建相机监听")
this._listener = this._camera.onCameraFrame(async frame=> {
//当使用tfjs时,每隔1秒(60帧)判断一次人脸位置
if(this.data.useTfjs&&this.data.containerShow){
if(this._frameWidth!=frame.width || this._frameHeight != frame.height){
this._frameWidth = frame.width;
this._frameHeight = frame.height;
this._xRatio = Math.round(this._canvasWidthPix*1000 / frame.width)/1000;
//等比例缩放后画布高的像素值
let tempCanvasnHeightPix = Math.floor(frame.height*this._xRatio);
//画布沿y轴偏移量
this._yoffset = Math.floor((this._canvasHeightPix-tempCanvasnHeightPix)/2);
console.log("初始化缩放倍数和偏移量",this._frameWidth,this._frameHeight,this._xRatio,this._yoffset)
}
this._count++;
if (this._count === 60) {
this.clearMarkCanvas();
if(this._model){
const res = await this.detectFace(frame);
this.validateFace(res);
}
this._count = 0;
}else if(this._count>60){
this._count = 0;
}
}
});
}
},
async detectFace(frame) {
const image = {
data: new Uint8Array(frame.data),
width: frame.width,
height: frame.height
}
const estimationConfig = {flipHorizontal: false};
return await this._model.estimateFaces(image, estimationConfig);
},
validateFace(res){
//this._canvasContext.strokeRect(0,0,this._canvasWidthPix,this._canvasHeightPix);
let msg = "";
if(res.length<1){
msg = '请保持头像居中';
this.setData({faceError:msg});
return;
}
/* if(res.length>1){
msg = '检测到多人';
this.setData({faceError:msg});
return;
} */
const face = res[0];
//画关键点
// const keypoints = face.keypoints
// for(let i=0; i<6; ++i){
// const point = this.transformPoint([keypoints[i].x,keypoints[i].y])
// this._canvasContext.fillRect(point[0],point[1],6,6)
// }
const box = face.box;
const start = this.transformPoint([box.xMin,box.yMin]);
const end = this.transformPoint([box.xMax,box.yMax]);
let size = [end[0] - start[0], end[1] - start[1]]
//画人脸预测框
//this._canvasContext.strokeRect(start[0], start[1], size[0], size[1]);
/* if (size[0] < 0.2 * this._canvasWidthPix){
msg = '距离太远';
this.setData({faceError:msg});
return;
} */
if (size[0] > this._canvasWidthPix*1.05){
msg = '距离太近';
this.setData({faceError:msg});
return;
}
if(start[0] < -0.06 * this._canvasWidthPix){
msg = "头像偏左,";
}else if(end[0] > 1.06 * this._canvasWidthPix){
msg = "头像偏右,";
}else if(start[1] < 0.14 * this._canvasHeightPix){
msg = "头像偏上,";
}else if(end[1] > 1.06 * this._canvasHeightPix){
msg = "头像偏下,";
}
if(msg){
this.setData({faceError:msg+'请保持头像居中'});
return;
}
this.setData({faceError:""});
},
transformPoint(point){
const x = Math.floor(point[0] * this._xRatio);
const y = Math.floor(point[1] * this._xRatio)+this._yoffset;
return [x,y]
},
clearMarkCanvas(){
this._canvasContext&&this._canvasContext.clearRect(0,0,this._canvasWidthPix,this._canvasHeightPix)
},
cameraError(e){
request.toast("摄像头打开失败,请退出重试或更换手机!")
}
如果要使用tfjs,请参考下面摄像头和画布的关系。画布透明地覆盖在摄像头上。如果要求隐形监控的话,可以将相机组件长宽设置为1px,同时,在onCameraFrame中截取视频帧图片进行上传,以达到隐形效果。
因本案例无特殊要求,所以采用了最简单的方式实现监控:每间隔一道题拍一次照片上传至服务端。这样做的弊端是,苹果用户拍照会有系统提示声(甲方没说就懒得改了...)
<block wx:if="{{cameraShow}}">
<view class="camera-container-out">
<view class="camera-container-in">
<camera
device-position="front"
flash="off"
frame-size="small"
binderror="cameraError"
class="camera"
></camera>
<canvas id="mark-canvas" class="mark-canvas" type="2d"></canvas>
</view>
</view>
</block>
.camera-container-out {
position: absolute;
top:100rpx;
right: 30rpx;
display: flex;
flex-direction: column;
align-items: center;
z-index: 101;
}
.camera-container-in {
display: flex;
justify-content: flex-start;
}
.camera{
width: 50px;
height: 70px;
z-index: 101;
}
.mark-canvas{
position: absolute;
width: 50px;
height: 70px;
z-index: 102;
}
6)拍照上传
图片保存使用阿里云oss服务。人脸比对也用的是阿里云服务。
takePhoto() {
const that = this;
if(!this._camera){
setTimeout(() => {
that.takePhoto();
}, 1100);
return;
}
this._camera.takePhoto({
quality: 'high',
success: (res) => {
let localSrc = res.tempImagePath;
let type = localSrc.substring(localSrc.lastIndexOf("."));
console.log("开始上传");
let policy = this._policy;
let signature = this._signature;
let oSSAccessKeyId = this._OSSAccessKeyId;
let date = new Date();
let filePath = that.data.examInfo.themeId+"/"+that.data.examInfo.examUser+"/"+date.Format('yyyyMMddhhmmssS')+type;
let uploadUrl = "https://域名.oss-cn-shanghai.aliyuncs.com";
wx.uploadFile({
url: uploadUrl, // 开发者服务器的URL。
filePath: localSrc,
name: 'file', // 必须填file。
formData: {
key: filePath,
policy: policy,
OSSAccessKeyId: oSSAccessKeyId,
signature: signature
},success: (res) => {
if (res.statusCode === 204||res.statusCode === 200) {
let photos = examApi.photos();
photos.push("/"+filePath);
let photoTimes = examApi.photoTimes();
photoTimes.push(date.getTime());
examApi.updatePhotoDatas(photos,photoTimes);
}
},
fail: err => {
console.log(err);
}
});
},fail: err =>{
console.log(err);
}
})
}
7)注意事项
因tfjs依赖包比较大,考试模块建议采用分包开发。本案例中tfjs依赖包如下:
"dependencies": {
"node-fetch": "2.6.1",
"@mediapipe/face_detection": "0.4.1646425229",
"@tensorflow-models/face-detection": "1.0.1",
"@tensorflow/tfjs-backend-webgl": "3.20.0",
"@tensorflow/tfjs-converter": "3.20.0",
"@tensorflow/tfjs-core": "3.20.0",
"fetch-wechat": "0.0.3"
}
其他注意事项:
防止页面重复打开;防止页面返回后定时任务和相机监控继续执行;防止tfjs初始化失败对业务的影响;相机组件要在页面渲染完成后初始化,否则会错位。
最后
自认为这边文章写得很水,感谢阅读x3。如有问题请留意。