そもそもRoomって?
Roomを導入すると言っても、何がなんだか全く分からないまま作業を進めていくのは、ちょっとスッキリしないですよね。
ということで、まずはRoomライブラリについて、その概要をざっくり掴んでおきたいと思います。
Roomというのは、一言で言えば『SQLiteデータベースの抽象レイヤーを提供するライブラリ』です。
さらに分かりやすく言い換えるならば、『SQLiteの扱いをシンプルにして、使いやすくしてくれるモノ』です。
SQLiteの導入自体はRoomを利用せずとも可能なのですが、その場合はコードが複雑になったり、SQLクエリの実行時にエラーが起こりやすくなったりする等、面倒くさいリスクを抱えることになります。開発者は『Android端末ではSQLiteがどのように動作するのか?』…といった、内部的な処理にまで気を回さなければならなくなり、開発効率も下がってしまいます。
そういったリスクや問題を解消してくれるライブラリがROOMということになります。
ROOMライブラリを利用することで、開発者は処理の詳細を意識することなく(隠匿化)、データベースの操作をシンプルに行う(抽象化)ことができるようになります。
よほどの理由がない限り、『SQLiteでローカルにデータを保存・管理する = Roomを利用する』…というふうにイコールで考えて良いかと思います。さらに詳しい説明が必要な場合は、公式ドキュメントをご参考下さい:
https://developer.android.com/training/data-storage/room
POINT!
- ・Room = SQLiteをシンプルに扱えるようにしてくれるライブラリ!
- ・Roomを利用しないとコードが複雑になったり、エラーが起こりやすくなったりする!
- ・Androidアプリ開発でローカルデータベースを利用する = Roomを利用する…と考えて良い!
作成するサンプルアプリの概要
Roomを導入する前に、まずは今回のチュートリアルで作成するサンプルアプリをご紹介しておきます。
一言で言えば、「友達の名前を登録するアプリ」(My Friends App)です。コードをできるだけ少なく、シンプルにするため、必要最低限の機能のみに絞りました。
UIパーツは次の項目から成り立っています
- 文字列(友人の名前)を入力するテキストエリア
- 入力した文字列を保存ボタン
- 全てのデータを削除するボタン
- 端末に保存されているデータ(文字列)を表示するエリア
画面のイメージは次のような感じです。
本番のアプリであれば、特定のデータを検索したり、特定の条件でデータを並び替えたりなど、データベースが得意とする機能を付け加えたいところですが、今回はあくまでRoomを実装することがゴールなので、こういった機能は省きました。
なお、今回はUIの構築に焦点を当てた記事ではないため、UIに関するコードの解説は大幅に省略します。その点、予めご了承ください。(みなさん、それぞれ思い思いのUIを構築してみてください)
それでは、このサンプルアプリの完成を目指して、最後まで頑張っていきましょう!
まずは依存関係の設定
Roomライブラリ導入にあたり、まず行う必要があるのが依存関係の設定です。
まずは、プロジェクトレベルの build.gradle.kts から確認していきましょう。(追加する必要があるものだけ抜粋しています)
plugins {
/* other plugins settings */
id("com.google.devtools.ksp") version "1.9.23-1.0.19" apply false
}
これは、KSP(Kotlin Symbol Processing) を利用するために必要なものです。
Roomライブラリはアノテーション(@をつけるやつ)を利用してコードを生成するのですが、KSPはそのアノテーションを解析しコードを生成するために必要なものになります。
また、KSPに限らずですが、この記事で紹介するライブラリ等のバージョンは、あくまで記事執筆時点での最新版であるという点に注意してください。
次に、モジュールレベルの build.gradle.kts の確認です。ここでは、追加する必要があるもの、もしくは初期セットアップ状態から書き換える必要がある可能性があるものを抜粋しています。
plugins {
/* other plugins settings */
id("com.google.devtools.ksp")
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.11"
}
dependencies {
/* other plugins settings */
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx)
ksp(libs.androidx.room.compiler)
implementation(libs.androidx.lifecycle.viewmodel.compose)
}
モジュールレベルの plugins
に追加する id
情報は、プロジェクトレベルの build.gradle.kts で定義したKSPを利用するためのものです。
composeOptions
の kotlinCompilerExtensionVersion
は初期セットアップ状態時にすでに記載されているものですが、このバージョンとKotlinのバージョンが適切な組み合わせになるように設定しなければならないため、状況によっては書き換える必要性が出てきます。
dependencies
にはRoomとKSPの他、UI管理のためViewModelも追加しています。
また、libs.versions.toml のバージョン管理情報は次のようになっています。
[versions]
agp = "8.3.0"
kotlin = "1.9.23"
coreKtx = "1.12.0"
junit = "4.13.2"
junitVersion = "1.1.5"
espressoCore = "3.5.1"
lifecycleRuntimeKtx = "2.7.0"
activityCompose = "1.8.2"
composeBom = "2024.02.02"
lifecycleViewmodelCompose = "2.7.0"
roomCompiler = "2.6.1"
roomKtx = "2.6.1"
roomRuntime = "2.6.1"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycleViewmodelCompose" }
androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "roomCompiler" }
androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "roomKtx" }
androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "roomRuntime" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
Roomを導入する上で、最低限の依存関係の設定は以上で終わりです。
バージョンの違いなどによるビルドエラーをいち早く発見するため、この時点で一度ビルドしてみて、問題なくビルドされることを確かめておいた方が良いでしょう。
もし、ビルドエラーが発生したら、落ち着いてエラーのメッセージを丁寧に確認してください。
この時点で発生したビルドエラーの原因の多くは、何らかの記述を忘れているか、バージョンの不一致などによるものです。
エラーメッセージから原因を特定し、問題のある箇所を修正すれば全く問題ありません。
エンティティの定義
さて、下準備が整ったのでここからいよいよRoomを使用したデータベースの定義などを行っていきます。
まずは、データベースのエンティティ(実体)を定義することから始めていきましょう。
エンティティという言葉に聞き馴染みがない方は、『エンティティの定義 = どのようなテーブルに、どのようなデータをどのようにして管理するのかを決める作業』だと言い換えると分かりやすいかもしれません。
データベースに関連するファイルを管理するためのdataパッケージを作成し、さらにそのdataパッケージ内にエンティティを定義するための MyFriendsData.kt ファイルを作成して、そのファイルにデータベースのエンティティを記述していくことにします。
今回は友達の名前を管理するためのデータベースが必要なので、データを一意に識別するためのID(整数)と、友人名(文字列)を次のように定義してみましょう。
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "sample")
data class MyFriend(
@PrimaryKey(autoGenerate = true)
val id: Int = 0,
@ColumnInfo(name = "name")
val name: String
)
@Entity
とアノテーションを指定し、テーブルの名前を定義します。(ここでは"sample"
)
そして data class
でテーブルに含まれるデータを定義します。
主キーとなるものは、@PrimaryKey
アノテーションで指定することができます。
autoGenerate
を true
に指定しておくと、ユーザーによってデータが追加される際に、自動的に一意となる整数を生成してくれるので、特別な形式で主キーを管理する必要がない場合に便利です。
列(カラム)の名前は @ColumnInfo
で指定することができます。(ここでは"name"
)
本番のアプリ開発ではもっと複雑なテーブルになるかと思いますが、今回はRoomを導入するためのサンプルということで、非常にシンプルな形にしておきます。
Dao(data access objects)の定義
さて、データベースのエンティティが決定したら、次はそのデータをどのように利用(操作)するのかを定義する必要があります。
今回のサンプルアプリでは、
- データの全取得
- データの保存(行の挿入)
- データの全削除(全行の削除)
の3つの操作を必要とするので、Daoは次のように定義できます。(事前に MyFriendsDao.kt ファイルを作成しておきましょう)
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
@Dao
interface MyFriendsDao {
@Query("SELECT * FROM sample")
fun getAll(): Flow<List<MyFriend>>
@Insert
suspend fun insertFriend(myFriend: MyFriend)
@Delete
suspend fun deleteAllMyFriends(allMyFriends: List<MyFriend>)
}
まず、@Dao
アノテーションでDaoの定義であるということを明確にします。
全データを取得するメソッドである getAll()
は Flow型でデータを取得するので、suspend fun
である必要はありませんが、他二つは非同期的に処理を行うため、suspend fun
で定義しています。
データの挿入や削除は、専用の@アノテーションを指定することで、SQLを記述せずに簡単に行えるようになっています。
ただし、複雑なクエリを実行したい場合は @Query
でSQLを記述する必要があるので、どんな時でも @Insert
や @Delete
で済ませられるというわけではありません。
今回の例ではシンプルな操作しか行わないので、上記のコードで十分です。
ただし、Daoはあくまで interface
なので、MyFriendsDao
がテーブル操作のメソッドを直接実行するわけではありません(この問題は後ほど解決します)
何はともあれ、これでエンティティとDaoという、データベースの実装に欠かせない要素が揃ったので、次のステップではデータベースの定義と作成を行っていきます。
データベースクラスの定義
さて、このステップではデータベースのクラスを定義していきますが、ここが一番の山場です。
正直に言って、初めてRoomを実装する際は、コードの意味を細かく理解しながら進めるのはとても困難だと思います。
最初は、コードをコピペしてテンプレートとして保管しておき、コードを深く理解するのはある程度経験を積んでからにしておいた方が良いかもしれません。
データベースクラスは次のように定義することができます。
MyFriendsDatabase.kt
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
@Database(entities = [MyFriend::class], version = 1)
abstract class MyFriendsDatabase: RoomDatabase() {
abstract fun myFriendsDao(): MyFriendsDao
companion object {
@Volatile
private var Instance: MyFriendsDatabase? = null
fun getMyFriendsDatabase(context: Context): MyFriendsDatabase {
return Instance ?: synchronized(this) {
Room.databaseBuilder(
context = context,
klass = MyFriendsDatabase::class.java,
name = "sample"
)
.build()
.also { Instance = it }
}
}
}
}
@Database
アノテーションでデータベースの定義であることを明確にし、entities
にすでに作成したエンティティ(MyFriend)クラスを渡します。また、versionはデータベースのバージョンを指定し、スキーマの変更を管理するのに使用します。
MyFriendsDatabaseクラスは RoomDatabase()
を継承しており、これがRoomデータベースクラスであることを示しています。このクラスはabstract(抽象クラス)であり、Roomが実装を自動的に行います。
myFriendsDao()
メソッドは、データベース操作を行うためのDAO(Data Access Object)を提供します。実際のデータベース操作は、このDAOを通じて行われます。
コンパニオンオブジェクトとして定義している部分は、シングルトンパターンで実装され、データベースインスタンスはアプリケーション内で一つだけ生成され、再利用されます。
@Volatile
アノテーションにより、Instance変数の値がキャッシュされず、常にメインメモリから読み書きされることを保証します。これにより、マルチスレッド環境でのデータ一貫性が保たれます。
getMyFriendsDatabase()
では、Instanceがnullでない場合はそれを返し、nullの場合はsynchronizedブロック内で新しいインスタンスを生成します。この方法により、インスタンスの生成がスレッドセーフであることが保証されます。
その他、細かい部分については、公式ドキュメント をご参考ください。
繰り返しますが、最初からコードを細かく深いところまで理解するのは困難なので、初めはコピペでも全然良いと思います。
どうしても気になるところだけ調べてみるとか、部分的に理解を深めていくのも良いかもしれません。
MyFriendsRepositoryの定義
データベースの定義も完了し、「準備完了!」と言いたいところですが、まだDaoを実際に利用するクラスが存在していません。
このステップでは、Daoを利用して、実際にテーブルの操作を行うリボジトリクラスを作成していきます。
MyFriendsRepository.kt ファイルを作成し、次のように定義しましょう。
MyFriendsRepository.kt
class MyFriendsRepository(private val myFriendsDao: MyFriendsDao) {
fun getAll() = myFriendsDao.getAll()
suspend fun insertFriend(myFriend: MyFriend)
= myFriendsDao.insertFriend(myFriend)
suspend fun deleteAllMyFriends(allMyFriends: List<MyFriend>)
= myFriendsDao.deleteAllMyFriends(allMyFriends)
}
今回はそもそも定義されている Dao のメソッドが少ないということもあり、これだけでOKです。
ただ、MyFriendsRepository はコンストラクタとしてDaoを必要とするため、このままでは利用できません。
次のステップとして、その依存関係を解決していきたいと思います。
依存関係の解決
MyFriendsRepositoryクラスのインスタンス作成時にはDaoが必要ですが、Daoを取得するにはデータベースクラスの myFriendsDao()
メソッドを実行しなければなりません。
そこで、まずはコンテナを作成し、MyFriendsRepositoryクラスのインスタンスを作成できるようにします。
MyFriendsRepositoryクラスのコンテナは次のように定義できます。
MyFriendsContainer.kt
class MyFriendsContainer(private val context: Context) {
val myFriendsRepository: MyFriendsRepository by lazy {
MyFriendsRepository(MyFriendsDatabase.getMyFriendsDatabase(context).myFriendsDao())
}
}
しかし、そもそもデータベースクラスのインスタンスを作成するには、アプリケーション全体のコンテキストを必要とするので、これだけではまだ依存関係は解決できていません。
ということで、アプリケーションクラスを作成し、さらなる依存関係の解決を図っていきましょう。
example.com.projectname パッケージの直下に MyFriendsApplication.kt ファイルを作成し、アプリケーションレベルのコンテナクラスを作成します。
アプリケーションクラスは次のように定義できます。
MyFriendsApplication.kt
class MyFriendsApplication: Application() {
lateinit var container: MyFriendsContainer
override fun onCreate() {
super.onCreate()
container = MyFriendsContainer(this)
}
}
これで、アプリ全体のコンテキストを利用して、MyFriendsContainerクラスを介してMyFriendsRepositoryクラスのインスタンスを作成・利用することができます。
また、AndroidManifest.xml ファイルにアプリケーション名を追加するのを忘れないようにしておきましょう。
<application
android:name=".MyFriendsApplication"
…other settings>
以上で、ようやくデータベースに関連するセットアップが完了しました!
しかし、まだデータベースの情報をUIに反映させる準備が整っていないので、次のステップでは ViewModel クラスを作成していきましょう。
ViewModelクラスの作成
ローカルデータベース(Room導入)のセットアップは既に完了しており、それ以外の部分についてはこの記事の焦点ではないので、ViewModelの定義は紹介程度に留めておきます。
Home.ktファイルにHomeコンポーザブルが定義されているとして、HomeViewModelクラスは次のように定義することができます。
class HomeViewModel (private val myFriendsRepository: MyFriendsRepository): ViewModel() {
fun getAll(): Flow<List<MyFriend>>
= myFriendsRepository.getAll()
fun insertFriend(friendName: String)
= viewModelScope.launch {
myFriendsRepository.insertFriend(MyFriend(name = friendName))
}
fun deleteAllMyFriends(allMyFriends: List<MyFriend>)
= viewModelScope.launch {
myFriendsRepository.deleteAllMyFriends(allMyFriends)
}
companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer {
val application = (this[APPLICATION_KEY] as MyFriendsApplication)
HomeViewModel(application.container.myFriendsRepository)
}
}
}
}
基本的に、MyFriendsRepositoryクラスのメソッドをそのまま利用(代入)しているだけです。
また、ViewModelクラスもコンストラクタでMyFriendsRepositoryを受け取る必要がある以上、また依存関係の問題を抱えることになりますが、それを解決しているのが、companion object
の部分です。
MyFriendsRepositoryは最終的にMyFriendsApplicationクラスを通じて利用することができるので、それを使ってViewModelクラスのインスタンスを作成できるようにしています。
あとはこのHomeViewModelクラスをHomeコンポーザブルで定義・利用すれば、サンプルアプリの完成です!
Homeコンポーザブルの作成
ViewModelクラス同様、UIの構築・管理に関してはこの記事のカバー範囲対象外なので、サラッとコードを紹介する程度に留めておきます。
HomeViewModelクラスを利用したHomeコンポーザブルは、次のように定義できます。
@Composable
fun HomeView(
modifier: Modifier = Modifier,
viewModel: HomeViewModel = viewModel(factory = HomeViewModel.Factory) // ..1
) {
val friendsList by viewModel.getAll().collectAsState(initial = emptyList()) // ..2
var friendNameInput by remember {
mutableStateOf("")
}
Column(
modifier = modifier
.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
LazyColumn(
modifier = Modifier.weight(.7F),
verticalArrangement = Arrangement.Center
) {
items(friendsList) {
Card(
modifier = Modifier
.width(200.dp)
.height(80.dp)
.padding(vertical = 8.dp)
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = it.name,
style = MaterialTheme.typography.displaySmall
)
}
}
}
}
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.weight(.3F)
) {
OutlinedTextField(
value = friendNameInput,
onValueChange = { friendNameInput = it }
)
Button(onClick = {
viewModel.insertFriend(friendNameInput)
}) {
Text(text = "SAVE")
}
Button(onClick = {
viewModel.deleteAllMyFriends(friendsList)
}) {
Text(text = "ALL DELETE")
}
}
}
}
ここでは、UIの調整は必要最低限に留めており、快適なユーザー体験を重視していません。その点予めご了承下さい。
重要なのは、 …1 のラインと …2 のラインです。
ライン1で、定義しておいた Factoryを使ってアプリケーションコンテキストを利用するViewModelクラスのインスタンスを作成できるようにしています。
そして、ライン2のところで、ローカルデータベースに格納されているデータを取得し、UIに反映させるための friendsList
を定義しています。
あとはHomeViewModelクラスで定義したメソッドを利用して、ButtonコンポーザブルのonClickパラメータに、データを保存したり、削除したりといった処理を加えれば完成です。
データはローカルデータベースに永続的に保存されるので、当然、アプリを終了させてもその情報は保持されます。
下は、アプリを終了させてもデータが保持されていることを示す動画です。
おわりに
初めてRoomを導入する際は、色々な壁にぶつかると思います。
バージョンの指定がよくわからなくてビルドエラーを起こしたり、Roomを導入するためのコードが理解できなくてイライラしたり…。
筆者も最初は何がなんだかわからず、失敗しまくりました。笑
でも焦らず、発生した問題やエラーを一つ一つ解決していけば必ずゴールに辿り着くことができます。
高機能で複雑なアプリを開発するには、アプリ開発における深い知見と豊富な経験が必要不可欠かもしれませんが、Roomを導入するだけならアプリ開発初心者でも全く問題ありません。
何か問題が起こっても、落ち着いてエラーログを確認し、まずはエラーが発生している箇所と原因を特定するように努めてください。
エラーの原因が特定できれば、その時点でほぼ、そのエラーは解決可能です。
エラーの原因を検索欄に入力して解決法を検索するか、ChatGPTなどのAIに解決法を聞くかすれば、適切な解決方法が見つかるはずです。
エラーは避けて通らなければならないものではなく、発生して当然のものとして捉え、気持ちに余裕を持ってアプリ開発を楽しみましょう!😀