一、功能描述
调取用户相机获取相机视频画面,解析画面中的二维码并得到解析结果。
实现效果:
- 扫码过程中,有扫描动画效果
- 扫码出结果后,扫码动画效果结束,并在相机视频当前帧定格并在画面中框选出监测到的二维码
在线体验demo:pts-f.vercel.app/demo/qrscan (项目部署在vercel上,需要qiang)
二、实现流程
三、核心代码
- React 组件 QRScanner
- 入参:
-
- onScanned: 扫码成功回调(成功后就会关闭相机视频,不再继续扫码行为)
- onError: 异常回调(并不一定会中断扫码行为,只不过可以通过这个回调捕获扫码过程中的异常)
javascript
const QRScanner: React.FC<QRScannerProps> = ({
onScanned,
onError,
// ...
}) => {
// ...
useEffect(() => {
// ...
const video = document.createElement("video");
const camera = getCamera(video);
// 开启相机
camera.start({ torch }).then(() => {
// 开启相机后,onFrame 注入监听回调,每帧动画即会触发该回调
const cleanupOnFrame = onFrame(() => {
try {
// 每帧动画中解码QRCode
const code = readQr?.(video);
if (code?.data) {
// 解码成功,触发组件 onScanned 事件
onScanned?.(code.data);
// 解码成功,结束扫码状态
cleanupCamera();
}
} catch (e) {
// 解码异常,触发组件 onError 事件
onError?.(e);
}
});
// 结束扫码状态:关闭相机,并清除 onFrame 中监听的回调
cleanupCamera = () => camera.pause().finally(cleanupOnFrame);
// 相机开启异常,触发组件 onError 事件
}, onError);
return cleanupCamera;
}, [
onScanned,
onError,
// ...
]);
// ...
return (
<>
<canvas ref={canvasRef} className={styles.canvas}></canvas>
<div className={scanFlag ? styles.line : ""} />
{/* ... ... */}
</>
)
}
四、关键点
4.1 相机操作-结合video标签显示相机视频流
- API reference: MediaDevices.getUserMedia; HTMLMediaElement.srcObject;
- 功能描述:通过 getCamera (入参传入依赖的 video)初始化创建一个 camera ,包含 camera.start()、camera.pause() 两个方法控制设备相机开关,并和 video 标签联动。
- 封装方法:
-
- camera.start():开启相机,并在 video 标签中显示相机视频;
- camera.pause():关闭相机,并 pause video;
- 细节:
-
- getUserMedia 方法中可通过 video.facingMode 入参控制采用设备的前置相机or后置相机(下面代码中默认优先采用 environment 后置相机,若设备没有后置相机退步采用前置);
- video.torch 为了控制设备手电筒开关,但这个 api 似乎失效;
- 由于 getUserMedia 是异步过程,在 getCamera 函数闭包中通过 streamP 控制是否已经请求了打开相机行为,若是则不再重复请求,等待上一次请求结果即可,直至相机关闭销毁此次状态。
- Code:
ini
const getCamera = (video: HTMLVideoElement) => {
let streamP: Promise<MediaStream> | null;
const start = async ({ useFrontCamera, torch }: ICameraStartOptions = {}) => {
if (streamP) return streamP;
video.play();
const facingModes = ["environment", "user"];
const getMedia = () =>
(streamP = navigator.mediaDevices.getUserMedia({
// @ts-ignore
video: { facingMode: facingModes[+!!useFrontCamera], torch },
}));
try {
streamP = getMedia();
} catch {
useFrontCamera = !useFrontCamera;
streamP = getMedia();
}
const stream = await streamP;
video.srcObject = stream;
return stream;
};
const pause = async () => {
video.pause();
const stream = await streamP;
streamP = null;
((video.srcObject || stream) as MediaStream)
?.getTracks()
.forEach((t) => t.stop());
};
return { start, pause };
};
4.2 基于canvas&jsqr实现解码
- API reference: CanvasRenderingContext2D.drawImage; CanvasRenderingContext2D.getImageData; jsqr[github];
- 功能描述:通过 readQrByCanvas (入参传入依赖的 canvas)初始化创建一个 readQr 方法,并通过 readQr 方法解析包含二维码的图片或视频并返回解码结果。
- 细节:
-
- 实践得出:jsqr 解析 qr 时长和图像分辨率有关,分辨率高则越快,因此需保证 canvas 绘制图像时在不产生数据冗余的情况下分辨率尽可能高(即确保canvas和视频画面的宽高一致)
- 解析 qr 需要逐帧解析,因此最好通过闭包初始创建一次canvasContext2D即可,不要频繁创建。
- alpha 入参是为了调整单张图片分辨率从而提高 jsqr 解析成功率加的,在视频画面解析时alpha 默认为1即可,不做调整。
- Code:
ini
const readQrByCanvas = (
canvas: HTMLCanvasElement,
enableDrawBox?: IEnableDrawBoxOptions
) => {
const ctx = canvas.getContext("2d", { willReadFrequently: true })!;
// @ts-ignore innerFn, ctx drawLine, controlled by `enableDrawBox` param.
const drawLine = (o, d) => {
ctx.beginPath();
ctx.moveTo(o.x, o.y);
ctx.lineTo(d.x, d.y);
enableDrawBox &&
(() => {
ctx.lineWidth = enableDrawBox.lineWidth;
ctx.strokeStyle = enableDrawBox.strokeStyle;
})();
ctx.stroke();
};
// @ts-ignore innerFn, ctx drawBox, based on `drawLine`.
const drawBox = (loc) => {
drawLine(loc.topLeftCorner, loc.topRightCorner);
drawLine(loc.topRightCorner, loc.bottomRightCorner);
drawLine(loc.bottomRightCorner, loc.bottomLeftCorner);
drawLine(loc.bottomLeftCorner, loc.topLeftCorner);
};
return function (
image: HTMLImageElement | HTMLVideoElement,
alpha: number = 1
) {
// 需保证 canvas 绘制图像和 video 或 img 一个分辨率
// @ts-ignore
canvas.width = image.videoWidth || image.width;
// @ts-ignore
canvas.height = image.videoHeight || image.height;
const { width, height } = canvas;
if (!width || !height || alpha <= 0) return null;
const oAlpha = Math.max(1 - alpha, 0) / 2;
ctx.drawImage(
image,
oAlpha * width,
oAlpha * height,
alpha * width,
alpha * height
);
const imageData = ctx.getImageData(0, 0, width, height);
// jsQR 解析 qr 时长和图像分辨率有关,分辨率高则越快,因此需保证 canvas 绘制图像和 video 或 img 一个分辨率
const code = jsQR(imageData.data, width, height);
code?.location && drawBox(code?.location);
return code;
};
};
4.3 逐帧动画添加/销毁监听回调
- API reference: requestAnimationFrame;
- 功能描述:通过 onFrame 传入监听回调 cb,并返回对应的清除函数 cleanupOnFrame。在此后每帧动画渲染过程中都会触发传入的 cb 回调函数,除非调用 cleanupOnFrame 手动清除。
- Code:
ini
const onFrame = (cb: (t: number) => void) => {
let isCleanup = false;
const frame = () =>
requestAnimationFrame((time) => {
if (isCleanup) return;
cb(time);
frame();
});
frame();
return () => {
isCleanup = true;
};
};
五、功能扩展
- 增加二维码图片上传解析的功能,调用解码函数 readQr 解析图片得到解码结果即可。(在在线体验demo中已经实现,可自行体验)
- 待增:
-
- 增加设备手电筒功能,目前用 getUserMedia 入参中的 video.torch 方法控制兼容性非常不好,需要寻找一种兼容性好的方案
- 增加图像多个二维码同时存在的识别解析问题,目前受限于 jsqr 第三方库的功能实现,智能解析单个二维码