🗄️

AndroidのRoomは怖くない

2023/11/03に公開

Roomとは

Android Jetpackの一部で、ざっくり言うとデータの永続化によく使用されるSQLiteを使いやすくするライブラリです。
Roomでは下記が記載されており、Androidでのデータの永続化において、Roomを使用することは強く推奨されています。

  • SQL クエリのコンパイル時検証。
  • 繰り返しが多く間違いを犯しやすいボイラープレート コードを最小限に抑える便利なアノテーション。
  • 効率的なデータベース移行パス。

こうしたことから、SQLite API を直接使用するのではなく、Room を使用することを強くおすすめします。

https://developer.android.com/training/data-storage/room

今回はこのRoomを使ってデータの永続化を実装していきたいと思います。

前置き

自身の環境がVersion catalogやComposite buildを使用しているので、公式の通り丸々使える訳ではないので、これらに軽く触れながら実装していきますが、Version catalogは別記事で取り上げているので詳細はそちらで見ていただければと思います。
また、Now in Androidを参考にしています。

https://github.com/android/nowinandroid

Roomの導入

今回使用するRoomはKSP対応なので、こちらを使用します。
割愛しますが依存注入にHiltを使っています。

toml

gradle/libs.versions.toml
[versions]
room = "2.6.0"
ksp = "1.9.20-1.0.13"

[libraries]
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }

# build logic用
ksp-gradlePlugin = { group = "com.google.devtools.ksp", name = "com.google.devtools.ksp.gradle.plugin", version.ref = "ksp" }

[plugins]
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }

Composit build

AndroidRoomConventionPlugin 自体はNow in Androidのものをそのまま流用できました。

https://github.com/android/nowinandroid/blob/3ccacba0fe8e47f499eb3eb581f02ecae19b24b3/build-logic/convention/src/main/kotlin/AndroidRoomConventionPlugin.kt

今回はKSP対応なのでBuild logic側の build.gradle.kts に先ほどtomlで記述したpluginを適応させる必要があります。
(これを忘れていてずっとbuildできなかった……)

build-logic/convention/build.gradle.kts
dependencies {
     // ...
+    compileOnly(libs.ksp.gradlePlugin)
}

gradlePlugin {
    plugins {
        // ...
+       register("androidLibrary") {
+           id = "sample.android.library"
+           implementationClass = "AndroidLibraryConventionPlugin"
+       }
    }
}

あとはAndroidRoomConventionPluginをdatabaseモジュールなどに読み込ませてあげれば導入の完了です。

core/database/build.gradle.kts
plugins {
     // ...
+    alias(libs.plugins.sample.android.room)
}

超余談:TYPESAFE_PROJECT_ACCESSORSが便利

Gradle 7.0では実験的な機能ですがsettings.gradleに下記を記述すると、存在するモジュールをtype safeで呼べるのでとても便利でした。

settings.gradle.kts
enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
[other module]/build.gradle.kts
dependencies {
-    implementation(project(":core:database"))
+    implementation(projects.core.database)
}

https://docs.gradle.org/7.0/userguide/declaring_dependencies.html#sec:type-safe-project-accessors

実装

めちゃくちゃ簡単ですが、スキームは下記の通りです。

カラム 概要
user_id ユーザID String(Primary key)
name ユーザ名 String
color ユーザアイコンの色 String

余談なのですが、colorをEnumで間違えて登録していたら、そのままRoomに登録できたので便利でした。
おそらくEnumも暗黙で文字列に変換するものがあった気がします。定かではないですが。

Entity

スキームが決まればEntityの実装をしていきます。
Now in Androidではこのあたり

UserEntity.kt
@Entity(
    tableName = "users"
)
data class UserEntity(
    @PrimaryKey
    @ColumnInfo(name = "user_id")
    val userId: String,
    val name: String,
    val color: String,
)
  • @Entity
    このアノテーションを付与することでRoomにEntityであることを認識させます。
  • @PrimaryKey
    ユニークであるPrimaryKeyを指定できます。1つのEntityに最低1つ必要です。
  • @ColumnInfo
    フィールド名を設定できます。
    SQLiteは大小の区別がないのでスネークケースで書かれるのが一般的ですが、Kotlinはキャメルケースで記述するので、その差分をここで吸収できます。
  • @Ignore
    今回は使用していませんが、永続化を無視することができます。

https://developer.android.com/training/data-storage/room/defining-data

