HTML5ゲームのできるまで ~起動の高速化(3)~

こんにちは。システム本部プラットフォーム部の坊野です。今回は前回のサンプルプログラムを用いてIndexed Database APIの使い方を説明しようと思います。

ときに、GitHub pageを用いてこの記事で用いているサンプルプログラムをみなさんのブラウザ上で実行できるようにしました。以下のURLで実行可能ですので、実際にこのアプリケーションを実行しながらこの記事を読んでいただければと思います。

http://denadev.github.io/html5-image-sample/test.html

前回の振り返り

前回の記事で説明したとおり、ネットワークキャッシュは図1のような複数の状態 (今回の例では関数) から構成されており、それらの状態を遷移することによって実装されています。

図1 ネットワークキャッシュの構成

図1 ネットワークキャッシュの構成

また、個々の関数に対してそれらがどのように動作しているのかを説明しました。とはいえ、残念ながら前回の記事ではこれらの関数をIndexed Database APIを用いてどのように実装するのかについては説明できませんでした。今回はまずIndexed Database APIについて簡単に説明した後、それを用いてキャッシュをどのように実装するのか説明しようとおもいます。

Indexed Database API

Indexed Database APIはJavaScriptでデータベースの制御を行うAPIです。このAPIは本来データベースの知識が必要となるのですが、今回のサンプルのようにキー (URL) と値 (Data URI) の対を読み書きするような場合は比較的簡単に利用することができます。たとえば、今回のサンプルで用いているメソッドは以下の6つのみとなります。

  • IDBFactory.open

  • IDBDatabase.createObjectStore

  • IDBDatabase.deleteObjectStore

  • IDBDatabase.transaction

  • IDBTransaction.objectStore

  • IDBObjectStore.get

  • IDBObjectStore.put

IDBFactory.open

データベースと接続します。

構文

 var request = indexedDB.open("テスト", 1);

パラメータ

DOMString name [in]

データベース名を指定します。この名前は各サイトの中で一意である必要があります。

上記の例のようにデータベース名として日本語も使うことができますが、日本語を利用する場合は文字化けを防ぐためにJavaScriptファイルのエンコーディングをUTF-8にするか文字列をエスケープシーケンスに変換 (たとえば “てすと” を “\u3066\u3059\u3068”) する必要があります。

signed long long version [in, optional]

データベースのバージョン番号を指定します。この値を変更することによってデータベースの構造の動的に変更することができるのですが、今回のサンプルのようにデータベースの構造が変わらない場合は常に1を指定しておけばいいです。

戻り値

メソッドの呼び出しに成功した場合openメソッドはIDBOpenDBRequestオブジェクトを返します。アプリケーションはこのオブジェクトのonsuccess, onerror, onupgradeneededプロパティに関数をセットしてデータベースの接続状況を取得します。

失敗した場合openメソッドは例外を発生させます、つまり戻り値は未定義ということです。

補足

ファイルなどと同様にIndexed Database APIを用いてデータベースを利用するためには最初にopenメソッドを呼ぶ必要があります。とはいえ、ファイルの場合と違ってopenメソッドはデータベースとの接続を開始するだけです。つまり、このメソッドがIDBOpenDBRequestオブジェクトを返したということはデータベースの接続に成功したことを意味しておらず、実際に成功するまでに時間がかかるということです。

今回のアプリケーションにもあてはまるのですが、openメソッドが呼び出されてから実際にデータベースとの接続が完了するまで時間があるため、アプリケーションは同じデータベースに対してopenメソッドを複数回呼び出す可能性があります。Indexed Database APIはこのような利用法に対応しているため、アプリケーション側での対応はとくに必要ありません。(データベース名とバージョン番号が同じ場合は最初に成功すれば以後の呼び出しも全て成功します。)

Webアプリケーションが複数のモジュールで構成されている場合、データベースの名前によっては2個以上のモジュールが同じデータベースを違うバージョン番号でオープンしてしまう可能性があります。データベース名の衝突を防止するため、データベース名は組織名やモジュール名、アプリケーション名など他のモジュールと衝突しにくい名前を用いたほうがよいです。

