駆け出しエンジニアぱかぱかの成長記録

引くほど忘れっぽい新卒2年目駆け出しSEぱかぱかの備忘録です。

【Android】Roomを使用したデータベース保存

こんにちは!ぱかぱかです!
今日は現在開発を進めている飲食店記録アプリ「グルメノート」で、店登録画面からデータベースにデータを登録する処理についてまとめていきたいと思います。
以前データベースを扱う実装をしたときは普通にSQLiteライブラリだけでデータを扱っていましたが、今回は勉強も兼ねてRoomライブラリを使用してみたいと思います。
Roomの他、ViewModelやコルーチンなど色々な内容が出てきて盛りだくさんになってしまいますがご承知おきください…

前回の記事

radish-se.hatenablog.com

前回はトップ画面からボタンを押して店登録画に遷移するところを作りました。

前回肝心の「行った店登録」ボタンを忘れていたので、追加しました。
デザインのダサさはさておき、ここから店の情報を登録できるようにしたいと思います!

要件の振り返り

店登録画面でやりたい操作は以下です。

・行きたい店の情報登録
・行った店の情報登録

要件が曖昧になってきているのでここで決めちゃいます。
(結局テキトー開発になっている…)

<行きたい店>
・店名・場所・ジャンル・メモ(リストに表示できる程度の文字数)を登録する。
・リスト上から行った店やお気に入り店に切り替えることが可能。

<行った店(訪問記録)>
・店名・場所・ジャンル・評価・シーン・感想を登録する。
・行った店を登録するというよりは訪問記録を登録するイメージ。
 1つの店に対して複数の登録を紐づられる。
・「店名」ではまだ登録していない店を新規で入力するか、行ったことのある店から選べるようにする。

データ構成の振り返り

暫定のなんちゃってER図は以下のようになっています。

一応前に以下の記事で考えたつもりなのですが、正直これでいいのかちょっと不安です。
これも勉強なので、作ってみて壁にぶち当たってから考えましょう…

radish-se.hatenablog.com

Roomについて

RoomはAndroidアプリがデータベースとやりとりする際に、その処理を自動化してくれるライブラリです。

従来のデータベース処理

従来のSQLiteを使ったデータベース処理は大昔に以下の記事で実装してみています。
radish-se.hatenablog.com

ここでは以下のオブジェクトが登場します。

SQLiteDatabaseオブジェクト SQLiteへのSQL実行を行なう。
ヘルパーオブジェクト SQLiteDatabaseオブジェクトの生成やデータベースそのものの管理を行なう。

しかしこのやり方では定型コードが多くなったり、オブジェクトの取得や解放を非同期で行う配慮が必要だったり、色々気をつけるべき点が出てきてしまいます。
今いるプロジェクトも普通にこの方法でやっているような。

Roomによるデータベース処理

Roomライブラリを利用すると、必要最小限のコードでデータベース処理を行えるようになります。
また、非同期処理も含めてデータベースとのやりとりを自動化してくれます。
Android公式でもRoomの使用が強く推奨されていました。
Room を使用してローカル データベースにデータを保存する  |  デベロッパー向け Android  |  Android Developers

Roomを使うためには以下のオブジェクト3点セットが必要になります。

エンティティ テーブル構造に対応したクラス。
DAO データ処理をまとめる。インターフェースとして定義する。
Room Database データベースそのものを管理。エンティティをもとにテーブルを作成したり、DAOインターフェースをもとにDAOインスタンスを生成したりする。

前Spring/Javaの案件にいた時にMybatisというライブラリを使っていたのですが、似た雰囲気を感じます。
DAOというワードを久しぶりに聞きました。

便利なものはじゃんじゃん活用しよう!ということで、早速始めていきます。

build.gradleにライブラリを設定

Roomを利用するためにbuild.gradle(Module)に依存関係を追加します。
Android公式ページから最新版の記述を拾ってきてコピペします。
2023年11月23日現在の最新バージョンは2.5.0のようです。

Room  |  Android デベロッパー  |  Android Developers

ただ、ここで問題発生!
ただコピペするだけでは以下エラーが発生してしまいました。

