Androidが標準で扱うデータベースはSQLiteです。
※詳細は「Androidで扱うデータベース」を参照
データベースを構築する方法は「SQLite API(Android SDK)」と「Room(Android Jetpack)」の2通りがあります。
今回は「Room」でデータベースを構築する方法を紹介します。
※環境:Android Studio Ladybug | 2024.2.1 Patch 2
Kotlin 2.0.0
androidx.sqlite:sqlite:2.4.0
androidx.room:room-*:2.6.1
目次
Roomとは
「Room」はAndroid Jetpackで提供されているAPIです。「SQLite API」に代わって、使用が推奨されています。
アノテーションとKotlinの構文を使ってデータベースのアクセスモデル(設計図)を記述し、それを基にシンボルプロセッサがアプリ専用APIを構築します。
専用APIから取り出された識別子(RoomDatabase)のDao(Data Access Object)を介して、マネージメントシステムへSQLを発行し、データベースにアクセスします。
シンボルプロセッサを使って専用APIを構築するところは、Android Studioが自動で行ってくれます。
環境設定
build.gradle(モジュール)へ、以下を追加します。
plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) // ⇐ kotlin-gradle-plugin : // id("com.google.devtools.ksp") version "2.0.20-1.0.24" id("com.google.devtools.ksp") version "2.0.0-1.0.23" // id("com.google.devtools.ksp") version "1.9.0-1.0.13" } : dependencies { : val room_version = "2.6.1" implementation ("androidx.room:room-runtime:$room_version") // To use Kotlin Symbol Processing (KSP) ksp ("androidx.room:room-compiler:$room_version") // optional - Kotlin Extensions and Coroutines support for Room implementation ("androidx.room:room-ktx:$room_version") : }
RoomはKSP(Kotlin Symbol Processor)を用いて、アノテーションとKotlinの構文を解析し、アプリ専用APIのコードを生成しています。
※KSPについては「Kotlin Symbol Processing API/KSP Overview」を参照
KSPとKotlinコンパイラーは密接な関係にあるので、対応するバージョンが決まっています。
ちなみに、対応しないバージョンを用いると、下記のようなメッセージが出て、バージョンの変更を促されます。
ksp-2.0.20-1.0.24 is too new for kotlin-1.9.0. Please upgrade kotlin-gradle-plugin to 2.0.20. ※ksp-X.X.X-Y.Y.Y X.X.X : kotlin-gradle-pluginのバージョン(Kotlinのバージョン) Y.Y.Y : KSPのバージョン
KSPのバージョンは「github : Kotlin Symbol Processing API」で調べることが出来ます。
データベースの実装例
データベース構築の方法が分かるように、簡単なサンプルを示します
サンプルデータベース
サンプルはゲームの情報を格納するデータベース(Game.db)です。
データベースはplayerテーブルを持ち、columnにプレイヤーの「id, name, score」が並び、rowにプレイヤーのデータが登録された順に並びます。
idはプライマリーキーで、プレイヤーを一意に識別するための番号です。
アクセスモデル(設計図)
アクセスモデルを記述します。アクセスモデルはアプリ専用APIの設計図です。
アクセスモデル | 概要 |
---|---|
@Entity付き data class | テーブルの定義 ・テーブル名 ・フィールド名、型、属性 Rowデータのひな型として使用 |
@Dao付き interface | データへアクセスするためにアプリで使用する関数を定義 |
@Database付き abstract class RoomDatabase | データベースの定義 ・データベース名 ・バージョン ・格納するテーブル(エンティティ) データベースの構築 データベース識別子として動作 |
@Entity
アノテーション@Entity付きのデータクラスで、テーブルの定義を行います。
@Entity data class Player( @PrimaryKey(autoGenerate = true) val id: Int, val name: String, val score: Int )
データクラスとテーブルの対応は次のようになっています。※詳細は「Room エンティティを使用してデータを定義する」を参照
@Entity付きデータクラス | テーブル | ||
---|---|---|---|
関数名 | テーブル名 | ||
プロパティ名 | Columnのフィールド名 | ||
プロパティ型 | Int | Columnのフィールド型 | INTETER |
Long | |||
Float | REAL | ||
Double | |||
String | TEXT | ||
ByteArray | BLOB | ||
Null許容修飾子(?) | あり | 属性なし | |
なし | Columnの“Not null”属性 |
サンプルの記述は次のテーブル定義と等価です。また、テーブル定義のことを「スキーマ」と呼びます。
CREATE TABLE Player (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
name TEXT NOT NULL,
score INTEGER NOT NULL
)
※↑↑このテーブル定義のことを「スキーマ」と呼ぶ↑↑
注意事項:@PrimaryKey
Kotlinの構文でPrimary Keyは表現できません。ですので、Roomのアノテーション@PrimaryKeyを用いて表現します。
「autoGenerate = true」は値の自動設定を意味しています。Rowが登録される毎に、ユニークなid値が自動生成されます。
注意事項:別名の定義(大文字、小文字の区別)データベースは基本的に大文字と小文字の区別を行いません。SQLiteは次のようになっています。
データベース内の記録 | SQLの構文 | コメント | |
---|---|---|---|
SQL予約語 | しない | 大文字を推奨 | |
テーブル名 | する | しない | 小文字を推奨 |
Columnのフィールド名 | する | しない | |
データ(TEXT型) | する | する | LIKE構文は区別しない |
※する・しない:大文字・小文字の区別をするかどうか ・コメント欄の推奨は私的な見解です |
データクラスのクラス名とプロパティ名に大文字が使われていると、データベースのテーブル名とフィールド名も大文字が使われた名前になります。しかし、SQLの構文は区別を行いません。この「大文字を記録できるが、SQLの構文で区別されない」状態が混乱を引き起こします。
ですので、テーブル名とフィールド名に別名を定義して、小文字に統一することをお勧めします。
@Entity(tableName = "player") // 別名:Player⇒player data class Player( @PrimaryKey(autoGenerate = true) val id: Int, @ColumnInfo(name = "name") val playerName: String, // 別名:palyerName⇒name @ColumnInfo(name = "score") val bestScore: Int // 別名:bestScore⇒score )
@Dao
アノテーション@Dao付きのインターフェースで、データベースへアクセスするためにアプリで使用する関数を定義します。
抽象関数の実装はシンボルプロセッサが行ってくれます。
@Dao interface PlayerDao { @Query("SELECT * FROM Player ORDER BY bestScore DESC LIMIT :num") fun fetchTopX(num: Int): List<Player> // 「:引数」でSQLへ引数を受け渡し @Insert fun insert(player: Player) // playerを追加、idは自動作成 @Delete fun delete(player: Player) // player.idを持つRowを削除 @Update fun update(player: Player) // player.idを持つRowをplayerの値で置き換え }
次のようなDao関数アノテーションが用意されています。※詳細は「Room DAO を使用してデータにアクセスする」を参照
Dao関数アノテーション | 概要 | Rowの照合 | 戻り値 | |
---|---|---|---|---|
@Insert | コンビニエンスメソッド | 挿入 | 挿入したRowのid | |
@Delete | 削除 | プライマリーキー | 削除したRowの数 | |
@Update | 更新 | 更新したRowの数 | ||
@Query | クエリメソッド | SQLを直接記述 | SQLで定義した条件 | SQLの返答 |
※Rowの照合:対象のRowを探す方法 |
コンビニエンスメソッドは、シンボルプロセッサに全てを任せることができるので便利ですが、複雑な問い合わせ(SQL)が出来ません。複雑なSQLはクエリメソッドを使います。
@Database
RoomDatabaseクラスを継承した子クラス(GameDb)で、データベースの定義を行います。子クラスにはアノテーション@Databasetを付けます。
この子クラス(GameDb)のインスタンスが、データベース識別子になります。
@Database(entities = arrayOf(Player::class), version = 1) // ポイント1,2 abstract class GameDb : RoomDatabase() { abstract fun playerDao(): PlayerDao // ポイント3 companion object { @Volatile // ポイント5 private var instance: GameDb? = null // ポイント6 fun getDatabase(context: Context): GameDb { return instance ?: synchronized(this) { // ポイント5,6 val _instans = Room.databaseBuilder( // ポイント4 context, // アプリのContext GameDb::class.java, // 自身のクラス "Game.db" // データベース名 ) // .createFromAsset("databases/Game.db") .build() instance = _instans // ポイント6 _instans } } } }
以下は記述のポイントです。
ポイント1:エンティティの指定データベースへ登録するエンティティ(テーブルの定義)を配列で指定します。
ポイント2:バージョンの指定データベースのバージョンを指定します。
前データベースと異なるバージョンを指定すると、移行処理(Migrationクラス)が実行されます(このサンプルは省略)。
ポイント3:Daoインスタンスの提供Daoインスタンスを取得する抽象関数を記述します。
シンボルプロセッサにより、この抽象関数は実装されます。
ポイント4:データベース識別子の作成データベースの定義(自身のクラス、データベース名、エンティティ、バージョン)をもとに、データベース識別子を作成します。
作成はビルダー(Room.databaseBuilder)を介して行います。
ポイント5:識別子の作成の重複を排除@Volatileで、同時刻にinstatnce変数へアクセスするスレッドを一つに制限します。
「synchronized(this) { コマンドブロック }」で、同時刻にコマンドブロックを実行するスレッドを一つに制限します。
ポイント6:識別子のシングルトン処理ポイント5の動作と合わせて、同じインスタンスの識別子が取得できるようにします。つまり、識別子はシングルトンになります。
アプリ専用APIの構築
アプリ専用APIは、Android Studioがシンボルプロセッサで自動作成します。
自動作成されたAPIは図のフォルダにあります。
サンプルの実行
サンプルはスコアランキングのトップ10をリスト表示します。
また、「+ボタン」でプレーヤー(ランダムに作成)をデータベースへ追加します。
class RankingActivity : ComponentActivity() { private lateinit var gameDb: GameDb private lateinit var playerFlow: MutableStateFlow<List<Player>> override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() setContent { GameaTheme { Scaffold( modifier = Modifier.fillMaxSize(), floatingActionButton = { FloatingActionButton( onClick = { lifecycleScope.launch(Dispatchers.Default) { gameDb.playerDao().insert(randomPlayer()) playerFlow.value = gameDb.playerDao().fetchTopX(10) } }, modifier = Modifier.padding(15.dp) ) { Icon(Icons.Default.Add, contentDescription = "Add") } } ) { innerPadding -> Box( modifier = Modifier.padding(innerPadding).fillMaxSize(), contentAlignment = Alignment.Center ) { RankingPanel(playerFlow.asStateFlow()) } } } } gameDb = (application as MyApplication).gameDb playerFlow = MutableStateFlow<List<Player>>(listOf<Player>()) lifecycleScope.launch(Dispatchers.Default) { val _list = gameDb.playerDao().fetchTopX(10) playerFlow.value = _list } } override fun onDestroy() { super.onDestroy() gameDb.close() } }
@Composable fun RankingPanel(playerFlow: StateFlow<List<Player>>) { val _players = playerFlow.collectAsState().value LazyColumn { items(items = _players, key = { it.id }) { Text( text = "%-8s %06d".format(it.name, it.bestScore), fontSize = 20.sp, fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Bold ) } } }
データベースのアクセスは重くなりがち(総Row数にもよりますが…)なので、RoomはメインスレッドでDao関数を実行することを許していません。
サンプルはワーカースレッドを立ち上げて実行しています。
関連記事: