概要
Android & Room で射影を扱うやり方について述べます。「Room とは何か」「Room はどうやって使うのか」については他の記事を参照してください。
想定するアプリ
青空文庫ビューアー
機能
- 本のタイトル一覧を表示
- 一覧でタップした本の詳細を別画面で表示
Book
本を表現するクラスです。本来であれば PrimaryKey には ISDN を用いるか、title と author の複合キーを用いるか、等をすべきでしょうが、今回は割愛します。
@Entity
class Book {
/** 本の名前 */
@PrimaryKey
var title: String = ""
/** 本の中身 */
var content: String = ""
/** いつ読んだか */
var lastRead: Long = 0L
/** どこまで読んだか */
var markIndex: Int = 0
}
Dao
本の一覧を表示するための getAll、タップした本の内容を表示するために内容を取得しに行く findContentByTitle を定義しておきます。
@Dao
interface BookRepository {
@Query("SELECT * FROM book ORDER BY lastRead DESC LIMIT 500")
fun getAll(): List<Book>
@Query("SELECT content FROM book WHERE title = :title LIMIT 1")
fun findContentByTitle(title: String): String?
}
すべての列を取り出すことの何が問題か?
上記の Book オブジェクトを RecyclerView で扱うとスクロールが非常に重くなります。
画像のような本の一覧を表示する際、 Book の content は不要です。content には巨大なデータが入りうるため、詳細ページを開く際に都度読み込めばそれで十分です。なので、 Dao の getAll メソッドでは content を取り出す必要はありません。
Room で射影を扱えるのか?
射影
リレーショナルデータベースのテーブルから一部の列を取り出す操作です。当然ながら SQLite でも SELECT で列を指定すれば射影を取り出せます。
取り出す列が1つだけなら、その列を @Query の SELECT で指定し、対応する型を戻り値の型に指定すれば取り出すことができます。
Room で射影
以下の例は複数の行を取り出すので、List で指定しています。
@Dao
interface BookRepository {
@Query("SELECT content FROM book")
fun getAllBookTitles(): List<String>
Room で射影?
しかし、取り出したい列が2つ以上の場合、たとえば Pair 等を使ったとしても上手くマッピングはしてくれませんでした。
@Dao
interface BookRepository {
@Query("SELECT title, last_read FROM book")
fun getAllBookForList(title: String): List<Pair<String, Long>>
以下のコンパイルエラーが出力されます。
エラー: Not sure how to convert a Cursor to this method's return type
このエラーを見ると Cursor にならマッピングできるようなので、Cursor で受け取って使うというのもやり方としてはあるでしょう。従来から慣れ親しんでいるやり方で処理できます。ただ、Cursor だとせっかく Room を使っている意味が薄くなるので、もうちょっと別のやり方を探りたいです。
余談ですが、Room は SQL に問題があるときにコンパイルエラーの形で指摘してくれるのが良いです。
では、どうするのか?
射影を表現する data クラスを定義し、それを Dao の @Query 関数の戻り値の型として指定すれば、射影を取り出すことが可能です。
射影クラス
射影として取り出したい列と同じ名前かつ型を持つ data クラスを定義します。
data class BookListItem(
val title: String,
val lastRead: Long
val markIndex: Int
)
テーブルの列名と異なるフィールド名を持たせたい場合は @ColumnInfo で列名を指定してください。
import androidx.room.ColumnInfo
data class BookListItem(
@ColumnInfo(name = "title") val bookTitle: String,
@ColumnInfo(name = "lastRead") val bookLastRead: Long
@ColumnInfo(name = "markIndex") val markedIndex: Int
)
Dao 修正
そして、Dao の戻り値の型に射影のデータクラスを指定します。
@Dao
interface BookRepository {
@Query("SELECT title, lastRead, markIndex FROM book")
fun getAllBookForList(): List<BookListItem>
これで Room で射影を扱えるようになります。
Room が生成するコード
Room では SQLite を使ったコードを自動生成しています。どのようなコードが作られているのかを実際に見てみることで、Room が使えない環境で SQLite にアクセスするコードを書かざるを得ない時に役立つでしょう。もっとも、 Room を使えるように働きかける方が後々の効率はずっと良いかもしれませんが
@Override
public List<BookListItem> getAllBookForList() {
final String _sql = "SELECT title, lastRead, markIndex FROM book";
final RoomSQLiteQuery _statement = RoomSQLiteQuery.acquire(_sql, 0);
final Cursor _cursor = __db.query(_statement);
try {
final int _cursorIndexOfTitle = _cursor.getColumnIndexOrThrow("title");
final int _cursorIndexOfLastRead = _cursor.getColumnIndexOrThrow("lastRead");
final int _cursorIndexOfMarkedIndex = _cursor.getColumnIndexOrThrow("markIndex");
final List<BookListItem> _result = new ArrayList<BookListItem>(_cursor.getCount());
while(_cursor.moveToNext()) {
final BookListItem _item;
final String _tmpTitle;
_tmpTitle = _cursor.getString(_cursorIndexOfTitle);
final long _tmpLastRead;
_tmpLastRead = _cursor.getLong(_cursorIndexOfLastRead );
final int _tmpMarkedIndex;
_tmpMarkedIndex = _cursor.getInt(_cursorIndexOfMarkedIndex);
_item = new BookListItem(_tmpTitle,_tmpLastRead,_tmpMarkedIndex);
_result.add(_item);
}
return _result;
} finally {
_cursor.close();
_statement.release();
}
}
Room がこういった定型的なコードをミスなく自動的に生成してくれるものだということを知っていれば、導入する際の説得もしやすいでしょう。
まとめ
Android かつ Room を使って SQLite の処理を実装している際に、テーブルの一部だけ必要な場合は、列を射影クラスで定義すれば射影を取り出すことが可能です。
ただ、今回のケースのように、巨大な値を列の1つとして SQLite に突っ込むよりは、別のアプローチを採った方が良いかもしれません。例えば画像を扱う場合、画像はファイルとしてアプリ内の領域に保存しておき、SQLite ではその参照先のみを入れておくようにする、といった感じです。そうしておけば、間違って SQLite に数GBのデータが入り込んでパフォーマンスに致命的な悪影響が生じる、といった心配もなくなります。
「テーブルを正規化すれば良いのでは?」とも思ったのですが、Android & SQLite で正規化をするとパフォーマンスが出ないという話を小耳に挟みました。自分では試していません。