org.gradle.api.GradleScriptException: A problem occurred evaluating project ':app'.
...
Caused by: org.gradle.internal.metaobject.AbstractDynamicObject$CustomMessageMissingMethodException: Could not find method kapt() for arguments [androidx.room:room-compiler:2.5.0] on object of type org.gradle.api.internal.artifacts.dsl.dependencies.DefaultDependencyHandler
...

「kapt()が見つけられないよ〜」と言われてしましました。

ここで以下サイトを参考にさせていただきながらエラー解消。
Android 初心者が Room を使おうとしてハマったこと #Android - Qiita

Kaptがないからpluginにkotlin-kaptを追加してあげると、今度は次のksp()で「KSPがないよ〜」と言われます。
かといってpluginにcom.google.devtools.kspを追加してあげると今度は「KaptとKSPは重複してるよ〜」と言われてしまう。
よって、kaptのpluginとkaptに関連するimportを消して、kspのpluginだけ適用してあげると解決するみたいです。

ちなみにKaptやKSPはアノテーションプロセッサのこと。
KSPはKaptの進化系みたいなものなので、公式でもkaptからKSPへの移行が勧められていました。

kapt から KSP に移行する  |  Android デベロッパー  |  Android Developers

結局、今回Moduleのbuild.gradle追加したものは以下のようになりました。

plugins {
    id 'kotlin-android'
    id 'com.google.devtools.ksp'
}

dependencies {

    def room_version = "2.5.0"

    implementation "androidx.room:room-runtime:$room_version"
    ksp "androidx.room:room-compiler:$room_version"
    implementation "androidx.room:room-rxjava2:$room_version"
    implementation "androidx.room:room-rxjava3:$room_version"
    implementation "androidx.room:room-guava:$room_version"
    testImplementation "androidx.room:room-testing:$room_version"
    implementation "androidx.room:room-paging:$room_version"
    implementation "androidx.room:room-ktx:2.6.0" // 後々必要になったのでここも追加しています!
}

Room KTXはデータベース トランザクション向けのコルーチン サポートとのことで、後ほどの処理実装時に必要になったので追加しました。

また、KSPを使うためにProjectにbuild.gradle 構成ファイルで KSP プラグインも宣言する必要があります。

plugins {
    id 'com.google.devtools.ksp' version '1.8.10-1.0.9' apply false
}

ふう…ようやく次に進めます。

エンティティを定義する

次はテーブル構造を表すエンティティクラスを作っていきます。
律儀にテーブルを分けようとしているので、その数だけ作ります。
一旦ここでは飲食店エンティティの例だけ示します。

カラム名 データ型 内容
restaurant_id INTEGER 飲食店のID(主キー)
restaurant_name TEXT 飲食店の名前
place_id INTEGER 場所のID(外部キー)
genre_id INTEGER ジャンルのID(外部キー)
created_time TEXT 作成日時
updated_time TEXT 更新日時

SQLiteではDATE型とかがなく、日付時刻が単なる文字列として扱われるんですね。
とりあえずTEXTで「YYYY-MM-DD HH:MM:SS.SSS」みたいに管理するとします。

これをEntityクラスに起こすと以下のようになります。

@Entity(tableName = "restaurants")
class RestaurantsEntity(
    @PrimaryKey(autoGenerate = true) val restaurant_id: Int,
    val restaurant_name: String,
    val place_id: Int,
    val genre_id: Int,
    val created_at: String,
    val updated_at: String
)

ポイントは以下です。
・コンストラクタの引数としてカラムのプロパティを定義。
・クラスアノテーションとして@Entityをつける。
・主キーには@PrimaryKeyをつける。
 自動インクリメントにしたい場合は@PrimaryKey(autoGenerate = true)とする。
・Kotlinなら何もつけなくても@NonNull扱いになる。
(オプショナル型にするとそのカラムもnull許容になる。)

DAOインターフェースを定義する

次にDAOインターフェースの中でデータ処理を定義します。
とりあえず今回は簡単な検索の処理と挿入の処理を書いてみました。
(本当はテーブル色々結合して登録しなきゃいけないけど…)

