第10章 开发通过二维码传输文件的应用

10.1 使用QR Code编码任意数据


本节先简要介绍QR Code然后介绍HTML5和JavaScript中如何处理二进制数据,以及如何把任意二进制数据编码成二维码。

  • 10.1.1 QR Code简介
  • 10.1.2 二进制数据
  • 10.1.3 使用JavaScript处理二进制数据

10.1.1 QR Code简介


以QR Code为代表的二维码是按照一定的规则把数据编码成二维的图像,设备通过摄像头拍摄QR Code后根据图像中方块的位置解析出其中所编码的信息。通常来说编码的信息越多,二维码的大小越大,或者图形越复杂。下面展示了两个二维码,使用常见应用均可以扫描得到这两个二维码中的信息。

QR Code本身带有冗余和校验能力,这意味着QR Code某些部分被遮挡或损坏不可见时,还有可能扫描得到原理的信息。QR Code中三个角上有三个如下图所示的方框图形,这三个图形用于扫描程序定位QR Code的位置和应对扫描的倾斜的校正,通常如果这三个符号被遮挡或损坏二维码就无法被扫描了。

10.1.2 二进制数据


计算机中的数据以二进制形式储存,可以理解是一串数字。如HTML,JavaScript,CSS等代码文件或者txt这样的文本文件实际上也是通过二进制形式存储,但它们采用某些文本编码,所以可以被文本编辑器打开。

文本编辑器打开文本文件时,会把二进制数据通过解码转换成文字。JavaScript有String.fromCharCode方法可以把一个编码转换为字符。调用一个字符串的charCodeAt方法可以把字符串中的字符转换为编码的值。下面代码打印了字符串每个字符的编码。


let str = '你好,HTML5!';
for (let i = 0; i < str.length; i++) {
    console.log(`字符 "${str[i]} " 的编码是:${str.charCodeAt(i)}`)
}
					   

这段代码的输出如下。


						字符 "你" 的编码是:20320
						字符 "好" 的编码是:22909
						字符 "," 的编码是:65292
						字符 "H" 的编码是:72
						字符 "T" 的编码是:84
						字符 "M" 的编码是:77
						字符 "L" 的编码是:76
						字符 "5" 的编码是:53
						字符 "!" 的编码是:65281 
						

下面代码打印了从汉字“你”编码20320开始的10个连续编码对应的字符。


							for (let i = 0; i < 10; i++) {
								let code = 20320 + i;
								console.log(`编码 ${code} 对应的字符是:"${String.fromCharCode(code)"}`);
							} 
						

输出结果如下。


						编码 20320 对应的字符是:"你"
						编码 20321 对应的字符是:"佡"
						编码 20322 对应的字符是:"佢"
						编码 20323 对应的字符是:"佣"
						编码 20324 对应的字符是:"佤"
						编码 20325 对应的字符是:"佥"
						编码 20326 对应的字符是:"佦"
						编码 20327 对应的字符是:"佧"
						编码 20328 对应的字符是:"佨"
						编码 20329 对应的字符是:"佩" 
						

可以理解文本文件是一类使用特殊编码的文件。如果希望设计一个能处理或者说能传输任意格式文件的程序,就需要直接处理二进制数据。

10.1.3 使用JavaScript处理二进制数据


前面介绍过JavaScript中用于Base64编码和解码的btoa和atob方法。在JavaScript中可以通过Base64编码直接读取任意文件,从而实现避免直接处理二进制数据。

通过Base64编码读取文件后,所得到的数据类型就是字符串,在使用qrcode库就可以把任意文件对应的Base64字符串转换为二维码。

但Base64编码会导致数据体积增加,降低了数据处理和传输的效率,JavaScript也提供了ArrayBuffer等方法直接处理二进制数据。

10.2 在HTML5应用中打开和读取文件


HTML5中可以使用type为file的<input>标签打开系统中的文件,在JavaScript使用FileReader读取文件内容。本节开始将在第七章课堂练习的基础上增加打开文件和通过二维码传输文件的功能。

  • 10.2.1 使用<input>标签打开文件
  • 10.2.2 以Base64格式读取文件

