Androidアプリ開発 Compose x Room を使って Database を操作する

以前に ROOMを使わずにSQLiteを扱う、Androidアプリ開発 の記事で、SQLiteOpenHelperを使ったSQLiteデーターベースアクセスを行いました。SQLiteOpenHelperを使った方法は、シンプルゆえにテーブル毎にSQLクエリを自前で実装しなければならず、ボイラープレートコードが多く発生します。また、SQLの構文チェックもしづらいため、記述間違いによるバグが起こりやすく、開発速度が上がりませんでした。 そこで今回は、Androidアプリ開発で推奨だれるRoomを導入してみました。パッケージのインストールがやや複雑で、つまづいた部分などを含め忘備録として残しておきます。

後半ではRoomを使ったサンプルコードを紹介します。 Google Codelab Room を使用してデータを永続化する のCodelaboのサンプルを参考に、Roomを使うために最小限の構成で制作してみました。ただし、アーキテクチャはCodelaboと同様なので、かなり実践的に使えるものとなってます。

開発環境

項目バージョン
macOS13.2
Android StudioHedgehog 2023.1.1 Patch 2

新規プロジェクトの作成

ここから解説するサンプルコードは、Android Studio 2023でEmpty Activityの新規プロジェクトを作成したところからです。KotlinおよびComposeの使用、ビルドスクリプトは.ktsを前提に解説します。

パッケージ&プラグインのインストール

この記事でインストールしたパッケージ:

パッケージ名バージョン備考
androidx.room:room-runtime2.6.1Roomで必要
androidx.room:room-compiler2.6.1Roomで必要
androidx.room:room-ktx2.6.1Roomで必要
androidx.lifecycle:lifecycle-viewmodel-compose2.7.0ViewModelで必要

使用するプラグイン:

項目バージョン備考
com.google.devtools.ksp1.9.0-1.0.13Roomで必要
org.jetbrains.kotlin.android1.9.0デフォルト

Room関係のパッケージをインストール

モジュール レベルの Gradle ファイル build.gradle.kts を開き、dependencies ブロックに以下を追加します:

build.gradle.kts
implementation("androidx.room:room-runtime:${rootProject.extra["room_version"]}") // 追加
ksp("androidx.room:room-compiler:${rootProject.extra["room_version"]}") // 追加
implementation("androidx.room:room-ktx:${rootProject.extra["room_version"]}") // 追加
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0") // ViewModelのために追加

プロジェクト レベルの Gradle ファイル build.gradle.kts を開いて以下を追加します:

build.gradle.kts
buildscript {
    extra.apply {
        set("room_version", "2.6.1")
    }
}

AndroidX のリリース ページから、Roomの最新の安定版リリース バージョン番号を確認できます。

KSPプラグインのインストール

KSP(Kotlin Symbol Processing) とは、Kotlinコードを処理するためのツールで、アノテーションプロセッサの一種です。Roomを使う際にKSPが必要となります。少しつまずきやすいのでバージョンなどに気をつけながらインストールしてください。KSPをインストールするためにプpluginsに以下を追加します:

build.gradle.kts
plugins {
    ...
    id("com.google.devtools.ksp") version "1.9.0-1.0.13"
}

今回使用するKotlinのバージョンが1.9.0だったので、KSPもそれに合わせる必要がありました。KSPにバージョンに関しては GitHubのkspリリース でご確認いただけます。

Javaバージョンを統一する

さらに、KSPとプロジェクトのJavaバージョンを合わせる必要がありました。あったため、アプリレベルのbuild.gradle.ktsを次のように変更しました:

build.gradle.kts
    compileOptions {
//        sourceCompatibility = JavaVersion.VERSION_1_8
//        targetCompatibility = JavaVersion.VERSION_1_8
        sourceCompatibility = JavaVersion.VERSION_17
        targetCompatibility = JavaVersion.VERSION_17
    }
    kotlinOptions {
//        jvmTarget = "1.8"
        jvmTarget = "17"
    }

以上で、AndroidプロジェクトでRoomを使えるようになりました。ここからは実際にRoomを使ったプログラミングの例を紹介していきます。 なお冒頭にも述べましたが、これから紹介するソースコードは Google Codelab Room を使用してデータを永続化する のCodelabを参考に、アレンジしたものとなります。ViewModelやComposableの基礎的な知識がある前提で読み進めてください。アーキテクチャはMVVMですが、ファクトリーメソッドや、コンテナー、DIなどの理解が要求されます。

【Roomの導入 Step1】エンティティを作成する

まずは、データベースのテーブルを表すエンティティを作成します。Users.ktとして、次のようなデータクラスを作成しました。@Entityアノテーションをつけることで、SQLiteのテーブル名と結びつく形です。

Users.kt
package com.apppppp.testroom.data

import androidx.room.Entity
import androidx.room.PrimaryKey

