Roomの実装方法まとめ

[前々回](https://qiita.com/kame_yang/items/131fa044dd8880bc3379)、[前回](https://qiita.com/kame_yang/items/a88c316b5fd90c212d78)に続き、今回は`Room`の実装方法をまとめてみようと思います。

# この記事の内容
公式の[ドキュメント](https://developer.android.com/guide/navigation?hl=ja)やトレーニング「[Android Kotlin の基礎](https://developer.android.com/courses/kotlin-android-fundamentals/overview?hl=ja)」のレッスン6「Room データベースとコルーチン」を参考に実装のポイントとエッセンスをまとめていきます。

### 前提知識

* kotlinの基礎的な文法
* AndroidStudioの使い方／アプリの作り方
* 画面や画面部品の配置方法
* `Navigation`の実装方法（[前々回](https://qiita.com/kame_yang/items/131fa044dd8880bc3379)記事参照）
* `ViewModel`の実装方法（[前回](https://qiita.com/kame_yang/items/a88c316b5fd90c212d78)記事参照）

### 開発環境

* Windows 10 Home
* Android Studio 4.2.1

# 作成するサンプル
簡単な単語帳アプリを作成します。
**Create**ボタンをクリックすると画面上の`ListView`に単語の一覧が表示され、**Save**ボタンのクリックにより内容をローカルDBに保存します。
ローカルDBにデータが保存済みの場合は**Load**ボタンによりデータを読み込む機能を実装します。
**Delete**ボタンがクリックされたらローカルDBのデータを消去します。
![01.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1073313/285b29d9-178e-ad2b-246b-a3810b2a1af6.gif)
この操作イメージだと少し分かりにくいかもしれませんが、**Load**ボタンをクリックしたとき`Room`DBからデータを取得し、その際オーダーを掛けているので画面上のデータの並びも変わっています。


# 前提となるコード
前述の通りこの記事では`Navigation`、`ViewModel`については既知のものとし、詳細は扱いません（詳しくは前回までの記事に書いているので、良かったら見てみてね）。
ただちょっとハンズオンっぽく進めていきたいので、前提となるコードを以下に記します。
また今回は`ListView`を`ViewModel`、`LiveData`、`DataBinding`により実装していますが、以下の記事を参考にさせていただきました（大変分かりやすく、参考になりました）。
[【Android】LiveData+DataBinding+ViewModelでListView作成](https://www.itcowork.co.jp/blog/?p=5223)

コード上、自分の記事で触れていない部分はコメントを残しています。
動作イメージはこんな感じ。
**Save**ボタン、**Load**ボタンは`Room`の領域なのでこの時点では未実装です。
また**Delete**ボタンは画面上の表示を削除するまでの実装です。
![02.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1073313/3ae7aaf4-4969-c229-de48-b904cb84a632.gif)

`Navigation`、`ViewModel`、`DataBinding`用に**gradle**ファイルを設定しておけばとりあえず上記のイメージ通りのアプリにはなります。

## レイアウトファイル

### activity_main.xml

```xml
<androidx.constraintlayout.widget.ConstraintLayout 
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <fragment
        android:id="@+id/myNavHostFragment"
        android:name="androidx.navigation.fragment.NavHostFragment"
        app:navGraph="@navigation/navigation"
        app:defaultNavHost="true"
        android:layout_width="match_parent"
        android:layout_height="match_parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>
```

### fragment_first.xml

アプリ起動時の画面です。

```xml
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools">

    <data>
        <variable
            name="startViewModel"
            type="com.warpstudio.android.roomsample.StartViewModel" />
    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:context=".StartFragment">

        <TextView
            android:id="@+id/startText"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="Welcome to Room Sample APP!"
            android:textSize="24sp"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

        <Button
            android:id="@+id/toSecondFragmentButton"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="To SecondFragment"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/startText"
            android:onClick="@{() -> startViewModel.onToSecondFragment()}"/>
    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>
```

### fragment_second.xml

遷移先の画面。

```xml
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools">

    <data>
        <variable
            name="secondViewModel"
            type="com.warpstudio.android.roomsample.SecondViewModel" />
    </data>

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="vertical">

        <androidx.constraintlayout.widget.ConstraintLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            tools:context=".SecondFragment">

            <Button
                android:id="@+id/createButton"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="Create"
                app:layout_constraintBottom_toBottomOf="parent"
                app:layout_constraintEnd_toStartOf="@+id/saveButton"
                app:layout_constraintHorizontal_bias="0.5"
                app:layout_constraintStart_toStartOf="parent"
                tools:layout_editor_absoluteY="0dp"
                android:onClick="@{()->secondViewModel.creteData()}"/>

            <Button
                android:id="@+id/saveButton"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="Save"
                app:layout_constraintBottom_toBottomOf="parent"
                app:layout_constraintEnd_toStartOf="@+id/loadButton"
                app:layout_constraintHorizontal_bias="0.5"
                app:layout_constraintStart_toEndOf="@+id/createButton"
                tools:layout_editor_absoluteY="0dp"
                android:onClick="@{()->secondViewModel.saveData()}"/>

            <Button
                android:id="@+id/loadButton"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="Load"
                app:layout_constraintBottom_toBottomOf="parent"
                app:layout_constraintEnd_toStartOf="@+id/deleteButton"
                app:layout_constraintHorizontal_bias="0.5"
                app:layout_constraintStart_toEndOf="@+id/saveButton"
                tools:layout_editor_absoluteY="0dp"
                android:onClick="@{()->secondViewModel.loadData()}"/>

            <Button
                android:id="@+id/deleteButton"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="Delete"
                app:layout_constraintBottom_toBottomOf="parent"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintHorizontal_bias="0.5"
                app:layout_constraintStart_toEndOf="@+id/loadButton"
                tools:layout_editor_absoluteY="0dp"
                android:onClick="@{()->secondViewModel.deleteData()}"/>
        </androidx.constraintlayout.widget.ConstraintLayout>

        <ListView
            android:id="@+id/wordList"
            android:layout_width="match_parent"
            android:layout_height="match_parent"/>
    </LinearLayout>
</layout>
```

### list_item.xml

`ListView`に表示する行単位のレイアウトです。

```xml
<layout xmlns:android="http://schemas.android.com/apk/res/android">

    <data>
        <variable
            name="wordData"
            type="com.warpstudio.android.roomsample.WordData" />
    </data>

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="horizontal">

        <TextView
            android:text="@{wordData.word}"
            android:textSize="18sp"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="0.8"/>
        <TextView
            android:text="@{wordData.speech}"
            android:textSize="18sp"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="0.2"/>
        <TextView
            android:text="@{wordData.meaning}"
            android:textSize="18sp"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"/>
    </LinearLayout>
</layout>
```

### navigation.xml

`Navigation`ファイルも一応載せときます。

```xml
<navigation 
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/navigation"
    xmlns:tools="http://schemas.android.com/tools"
    app:startDestination="@id/startFragment">

    <fragment
        android:id="@+id/startFragment"
        android:name="com.warpstudio.android.roomsample.StartFragment"
        android:label="StartFragment"
        tools:layout="@layout/fragment_start">
        <action
            android:id="@+id/action_startFragment_to_secondFragment"
            app:destination="@id/secondFragment" />
    </fragment>
    <fragment
        android:id="@+id/secondFragment"
        android:name="com.warpstudio.android.roomsample.SecondFragment"
        android:label="SecondFragment"
        tools:layout="@layout/fragment_second"/>
</navigation>
```


## Fragmentクラス
_**MainActivity.xml**_ は何もいじっていないので省略。

### StartActivity.kt

`ViewModel`使ってますが、次の画面に映るボタンがあるだけなんでわざわざそこまでする必要はないんですけどね……。

```kotlin
class StartFragment : Fragment() {

    private lateinit var binding: FragmentStartBinding
    private lateinit var viewModel: StartViewModel
    private lateinit var viewModelFactory: StartViewModelFactory

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        binding = DataBindingUtil.inflate(
            inflater,
            R.layout.fragment_start,
            container,
            false
        )
        viewModelFactory = StartViewModelFactory()
        viewModel = ViewModelProvider(this, viewModelFactory)
            .get(StartViewModel::class.java)
        viewModel.eventToSecondFragment
            .observe(viewLifecycleOwner, Observer { isToSecond ->
            if (isToSecond) {
                toSecondFragment()
            }
        })
        binding.startViewModel = viewModel

        return binding.root
    }

    private fun toSecondFragment() {
        val action = StartFragmentDirections
            .actionStartFragmentToSecondFragment()
        NavHostFragment.findNavController(this).navigate(action)
        viewModel.onToSecondFragmentComplete()
    }
}
```

### StartViewModel.kt

```kotlin
class StartViewModel : ViewModel() {

    private val _eventToSecondFragment = MutableLiveData<Boolean>()
    val eventToSecondFragment: LiveData<Boolean>
        get() = _eventToSecondFragment

    init {
    }

    fun onToSecondFragment() {
        _eventToSecondFragment.value = true
    }

    fun onToSecondFragmentComplete() {
        _eventToSecondFragment.value = false
    }
}
```

### StartViewModelFactory.kt

```kotlin
class StartViewModelFactory: ViewModelProvider.Factory {
    override fun <T : ViewModel?> create(modelClass: Class<T>): T {
        if (modelClass.isAssignableFrom(StartViewModel::class.java)) {
            return StartViewModel() as T
        }
        throw IllegalArgumentException("ERR!")
    }
}
```

### SecondFragment.kt

メインの画面です。
`ListView`に`Adapter`を設定しています。

```kotlin
class SecondFragment : Fragment() {

    private lateinit var binding: FragmentSecondBinding
    private lateinit var secondViewModel: SecondViewModel
    private lateinit var secondViewModelFactory: SecondViewModelFactory

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        binding = DataBindingUtil.inflate(
            inflater,
            R.layout.fragment_second,
            container,
            false
        )
        secondViewModelFactory = SecondViewModelFactory()
        secondViewModel = ViewModelProvider(this, secondViewModelFactory)
            .get(SecondViewModel::class.java)

        // wordListのObserverを登録
        secondViewModel.wordList.observe(viewLifecycleOwner, Observer { words ->
            // 作成されたWordListをもとにListViewのデータを更新
            binding.wordList.adapter = WordAdapter(words)
        })

        binding.secondViewModel = secondViewModel
        binding.lifecycleOwner = viewLifecycleOwner

        // ListViewのAdapterを初期化
        binding.wordList.adapter = WordAdapter(ArrayList(0))

        return binding.root
    }
}
```

### SecondViewModel.kt

主役（？）となる`ViewModel`クラス。

```kotlin
class SecondViewModel: ViewModel() {

    private val _wordList = MutableLiveData<List<WordData>>()
    val wordList: MutableLiveData<List<WordData>>
        get() = _wordList

    init {
    }

    // Createボタンクリック時のイベント
    fun creteData() {
        wordList.value = createWordList()
    }

    // Saveボタンクリック時のイベント
    fun saveData() {
    }

    // Loadボタンクリック時のイベント
    fun loadData() {
    }

    // Deleteボタンクリック時のイベント
    fun deleteData(){
        wordList.value = ArrayList(0)
    }

    private fun createWordList() : ArrayList<WordData> {
        var data = arrayListOf(
            WordData("ethos", "名", "気風"),
            WordData("mediocre", "形", "平凡な"),
            // (略)
            WordData("lurch", "動", "よろめく")
        )
        return data
    }
}
```

### SecondViewModelFactory.kt

コピペでOKな`ViewModelFactory`クラス。

```kotlin
class SecondViewModelFactory: ViewModelProvider.Factory {
    override fun <T : ViewModel?> create(modelClass: Class<T>): T {
        if (modelClass.isAssignableFrom(SecondViewModel::class.java)) {
            return SecondViewModel() as T
        }
        throw IllegalArgumentException("ERR!")
    }
}
```


## データ／Adapterクラス

カスタマイズした`ListView`に`DataBinding`でデータを表示するために実装するクラスです。

### WordData.kt
単語の単語名、品詞、意味を保持するデータクラス。

```kotlin
data class WordData (
    var word: String,
    var speech: String,
    var meaning: String
    )
```

### WordAdapter.kt
`ListView`用の`Adapter`クラスです。

```kotlin
// Adapterクラス
class WordAdapter(private var wordDatas: List<WordData>): BaseAdapter() {

    // WordAdapterクラス生成時に呼び出されるメソッド
    override fun getView(
        posistion: Int,
        convertView: View?,
        parent: ViewGroup
    ): View {
        val binding = if (convertView == null) {
            ListItemBinding.inflate(LayoutInflater.from(parent.context), parent, false)
        } else {
            DataBindingUtil.getBinding(convertView) ?: throw IllegalStateException()
        }
        with(binding) {
            wordData = wordDatas[posistion]
            executePendingBindings()
        }

        return binding.root
    }

    // その他Overrideが必要なメソッド
    override fun getItem(position: Int) = wordDatas[position]
    override fun getItemId(position: Int) = position.toLong()
    override fun getCount() = wordDatas.size
}
```

# Gradleファイルの更新

作業に入る前に**gradle**ファイルを更新していきます。
`Room`はこの辺りに起因するビルドエラーが出やすいので注意してください。

## build.gradel(app)ファイルの更新

`Room`をか使う場合は`kapt`プラグインと`dependencies`ブロックに下記の内容を追加してください。

```gradle
plugins {
    // (略)
    id 'kotlin-kapt'
}

dependencies {
    // (略)
    // Room
    implementation "androidx.room:room-runtime:2.3.0"
    kapt "androidx.room:room-compiler:2.3.0"
    implementation "androidx.room:room-ktx:2.3.0"
    testImplementation "androidx.room:room-testing:2.3.0"
}
```

# DBの実装

それではDBを実装していきます。
`Room`には`Entity`、`DAO`、`Database`の3要素が必要なので、まずはそれらのクラスを実装していきます。

## Entityクラスの作成

新規 _**Kotlin Class**_ を作成し、`data class`を定義します。
`Entity`クラスはDBのテーブルに該当し、各の名前と型、主キーの情報を保持します。
あわせてクラスには`@Entity`アノテーションとテーブル名の設定が必要です。

```kotlin
@Entity(tableName = "word_data_table")
data class WordEntity (

    @PrimaryKey(autoGenerate = true)
    var wordId: Long = 0L,

    @ColumnInfo(name = "word")
    var word: String,

    @ColumnInfo(name = "speech")
    var speech: String,

    @ColumnInfo(name = "meaning")
    var meaning: String
)
```

上記では主キーの`autoGenerate`属性を**true**としていますが、これにより**CREATE**等の際に主キーは自動生成されます。


## DAOクラスの作成

`DAO`用に新規 _**Kotlin Class**_ を作成します。
`Room`における`DAO`はDBに対する**CREATE**、**INSERT**、**DELETE**等の操作を定義するクラスといったところでしょうか。
`DAO`は`interface`である必要があります。
また、DBには`コルーチン`を使用してアクセスするため、非同期で実行するメソッドに対して**suspend**キーワードを設定しておきます。


```kotlin
@Dao
interface WordDao {
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insert(word: List<WordEntity>)

    @Delete
    suspend fun delete(word: List<WordEntity>)

    @Query("SELECT * FROM word_data_table ORDER BY wordId DESC")
    suspend fun getAllWord(): List<WordEntity>
}
```

基本的には`@Query`アノテーションを設定したメソッドに自分でSQL文を書く必要がありますが、`INSERT`や`UPDATE`等予め定義された操作に関してはプリセットのアノテーションを利用することもできます。

## Databaseクラスの作成

最後に`Database`クラス（DBホルダークラスト言った方が正しいかな？）を作成します。
DBインスタンスが**null**の場合にインスタンスを生成する`getInstance`メソッドを作成します。

```kotlin
@Database(entities = [WordEntity::class], version = 1, exportSchema = false)
abstract class WordDatabase: RoomDatabase() {

    abstract val wordDao: WordDao

    companion object {

        @Volatile
        private var INSTANCE: WordDatabase? = null

        fun getInstance(context: Context): WordDatabase {
            synchronized(this) {
                var instance = INSTANCE

                if (instance == null) {
                    instance = Room.databaseBuilder(
                        context.applicationContext,
                        WordDatabase::class.java,
                        "database"
                    )
                        .fallbackToDestructiveMigration()
                        .build()
                    INSTANCE = instance
                }
                return instance
            }
        }
    }
}
```

# DBへのアクセス

前々章で実装したDBに`ViewModel`からアクセスします。

## ViewModel、ViewModelFactoryの改修

`ViewModel`、`ViewModelFactory`について、引数に`DAO`と`Application`を受け取るよう改修します。
下記のコードでは`AndroidViewModel`を継承元としています。これは`ViewModel`と基本的には同じですが、コンストラクタのパラメーターとしてアプリケーションの`Context`を受け取ることができます。

### ViewModel

```kotlin
class SecondViewModel(
    val database: WordDao,
    application: Application): AndroidViewModel(application) {
    // 略
}
```

### ViewModelFactory

```kotlin
class SecondViewModelFactory(
    private val dataSource: WordDao,
    private val application: Application): ViewModelProvider.Factory {
    @Suppress("unchecked_cast")
    override fun <T : ViewModel?> create(modelClass: Class<T>): T {
        if (modelClass.isAssignableFrom(SecondViewModel::class.java)) {
            return SecondViewModel(dataSource, application) as T
        }
        throw IllegalArgumentException("ERR!")
    }
}
```

### Fragment

`UI Controller`の（今回は`Fragment`）`onCreate()`メソッドで`ViewModel`を生成します。
前述の通り`ViewModel`のコンストラクタには`DAO`のインスタンスとアプリケーションの`Context`が必要なため、個別に作成しておきます。

```kotlin
    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        // (略)
        // アプリケーションの生成
        val application = requireNotNull(this.activity).application
        // DAOインスタンスの生成
        val dataSource = WordDatabase.getInstance(application).wordDao
        // ViewModelFactoryの生成
        secondViewModelFactory = SecondViewModelFactory(dataSource, application)
        // ViewModelFactoryの生成
        secondViewModel = ViewModelProvider(this, secondViewModelFactory)
            .get(SecondViewModel::class.java)
        // (略)
    }
```

## 各処理の実装

`ViewModel`にDBにアクセスする処理を追記していきます。
DBへのアクセスはすべて`コルーチンスコープ`内で処理する点に注意してください。

### ViewModel

```kotlin
class SleepTrackerViewModel(
        val database: SleepDatabaseDao,
        application: Application) : AndroidViewModel(application) {
    // (略)
    // Createボタンクリック時のイベント
    fun creteData() {
        _wordList.value = createWordList()
    }

    // Saveボタンクリック時のイベント
    fun saveData() {
        // Createが実行され、LiveDataに値が設定されている場合のみ処理を実行
        if (!_wordList.value.isNullOrEmpty()) {
            // DataをEntityのListに置き換え
            val words = _wordList.value!!.map {
                WordEntity(word = it.word, speech = it.speech, meaning = it.meaning)
            } as ArrayList<WordEntity>

            // 保存処理はコルーチンのスコープ内で実行
            viewModelScope.launch {
                database.insert(words)
            }
        }
    }

    // Loadボタンクリック時のイベント
    fun loadData() {
        // コルーチンのスコープ内で実行
        viewModelScope.launch {
            // DBから全レコードを取得
            val allWords = database.getAllWord()

            // EntityをDataのListに置き換え
            val wordList = allWords.map {
                WordData(word = it.word, speech = it.speech, meaning = it.meaning)
            } as ArrayList<WordData>

            // LiveDataにListを設定
            _wordList.value = wordList
        }
    }

    // Deleteボタンクリック時のイベント
    fun deleteData(){
        // LiveDataの削除処理
        if (!_wordList.value.isNullOrEmpty()) {
            _wordList.value = ArrayList(0)
        }

        // DBの処理
        // コルーチンのスコープ内で処理
        viewModelScope.launch {
            val allWords = database.getAllWord()
            database.delete(allWords)
        }
    }
    // (略)
}
```

# Recap

今回は以上です。
`Room`はそれ自体は非常に便利なのですが細かなビルドエラーが出やすく、それがちょっと厄介ですね。