我最近遇到了一个有趣的Web应用程序项目,该项目有一个特殊的要求:既要提供保存/加载功能,有不能依赖于cookie、本地存储或服务器端存储(没有帐户,也不用登录)。对于这个保存功能,我的实现方法是,首先获取数据,将其序列化为JSON,然后动态创建一个带有数据URL和下载属性集的新链接元素,并在该链接上触发单击事件。虽然这种方式在桌面浏览器上运行状况良好,但是在移动版Safari浏览器上,却惨遭失败。
之所以出现这种情况,问题在于移动版Safari浏览器会忽略link元素中的download属性。这样的话,就会导致序列化的JSON数据将显示在浏览器窗口中,而无法将其存储到用户的设备上。同时,我们还没有办法禁用它。
对于上述问题,我们的解决方案是:向用户显示存储数据的内容,并将其保存到设备中。很明显,就这里来说,图像是一个显而易见的选择。虽然它无法创建相同的保存/加载体验,但是已经足够接近了。
我们曾经尝试过二维码,结果发现,虽然它们非常容易生成,但解码的时候,事情就没有那么简单了,并且需要包含一些非常臃肿的库,所以,我们很快就将其放弃了。
接下来的挑战是,如何在PNG中存储任意文本数据。实际上,这并不是一个新想法,并且之前已经做过,但是我不想建立一个完全通用的存储容器,倒是很乐意施加一些约束,从而使我的工作更轻松一些。
约束/需求
听起来很简单,对吧?实际上,这里有几个坑。但首先让我们看看一般性的方法。
图像实际上就是一个2D像素阵列。每个像素对应一个3个字节的元组,每个字节对应于RGB中的一个颜色分量。其中,每个颜色分量的取值范围为0到255。这种组织方法有助于自然地存储字节/字符数组。例如,单个像素可用于存储一个ASCII字符数组['F', 'T', 'W'],这实际上就是将ASCII码编码为颜色强度,具体如图所示...
这样,我们就会得到一个相当灰暗无聊的像素,但它存储了我们想要的数据。利用这种方式,我们可以对一个整个句子进行类似的处理,例如“The quick brown fox jumps over the lazy dog”进行相应的编码处理后,得到如下所示的序列......
84 104 101
32 113 117
105 99 107
32 98 114
111 119 110
32 102 111
120 32 106
117 109 112
115 32 111
118 101 114
32 116 104
101 32 108
97 122 121
32 100 111
103
这实际上就是得到了15个像素,具体如下所示......
注意,最后一个3元组中只有一个字符代码,因此,需要用两个零值进行填充,从而得到一个像素。
上面介绍的是一种基本的方法。但是,它无法满足我们的所有需求:
将对象转换为字节数组
现在,我们已经找到了一个通用方法,并为字节数组找到了相应的容器。下一步的工作,就是将对象转换为可以存储在字节数组中的形式。这其实非常简单,只需借助JSON.stringify()和TextEncoder.encode(),我们就能够得到一个Uint8Array。之后,还可以计算出存储这些数据所需的方形图像的大小。
var strData = JSON.stringify(myObjData);
var uint8array = (new TextEncoder('utf-8')).encode(strData);
var dataSize = Math.ceil(Math.sqrt(uint8array.length / 3));
将字节数组转换为图像数据
接下来,我们需要读取字节数组中的数据,并将其转换为可与画布一起使用的ImageData对象。在这里,我遇到了第一个坎——ImageData需要的是Uint8ClampedArray,我们这里的却是Uint8Array。从根本上说,由于我的数据在某种意义上已被TextEncoder转换所“钳制”,所以我并不需要太担心这个问题。
由于我们需要无损格式来存储图像数据,因此,这里选择将PNG作为输出格式。这也意味着,我们不是将数据存储为RGB格式,而是将其存储为RGBA格式。也就是说,每个像素还有一个额外的Alpha通道,因此,需要用到额外的字节。但是经过一番尝试后发现,当alpha通道设置为零时,会遇到一个RGB受损方面的问题。
这给我带来了很大的麻烦,我不得不鼓捣代码,将3元组字节数组转换为4元组数组,并将第4个(alpha)分量设置为完全不透明(即255)。这对于以后的解码来说是一个优势,因为可以轻松跳过所有零填充数据。它不是最有效的代码,但是,至少能够达到预期目的。
var paddedData = new Uint8ClampedArray(dataSize * dataSize * 4);
var idx = 0;
for (var i = 0; i < uint8array.length; i += 3) {
var subArray = uint8array.subarray(i, i + 3);
paddedData.set(subArray, idx);
paddedData.set([255], idx + 3);
idx += 4;
}
作为附带的好处,现在得到了正确类型的Uint8ClampedArray字节数组,所以,我们终于可以构造ImageData对象了。
var imageData = new ImageData(paddedData, dataSize, dataSize);
绘制图像
有了ImageData对象,接下来就可以创建一个画布,以便绘制JSON编码的图像数据。首先,我们需要将画布创建为“off screen”类型,并检索其上下文,并将背景设置为纯色(实际上,这里的实际颜色无关紧要)。
var imgSize = 256;
var canvas = document.createElement('canvas');
canvas.width = canvas.height = imgSize;
var ctx = canvas.getContext('2d');
ctx.fillStyle = '#AA0000';
ctx.fillRect(0, 0, imgSize, imgSize);
然后,我们就可以“绘制”表示方形图像大小的像素了。实际上,这里的方形图形就是我们编码后的数据。
ctx.fillStyle = 'rgb(' + dataSize +',0,0)';
ctx.fillRect(0, 0, 1, 1);
下面,我们来渲染图像数据……
ctx.putImageData(imageData, 0, 1);
保存图像
现在,我们可以将图像从画布保存到文件系统(对于移动版Safari浏览器来说,就是显示到一个新选项卡中),这需要借助于一些jQuery代码...
$('body').append('<a id="hiddenLink" href="' + canvas.toDataURL() +
'" style="display:none;" download="image.png">Download</a>');
var link = $('#hiddenLink')[0];
link.click();
link.remove();
最终结果是这样的:
当然,下一步是解码图像并从中取出原始JSON,这些步骤我们将在下一篇文章中加以详细介绍,请读者耐心等待。祝阅读愉快!