2013年7月31日水曜日

PCで作成したSQLite DBをAndroidアプリで使う

辞書アプリなどのSQLiteに保存されたデータを使うアプリでは、アプリの初回起動時に何らかの方法でデータをDBにインサートする必要があると思います。
件数が少なければ外部ファイルからインサートしてもいいのですが、件数が多くなってくるとこのインサート処理にすごく時間がかかります。
ぐぐるとPCで予め作成したSQLiteのDBファイルをAndroidのassetsディレクトリに含めておき、アプリ起動時にそのままアプリのDBの置き場所(/data/data/<アプリのパッケージ>/databases)にコピーしてしまうという方法を見つけたので試してみました。

Y.A.M の 雑記帳: Android あらかじめ作成した SQLite database をアプリに取り込む

いつものようにすぐに上記のyanzmさんのサイトがヒットしたので、手順に従ってみました。

Androidが読み込めるSQLiteのDBファイルはandroid_metadataという以下の様なロケールの情報を含んだテーブルが必要となるらしいです。

create table android_metadata (  
    locale text default 'en_US'  
); 

自分で作成したテーブル+このテーブルが入っていればAndroidでもそのまま読み込まれるようです。詳細は上記ブログを確認して下さい。

上記のサイトで紹介されているSQLiteOpenHelperを継承したDBHelperクラスは、よくあるgetReadableDatabaseを使用せず独自にDBをオープンしていますが、既存のgetReadableDatabaseをそのまま使いたかったのでさらに調査を進めた所、getReadableDatabaseをそのまま使いつつDBはコピーしたものを使うようなDBHelperクラスの実装を見つけました。

[Android Programming] sqliteのDBファイルをPCでつくってandroidで使う - ipreachableの日記

naichilab - Android / iOSアプリ開発メモ: PCで作ったSQLiteのDBファイルをAndroidで使う

(下のブログでは上のブログの実装にgetReadableDatabase部分も追記しています。)

上記サイトの実装で確かに動作したのですが、ログを見るとgetReadableDatabaseやgetWritableDatabaseを呼ぶたびにDBがコピーされていました。

原因究明のためSQLiteOpenHelperのgetReadableDatabaseやgetWritableDatabaseの中身を調べると、onCreateが呼ばれる条件はSQLiteの’PRAGMA user_version;’の実行結果が0であるということが分かりました。

‘PRAGMA user_version;’って何?ってことでさらにググると以下のサイトにこのように書かれていました。

SQLiteのユーザバージョンを利用する - Basic

ユーザーによって設定できるバージョン番号を管理します。PRAGMA schema_versionコマンドとは違い、SQLite内部では使用されません。

どうやらSQLiteが内部で管理しているDBのバージョンのようですね。

SQLiteOpenHelperはこのuser_versionが0であれば作成したばかりのDBであると判断し、onCreate()を呼んで初期化を行います。
その後SQLiteOpenHelperのコンストラクタで渡したversionの値をuser_versionに保存します。
次回getReadableDatabaseを呼んだ時はこのuser_versionが0でないので、onCreateは呼ばれないという仕組みです。
ちなみにonDowngradeとonUpgradeもこのuser_versionを利用して実装されています。
SQLiteに保存されたuser_versionの値よりSQLiteOpenHelperのコンストラクタに渡したバージョンの方が大きければonUpgradeが呼ばれます。

これで上記実装がなぜgetReadableDatabaseを呼ぶたびにDBがコピーされるかの謎が分かりました。
以下は上記実装の抜粋です。

@Override
public synchronized SQLiteDatabase getWritableDatabase() {
    SQLiteDatabase database = super.getWritableDatabase();
    if (createDatabase) {
        try {
            database = copyDatabase(database);
        } catch (IOException e) {
            Log.wtf(TAG, e);
            // TODO: エラー処理
        }
    }
    return database;
}

private SQLiteDatabase copyDatabase(SQLiteDatabase database) throws IOException {
    // dbがひらきっぱなしなので、書き換えできるように閉じる
    database.close();

    // コピー!
    InputStream input = context.getAssets().open(SRC_DATABASE_NAME);
    OutputStream output = new FileOutputStream(databasePath);
    copy(input, output);

    createDatabase = false;
    // dbを閉じたので、また開く
    return super.getWritableDatabase();
}

