2 MB 超の画像データを無理矢理 localStorage に保存してみた

HTML5 にはローカルにデータを保存する仕組みがいくつかありますが、MobileSafari (iOS 6) の場合 localStorage は 5 MB までの容量制限があります。
JavaScript の文字コードは UTF-16 なので、ASCII 文字も 2 バイトの容量を食います。よって、ASCII 文字列を保存する場合は実質 2.5 MB までしか使えません。
バイナリデータは Base64 エンコードして保存するでしょうから、保存できるデータは元の容量で最大約 1.875 MB ということになります。
たった 5 MB しか保存できないのになんかもったいないですよね!

前提(2013/02/07 追記)

localStorage の特徴として以下の内容を前提としています。ブラウザの種類やバージョンによってはデータが壊れないものもありますし、UTF-16 として有効な印字文字で構成されている文字列を保存してもデータが壊れることもあるかもしれません。
ブラウザをスマートフォンに限っても、世の中にはメーカーが魔改造した Android 標準ブラウザの亜種が山ほどあるので、どのブラウザでもこの前提を下に考えれば大丈夫である可能性が高そうというものにしています。

  • バイナリを保存するとデータが壊れる
  • \u0000 が入っているとデータが壊れる
  • UTF-16 として無効なコード値が入っているとデータが壊れるかもしれない1
  • UTF-16 として有効な印字文字を保存すればデータは壊れない

以下の内容はあくまで上の前提に従ったもの、かつ単純なものなので、前提をなくせばより良い方法はいくらでもあるでしょうし、前提があってもよりコンパクトに保存することや高速に処理することもできると思います。

容量を減らせないか?

バイナリデータを Base64 エンコードすると 64 種類の ASCII 文字 (+, /, 0-9, A-Z, a-z) に加えて = の計 65 文字から構成される文字列になります。
これらの文字から任意のペアの下位バイトを抽出して、UTF-16 の上位バイトと下位バイトに割り当てることで ASCII 2 文字を UTF-16 (BMP) 1 文字で表現することができます。

各文字の下位バイトは 0x2B、0x2F-0x39、0x3D、0x41-0x5A、0x61-0x7A になるわけですが、Wikipedia を参照するとこれらの組み合わせによって作成されるコード値には何らかの文字が割り当てられていることがわかります。2

範囲 名称 日本語名称
U+2B00-2BFF Miscellaneous Symbols and Arrows その他の記号及び矢印
U+2F00-2FDF Kangxi Radicals 康熙部首
U+3000-303F CJK Symbols and Punctuation CJKの記号及び句読点
U+3040-309F Hiragana 平仮名
U+3100-312F Bopomofo 注音符号
U+3130-318F Hangul Compatibility Jamo ハングル互換字母
U+3200-32FF Enclosed CJK Letters and Months 囲みCJK文字・月
U+3300-33FF CJK Compatibility CJK互換用文字
U+3400-4DBF CJK Unified Ideographs Extension A CJK統合漢字拡張A
U+4DC0-4DFF Yijing Hexagram Symbols 六十四卦
U+4E00-9FFF CJK Unified Ideographs CJK統合漢字

なんかいけそうですね!

ASCII 文字列を圧縮してみる

上記のコンセプトに基いて実装してみました。といってもこちらのコードをほとんどそのまま使っています・・・。