IDBDatabase.createObjectStore

オブジェクトストア (データベースで言うところのテーブル) を新規作成します。(Indexed Database APIは数字や文字列だけでなくJavaScriptのオブジェクトをそのまま読み書きできるため「テーブル」ではなく「オブジェクトストア」という名称を用いています。)

構文

  var store = database.createObjectStore("キャッシュ");

パラメータ

DOMString name [in]

オブジェクトストアの名前を指定します。この名前はデータベースの中で一意である必要があります。(逆に複数のデータベースが同じ名前のオブジェクトストアを持つことは可能です。たとえば “データベース1” と “データベース2” の両方が “キャッシュ” という同名のオブジェクトストアを持つことが可能です。)

IDBObjectStoreParameters optionalParameters [in, optional]

オブジェクトストアを作成する際に用いられる追加パラメータを指定します。今回のアプリケーションでは利用しないので詳しい説明は省略します。

戻り値

オブジェクトストアの作成に成功した場合はIDBObjectStoreオブジェクトを返します。とはいえIndexed Database APIでオブジェクトストアの読み書きをする場合、通常IDBTransaction.objectStoreメソッドを用いて作成されたIDBObjectStoreオブジェクトを用いるため、このIDBObjectStoreオブジェクトを利用することはほとんどありません。

また、オブジェクトストアの作成に失敗した場合は例外が発生するため戻り値は未定義です。

補足

Indexed Database APIでは1個のデータベースは複数のオブジェクトストアを持つことができます。たとえば、ある1個のモジュールが複数の種類のオブジェクトの保存をおこないたい場合、1個のデータベースの中に複数のオブジェクトストアを作成して利用することができます。(複数のデータベースを作成して利用するという方法もあるのですが、IDBFactory.openメソッドの部分で説明したとおりデータベース名は他のモジュールのものと衝突する可能性があるので、可能ならば1個のモジュールは1個のデータベースのみ利用するのが賢明です。)

Indexed Database APIではオブジェクトストアの作成・削除はIDBOpenDBRequestオブジェクトのonupgradeneededプロパティにセットされた関数の中でのみ可能となっています。このため、通常図2のようにcreateObjectStoreメソッドはdeleteObjectStoreメソッドと一緒に利用されます。

図2 オブジェクトストアの作成

図2 オブジェクトストアの作成

IDBDatabase.deleteObjectStore

オブジェクトストアを削除します。

構文

  database.deleteObjectStore("キャッシュ");

パラメータ

DOMString name [in]

オブジェクトストアの名前を指定します。(createObjectStoreメソッドのものと同様です。)

補足

このdeleteObjectStoreメソッドでデータベース中に存在しないオブジェクトストアを削除しようとした場合は例外が発生します。例外の発生を避けるためには、1のようにdeleteObjectStoreメソッドを呼び出す前にそのオブジェクトストアがデータベースの中に存在するかどうかを確認する必要があります。

IDBDatabase.transaction

トランザクションを作成します。トランザクションは大まかにいえば複数の読み書き動作を一度に行うための仕組みのことです。

構文

  var transaction = database.transaction("キャッシュ", "readwrite");

パラメータ

DOMString or sequence storeNames [in]

このトランザクションの対象となるオブジェクトストアを指定します。["キャッシュ1", "キャッシュ2"] のように複数のオブジェクトストア名を指定してそれらのオベジェクトストアへの読み書きをまとめて行うことも可能なのですが、今回利用しているオブジェクトストアは1個のみなので常に一定となります。また、上記の例のように利用するオブジェクトストアが1個のみの場合はそのオブジェクトストア名を直接指定することができます。

IDBTransactionMode mode [in, optional]

このトランザクションの動作を指定します。今回のアプリケーションのように複数の動作をまとめて行う必要がない場合は読み込み時は"readonly", 書き込み時は"readwrite"を指定します。

戻り値