@Dao
interface RestaurantsEntityDAO {
    @Query("SELECT * FROM restaurants WHERE restaurant_id = :id")
    suspend fun findById(id: Int): RestaurantsEntity
    @Insert
    suspend fun insert(restaurantsEntity: RestaurantsEntity)
}

ポイントは以下です。
・クラスアノテーションとして@Daoをつける。
・クエリを書く場合は@Queryを使用して@Query("クエリ")のように記述する。
・単純なINSERT/UPDATE/DELETEなら、@Insert・@Update・@Delete等を使う。

suspendをつけてコルーチンでうまいこと非同期処理をやる算段になっております。

Room Databaseの作成

最後にRoomDatabaseを作成します。

RoomDatabaseを継承した抽象クラスを作成しますが、クラス名は慣習的にAppDatabaseとすることが多いそうなのでそれに倣います。

@Database(entities = [RestaurantsEntity::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
    companion object {
        private var _instance: AppDatabase? = null
        fun getDatabase(context: Context): AppDatabase {
            if (_instance == null) {
                _instance = Room.databaseBuilder(context.applicationContext, AppDatabase::class.java, "gourmet_note_db").build()
            }
            return _instance!!
        }
    }
    abstract fun createRestaurantsEntityDAO(): RestaurantsEntityDAO
}

ポイントは以下です。
・クラスアノテーションとして@Databaseをつける。
 利用するエンティティクラスを配列として指定する。
・DAOインターフェースを戻り値とする抽象メソッドを記述する。

Room Databaseインスタンスの生成はリソースを消費するので、生成したインスタンスをアプリ内で再利用する必要があります。
インスタンスが存在しない場合のみにに実行されるようなコードになっています。

Roomを利用して登録処理を行う

最後にRoomを使ってデータベース処理を行ってみます。
ここで、登録画面で入力した情報をアクティビティのライフサイクルに影響されずに保持できるViewModelを使っていきます。
(ViewModelに関してはまた別の記事でまとめたいところ…)
Room Databaseのインスタンスの取得にはコンテキストが必要になるので、Applicationオブジェクトを利用できるAndroidViewModelというのを用います。

class RegistrationVisitedRestaurantViewModel(application: Application): AndroidViewModel(application) {
    private val _db: AppDatabase

    // 画面で持ちたいデータをプロパティ化
    var restaurantName = ""
    var place = ""
    var genre = ""
    var evaluation = ""
    var memo = ""

    // Room Databaseの作成
    init {
        _db =AppDatabase.getDatabase(application)
    }

    @RequiresApi(Build.VERSION_CODES.O)
    suspend fun addRestaurant(): Job {
        val restaurantsEntity = RestaurantsEntity(0, this.restaurantName, 1, 1, DateTime.getCurrentDateTime(), DateTime.getCurrentDateTime())
        // DAOオブジェクト取得
        val restaurantsEntityDAO = _db.createRestaurantsEntityDAO()
        // コルーチンスコープの準備
        val job = viewModelScope.launch {
            // restaurantsテーブルへの登録実行
            restaurantsEntityDAO.insert(restaurantsEntity)
        }
        // コルーチンスコープの戻り値をリターン
        return job
    }
}

最後に、登録画面のフラグメントで「行った店登録」ボタンを押した時の処理の中でViewModelに定義したaddRestaurant()を呼び出します。
コルーチンスコープの中で呼び出してあげる必要があります。

        val registrationButton = registVisitedRestFragment.findViewById<Button>(R.id.registration_button)
        registrationButton.setOnClickListener {
            _registrationVisitedRestaurantViewModel.restaurantName = etRestaurantName.text.toString()

            lifecycleScope.launch {
                _registrationVisitedRestaurantViewModel.addRestaurant()
            }
        }

いざ実践

今回は入力欄たくさんあるけどシンプルにrestaurantsテーブルの"restaurant_name"の登録だけを試します!
ジャンルエンティティや場所エンティティを作ってテーブル結合して…とやろうかと思いましたが、内容が長くなりすぎるので次回で…

モスバーガーと入力し登録ボタンを押すと…

ちゃんとrestaurantsテーブルに登録されました!
restaurant_idは勝手に自動採番されていますね。

次回は色々エンティティを作ってくっつけてあるべき姿で登録できるようにしたいと思います!
ではまた!