DAO

次はDAO(Data Access Object)を実装していきます。
interfaceを実装することでRoomがQueryする具象クラスを生成してくれたはずです。
Now in Androidではこのあたり

UserDao.kt
@Dao
interface UserDao {
    /** ユーザー一覧取得 */
    @Query(value = """SELECT * FROM users""")
    suspend fun getUsers(): List<UserEntity>

    /** ユーザー挿入 */
    @Upsert
    suspend fun upsertUsers(users: List<UserEntity>)

    /** ユーザーの削除 */
    @Query(
        value = """
            DELETE FROM users
            WHERE user_id in (:userIds)
        """
    )
    suspend fun deleteUsers(userIds: List<String>)
}

今回はCoroutineを使用して非同期処理で実行するためsuspendを使用していますが、Flowで受け取る場合はなくても良いはずです。

  • @Query
    SELECTを使用してテーブルにアクセスして取得します。
  • @Insert
    Entityを引数に渡すことでテーブルに挿入してくれます。オプションでデータがコンフリクトした場合の処理のstrategyも用意されています。
  • @Update
    PrimaryKeyと一致するものを更新してくれます。
  • @Upsert
    受け取ったEntityが既に存在している場合は更新(Update)。存在していなければ挿入(Insert)してくれます。
  • @Delete
    受け取ったEntityのカラムを削除してくれます。
    今回、自分の実装はuserIdをリストで受け取って、該当のカラムを削除してほしかったので@Queryで記述しています。

https://developer.android.com/training/data-storage/room/accessing-data

Database

SQLiteと接続するものになります。
@Databaseentiriesに必要なEntityを列挙してabstractでdaoメソッドを含ませることでDAOを介してアクセスすることができるようになります。
Now in Androidではこのあたり

SampleDatabase
@Database(
    entities = [UserEntity::class],
    version = 1,
)
abstract class SampleDatabase : RoomDatabase() {
    abstract fun userDao(): UserDao
}

https://developer.android.com/training/data-storage/room#database

今回は初回なので記述していませんが、バージョンを上げる際にマイグレーションが必要になった場合の処理も指定可能です。

https://developer.android.com/training/data-storage/room/migrating-db-versions

依存注入

今回はHiltを使用しているのでModuleを記述する必要があります。

DatabaseModule
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
    @Provides
    @Singleton
    fun providesHouseworkDatabase(
        @ApplicationContext context: Context,
    ): HouseworkDatabase = Room.databaseBuilder(
        context = context,
        klass = SampleDatabase::class.java,
        name = "sample-database"
    ).build()
}
DaosModule
@Module
@InstallIn(SingletonComponent::class)
object DaosModule {
    @Provides
    fun providesUserDao(
        database: HouseworkDatabase,
    ): UserDao = database.userDao()
}

これによってシングルトンで呼び出せるようになりました。

使用例

サーバから受け取ったデータをRoomで保存する処理に追加してみます。

UserRepositoryImpl.kt
class UserRepositoryImpl @Inject constructor(
    remoteDataStore: RemoteDataStore,
    @Dispatcher(SampleDispatchers.IO) private val ioDispatcher: CoroutineDispatcher,
+   private val userDao: UserDao,
) : UserRepository {
    override suspend fun getUsers(isSync: Boolean): List<User> = withContext(ioDispatcher) {
+       if (isSync) {
            remoteDataStore.fetchUsers()
+		.apply {
+		    userDao.upsertUsers(this)
+		}
+       } else {
+           userDao.getUsers()
+       }
    }
}

実際はModelに変更するためのMapperなど挟んでいたりします。
あとはオプションでDomain層にてUseCaseを挟んでViewModelで呼び出すことができます。

GetUsersUseCase.kt
class GetUsersUseCase @Inject constructor(
    private val userRepository: UserRepository,
) {
    suspend operator fun invoke(isSync: Boolean = false): Result<List<User>> = runCatching {
        userRepository.getUsers(isSync = isSync)
    }.recoverCatching {
        userRepository.getUsers(isSync = false)
    }
}

雑感

Androidのアプリ開発に携わり初めて9年近くになりましたが、SQLにずっと苦手意識があり気が進みませんでした。ですが、Roomにより実装しやすくなったと感じました。
個人的にはDataStoreよりは実装しやすかったかなと感じます。それはまた別の機会があれば書こうと思います。

参考

Discussion