トランザクションの作成に成功した場合はIDBTransactionオブジェクトを返します。今回のアプリケーションでは利用していませんが、キャンセルや書き込みエラーなどの処理を行う場合はこのオブジェクトのプロパティにコールバック関数を設定します。

補足

Indexed Database APIではオブジェクトストアへの読み書きを行う際には、通常まずトランザクションを作成し、そのトランザクションにオブジェクトストアへの読み書き要求を登録してからそのトランザクションを実行します。

とはいえ、今回のアプリケーションのように複数の読み書き動作をまとめて行う必要がない場合は以下の例のようにIDBTransaction.objectStoreやIDBObjectStore.get, IDBObjectStore.putメソッドと一緒に利用されます。

  database.transaction("キャッシュ", "readonly").
      objectStore("キャッシュ").get("キー");
  database.transaction("キャッシュ", "readwrite").
      objectStore("キャッシュ").put("値", "キー");

ブラウザはreadonlyトランザクションを並列実行するのですが、readwriteトランザクションはたとえそのトランザクションが書き込みを行わなかったとしても逐次実行されます。つまり、readwriteトランザクションは対象となる全てのオブジェクトストアに対して実行中のトランザクションが全て終了するのを待ってから実行を開始します。また、これらのオブジェクトストアに対する以後のトランザクションはこのreadwriteトランザクションが終了後に実行を開始します。このことから、他のトランザクションへの影響を最小限にするためにreadwriteトランザクションは書き込みが本当に必要な時に最低限のオブジェクトストアに対しておこなう必要があります。

IDBTransaction.objectStore

トランザクションで利用可能なオブジェクトストアを取得します。

構文

  var objectStore = transaction.objectStore("キャッシュ");

パラメータ

DOMString name

利用するオブジェクトストアの名前を指定します。IDBDatabase.transactionメソッドの所で説明しましたがこで指定できるオブジェクトストアはIDBDatabase.transactionのstoreNamesで指定したものの中から選択する必要があります。

戻り値

オブジェクトストアの取得に成功した場合はIDBObjectStoreオブジェクトを返します。このIDBObjectStoreオブジェクトはIDBTransactionが完了するまでの間のみ有効なオブジェクトです。

IDBObjectStore.get

オブジェクトストアへの読み込み要求を作成します。

構文

  var request = objectStore.get("キー");

パラメータ

any key

オブジェクトストアへの読み込みに用いるキー値を指定します。今回のアプリケーションはURL (stringプリミティブ) を用いていますが、他にキーとして利用可能な型としてはnumberプリミティブ、Dateオブジェクト、およびこれらの型のみで構成されるArrayオブジェクトがあります。また、今回は利用していませんがIDBKeyRangeオブジェクトを用いて特定の条件を満たす最初の値を取り出すこともできます。

戻り値

読み込み要求の作成に成功した場合はIDBRequestオブジェクトを返します。とはいえ、このことはオブジェクトストアの中に指定されたキーに対する値が存在することを意味していません、つまり、指定されたキーに対する値が存在しない場合もgetメソッドはIDBRequestオブジェクトを返します。

読み込み要求の作成に失敗した場合 (たとえばキーの型が不適切な場合) は例外を発生させます。

補足

このgetメソッドのキーとしてStringオブジェクトやNumberオブジェクトは利用できません。つまり、以下のような呼び出しは例外が発生するということです。

  objectStore.get(new String("キー"));

今回のアプリケーションはstringプリミティブのみを利用しているので問題ないのですが、Stringオブジェクトも利用している場合は注意が必要です。

オブジェクトストア内にキーに対応する値が存在しない場合、getメソッドはその値としてundefinedを返します。つまり、キーに対応する値が存在する場合でも存在しない場合でもgetメソッドはonsuccessコールバックを呼び出すということです。詳しい説明は省略しますが、IDBObjectStore.openCursorメソッドを用いるキーに対応する値が存在するかどうかを調べることができます。

詳しい説明は省略しますが、getメソッドがonsuccessコールバックを呼び出した後、そのトランザクションがエラーを返す場合があります。本来このような場合アプリケーションはonsuccessコールバックで返された値を破棄すべきなのですが、今回のアプリケーションの場合では実害はないのでトランザクションの成功・失敗にかかわらず返された値を用いています。