10.2.1 使用<input>标签打开文件


<input>标签在表单中用于处理用户输入,当它的类型被设置为file时,它将接收一个文件作为参数。下面代码定义了一个可以选择和打开多个文件的<input>标签。


<label for="fileInput">选择文件:</label><br>
<input type="file" id="fileInput" multiple><br>
							

代码效果如下图所示。

给这个input元素绑定change事件后,可实现用户选择文件后调用事件处理函数,可以在该函数中使用JavaScript读取文件内容。下面代码给该input绑定了事件处理函数。


                            let fileInput = document.querySelector('#fileInput');
                            fileInput.addEventListener('change', (event) => {});
						

10.2.2 以Base64格式读取文件


在事件处理函数中,通过event.target拿到input元素,该元素的files属性中是其打开的文件列表。然后通过FileReader对象读取文件内容并保存到一个文件数组中。实现代码如下。


        fileInput.addEventListener('change', (event) => {
								for (let fileObj of event.target.files) {
								  let reader = new FileReader();
								  reader.addEventListener("loadend", (event) => {
									let file = {
									  base64Data: reader.result,
									  name: fileObj.name,
									  size: fileObj.size,
									  lastModified: fileObj.lastModified,
									  type: fileObj.type,
									};
									let index = fileList.length;
									fileList.push(file);
									fileListOpened.innerHTML += `
  • ${file.name} - ${getHumanSize(file.size)} - ${getHumanSize(file.base64Data.length)}
  • `; }); reader.readAsDataURL(fileObj); } });

    HTML的代码如下所示。

    
            <div id="selectFile">
    								  <p>已打开的文件</p>
    								  <ul id="fileListOpened">
    								    <li>文件名 - 文件大小 - Base64编码后的大小</li>
    								  </ul>
    								  <label for="fileInput">选择文件:</label><br>
    								  <input name="fileInput" type="file" id="fileInput" multiple><br>
    								</div>
    						

    这段代码除了用于选择文件的<input>外,还定义了一个用于显示当前已经打开的文件列表的<ul>。打开文件后的效果如下图所示。

    10.3 切块传输


    对于较大的文件,几乎无法通过单个二维码传输,以上面1.4.3.html为例,10.22KB的Base64编码数据包含1万多个字符,如果要放到单个二维码中,二维码尺寸将非常大,难以被展示,更难被扫描。

    若想实现通过二维码传输任意文件需要对文件数据进行切分,每张二维码只传输一部分数据,接收者分别扫描每张二维码,之后按照原来的顺序组合即可得到数据。

    • 10.3.1 数据切分
    • 10.3.2 选择要传输的文件
    • 10.3.3 生成数据包二维码
    • 10.3.4 显示和播放二维码

    10.3.1 数据切分


    已有字符串形式的数据,只需设定每次传输最大的限制,很容易将数据切成多块。但还需要让接收者知道如何把这些数据组装起来。可以定义一种“数据包”结构,其中除了传输的文件数据块外,还包含文件名称,文件大小,总共分了多少块和当前包内包含的文件块的序号。

    根据这些信息接收者拿到所有包之后,即可根据数据块的序号,把数据拼起来从而得到原始数据。

    10.3.2 选择要传输的文件


    给展示已打开文件列表的ul元素添加点击事件处理函数。

    
    let currentSelectedFileId = -1;
    fileListOpened.addEventListener('click', (event) => {
        if (event.target.id) { // 得到li元素的id属性
            let toSelect = parseInt(event.target.id.split('_')[1]);
            if (toSelect != currentSelectedFileId) {
                currentSelectedFileId = toSelect;
                fileTransState.innerHTML = `<p>当前选中的文件是:${fileList[toSelect].name}</p>`
            }
        }
    })
    						

    点击该ul元素中的li元素时,点击事件冒泡到ul,该处理函数会被调用,通过li元素的id属性到fileList取到该文件的信息。

    因为该html文件中还保留了第七章把输入的字符转换为二维码的功能,所以额外添加一个单选框,允许用户选择使用字符串转二维码或文件转二维码。修改后的HTML部分代码如下。

    
            <label><input type="radio" name="textOrFile" value="File" id="fileMode" checked>传输文件</label>
            <label><input type="radio" name="textOrFile" value="Text" id="textMode">文字转二维码</label>
            <div id="inputText">
                <label>输入要转要传输的文本:</label><br>
                <textarea id="inputString" cols="50" rows="10"></textarea><br>
            </div>
            <div id="selectFile">
                <p>已打开的文件 (点击选择文件):</p>
                <ul id="fileListOpened">
                    <li>文件名 - 文件大小 - Base64编码后的大小</li>
                </ul>
                <label for="fileInput">选择文件:</label><br>
                <input name="fileInput" type="file" id="fileInput" multiple><br>
                <label for="fileChunkSize">文件分片大小:</label><input name="chunkSize" id="chunkSize" type="number" value="400">
                <div id="fileTransState"></div>
            </div>
            <br>
            <button id="buttonGenerate">生成二维码</button>
            <div id="outputArea"></div>
    						

    再添加控制输入文字和选择文件的两个div的显示和隐藏。

    
    let fileMode = document.querySelector('#fileMode');
    let textMode = document.querySelector('#textMode');
    let inputText = document.querySelector('#inputText');
    let selectFile = document.querySelector('#selectFile');
    fileMode.addEventListener('change', modeChange);
    textMode.addEventListener('change', modeChange);
    function modeChange() {
        if (fileMode.checked) {
            inputText.style.display = 'none';
            selectFile.style.display = '';
        }
        else {
            inputText.style.display = '';
            selectFile.style.display = 'none';
        }
    }
    						

    10.3.3 生成数据包二维码


    先读取当前设定的文件分片大小,根据文件Base64编码数据长度计算包的数量,然后计算出每个包的数据和序号。构造好数据包再调用QRCode生成二维码。为保证同一时间只屏幕显示一个二维码,新生成的二维码都被设置为不可见。实现代码如下。

    
    						function createDataPackages(fileId) {
    							if (!fileList[fileId]) { // 检查下标为 fileId 的数据是否存在
    							    return;
    							}
    					   let file = fileList[fileId]
    					   let chunkSizeInt = chunkSize.value;
    							if (file.chunkSize != chunkSizeInt) {
    					            file.chunkSize = chunkSizeInt;
    					            file.packageCount = Math.floor(1+(file.base64Data.length-1)/chunkSizeInt);
    							}
    							let finishCount = 0;
    							outputArea.innerHTML = '';
    							for (let i = 0; i * chunkSizeInt < file.base64Data.length; i++) {
    							    let startIndex = chunkSizeInt * i;
    							    let length = chunkSizeInt;
    							    let canvasId = "canvas_" + i;
    						      let newCanvas = "<canvas id='"+canvasId+"'></canvas>";
    							                
    						      let packageData = file.base64Data.substr(startIndex, length);
    						      let packageObj = {
    				                  i: i,
    						                f: file.name,
    						                c: file.packageCount,
    						                d: packageData,
    					                    C: chunkSize,
    							        }
    							    outputArea.innerHTML += newCanvas;
    					            setTimeout(()=> {
    					                    canvasObj = document.getElementById(canvasId);
    						                QRCode.toCanvas(canvasObj, JSON.stringify(packageObj), function (error) {
    						                        if (error) 
    							                        console.error(error);
    							                    else {
    							                            finishCount++;
    							                            fileTransState.innerHTML = `<p>当前选中的文件是:${file.name}。生成的二维码数/总数: ${finishCount} / ${file.packageCount}</p>`;
    							                            canvasObj.style.display = 'none';
    							                    }
    							                });
    							        }, 0);
    						    }
    				  }
    						

    10.3.4 显示和播放二维码


    生成二维码时,已经把二维码都设置为隐藏状态。播放二维码时只需要每次选择一张设为可见,并隐藏上一张。

    先创建四个用于播放控制的按钮。HTML代码如下所示。

    
                   <button id="btnStart">开始播放二维码</button>
                   <button id="btnStop">停止播放</button>
                   <button id="btnNext">下一张</button>
                   <button id="btnPrevious">上一张</button>
    						

    再分别创建它们的事件处理函数。其中start和stop中用到的intervalHandle是函数外定义的变量。

    
              function next() {
    			         let file = fileList[currentSelectedFileId];
    								    if (currentPackageId >= 0) {
    								    document.getElementById('canvas_' + currentPackageId).style.display = 'none';
    								    }
    								    currentPackageId = (currentPackageId + 1) % file.packageCount;
    								    document.getElementById('canvas_' + currentPackageId).style.display = '';
    								    updatePlayStatus()
    								  }
    								  function back() {
    								    let file = fileList[currentSelectedFileId];
    								    if (currentPackageId >= 0) {
    								        document.getElementById('canvas_' + currentPackageId).style.display = 'none';
    								  }
    								    currentPackageId = (currentPackageId - 1) % file.packageCount;
    								    document.getElementById('canvas_' + currentPackageId).style.display = '';
    								    updatePlayStatus()
    								  }
    								  function start() {
    								    intervalHandle = setInterval(next, parseInt(intervalMs.value));
    								  }
    								  function stop() {
    								    clearInterval(intervalHandle);
    								  }
    						

    点击播放按钮时,将按照时间间隔的设定每次显示出一张二维码,并显示播放的进度。点击停止时会暂停自动播放。点击上一张和下一张则可手动控制播放。

    至此文件转二维码功能已经实现。使用的方法是先点击选择文件按钮,从弹出的对话框中选择一个文件。然后点击屏幕上出现的文件列表中的文件,选择要传输的文件,然后点击生成二维码。然后点击开始播放二维码即可滚动播放二维码。程序界面如下图所示。

    当文件接收者扫描和解析每一张二维码后,按照数据包序号拼接好数据,即可得到传输的文件。

    10.4 扫描二维码


    本节开始二维码扫描功能的开发,将使用HTML5-QRCode库。HTML5应用扫描二维码时需要先申请相机权限,或者在调试时,可从选择本地图片进行识别。

    • 10.4.1 使用HTML5-QRCode
    • 10.4.2 拼接扫描结果
    • 10.4.3 下载拼接后的文件

    10.4.1 使用HTML5-QRCode


    使用下面命令安装HTML5-QRCode库。

    
                                npm i html5-qrcode
    						

    创建新的<div>标签用于容纳扫描相关的组件。

    
    							<div id="scanQRCode">
    								<div id="qr-reader"></div>
    								<div id="qr-reader-results"></div>
    								<div id="getFileState"></div>
    							</div>
    					   

    使用下面的代码加载和使用HTML5-QRCode的二维码扫描器。

    
    function startQRCodeReader() {                                               
    	var lastResult, countResults = 0;
      function onScanSuccess(decodedText, decodedResult) {
    	  if (decodedText !== lastResult) {
    		  ++countResults;
    		  lastResult = decodedText;
    		  let data = JSON.parse(decodedText);
    		  loadPackage(data);
        }                                                                                                   
    }
      var html5QrcodeScanner = new Html5QrcodeScanner.Html5QrcodeScanner(
    	   "qr-reader", { fps: 10, qrbox: Math.min(window.innerWidth, window.innerHeight) * 0.8 });
    	html5QrcodeScanner.render(onScanSuccess);
    }
    						

    当扫描到结果时将调用onScanSuccess处理扫描的结果,onScanSuccess中判断了新的扫描结果和上一个结果是否一致来排除(相邻的)重复扫描结果。

    10.4.2 拼接扫描结果


    上一小节onScanSuccess函数在检查扫描结果不与上一次结果重复时,把扫描的结果用JSON.parse解析后传给loadPackage解析。loadPackage用于拼接数据包。其实现如下。

    
    						function loadPackage(data) {
    							let xid = data['f'] + ' ' + data['C'] + ' ' + data['s']; // 文件名 + 分片大小 + 文件总大小
    							if (!fileScannerStore[xid]) {
    								fileScannerStore[xid] = {
    							        f: data['f'],  // 文件名
    							        c: data['c'],  // 数据包总数
    							        C: data['C'],  // 分片大小
    							        s: data['s'],  // 文件总大小
    							        r: 0,          // 已经接收到的数据包数量
    							        d: {}          // 各数据包的数据
    								}
    							    addLog(`

    接收到新文件: ${data['f']}, 传输大小:${getHumanSize(data['s'])},分片数量:${data['c']}

    `); } if (!fileScannerStore[xid]['d'][data['i']]) { fileScannerStore[xid]['r'] ++; fileScannerStore[xid]['d'][data['i']] = data['d']; addLog(`

    收到文件: ${data['f']} 的数据包 ${data['i']}, ${fileScannerStore[xid]['r']} / ${fileScannerStore[xid]['c']} 完成

    `); if (fileScannerStore[xid]['r'] === fileScannerStore[xid]['c']) { addLog('

    文件: ' + xid + '传输完毕

    '); updateGetFileState(); } }}

    fileScannerStore是定义在函数外的变量,用于保存所有传输中的文件的状态。每扫描一个数据包可以解析出如下内容:文件名、数据包总数、分片大小、文件总大小和当前包中的数据。

    如果之前没有接收到这个文件的其他数据包,则会在fileScannerStore中创建一个新的状态记录。否则会在之前的记录中查找是否已经接收到过这个序号的包,如果没有接收到则保存这个包的数据。

    每收到一个新的数据包都会调用updateGetFileState函数更新当前接受的各文件的状态,也会输出一条日志。

    10.4.3 下载拼接后的文件


    updateGetFileState函数用来更新当前接受的各文件的状态,但一个文件已经接收完毕(即接收到的包等于总包数)时,会给这个文件显示下载按钮。实现代码如下。

    
    						      function updateGetFileState() {
    								      let getFileState = document.querySelector('#getFileState');
    								      getFileState.innerHTML = ''
    								      for (let x in fileScannerStore) {
    								            let f = fileScannerStore[x];
    								            let curHTML = `
    ${f.f} - ${f.r} / ${f.c}`; if (f.r === f.c) { curHTML += `
    ` } curHTML += '
    ' getFileState.innerHTML += curHTML; } }

    实现按钮通过其父元素绑定事件处理函数用来下载文件。事件处理函数通过id找到对应的文件数据,按序号拼接后,通过DataUrl生成一个<a>标签进行下载。实现代码如下所示。

    
    							     function downloadFile(event) {
    								        let f = fileScannerStore[event.target.id];
    								        let name = f.f;
    								        let data = "";
    								        for (let i = 0; i < f.c; i++) {
    								                data += f.d[i];
    								        }
    								        let blob = dataURLtoBlob(data);
    								        let url = URL.createObjectURL(blob);
    								        let save_link = document.createElementNS("http://www.w3.org/1999/xhtml", "a");
    								        save_link.href = url;
    								        save_link.download = name;
    								        save_link.click();
    								    }
    								
    								    var dataURLtoBlob = function(dataurl) { 
    								        var arr = dataurl.split(','),
    								                mime = arr[0].match(/:(.*?);/)[1],
    								                bstr = atob(arr[1]),
    								                n = bstr.length,
    								                u8arr = new Uint8Array(n);
    								        while (n--) {
    								                u8arr[n] = bstr.charCodeAt(n);
    								        }
    								        return new Blob([u8arr], { type: mime });
    								    }
    					  

    下图展示了扫描过程中输出的日志,和扫描并解析完成所有二维码后的文件显示出的下载文件按钮。点击下载文件按钮浏览器会开始下载扫描得到的文件。这时文件数据实际已经在JavaScript的变量中,所以这个下载过程实际是把浏览器中的数据保存到本地文件,完全不会消耗网络。

    10.5 小结


    本章实现了一种新奇的文件传输方式——通过屏幕上的二维码传输文件,实际使用中这种方法传输速度慢,难以传输大文件,但这种方法的优点是传输的内容完全不经过网络。

    本项目的名称是Moving Picture Code,意为动态图片码,是笔者的一个开源项目,从2018年4月想到这个方法,并在2021年付诸实现,现在放到这里作为一个例子,可在GitHub官方网站上搜索sxwxs/MovingPictureCode,即可访问该项目。另外,有一个实现了类似功能但目前功能更加完善的开源项目,可在GitHub网站上搜索sz3/cfc。