Roomでデータベースを構築

投稿日:  更新日:

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を発行し、データベースにアクセスします。

Roomのプログラム構成

シンボルプロセッサを使って専用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」を参照

Roomのコンパイル環境

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」で調べることが出来ます。

KSPのバージョン

スポンサーリンク

データベースの実装例

データベース構築の方法が分かるように、簡単なサンプルを示します

サンプルデータベース

サンプルはゲームの情報を格納するデータベース(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のフィールド名
プロパティ型IntColumnのフィールド型INTETER
Long
FloatREAL
Double
StringTEXT
ByteArrayBLOB
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は図のフォルダにあります。

アプリ専用APIの所在

サンプルの実行

サンプルはスコアランキングのトップ10をリスト表示します。

また、「+ボタン」でプレーヤー(ランダムに作成)をデータベースへ追加します。

補助関数(randomPlayer)
private fun randomPlayer(): Player {
    val _name = randomName()
    val _score = randomScore(6)
    return Player(name = _name, score = _score)
}
private fun randomScore(length: Int): Int {
    return (Math.random() * 10.0.pow(length)).toInt()
}
private fun randomName(): String {
    val _charPos = (Math.random() * 26).toInt()
    return "%s-San".format('A' + _charPos)
}
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関数を実行することを許していません。

サンプルはワーカースレッドを立ち上げて実行しています。

スポンサーリンク

関連記事:

Androidアーキテクチャコンポーネント(AAC)は「堅牢でテストとメンテナンスが簡単なアプリの設計を支援する」とドキュメントで説明されています。 有効そうだけど、実態がよくわからないので、いろいろ調べて理解した内容をまとめました。 ...
Androidは標準でSQLiteというRDBMS(Relational Database Management System)を扱います。 SQLiteは少し個性的なデータベースです。 データベース全般の説明を通して、SQLiteと他の違いをまとめます。 ※環境:Android Studio Ladybug | 2024.2.1 Patch 2     Kotlin 2.0.0 ...
データベースを扱うアプリケーションの開発で、動作を確認するためにデータベースの閲覧が出来ると便利です。 データベースは、マネージメントシステムを介してアクセスされるため、プログラムからブラックボックスに見えます。 ですので、データベース内部の問題は見つけ難いです。 デバックおいてデータベースの閲覧が出来れば、内部の問題を用意に確認できます。 ※環境:Android Studio Ladybug | 2024.2.1 Patch 2 ...
Androidが標準で扱うデータベースはSQLiteです。 ※詳細は「Androidで扱うデータベース」を参照 データベースを構築する方法は「SQLite API(Android SDK)」と「Room(Android Jetpack)」の2通りがあります。 今回は「SQLite API」でデータベースを構築する方法を紹介します。 ※環境:Android Studio Ladybug | 2024.2.1 Patch 2     Kotlin 2.0.0 ...
スポンサーリンク