IDBObjectStore.put

オブジェクトストアへの書き込み要求を作成します。

構文

  var request = objectStore.put("値", "キー");

パラメータ

any value

オブジェクトストアへの書き込みに用いる値を指定します。今回のアプリケーションはData URI (stringプリミティブ) を用いていますが、JavaScriptのObjectを指定することもできます。とはいえFunctionオブジェクトなどコピーが難しいものを指定するこはできません。

any key (オブション)

オブジェクトストアへの書き込みに用いるキー値を指定します。このキーの設定を省略する方法もあるのですが、今回のアプリケーションでは利用していないので省略します。

戻り値

IDBObjectStore.getメソッドの場合と同様に読み込み要求の作成に成功した場合はIDBRequestオブジェクトを返し、失敗した場合は例外が発生します。

補足にもかいてありますが、書き込みの成功や失敗をチェックしたい場合はIDBTransactionオブジェクトのプロパティを用いる必要があります。

また、putメソッドはreadwriteモードで作成されたトランザクションにおいてのみ実行可能なため、それ以外のモードで作成されたトランザクションに対してputメソッドを実行した場合は例外が発生します。

補足

このputメソッドはキーに対応する値が存在する場合はその値を上書きし、存在しない場合はキーと値の対を追加します。今回は利用していませんが、追加のみの (上書きしたくない) 場合はIDBObjectStore.addメソッドを利用します。

また、putメソッドで書き込み要求された値はこのIDBObjectStoreオブジェクトを保有するIDBTransactionオブジェクトによって実際のデータベースに書き込まれます。つまり、このputメソッドが返すIDBRequestオブジェクトのonsuccessコールバックが呼び出されたとしても実際のデータベースへの書き込みに失敗する場合があるということです。(たとえば、利用可能なデータベースの容量を超えて書き込みを行った場合にこの状態が発生します。)

キャッシュの実装

かなり長くなってしまいましたが、今回のアプリケーションで用いているIndexed Database APIのメソッドについての説明が終了しましたので、これらのメソッドを用いてどのようにキャッシュを実装するのか説明します。今回のアプリケーションが用いているキャッシュに必要な機能は以下のとおりです。

  • インターフェースの取得
    キャッシュが利用可能かどうかを調べて、利用可能であればキャッシュを利用するためのインターフェースを返します。(この場合のインターフェースはアプリケーションがキャッシュを利用するためのメソッドの集合のことです。)

  • キャッシュのオープン
    キャッシュが用いているデータベースに接続します。もしもデータベースが存在しない場合はデータベースを初期化します。

  • データの読み込み
    データベースから与えられたURLに対応するデータ (Data URI) を取得し、それを返します。

  • データの書き込み
    データベースに与えられたURLとデータ (Data URI) の対を書き込みます。

インターフェースの取得

今回のアプリケーションはにキャッシュの内部構造を理解しなくてもローダーがキャッシュを利用できるようにするため、キャッシュを利用するためのインターフェースを実装したオブジェクトを作成し、それを返しています。この部分のコードは図3のようになっています。

この部分で重要なところは792行から794行にある “if (window.indexedDB) {...} ” という部分です。この部分は「このアプリケーションを実行しているブラウザがIndexed Database APIに対応しているかどうか調べて、対応している場合のみIndexed Database APIを用いたキャッシュを提供する」という意味のコードです。残念ながら現在のところ全てのブラウザでIndexed Database APIが利用することはできません。たとえばAndroid 2.3のような比較的古いOSのブラウザはそもそもIndexed Database APIに対応していませんし、FirefoxやInternet Explorer 11のように対応しているブラウザでも実行環境によって利用できない場合があります。このため、多くのHTML5 APIはブラウザが対応しているかどうかをチェックする必要があります。

図3 インターフェースの取得

図3 インターフェースの取得

キャッシュのオープン

