【Android】はじめてのRoom

10 min read読了の目安(約9100字

【Android】分かった気になれる!アーキテクチャ・MVVM概説 ではアーキテクチャ・MVVMの概要をコードを記述せずに概念のみ説明してみました。
その後の投稿では、実践編として各ライブラリを実際にコードを記述し記事にしています。
これまでの記事 :

MVVMシリーズ実践編第二回目のテーマは、Roomです。
DBの一つであるSQLiteを容易に扱うことのできるRoomライブラリについて記事を書こうと思います。

下図のMVVMの構造において、Roomが担うのは黄緑の枠線で示された部分です。
参照:https://developer.android.com/jetpack/docs/guide#overview

mvvm_room.png

はじめに

本記事では、Roomを使用しDB(SQLite)でのデータ保存・取得処理を実装します。
今回は、添付の画面キャプチャーのようなアプリを例に説明していきます。
このアプリは、前回記事:【Android】はじめてのDataBindingで作成したアプリにDBへのデータ保存機能を追加したものです。
「SET!!!」ボタンを押下したときに入力した文字列(Play Call)をDBに保存します。

(
Play Callとは
アメリカンフットボールの試合中に伝えられる作戦のことです。
本アプリでは、入力した文字列をPlay Callに見立てて各種オブジェクトを命名しました。
)

データ保存の流れ 次回アプリ起動時
room_american_football.gif Screenshot_1577005696.png

Roomの導入

まず、appレベルのbuild.gradleにて、dependenciesにRoomライブラリを追加しsyncを実行します。
これで、導入は完了し各種Roomが提供するコンポーネントを使用することができるようになりました!

build.gradle(app)
dependencies {
    // 略

    // room
    def room_version = "2.2.0-rc01"

    implementation "androidx.room:room-runtime:$room_version"
    kapt "androidx.room:room-compiler:$room_version"
}

Roomの実装

  • Database
  • Entity
  • DAO(Data Access Object)

Roomライブラリは上記3つのコンポーネントを提供し、それらを用いてDB(SQLite)上でデータを管理します。
クラスやインターフェースなどにこれらのアノテーションをつけることで、Roomが提供する各コンポーネントとして振舞うようになります。

それぞれのコンポーネントの説明を実際のコードを用いて行います。

Database: @Database

SQLiteと直接接続する部分になります。
アプリ内でインスタンスを生成し、DBでのデータ管理を実装します。

このRoomDatabaseクラスは、abstract class@Databaseアノテーションをつけることで定義されます。
Google公式ガイドによると、@Databaseアノテーションがついたクラスは以下の条件を満たす必要があります。

  • RoomDatabaseクラスを継承すること
  • Databaseに関連づけられているEntityのリストをアノテーションに含むこと
  • @Daoアノテーションで定義されたインターフェースDaoの抽象メソッドを含むこと

本アプリでの実装:

AppDatabase.kt
@Database(entities = [PlayCallEntity::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
    abstract fun playCallDao(): PlayCallDao
}

Entity: @Entity

DBのテーブルを示します。
data class@Entityアノテーションをつけることで定義されます。
Entity内の要素につけることのできるアノテーションとして、主に以下のものがあげられます。

  • @PrimaryKey
    主キーに設定したい要素につけます。主キーは、1つのEntityクラスに最低1つ必要です。

  • @ColumnInfo
    DB内でのカラム情報をアプリとは別で設定したい場合につけます。
    このアノテーションのnameプロパティが主に使用されるのではないかと思います。アプリ内では一般的にキャメルケースで要素を命名しますが、SQLiteではテーブル名やカラム名の大文字・小文字は区別されず、スネークケースで定義されることが多いと思われます。このような理由から、ColumnInfoアノテーションのnameプロパティを使用してアプリで定義した要素名とは別の名前でSQLiteのカラム名を設定しておくのが良いと考えられます。

  • @Ignore
    このアノテーションがついた要素はDBのカラムに追加されません。つまり、そのデータは永続化されません。

本アプリでの実装:

PlayCallEntity.kt
@Entity(tableName = "play_calls")
data class PlayCallEntity(
    @PrimaryKey
    @ColumnInfo(name = "description")
    val description: String
)

DAO: @Dao

DAO(Data Access Object)は、Databaseにアクセスするためのメソッドを格納するオブジェクト、インターフェースです。
interface@Daoアノテーションをつけることで定義されます。
このインターフェース内で、以下のアノテーションをつけることで、データ挿入・削除・取得処理などを実行するメソッドを定義します。

  • 挿入: @Insert
    Entityを引数とするメソッドにつけるだけで、Entityに基づくデータをDBに挿入できます。

  • 削除: @Delete
    削除したいレコードのEntityを引数とするメソッドにつけるだけで、DB内の対象のEntityを削除することができます。

  • 取得
    @Queryアノテーション内にSQL文であるSELECT文を記述することで実装します。
    SQL文についてはこちらを参照
    基本的なSQL文 : Oracle公式ガイド

本アプリでの実装:

@Dao
interface PlayCallDao {
    // データの取得メソッド
    @Query("SELECT * FROM play_calls")
    fun loadAllPlayCall(): List<PlayCallEntity>

    // 挿入メソッド
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    fun savePlayCall(playCallEntity: PlayCallEntity)
}

使ってみる

用意したRoomのコンポーネントを使用し、Viewを変更するよう実装します。

Databaseをインスタンス化

まず、定義したDatabaseをインスタンス化します。

今回はひとまずApplicationクラスを作成し、companion objectとしてAppDatabaseのインスタンスを定義することでアプリ起動中はAppDatabaseのインスタンスを共有できるようにしました。
その後、onCreateメソッド内でデータベースをビルドしました。

これで、AppDatabaseをアプリ内で使う準備ができました。

Application.kt
class Application : Application() {
    companion object {
        lateinit var database: AppDatabase
    }

    override fun onCreate() {
        super.onCreate()
        // AppDatabaseをビルドする
        database = Room.databaseBuilder(
            applicationContext,
            AppDatabase::class.java,
            "app_database"
        ).build()
    }
}

DAO内のメソッドを呼び出す

今回はViewModelにて、databaseおよびPlayCallDaoを呼び出しました。
まず、重要な部分を抽出します。

// PlayCallDaoをインスタンス化
private val dao = Application.database.playCallDao()

// daoのデータ取得処理を呼び出す
dao.loadAllPlayCall()

// daoのデータ保存処理を呼び出す
// playCallはメソッドに渡されたPlayCallクラスの引数
dao.savePlayCall(PlayCallEntity(description = playCall.first().description))

次に、実際の実装です。
DAO内のメソッドはメインスレッドからは呼び出せないため、今回はひとまずAsyncTaskを採用しています。
これをViewから呼び出すことで、最初に添付したキャプチャーのように入力履歴のリストを表示することができます。
(RecyclerView実装の説明は省略)

FootballViewModel.kt
class FootballViewModel {
    private val dao = Application.database.playCallDao()

    // 略

    // Fragmentから呼ばれる
    fun loadPlayCallHistoryList() {
        val asyncLoad = AsyncLoad(dao, this)
        asyncLoad.execute()
    }

    // Fragmentから呼ばれる
    fun savePlayCall(playCall: PlayCall) {
        val asyncSave = AsyncSave(dao, this)
        asyncSave.execute(playCall)
    }
}

// Coroutinesを使いたい
class AsyncLoad(private val dao: PlayCallDao, private val viewModel: FootballViewModel) : AsyncTask<Void, Void, List<PlayCall>>() {

    override fun onPreExecute() {}

    override fun doInBackground(vararg voids: Void): List<PlayCall>? {
        val playCallMutableList = mutableListOf<PlayCall>()
        dao.loadAllPlayCall().forEach { playCall ->
            playCallMutableList.add(PlayCall(description = playCall.description))
        }
        return playCallMutableList
    }

    override fun onPostExecute(listOfPlayCalls: List<PlayCall>) {
        viewModel.setPlayCallHistoryList(listOfPlayCalls)
    }
}

class AsyncSave(private val dao: PlayCallDao, private val viewModel: FootballViewModel) : AsyncTask<PlayCall, PlayCall, Void>() {

    override fun onPreExecute() {}

    override fun doInBackground(vararg playCall: PlayCall): Void? {
        dao.savePlayCall(PlayCallEntity(description = playCall.first().description))
        return null
    }

    override fun onPostExecute(result: Void?) {
        val asyncLoad = AsyncLoad(dao, viewModel)
        asyncLoad.execute()
    }
}

(
今後の編集予定箇所:

  • DIを実装する
    Repositoryでデータ管理に関する処理を実装できるよう修正したいです。
    この程度のアプリではほとんど意味がありませんが、RepositoryでラップしDIすることで以下のような様々な利点があるからです。
    DIを実装し、あるデータの処理に関するメソッドを1つのRepositoryに集約することで、
    1.そのデータを扱う各ViewModelの肥大化を抑えることができる
    2.ViewModelでは、データの参照元(ローカル?リモート?どのDB?)を気にする必要がなくなる
    3.修正時は、Repositoryを修正するだけでよい
    4.テストをしやすくなる(この利点を感じられるほど、私は十分にテストしたことがありませんが)

  • Coroutinesの適用
    現在AsyncTaskDAOを呼び出していますが、Coroutinesに移行したいです。
    AsyncTaskだとデータを取得するためのクラスや保存するためのクラスを独自に定義する必要があり、ViewModelのファイルが肥大化してしまうからです。
    また、コンストラクタとしてViewModelDaoを渡しており、この部分が回りくどく冗長であると感じるからです。
    )

-> 2020/05/14 更新しました!

Coroutines を用いることで、非同期処理を1つのメソッドで簡潔に書けるようになりました。
詳しくは、【Android】はじめてのCoroutines をご覧ください。


class FootballViewModel {
    private val dao = Application.database.playCallDao()

    // 略

    fun loadPlayCallHistoryList() {
        val playCallMutableList = mutableListOf<PlayCall>()
        CoroutineScope(Dispatchers.Main).launch {
            withContext(Dispatchers.Default) {
                dao.loadAllPlayCall().forEach { playCall ->
                    playCallMutableList.add(PlayCall(description = playCall.description))
                }
            }
            setPlayCallHistoryList(playCallMutableList)
        }
    }

    fun savePlayCall(playCall: PlayCall) {
        CoroutineScope(Dispatchers.Main).launch {
            withContext(Dispatchers.Default) {
                dao.savePlayCall(PlayCallEntity(description = playCall.description))
            }
            loadPlayCallHistoryList()
        }
    }
}

まとめ

今回は、Roomを用いてSQLiteでのデータ管理を実装してみました。
SQLiteの知識に乏しいため不明な点も残っていますが、基本は抑えられたのではないかと考えています。
今後は、CoroutinesやDIの実装を進めアーキテクチャMVVMシリーズ実践編を完結する予定です。

コメント、編集依頼は絶賛募集中です!

ソースコード

ソースコードはGitHubにあげています。

https://github.com/iTakahiro/ArchitectureFootball

関連記事

参考にした資料