KotlinでToDoリストアプリを作成 - その２

## はじめに
前回はAndroid初学者向けにKotlinでToDoリストアプリを作成しました。今回はRoomライブラリを使ってデータの永続化を行い、アプリ終了後もデータを保持できるようにします。
## 概要
Android Studio(Electric Eel)でToDoリストアプリを作成します。

### 今回実装する機能
- データの永続化機能

### 実装に用いる技術
- Roomライブラリ
以下、ざっくりとRoomの説明です。

Room は、次の 3 つの主要コンポーネントで構成されます。
- データベースクラス。データベースを保持し、アプリの永続データに対する基礎的な接続のメイン アクセス ポイントとして機能します。
- データエンティティ。アプリのデータベースのテーブルを表します。
- データアクセス オブジェクト（DAO）。アプリがデータベースのデータのクエリ、更新、挿入、削除に使用できるメソッドを提供します。

## 導入
Roomライブラリのセットアップ（build.gradle）
- pluginにkotlin-kaptを追加します。
```
plugins {
    ~
    id 'kotlin-kapt'
}
```

:::note
※今回使用するライブラリはRoomのみなのでKSP（Kotlin Symbol Processing）も使用できます。KSPの方がKotlinの言語構造を詳細に把握しておりコードを直接分析できるため、KAPTと比較してビルドが最大2倍速くなるようです。
https://developer.android.com/studio/build/migrate-to-ksp?hl=ja#groovy
:::

- dependenciesに以下を追加します。
```
dependencies {
    ~
    implementation 'androidx.room:room-runtime:2.5.2'
    implementation 'androidx.room:room-ktx:2.5.2'
    kapt 'androidx.room:room-compiler:2.5.2'
}
```

## 実装
### TodoDatabase.kt (データベースクラス)
```kotlin
package com.example.mytodo.room

import androidx.room.Database
import androidx.room.RoomDatabase

@Database(entities = [Todo::class], version = 1, exportSchema = false)
abstract class TodoDatabase: RoomDatabase() {
    abstract fun todoDAO(): TodoDAO
}
```

### Todo.kt (データエンティティ)
```kotlin
package com.example.mytodo.room

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

@Entity
data class Todo(
    @PrimaryKey(autoGenerate = true)
    @ColumnInfo val id: Int,
    @ColumnInfo var order: Int,
    @ColumnInfo val title: String,
    @ColumnInfo val detail: String
)
```
※前回からファイルの配置場所を変更しています
https://qiita.com/ist-h-i/items/b243a0bf0bef45fa2b7e

### TodoDAO.kt (データアクセス オブジェクト)
```kotlin
package com.example.mytodo.room

import androidx.room.*

@Dao
interface TodoDAO {

    @Query("select * from todo order by order")
    fun getAll(): List<Todo>

    @Insert(onConflict = OnConflictStrategy.IGNORE)
    fun insert(todo: Todo): Long

    @Update(onConflict = OnConflictStrategy.REPLACE)
    fun update(todo: Todo)

    @Transaction
    fun upsert(todo: Todo) {
        val id = insert(todo)
        if (id == -1L) {
            update(todo)
        }
    }

    @Query("delete from todo where id = :id")
    fun delete(id: Int)
    
}
```