Indexed Database APIのところでも説明しましたが、データベースを利用する前にはデータベースをオープン (接続) する必要があります。この部分のコードは図4のようにIDBDatabase.openメソッドを呼んでいるのですが、今回はちょっと特殊な方法を用いています。

図4 キャッシュのオープン

図4 キャッシュのオープン

通常、Indexed Database APIのようにコールバック関数を必要とするAPIを利用する際には以下の例のようにFunctionオブジェクトを作成してそれを用いることが多いと思われます。

  test.Loader.IndexedDatabaseCache.prototype.open =
      function(listener) {
    var request = window.indexedDB.open('Test', 1);
    request.onerror = function(event) {
      listener.handleOpen(false);
    };
    request.onsuccess = function(event) {
      var request = /** @type {IDBRequest} */ (event.target);
      test.Loader.IndexedDatabaseCache.database_ =
          /** @type {IDBDatabase} */ (request.result);
      listener.handleOpen(true);
    };
    request.onupgradeneeded = function(event) {
      var request = /** @type {IDBRequest} */ (event.target);
      var database = /** @type {IDBDatabase} */ (request.result);
      if (database.objectStoreNames.contains('Cache')) {
        database.deleteObjectStore('Cache');
      }
      database.createObjectStore('Cache');
    };
  };

JavaScriptは関数の中からその外部の変数にアクセスできるので、この方法は非常に利便性が高いのですが、ゲームのようなアプリケーションで利用する場合は問題も存在します。この方法は一時的に複数のオブジェクトを作成・破棄するため、ガベージコレクションを引き起こしてアプリケーションが一時停止する可能性があります。(この停止時間はデバイスが非力であればあるほど長くなります。) ゲームが一時停止してしまうことはユーザにとって重大な問題のため、ゲームを作成する際には無駄なオブジェクトの作成はできるだけ避ける必要があります。(他にも非力なデバイスではFunctionオブジェクトの作成自体時間がかかるという問題もあります。) このため、今回のアプリケーションはstaticメソッドを用いることによって可能な限りこの問題の発生しないようにしています。あと、これらのstaticメソッドで用いられる変数listenerをIDBOpenDBRequestオブジェクトのプロパティに設定してこれらのメソッドから利用できるようにしています。

また、図4のとおりIDBDatabase.openメソッドでは3個のコールバック関数を指定する必要がありますが、これらのコールバック関数間の状態遷移図は図5のようになります。

図5コールバック関数の遷移

図5コールバック関数の遷移

図5のとおりデータベースを初期化するためにonupdateneededコールバック関数が呼び出された場合でもデータベースの接続が完了した際にはonsuccessコールバックが呼び出されます。ですから、図6のとおりonupdateneededコールバックはデータベースの初期化処理のみとなります。

図6 データベースの初期化

図6 データベースの初期化

データの読み込み

このキャッシュは1個のデータベースと1個のオブジェクトストアのみを利用しているため、データの読み込み部分は図7のように非常に簡潔になります。

図7 データの読み込み

図7 データの読み込み

あと、本アプリケーションでは簡略化のため図8のようにgetリクエストがエラーになった場合は空文字列を返しています。

図8 データ読み込み完了時の処理

図8 データ読み込み完了時の処理

データの書き込み

今回のアプリケーションはトランザクションのエラー処理を行っていないため、図9のようにデータの書き込み処理も非常に簡潔になります。

図9 データの書き込み

図9 データの書き込み

また、IDBObjectStore.putメソッドの所でも書きましたが、データベースへの書き込みはIDBTransactionオブジェクトが行うため、図10のとおり完了時の処理も非常に簡潔になります。

図10 データ書き込み完了時の処理

図10 データ書き込み完了時の処理

まとめ

今回はIndexed Database APIを用いてキャッシュをどのように実装するのか説明しました。大変長くなってしまったのですが、ゲームのように速度が非常に重要となるアプリケーションの場合、簡単なキャッシュの実装ですら通常の方法とは異なる特殊な方法を用いる必要があるということを理解していただければと思います。

次回は読み込んだ画像に対して画像処理をしている部分について説明したいと思います。