概要
趣味の Android アプリ開発で、DB を使う処理に Android Architecture Components の永続化ライブラリ Room を使ってみようとしたら、いくつか詰まったところがあったのでまとめました。
Room
SQLite を扱う際に面倒なところを Annotation Processing で自動生成してくれる便利なライブラリです。Android Architecture Components の1つとして公開されています。詳細は公式ドキュメント(
Save data in a local database using Room )をご覧ください。
環境
Android Studio | 3.1 |
---|---|
Build | #AI-173.4670197, built on March 22, 2018 |
JRE | 1.8.0_152-release-1024-b02 amd64 |
JVM | OpenJDK 64-Bit Server VM by JetBrains s.r.o |
OS | Windows 10 10.0 |
Kotlin | 1.2.31 |
Android Gradle Plugin | 3.1.0 |
メニューバーの Help -> About で下記のダイアログを出して
赤枠で囲っている箇所をクリックすると値をクリップボードにコピーできて便利でした。
導入
公式の Save data in a local database using Room に従って進めていきます。この説明が不要であれば このリンクをクリックして、詰まった点にお進みください。
依存の追加
プロジェクトルートの build.gradle に、 google() のリポジトリを追加します。
allprojects {
repositories {
google()
jcenter()
}
}
そして、 app/build.gradle に room 関連の依存を追加します。
apply plugin: 'kotlin-kapt'
// ...
dependencies {
implementation "android.arch.persistence.room:runtime:1.0.0"
kapt "android.arch.persistence.room:compiler:1.0.0"
Entity/ DAO/ DataBase クラスの用意
例として、閲覧履歴を DB に保存する Entity を中心にして、クラスを用意してみます。
Entity
DB のテーブルに入れる実体のクラスです。
クラスに @Entity アノテーションを付与します。
import android.arch.persistence.room.Entity
import android.arch.persistence.room.PrimaryKey
@Entity
class ViewHistory {
@PrimaryKey(autoGenerate = true)
var _id: Int = 0
var articleName: String = ""
var lastDisplayed: Long = 0
}
DAO
DAO は Database Access Object の略で、その名の通り DB にアクセスするためのメソッドを持つオブジェクトです。
@Dao アノテーションをつけた interface を定義します。Insert メソッドはアノテーションを付けるだけでよく、SELECT と DELETE は SQL クエリをメソッドのアノテーションに書いて定義するようです。
import android.arch.persistence.room.Dao
import android.arch.persistence.room.Insert
import android.arch.persistence.room.Query
@Dao
interface ViewHistoryDataAccessor {
@Query("SELECT * FROM viewhistory")
fun getAll(): List<ViewHistory>
@Insert
fun insert(entity: ViewHistory)
@Query("DELETE FROM viewhistory")
fun deleteAll()
}
DataBase
以下の条件を満たす必要があります。
- RoomDatabase クラスを継承した abstract クラスで定義している
- Entity のリストをアノテーション内に列挙している
- @Dao アノテーションを付けた DAO クラスを返す、引数なしの abstract メソッドを持つ
abstract クラスで定義し @DataBase アノテーションを付け、どの Entity を扱うかを配列形式で指定します。Java と Kotlin では配列の書き方が違う(主に括弧)のでサンプルコードを参照する際は注意してください。
import android.arch.persistence.room.Database
import android.arch.persistence.room.RoomDatabase
import jp.toastkid.wikipediaroulette.history.view.ViewHistory
import jp.toastkid.wikipediaroulette.history.view.ViewHistoryDataAccessor
@Database(entities = [ViewHistory::class], version = 1)
abstract class DataBase: RoomDatabase() {
abstract fun viewHistoryAccessor(): ViewHistoryDataAccessor
}
図
Code Iris で図を表示すると以下の通りです。
_Impl というクラス名の DataBase_Impl と ViewHistoryDataAccessor_Impl が Room の自動生成したクラスです。なお、Java クラスです。
自動生成されるクラス
ちょっと長いですが、以下にコードを全部載せてみます。
ViewHistoryDataAccessor_Impl.java
import android.arch.persistence.db.SupportSQLiteStatement;
import android.arch.persistence.room.EntityInsertionAdapter;
import android.arch.persistence.room.RoomDatabase;
import android.arch.persistence.room.RoomSQLiteQuery;
import android.arch.persistence.room.SharedSQLiteStatement;
import android.database.Cursor;
import java.lang.Override;
import java.lang.String;
import java.util.ArrayList;
import java.util.List;
public class ViewHistoryDataAccessor_Impl implements ViewHistoryDataAccessor {
private final RoomDatabase __db;
private final EntityInsertionAdapter __insertionAdapterOfViewHistory;
private final SharedSQLiteStatement __preparedStmtOfDeleteAll;
public ViewHistoryDataAccessor_Impl(RoomDatabase __db) {
this.__db = __db;
this.__insertionAdapterOfViewHistory = new EntityInsertionAdapter<ViewHistory>(__db) {
@Override
public String createQuery() {
return "INSERT OR ABORT INTO `ViewHistory`(`_id`,`articleName`,`lastDisplayed`) VALUES (nullif(?, 0),?,?)";
}
@Override
public void bind(SupportSQLiteStatement stmt, ViewHistory value) {
stmt.bindLong(1, value.get_id());
if (value.getArticleName() == null) {
stmt.bindNull(2);
} else {
stmt.bindString(2, value.getArticleName());
}
stmt.bindLong(3, value.getLastDisplayed());
}
};
this.__preparedStmtOfDeleteAll = new SharedSQLiteStatement(__db) {
@Override
public String createQuery() {
final String _query = "DELETE FROM viewhistory";
return _query;
}
};
}
@Override
public void insert(ViewHistory entity) {
__db.beginTransaction();
try {
__insertionAdapterOfViewHistory.insert(entity);
__db.setTransactionSuccessful();
} finally {
__db.endTransaction();
}
}
@Override
public void deleteAll() {
final SupportSQLiteStatement _stmt = __preparedStmtOfDeleteAll.acquire();
__db.beginTransaction();
try {
_stmt.executeUpdateDelete();
__db.setTransactionSuccessful();
} finally {
__db.endTransaction();
__preparedStmtOfDeleteAll.release(_stmt);
}
}
@Override
public List<ViewHistory> getAll() {
final String _sql = "SELECT * FROM viewhistory";
final RoomSQLiteQuery _statement = RoomSQLiteQuery.acquire(_sql, 0);
final Cursor _cursor = __db.query(_statement);
try {
final int _cursorIndexOfId = _cursor.getColumnIndexOrThrow("_id");
final int _cursorIndexOfArticleName = _cursor.getColumnIndexOrThrow("articleName");
final int _cursorIndexOfLastDisplayed = _cursor.getColumnIndexOrThrow("lastDisplayed");
final List<ViewHistory> _result = new ArrayList<ViewHistory>(_cursor.getCount());
while(_cursor.moveToNext()) {
final ViewHistory _item;
_item = new ViewHistory();
final int _tmp_id;
_tmp_id = _cursor.getInt(_cursorIndexOfId);
_item.set_id(_tmp_id);
final String _tmpArticleName;
_tmpArticleName = _cursor.getString(_cursorIndexOfArticleName);
_item.setArticleName(_tmpArticleName);
final long _tmpLastDisplayed;
_tmpLastDisplayed = _cursor.getLong(_cursorIndexOfLastDisplayed);
_item.setLastDisplayed(_tmpLastDisplayed);
_result.add(_item);
}
return _result;
} finally {
_cursor.close();
_statement.release();
}
}
}
DataBase_Impl.java
import android.arch.persistence.db.SupportSQLiteDatabase;
import android.arch.persistence.db.SupportSQLiteOpenHelper;
import android.arch.persistence.db.SupportSQLiteOpenHelper.Callback;
import android.arch.persistence.db.SupportSQLiteOpenHelper.Configuration;
import android.arch.persistence.room.DatabaseConfiguration;
import android.arch.persistence.room.InvalidationTracker;
import android.arch.persistence.room.RoomOpenHelper;
import android.arch.persistence.room.RoomOpenHelper.Delegate;
import android.arch.persistence.room.util.TableInfo;
import android.arch.persistence.room.util.TableInfo.Column;
import android.arch.persistence.room.util.TableInfo.ForeignKey;
import android.arch.persistence.room.util.TableInfo.Index;
import java.lang.IllegalStateException;
import java.lang.Override;
import java.lang.String;
import java.util.HashMap;
import java.util.HashSet;
import jp.toastkid.wikipediaroulette.history.roulette.RouletteHistoryDataAccessor;
import jp.toastkid.wikipediaroulette.history.roulette.RouletteHistoryDataAccessor_Impl;
import jp.toastkid.wikipediaroulette.history.view.ViewHistoryDataAccessor;
import jp.toastkid.wikipediaroulette.history.view.ViewHistoryDataAccessor_Impl;
public class DataBase_Impl extends DataBase {
private volatile RouletteHistoryDataAccessor _rouletteHistoryDataAccessor;
private volatile ViewHistoryDataAccessor _viewHistoryDataAccessor;
protected SupportSQLiteOpenHelper createOpenHelper(DatabaseConfiguration configuration) {
final SupportSQLiteOpenHelper.Callback _openCallback = new RoomOpenHelper(configuration, new RoomOpenHelper.Delegate(1) {
public void createAllTables(SupportSQLiteDatabase _db) {
_db.execSQL("CREATE TABLE IF NOT EXISTS `RouletteHistory` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `articleName` TEXT NOT NULL, `lastDisplayed` INTEGER NOT NULL)");
_db.execSQL("CREATE TABLE IF NOT EXISTS `ViewHistory` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `articleName` TEXT NOT NULL, `lastDisplayed` INTEGER NOT NULL)");
_db.execSQL("CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)");
_db.execSQL("INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, \"9c0eaf6fca48c1d7ac4c795fdcdb7353\")");
}
public void dropAllTables(SupportSQLiteDatabase _db) {
_db.execSQL("DROP TABLE IF EXISTS `RouletteHistory`");
_db.execSQL("DROP TABLE IF EXISTS `ViewHistory`");
}
protected void onCreate(SupportSQLiteDatabase _db) {
if (mCallbacks != null) {
for (int _i = 0, _size = mCallbacks.size(); _i < _size; _i++) {
mCallbacks.get(_i).onCreate(_db);
}
}
}
public void onOpen(SupportSQLiteDatabase _db) {
mDatabase = _db;
internalInitInvalidationTracker(_db);
if (mCallbacks != null) {
for (int _i = 0, _size = mCallbacks.size(); _i < _size; _i++) {
mCallbacks.get(_i).onOpen(_db);
}
}
}
protected void validateMigration(SupportSQLiteDatabase _db) {
final HashMap<String, TableInfo.Column> _columnsRouletteHistory = new HashMap<String, TableInfo.Column>(3);
_columnsRouletteHistory.put("_id", new TableInfo.Column("_id", "INTEGER", true, 1));
_columnsRouletteHistory.put("articleName", new TableInfo.Column("articleName", "TEXT", true, 0));
_columnsRouletteHistory.put("lastDisplayed", new TableInfo.Column("lastDisplayed", "INTEGER", true, 0));
final HashSet<TableInfo.ForeignKey> _foreignKeysRouletteHistory = new HashSet<TableInfo.ForeignKey>(0);
final HashSet<TableInfo.Index> _indicesRouletteHistory = new HashSet<TableInfo.Index>(0);
final TableInfo _infoRouletteHistory = new TableInfo("RouletteHistory", _columnsRouletteHistory, _foreignKeysRouletteHistory, _indicesRouletteHistory);
final TableInfo _existingRouletteHistory = TableInfo.read(_db, "RouletteHistory");
if (! _infoRouletteHistory.equals(_existingRouletteHistory)) {
throw new IllegalStateException("Migration didn't properly handle RouletteHistory(jp.toastkid.wikipediaroulette.history.roulette.RouletteHistory).\n"
+ " Expected:\n" + _infoRouletteHistory + "\n"
+ " Found:\n" + _existingRouletteHistory);
}
final HashMap<String, TableInfo.Column> _columnsViewHistory = new HashMap<String, TableInfo.Column>(3);
_columnsViewHistory.put("_id", new TableInfo.Column("_id", "INTEGER", true, 1));
_columnsViewHistory.put("articleName", new TableInfo.Column("articleName", "TEXT", true, 0));
_columnsViewHistory.put("lastDisplayed", new TableInfo.Column("lastDisplayed", "INTEGER", true, 0));
final HashSet<TableInfo.ForeignKey> _foreignKeysViewHistory = new HashSet<TableInfo.ForeignKey>(0);
final HashSet<TableInfo.Index> _indicesViewHistory = new HashSet<TableInfo.Index>(0);
final TableInfo _infoViewHistory = new TableInfo("ViewHistory", _columnsViewHistory, _foreignKeysViewHistory, _indicesViewHistory);
final TableInfo _existingViewHistory = TableInfo.read(_db, "ViewHistory");
if (! _infoViewHistory.equals(_existingViewHistory)) {
throw new IllegalStateException("Migration didn't properly handle ViewHistory(jp.toastkid.wikipediaroulette.history.view.ViewHistory).\n"
+ " Expected:\n" + _infoViewHistory + "\n"
+ " Found:\n" + _existingViewHistory);
}
}
}, "9c0eaf6fca48c1d7ac4c795fdcdb7353");
final SupportSQLiteOpenHelper.Configuration _sqliteConfig = SupportSQLiteOpenHelper.Configuration.builder(configuration.context)
.name(configuration.name)
.callback(_openCallback)
.build();
final SupportSQLiteOpenHelper _helper = configuration.sqliteOpenHelperFactory.create(_sqliteConfig);
return _helper;
}
@Override
protected InvalidationTracker createInvalidationTracker() {
return new InvalidationTracker(this, "RouletteHistory","ViewHistory");
}
@Override
public RouletteHistoryDataAccessor rouletteHistoryAccessor() {
if (_rouletteHistoryDataAccessor != null) {
return _rouletteHistoryDataAccessor;
} else {
synchronized(this) {
if(_rouletteHistoryDataAccessor == null) {
_rouletteHistoryDataAccessor = new RouletteHistoryDataAccessor_Impl(this);
}
return _rouletteHistoryDataAccessor;
}
}
}
@Override
public ViewHistoryDataAccessor viewHistoryAccessor() {
if (_viewHistoryDataAccessor != null) {
return _viewHistoryDataAccessor;
} else {
synchronized(this) {
if(_viewHistoryDataAccessor == null) {
_viewHistoryDataAccessor = new ViewHistoryDataAccessor_Impl(this);
}
return _viewHistoryDataAccessor;
}
}
}
}
こうした DB を扱う際の煩雑なコードを自動で生成してくれるのはありがたいです。
詰まった点
簡単に使うだけなら、ドキュメントに従って Entity/ DAO/ DataBase を作ればよいだけらしいですが……
cannot find implementation
Caused by: java.lang.RuntimeException: cannot find implementation for jp.toastkid.wikipediaroulette.db.DataBase. DataBase_Impl does not exist
at android.arch.persistence.room.Room.getGeneratedImplementation(Room.java:92)
at android.arch.persistence.room.RoomDatabase$Builder.build(RoomDatabase.java:454)
at jp.toastkid.wikipediaroulette.MainActivity.onCreate(MainActivity.kt:49)
at android.app.Activity.performCreate(Activity.java:6955)
自動生成されるはずの実装クラスがないと言われています。
原因
単に Kotlin でアノテーションプロセシングしたい場合は kapt を使えという話でした。
修正
kapt を導入しましょう。
修正前
annotationProcessor で指定している箇所を
dependencies {
annotationProcessor "android.arch.persistence.room:compiler:1.0.0"
修正後
kapt に変え、 kotlin-kapt プラグインの指定を追加します。
+apply plugin: 'kotlin-kapt'
dependencies {
- annotationProcessor "android.arch.persistence.room:compiler:1.0.0"
+ kapt "android.arch.persistence.room:compiler:1.0.0"
参考
Android Room Persistences library and Kotlin
w: 警告: 注釈プロセッサ'org.jetbrains.kotlin.kapt3.ProcessorWrapper'から-source '1.8'より小さいソース・バージョン'RELEASE_7'がサポートされています
これはただの警告なので無視してよいとのことです。
参考
Room (possibly others) dont seem to compile with Java 8 #35
Cannot find getter for field.
エラー: Cannot find getter for field.
private int _id;
Entity のフィールドに Getter がないと言われます。
修正
@Entity
class RouletteHistory {
@PrimaryKey(autoGenerate = true)
- private var _id: Int = 0
+ var _id: Int = 0
private で宣言して getter を用意していないことが原因でした。
Cannot access database on the main thread since it may potentially lock the UI for a long period of time.
UI スレッドで DB の読み込みや書き込みを実行すると、 IllegalStateException でアプリがクラッシュします。DBからデータを読み込むのは重い処理なのでUIスレッドから実行しない、という制限があります。これは OR Mapper の Orma 等でも共通の考え方で作られています。
Caused by: java.lang.IllegalStateException: Cannot access database on the main thread since it may potentially lock the UI for a long period of time.
at android.arch.persistence.room.RoomDatabase.assertNotMainThread(RoomDatabase.java:164)
at android.arch.persistence.room.RoomDatabase.beginTransaction(RoomDatabase.java:211)
at jp.toastkid.wikipediaroulette.history.roulette.RouletteHistoryDataAccessor_Impl.insert(RouletteHistoryDataAccessor_Impl.java:51)
RxJava と関連ライブラリを導入
私は意識が低いので、非同期処理を実装してくれと言われたらすぐに RxJava を使います。
dependencies {
implementation 'io.reactivex.rxjava2:rxjava:2.1.12'
implementation 'io.reactivex.rxjava2:rxandroid:2.0.2'
implementation 'io.reactivex.rxjava2:rxkotlin:2.2.0'
Insert
修正前
dataBase.rouletteHistoryAccessor().insert(rouletteHistory)
修正後
1メソッドの実行だけを別スレッドで動かしたいなら Completable#fromAction を使います。
Completable.fromAction { dataBase.rouletteHistoryAccessor().insert(rouletteHistory) }
.subscribeOn(Schedulers.io())
.subscribe()
Get
修正前
val entities: Iterable<RouletteHistory> = dataBase.rouletteHistoryAccessor().getAll()
entities.forEach { println(it.articleName) }
修正後
getAll の実行スレッドを変えるためにいったん Single オブジェクトを生成して、途中で Observable に変換しています。
Single.fromCallable { dataBase.rouletteHistoryAccessor().getAll() }
.subscribeOn(Schedulers.io())
.flatMapObservable { it.toObservable() }
.map { it.articleName }
.subscribe({ println(it) })
DBのバージョン管理
この Room は Orma と違い、DataBase クラスの実装を変えた場合はバージョンを変更する必要があります。
@Database(entities = [RouletteHistory::class, ViewHistory::class], version = 1)
abstract class DataBase: RoomDatabase() {
この箇所を管理が確実になってよいと考えるか、面倒になったと思うかは人それぞれな気がします。
まとめ
Kotlin で Room を使う際は以下に気を付けましょう。
- room の依存を追加する際は annotationProcessor ではなく kapt を使う
- Entity のフィールドには getter メソッドを作るか public にする
ほか、Kotlin には限りませんが以下も気を付けましょう。
- DB にアクセスするメソッドを呼ぶときはバックグラウンドスレッドで処理する
- DataBase クラスを修正する際は version を変更する
感想
軽く使ってみた感じだと、すでに Orma で書いてあるコードをこの Room に全部置き換えるようなことはしなくてもよいのかなと思いました。