// cf. http://codereview.stackexchange.com/questions/3569/pack-and-unpack-bytes-to-strings
var ASCII;
(function() {
    var QUANTUM = 65536;

    ASCII = {
        pack: function(asciiData) {
            var size = asciiData.length;

            var charCodes = [];
            for (var i = 0; i < size; ) {
                charCodes.push(((asciiData.charCodeAt(i++) & 0xff) << 8) | (asciiData.charCodeAt(i++) & 0xff));
            }

            //var data = String.fromCharCode.apply(null, charCodes);
            // JavaScriptCore limits the length of arguments to 65536
            // cf. https://bugs.webkit.org/show_bug.cgi?id=80797
            var cnt = charCodes.length;
            var packedData = "";
            for (i = 0; i < cnt; i += QUANTUM) {
                packedData += String.fromCharCode.apply(null, charCodes.splice(0, QUANTUM));
            }

            // slower on Safari
            // var packedData = "";
            // for (var i = 0; i < size; ) {
            //     packedData += String.fromCharCode(((asciiData.charCodeAt(i++) & 0xff) << 8) | (asciiData.charCodeAt(i++) & 0xff));
            // }

            return packedData;
        },
        unpack: function(packedData) {
            var len = packedData.length;
            var code;

            var asciiData = "";
            for (var i = 0; i < len; i++) {
                code = packedData.charCodeAt(i);
                asciiData += String.fromCharCode(code >>> 8, code & 0xff);
            }

            // slower on Chrome and Safari
            // var charCodes = [];
            // for (var i = 0; i < len; i++) {
            //     code = packedData.charCodeAt(i);
            //     charCodes.push(code >>> 8, code & 0xff);
            // }

            // var cnt = charCodes.length;
            // var asciiData = "";
            // for (i = 0; i < cnt; i += QUANTUM) {
            //     asciiData += String.fromCharCode.apply(null, charCodes.splice(0, QUANTUM));
            // }

            return asciiData;
        }
    };
})();

デモ

以下、2.24 MB の画像を保存するデモです。MobileSafari (iOS 5.0, 5.1, 6.0) と Chrome 24 (PC) で動作確認しています。
localStorage にデータがなければ画像をダウンロードして localStorage に保存し、その後に画像を表示します。
最初に localStorage.clear() を実行していますが、QueryString に clear=false を指定することで回避することもできます。

  1. Base64 エンコードしてそのまま保存するデモ

http://dev.abicky.net/hatena/pack_image/

  1. Base64 エンコードした上で ASCII 2文字を UTF-16 1文字に変換して保存するデモ

http://dev.abicky.net/hatena/pack_image/?pack=true

  1. バイナリのまま保存するデモ

http://dev.abicky.net/hatena/pack_image/?base64=false

1 は Base64 エンコードしてそのまま保存しようとすると優に 5 MB を超えるので “QUOTA_EXCEEDED_ERR” になるはずです。
2 は ASCII 2 文字を UTF-16 1 文字で表現しているので問題なく保存できています。
3 は Base64 エンコードせずにバイナリのまま保存しています。保存できてしまうってことは localStorage はバイナリのまま保存すると UTF-16 にはならないっぽいですね・・・。

っで、デモを見てわかるとおり、画像がキャッシュされていれば localStorage からデータを取り出して解凍(?)して…(iPhone 5 iOS 6.0.1 で1秒程度)とするよりも img.src に URL を代入した方がよっぽど速そうです。取得しようとしている画像がキャッシュされてるかどうかを JavaScript から調べる方法ってないんでしょうか・・・?3
また、バイナリで保存できるのであればそうすればいいじゃないかと思うんですが、iOS 5.0, iOS 5.1 のシミュレータで clear=false を指定して確認したところ、シミュレータを終了するとデータが壊れるみたいです。実機では確認していません。
iOS 6.0 の場合は実機の電源を落としたりシミュレータを終了したりしても正常にロードできるようです。それでもちょっと不安です。

というわけで、大容量のバイナリデータを保存するのは悩みものですね・・・

  1. 保存する時に壊れる可能性もあるでしょうし、String.fromCharCode の結果がおかしなものになるかもしれません 

  2. UTF-16 の場合 BMP のコード値はそのまま Unicode のコードポイントに対応します 

  3. Firefox だと navigator.mozIsLocallyAvailable というのがあるっぽいです 

広告
独立性の仮定と平均場近似の関係 実践 git rebase --onto
※このエントリーははてなダイアリーから移行したものです。過去のコメントなどはそちらを参照してください