### MainActivity.kt (メインクラス)
```kotlin
package com.example.mytodo

import android.content.DialogInterface
import android.os.Bundle
import android.view.Window
import android.widget.Button
import android.widget.EditText
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
import androidx.room.Room
import com.example.mytodo.room.Todo
import com.example.mytodo.room.TodoDAO
import com.example.mytodo.room.TodoDatabase
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch


class MainActivity : AppCompatActivity() {

    // Room Databaseを用意（未初期化）
    private lateinit var db: TodoDatabase
    private lateinit var dao: TodoDAO

    // 表示するリストを用意（今は空）
    private var addList = ArrayList<Todo>()

    // RecyclerViewを宣言
    private lateinit var recyclerView: RecyclerView

    // RecyclerViewのAdapterを用意（未初期化）
    private lateinit var recyclerAdapter: RecyclerAdapter

    @OptIn(DelicateCoroutinesApi::class)
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // ヘッダータイトルを非表示
        supportRequestWindowFeature(Window.FEATURE_NO_TITLE)

        // Viewをセット
        setContentView(R.layout.activity_main)

        // View要素を取得
        val btnAdd: Button = findViewById(R.id.btnAdd)
        recyclerView = findViewById(R.id.rv)

        // コンテンツを変更してもRecyclerViewのレイアウトサイズを変更しない場合はこの設定を使用してパフォーマンスを向上
        recyclerView.setHasFixedSize(true)

        // レイアウトマネージャーで列数を2列に指定
        recyclerView.layoutManager = GridLayoutManager(this, 2, RecyclerView.VERTICAL, false)
        val itemDecoration: RecyclerView.ItemDecoration =
            DividerItemDecoration(this, DividerItemDecoration.VERTICAL)
        recyclerView.addItemDecoration(itemDecoration)

        // Roomオブジェクトを初期化
        this.db = Room.databaseBuilder(
            this, TodoDatabase::class.java, "todo.db"
        ).build()
        this.dao = this.db.todoDAO()
        
        // 初期表示時にToDoレコードを全件取得
        GlobalScope.launch {
            // RoomからTodoリストを取得
            addList = dao.getAll() as ArrayList<Todo>

            // recyclerAdapterの初期化
            recyclerAdapter = RecyclerAdapter(addList)

            // RecyclerViewにAdapterをセット
            recyclerView.adapter = recyclerAdapter
        }

        // 追加ボタン押下時にAlertDialogを表示する
        btnAdd.setOnClickListener {

            // AlertDialog内の表示項目を取得
            val view = layoutInflater.inflate(R.layout.add_todo, null)
            val txtTitle: EditText = view.findViewById(R.id.title)
            val txtDetail: EditText = view.findViewById(R.id.detail)

            // AlertDialogを生成
            android.app.AlertDialog.Builder(this)
                // AlertDialogのタイトルを設定
                .setTitle(R.string.addTitle)
                // AlertDialogの表示項目を設定
                .setView(view)
                // AlertDialogのyesボタンを設定し、押下時の挙動を記述
                .setPositiveButton(R.string.yes) { _: DialogInterface, _: Int ->
                    // ToDoを生成
                    val id = txtTitle.text.toString().hashCode() / 2 - txtDetail.text.toString()
                        .hashCode() / 2
                    val data = Todo(id, this.addList.size, txtTitle.text.toString(), txtDetail.text.toString())
                    // Todoアイテムの重複をチェック
                    if (addList.contains(data)) {
                        Toast.makeText(
                            this, R.string.duplicateTitle, Toast.LENGTH_LONG
                        ).show()
                    } else {
                        // 表示するリストの最後尾に追加
                        addList.add(data)
                        // 表示するリストを更新(アイテムが挿入されたことを通知)
                        recyclerAdapter.notifyItemInserted(addList.size - 1)
                        GlobalScope.launch {
                            // Roomに追加
                            dao.upsert(data)
                        }
                    }
                }
                // AlertDialogのnoボタンを設定
                .setNegativeButton(R.string.no, null)
                // AlertDialogを表示
                .show()
        }

        // 表示しているアイテムがタッチされた時の設定
        val itemTouchHelper = ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(
            // アイテムをドラッグできる方向を指定
            ItemTouchHelper.UP or ItemTouchHelper.DOWN or ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT,
            // アイテムをスワイプできる方向を指定
            ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT
        ) {
            // アイテムドラッグ時の挙動を設定
            override fun onMove(
                recyclerView: RecyclerView,
                viewHolder: RecyclerView.ViewHolder,
                target: RecyclerView.ViewHolder
            ): Boolean {
                // アイテム位置の入れ替えを行う
                val fromPos = viewHolder.adapterPosition
                val toPos = target.adapterPosition
                recyclerAdapter.notifyItemMoved(fromPos, toPos)
                // Roomの情報を更新
                val fromData = addList[fromPos]
                fromData.order = toPos
                val toData = addList[toPos]
                toData.order = fromPos
                GlobalScope.launch {
                    dao.upsert(fromData)
                    dao.upsert(toData)
                }
                return true
            }

            // アイテムスワイプ時の挙動を設定
            override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
                // アイテムスワイプ時にAlertDialogを表示
                android.app.AlertDialog.Builder(this@MainActivity)
                    // AlertDialogのタイトルを設定
                    .setTitle(R.string.removeTitle)
                    // AlertDialogのyesボタンを設定
                    .setPositiveButton(R.string.yes) { arg0: DialogInterface, _: Int ->
                        try {
                            // AlertDialogを非表示
                            arg0.dismiss()
                            // Roomから削除
                            val todo = addList[viewHolder.adapterPosition]
                            val id = todo.title.hashCode() / 2 - todo.detail.hashCode() / 2
                            GlobalScope.launch {
                                dao.delete(id)
                            }
                            // UIスレッドで実行
                            runOnUiThread {
                                // スワイプされたアイテムを削除
                                addList.removeAt(viewHolder.adapterPosition)
                                // 表示するリストを更新(アイテムが削除されたことを通知)
                                recyclerAdapter.notifyItemRemoved(viewHolder.adapterPosition)
                            }
                        } catch (ignored: Exception) {
                        }
                    }.setNegativeButton(R.string.no) { _: DialogInterface, _: Int ->
                        // 表示するリストを更新(アイテムが変更されたことを通知)
                        recyclerAdapter.notifyDataSetChanged()
                    }
                    // AlertDialogを表示
                    .show()
            }
        })

        // 表示しているアイテムがタッチされた時の設定をリストに適用
        itemTouchHelper.attachToRecyclerView(recyclerView)
    }
}
```
※他クラスは前回を参照ください
https://qiita.com/ist-h-i/items/b243a0bf0bef45fa2b7e

## プロジェクトビルド&実行
ヘッダーメニューよりBuild > Rebuild Projectを行いエラーが出ないことを確認して実行


- 初期表示（データ0件）
![1st_init.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3342288/b8ea0f72-8726-5885-af0e-c9e73e97a999.jpeg)


- ToDo追加後アプリを落として再起動
![1st_add.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3342288/3c92c935-77fb-ce6b-e7c8-8e3e5c845693.jpeg) ![1st_add_edited.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3342288/aeded5b8-9d81-d7a4-0e5d-6d11dc675032.jpeg)
 

- ToDo移動後アプリを落として再起動
![1st_move.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3342288/9ef3b2c8-efcf-cb55-e8e1-21f9f546c8fb.jpeg) ![1st_moved.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3342288/50c7ea42-1887-0c07-1807-c19b7ed8bcfb.jpeg)


- ToDo削除後アプリを落として再起動
![1st_delete.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3342288/fd684e04-2708-6bfa-742c-52fe77fcab50.jpeg) ![1st_deleted.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3342288/86ed79d6-dc5c-d9a0-60c5-c50135c443a1.jpeg)



## まとめ
今回はRoomライブラリを使用してデータの永続化を行い、アプリを落としても以前の入力内容が保存されるようにしました。
次回は要素の追加や編集機能・ソート機能を追加する予定です。