@Override
public void onCreate(SQLiteDatabase db) {
    super.onOpen(db);
    // getWritableDatabase()したときに呼ばれてくるので、
    // 初期化する必要があることを保存する
    this.createDatabase = true;
}

流れとしては以下の通りです。

  1. getWritableDatabaseを呼ぶ
  2. オーバーライドしたgetWritableDatabaseの中でsuper.getWritableDatabase()を呼ぶ
  3. 初めはDBが存在しないはずなので、空のDBが作成された後空のDBのuser_versionは0なのでonCreateが呼ばれる
  4. onCreateでthis.createDatabase = trueとなる
  5. super.getWritableDatabaseの処理が終わってcreateDatabaseがtrueになっているので、copyDatabase(database)が呼ばれる
  6. DBコピー後に再度super.getWritableDatabaseが呼ばれる
  7. 今度はコピーされたDBが存在するが、ここでコピー後のDBのuser_versionが0だと再度onCreateが呼ばれる
  8. 再度onCreateでthis.createDatabase = trueとなるので、再度getWritableDatabaseを呼んだ時にまたcopyDatabase(database)が呼ばれてしまう

対策としてはcopyDatabaseの後にSQLiteDatabaseのsetVersionを呼んでコピー後のDBのuser_versionをSQLiteDBHelperに渡したバージョンと同じものに設定してもいいですが、私は以下のように回避してみました。以下はScalaで書かれています。Javaの人ごめんなさい。

object UnitsDBHelper {
  private val DB_VERSION = 1
  private val DB_NAME_ASSET = "units.db"
  private val DB_NAME = "units"
}

class UnitsDBHelper(context: Context) extends SQLiteOpenHelper(context, UnitsDBHelper.DB_NAME, null, UnitsDBHelper.DB_VERSION) {

  import UnitsDBHelper._

  private val DB_PATH = context.getDatabasePath(DB_NAME).getAbsolutePath
  private var doesDBExist = {
    val checkDb = try {
      SQLiteDatabase.openDatabase(DB_PATH, null, SQLiteDatabase.OPEN_READONLY)
    } catch {
      case e: SQLiteException => null
    }
    if (checkDb != null) checkDb.close()
    checkDb != null
  }

  override def getReadableDatabase = getDatabase(super.getReadableDatabase)

  override def getWritableDatabase = getDatabase(super.getWritableDatabase)

  private def getDatabase(getDBFunc: () => SQLiteDatabase) = synchronized {
    if (!doesDBExist) {
      try {
        copyDatabase()
        doesDBExist = true
      } catch {
        case e: IOException => {
          throw new Error("Error copying database")
        }
      }
    }
    getDBFunc()
  }

  private def copyDatabase() = {
    val input = context.getAssets.open(DB_NAME_ASSET)
    val output = new FileOutputStream(DB_PATH)
    val buffer = new Array[Byte](1024)
    var size = 0
    while (-1 != {size = input.read(buffer); size}) {
      output.write(buffer, 0, size)
    }

    output.flush()
    output.close()
    input.close()
  }

  def onCreate(db: SQLiteDatabase) {}

  def onUpgrade(p1: SQLiteDatabase, p2: Int, p3: Int) {}
}

doesDBExistはyanzmさんのブログのcheckDataBaseExistsメソッドと同じで、すでにDBがあるかどうかチェックしています。最初にDBがない状態であればこの値はfalseになるので、copyDatabaseが呼ばれDBがコピーされます。その後doesDBExistをtrueにして2回目以降のgetReadableDatabaseの呼び出しではDBの存在チェックはしないようにしています。

その後getDBFunc()でパラメータで渡されたsuper.getReadableDatabaseなどの関数が呼び出されます。SQLiteOpenHelperのgetReadableDatabaseは初回呼び出し時でも既にDBが存在していれば空のDBなどは作成されないみたいです。その後コピー後のuser_versionの値を見て0ならばonCreateが呼ばれ、その後user_versionの値が1となります(コンストラクタで1を渡しているため)。

以降のgetReadableDatabaseの呼び出しはdoesDBExistが常にtrueとなるはずなので、DBのコピーは起きない仕組みです。

今のところこれで動いていますが、何か問題があれば教えて下さい。

0 件のコメント:

コメントを投稿