Kotlin で Room を使う際に詰まったところをまとめる

More than 1 year has passed since last update.


概要

趣味の 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 で下記のダイアログを出して

dialog.png

赤枠で囲っている箇所をクリックすると値をクリップボードにコピーできて便利でした。



導入

公式の Save data in a local database using Room に従って進めていきます。この説明が不要であれば このリンクをクリックして、詰まった点にお進みください。


依存の追加

プロジェクトルートの build.gradle に、 google() のリポジトリを追加します。


build.gradle

allprojects {

repositories {
google()
jcenter()
}
}

そして、 app/build.gradle に room 関連の依存を追加します。


app/build.gradle

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

以下の条件を満たす必要があります。


  1. RoomDatabase クラスを継承した abstract クラスで定義している

  2. Entity のリストをアノテーション内に列挙している


  3. @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 で図を表示すると以下の通りです。

dao.png

_Impl というクラス名の DataBase_Impl と ViewHistoryDataAccessor_Impl が Room の自動生成したクラスです。なお、Java クラスです。


自動生成されるクラス

ちょっと長いですが、以下にコードを全部載せてみます。


ViewHistoryDataAccessor_Impl.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


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 で指定している箇所を


app/build.gradle

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 を使います。


app/build.gradle

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 に全部置き換えるようなことはしなくてもよいのかなと思いました。