@Entity(tableName = "users")
data class Users(
    @PrimaryKey(autoGenerate = true)
    val id: Int = 0,
    val name: String,
    val age: Int
)

Roomを使うと、このデータクラスを元に自動でテーブルを作ってくれます。つまり、下記のSQL文を発行する手間がなくなるわけです。とっても便利。

sql
CREATE TABLE users (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    name TEXT NOT NULL,
    age INTEGER NOT NULL
);

【Roomの導入 Step2】DAOを作成する

DAOはデータ アクセス オブジェクトと呼ばれるもので、データベースとアプリケーション(ロジック)の間を取り持つ、抽象インターフェースです。要するに、データベースのSQLクエリを抽象化して、データベース操作を簡単にしてくれるです。

UsersDao.kt
package com.apppppp.testroom.data

import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Update
import kotlinx.coroutines.flow.Flow

@Dao
interface UsersDao {
    @Insert(onConflict = OnConflictStrategy.IGNORE)
    suspend fun insert(item: Users)

    @Update
    suspend fun update(item: Users)

    @Delete
    suspend fun delete(item: Users)

    @Query("SELECT * from users WHERE id = :id")
    fun getItem(id: Int): Flow<Users>

    @Query("SELECT * from users ORDER BY name ASC")
    fun getAllItems(): Flow<List<Users>>
}

本来はSQLを発行して行うロジック実装を、こんな簡潔に書くことができます。Room恐るべし。

【Roomの導入 Step3】データベース インスタンスを作成する

データベースのインスタンスを作成します。これは SQLiteOpenHelper のようなものだと理解しています。 SQLiteOpenHelper と同様にシングルトンで発行し、アプリ内でインスタンスをひとつだけ持つようにします。このクラスは、のちに紹介するApplicationを継承したクラスで呼び出します

MyDatabase.kt
package com.apppppp.testroom.data

import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase

/**
 * Database class with a singleton Instance object.
 */
@Database(entities = [Users::class], version = 1, exportSchema = false)
abstract class MyDatabase : RoomDatabase() {
    abstract fun usersDao(): UsersDao

    companion object {
        @Volatile
        private var INSTANCE: MyDatabase? = null

        fun getDatabase(context: Context): MyDatabase {
            return INSTANCE ?: synchronized(this) {
                Room.databaseBuilder(context, MyDatabase::class.java, "my_database").build().also { INSTANCE = it }
            }
        }
    }
}

【Roomの導入 Step4】リポジトリを実装する

Roomのベースができたところで、ここからはリポジトリを作成します。リポジトリは、ViewModelなどへデータを提供したり処理したりするための、Daoとの仲介役となります。

リポジトリのインターフェースの作成

まずはインターフェースを作成します:

UsersRepository.kt
package com.apppppp.testroom.data

import kotlinx.coroutines.flow.Flow

interface UsersRepository {

    fun getAllUsers(): Flow<List<Users>>

    fun getUserStream(id: Int): Flow<Users>

    suspend fun insertUser(user: Users)

    suspend fun updateUser(user: Users)

    suspend fun deleteUser(user: Users)
}

リポジトリインターフェースの実装

このインターフェースを実装したクラスを作成します:

OfflineUsersRepository.kt
package com.apppppp.testroom.data

import kotlinx.coroutines.flow.Flow

class OfflineUsersRepository(private val userDao: UsersDao) : UsersRepository {
    override fun getAllUsers(): Flow<List<Users>> = userDao.getAllItems()

    override fun getUserStream(id: Int): Flow<Users> = userDao.getItem(id)

    override suspend fun insertUser(user: Users) = userDao.insert(user)

    override suspend fun updateUser(user: Users) = userDao.update(user)

    override suspend fun deleteUser(user: Users) = userDao.delete(user)
}

コンテナの作成

リポジトリはViewModelなどから直接生成するのではなく、次のようなコンテナを使ってインスタンス化させます。

AppContainer.kt
package com.apppppp.testroom.data

import android.content.Context

interface AppContainer {
    val usersRepository: UsersRepository
}

class AppDataContainer(private val context: Context) : AppContainer {
    override val usersRepository: UsersRepository by lazy {
        OfflineUsersRepository(MyDatabase.getDatabase(context).usersDao())
    }
}

【Roomの導入 Step5】Applicationの実装

データベースのインスタンスを呼び出すために、Applicationを継承したカスタムクラスを作成します。ここではMyApplicationとしました。

MyApplication.kt
package com.apppppp.testroom

import android.app.Application
import com.apppppp.testroom.data.AppContainer
import com.apppppp.testroom.data.AppDataContainer

class MyApplication: Application() {
    
    /**
     * AppContainer instance used by the rest of classes to obtain dependencies
     */
    lateinit var container: AppContainer
    
    override fun onCreate() {
        super.onCreate()
        container = AppDataContainer(this)
    }
}

クラスを作成したら、マニフェストのapplicationタグに追加します:

AndroidManifest.xml
    <application
        android:name=".MyApplication"
        ...

