AndroidのRoomは怖くない
Roomとは
Android Jetpackの一部で、ざっくり言うとデータの永続化によく使用されるSQLiteを使いやすくするライブラリです。
Roomでは下記が記載されており、Androidでのデータの永続化において、Roomを使用することは強く推奨されています。
- SQL クエリのコンパイル時検証。
- 繰り返しが多く間違いを犯しやすいボイラープレート コードを最小限に抑える便利なアノテーション。
- 効率的なデータベース移行パス。
こうしたことから、SQLite API を直接使用するのではなく、Room を使用することを強くおすすめします。
今回はこのRoomを使ってデータの永続化を実装していきたいと思います。
前置き
自身の環境がVersion catalogやComposite buildを使用しているので、公式の通り丸々使える訳ではないので、これらに軽く触れながら実装していきますが、Version catalogは別記事で取り上げているので詳細はそちらで見ていただければと思います。
また、Now in Androidを参考にしています。
Roomの導入
今回使用するRoomはKSP対応なので、こちらを使用します。
割愛しますが依存注入にHiltを使っています。
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のものをそのまま流用できました。
今回はKSP対応なのでBuild logic側の build.gradle.kts
に先ほどtomlで記述したpluginを適応させる必要があります。
(これを忘れていてずっとbuildできなかった……)
dependencies {
// ...
+ compileOnly(libs.ksp.gradlePlugin)
}
gradlePlugin {
plugins {
// ...
+ register("androidLibrary") {
+ id = "sample.android.library"
+ implementationClass = "AndroidLibraryConventionPlugin"
+ }
}
}
あとはAndroidRoomConventionPlugin
をdatabaseモジュールなどに読み込ませてあげれば導入の完了です。
plugins {
// ...
+ alias(libs.plugins.sample.android.room)
}
TYPESAFE_PROJECT_ACCESSORS
が便利
超余談:Gradle 7.0では実験的な機能ですがsettings.gradle
に下記を記述すると、存在するモジュールをtype safeで呼べるのでとても便利でした。
enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
dependencies {
- implementation(project(":core:database"))
+ implementation(projects.core.database)
}
実装
めちゃくちゃ簡単ですが、スキームは下記の通りです。
カラム | 概要 | 型 |
---|---|---|
user_id | ユーザID | String(Primary key) |
name | ユーザ名 | String |
color | ユーザアイコンの色 | String |
余談なのですが、color
をEnumで間違えて登録していたら、そのままRoomに登録できたので便利でした。
おそらくEnumも暗黙で文字列に変換するものがあった気がします。定かではないですが。
Entity
スキームが決まればEntityの実装をしていきます。
Now in Androidではこのあたり
@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
今回は使用していませんが、永続化を無視することができます。
DAO
次はDAO(Data Access Object)を実装していきます。
interfaceを実装することでRoomがQueryする具象クラスを生成してくれたはずです。
Now in Androidではこのあたり
@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
で記述しています。
Database
SQLiteと接続するものになります。
@Database
のentiries
に必要なEntityを列挙してabstractでdaoメソッドを含ませることでDAOを介してアクセスすることができるようになります。
Now in Androidではこのあたり
@Database(
entities = [UserEntity::class],
version = 1,
)
abstract class SampleDatabase : RoomDatabase() {
abstract fun userDao(): UserDao
}
今回は初回なので記述していませんが、バージョンを上げる際にマイグレーションが必要になった場合の処理も指定可能です。
依存注入
今回はHiltを使用しているのでModuleを記述する必要があります。
@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()
}
@Module
@InstallIn(SingletonComponent::class)
object DaosModule {
@Provides
fun providesUserDao(
database: HouseworkDatabase,
): UserDao = database.userDao()
}
これによってシングルトンで呼び出せるようになりました。
使用例
サーバから受け取ったデータをRoomで保存する処理に追加してみます。
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で呼び出すことができます。
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