これで、アプリ起動時にMyApplicationクラスが呼び出され、データベースインスタンスがシングルトンで一度だけ生成されます。以上でRoomの設定は終わりです。ここからは、ViewModelでリポジトリを介してデータのやり取りを行うサンプルを紹介します。

【Roomの導入 Step6】AppViewModelProvider

Codelabを参考に、複数のViewModelを管理できるProviderを作ってみました:

AppViewModelProvider.kt
package com.apppppp.testroom.ui

import android.app.Application
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewmodel.CreationExtras
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.apppppp.testroom.MyApplication
import com.apppppp.testroom.ui.home.HomeViewModel

object AppViewModelProvider {

    val Factory = viewModelFactory {
        // Initializer for HomeViewModel
        initializer {
            HomeViewModel(inventoryApplication().container.usersRepository)
        }
    }
}

/**
 * Extension function to queries for [Application] object and returns an instance of
 * [MyApplication].
 */
fun CreationExtras.inventoryApplication(): MyApplication =
    (this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] as MyApplication)

【Roomの導入 Step7】UIの実装

あとはいつも通り、Composeを使ったUIの実装を行なっていきます。今回はテストですので、一画面のみの実装となります。

HomeScreen

フロートボタンを設置して、ボタンを押すとデータベースにデータが挿入されます。そしてそのデータをLazyColumnでリスト表示させます。リストアイテムをクリックすると、そのデータがデータベースから削除され、UIも更新される仕組みにっています。

HomeScreen.kt
package com.apppppp.testroom.ui.home

import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.apppppp.testroom.ui.AppViewModelProvider
import androidx.lifecycle.viewmodel.compose.viewModel
import com.apppppp.testroom.data.Users

@Composable
fun HomeScreen(
    modifier: Modifier = Modifier,
    viewModel: HomeViewModel = viewModel(factory = AppViewModelProvider.Factory)
) {
    val homeUiState by viewModel.homeUiState.collectAsState()

    Scaffold(
        topBar = {
            Text(
                text = "ROOMテスト",
                style = MaterialTheme.typography.titleLarge,
                modifier = Modifier.padding(16.dp)
            )
        },
        floatingActionButton = {
            FloatingActionButton(
                onClick = {viewModel.addUser()},
                shape = MaterialTheme.shapes.medium,
            ) {
                Icon(
                    imageVector = Icons.Default.Add,
                    contentDescription = "追加"
                )
            }
        },
    ) { innerPadding ->
        LazyColumn(content = {
            items(items = homeUiState.userList, key = { it.id }) { user ->
                HomeListItem(
                    user = user,
                    modifier = Modifier
                        .clickable {
                            viewModel.deleteUser(user)
                        }
                )
            }
        },
            modifier = Modifier
                .padding(innerPadding)
        )
    }
}

@Composable
fun HomeListItem(user: Users, modifier: Modifier = Modifier) {
    Text(
        text = "${user.id} : ${user.name} (${user.age})",
        modifier = modifier.padding(16.dp)
    )
}

HomeViewModel

HomeViewModel.kt
package com.apppppp.testroom.ui.home

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.apppppp.testroom.data.Users
import com.apppppp.testroom.data.UsersRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch

class HomeViewModel(private val usersRepository: UsersRepository): ViewModel() {
    /**
     * Holds home ui state. The list of items are retrieved from [UsersRepository] and mapped to
     * [HomeUiState]
     */

    val homeUiState: StateFlow<HomeUiState> =
        usersRepository.getAllUsers().map { HomeUiState(it) }
            .stateIn(
                scope = viewModelScope,
                started = SharingStarted.WhileSubscribed(TIMEOUT_MILLIS),
                initialValue = HomeUiState()
            )

    companion object {
        private const val TIMEOUT_MILLIS = 5_000L
    }

    fun addUser() {
        viewModelScope.launch {
            val newUser = Users(name = "New User", age = 30)
            usersRepository.insertUser(newUser)
        }
    }

    fun deleteUser(user: Users) {
        viewModelScope.launch {
            usersRepository.deleteUser(user)
        }
    }
}

/**
 * Ui State for HomeScreen
 */
data class HomeUiState(val userList: List<Users> = listOf())

まとめ

今回、Roomをはじめて実装してみましたが、とても便利です。テーブル数が多くなった場合でも、Roomを使えば保守管理が行いやすそうです。パッケージのインストールに少し手間取りましたが、バージョンの互換性に気をつければクリアできる問題でした。 また、RoomやViewModelの実装部分で、アーキテクチャの部分でDIやコンテンア、ファクトリーメソッドなどの実装方法がとても勉強になりました。ぜひ Google Codelab Room を使用してデータを永続化する もご参考ください。

関連記事

最後までご覧いただきありがとうございます!

▼ 記事に関するご質問やお仕事のご相談は以下よりお願いいたします。
お問い合わせフォーム

関連記事