Kotlin + Realm + RecyclerViewを使ってTodoアプリを作る


Realm を使ったシンプルなサンプルアプリを作ってみました。
最初にダミーデータを作成しRecyclerViewを使ってリスト表示します。
今回はセルがタップされたときにデータを削除し、メッセージをToastで表示するようにしました。

全てのソースコードは[こちら](https://github.com/konatsup/RealmTodo)にあります。(GitHub)

<img src="https://i.gyazo.com/d8b40d59416333a82d69bf536ead6700.gif" height="600">


# 環境

- Kotlin 1.3.50
- Realm 5.15.2
- realm-android-adapter 3.1.0
- recyclerview-v7:29.1.0
- cardview-v7:29.1.0
- targetSDK: 29

# 手順

1. Realmの初期設定
2. CustomAdapterの作成
3. データ作成と一覧表示
4. Itemをクリックしたときの処理を追加する

# 1. Realm の初期設定

Realm 公式ドキュメントに乗っ取りながらやると良いと思います。
https://realm.io/docs/java/5.15.2/

## build.gradle の dependencies に以下を追加

```gradle
classpath "io.realm:realm-gradle-plugin:5.15.2"
```

## app/build.gradle の上部に以下を追加

```gradle
apply plugin: 'kotlin-kapt'
apply plugin: "realm-android"
```

## app/build.gradleのdependenciesに以下を追加

```gradle
implementation 'com.android.support:recyclerview-v7:29.1.0'
implementation 'com.android.support:cardview-v7:29.1.0'
implementation 'io.realm:android-adapters:3.1.0'
```

### [補足] realm-android-adapters について

Realm公式が出しているAdapterのライブラリです。表示しているデータの中身が変わったときにViewを更新するため`notifyDataSetChanged()`等を呼ぶことが多いですが、Realmを使う場合DBの内容が書き変わったことを受け取りView を更新しなければならず、処理が複雑になることが考えられます。

このライブラリを使うと、RealmのDB内が書き換わったときに自動でViewを更新してくれて便利なので、今回はこれを使用します。
(ちなみに、ListViewとRecyclerViewの2つに対応しています)

参考: [realm/realm-android-adapter](https://github.com/realm/realm-android-adapters)


## Applicationクラスの作成
Applicationクラスを作成します。アプリを起動したときに実行するようにし、Realmのinitializeの処理を行います。
今回はアプリ名がRealmTodoなので`RealmTodoApplication.kt`という名前で作成しました。

```kotlin:RealmTodoApplication.kt
class RealmTodoApplication : Application() {
    override fun onCreate() {
        super.onCreate()
        Realm.init(this)
        val realmConfig = RealmConfiguration.Builder()
            .deleteRealmIfMigrationNeeded()
            .build()
        Realm.setDefaultConfiguration(realmConfig)
    }
}
```
※ すでにDBにデータがある状態でモデルとなるクラスを変更(例えばプロパティを追加など)した場合、アプリを削除し再インストールする必要が出てきて正直手間になります。そこで、RealmConfigurationにて`.deleteRealmIfMigrationNeeded()`を設定すると、もともとあったデータを全て削除してくれるようになりこの手間を省くことができるようになります。

参考: [Migrations | Realm Java 5.15.2](https://realm.io/docs/java/5.15.2/#migrations)

また、`AndroidManifest.xml`の<application>タグにname属性を追加し、先ほど作成したApplicationクラスを指定します。

```xml:AndroidManifest.xml
    <application
        android:name=".RealmTodoApplication"
        /* 省略 */
    ></application>

</manifest>
```

※ここでApplicationクラスを確認するとクラス名が灰色になっており参照できていないように見えますが、実際はちゃんと参照できているのでこのままでOKです。
<img src="https://i.gyazo.com/fcda4da25df082fcfb7e12ba782481b9.png" width="500">

これでRealm関連の初期設定は完了です。


# 2. CustomAdapterの作成
## リストのItemとなるレイアウトの作成
CardViewを使っていい感じのリストにします。
<img src="https://i.gyazo.com/d4b8777d1d5e6f8f3aee2d889b435c49.png" width="500">

```xml:list_item.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:card_view="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_margin="8dp"
    card_view:cardElevation="2dp">

    <LinearLayout
        android:id="@+id/container"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:gravity="center_vertical"
        android:orientation="horizontal"
        android:background="?android:attr/selectableItemBackground"
        android:clickable="true"
        android:focusable="true">

        <ImageView
            android:id="@+id/imageView"
            android:layout_width="50dp"
            android:layout_height="50dp"
            android:layout_margin="8dp"
            card_view:srcCompat="@mipmap/ic_launcher" />

        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:layout_marginTop="8dp"
            android:layout_marginEnd="8dp"
            android:layout_marginBottom="8dp"
            android:orientation="vertical">

            <TextView
                android:id="@+id/contentTextView"
                android:layout_width="match_parent"
                android:layout_height="0dp"
                android:layout_weight="2"
                android:gravity="center_vertical"
                android:text="やること"
                android:textSize="18sp" />

            <TextView
                android:id="@+id/dateTextView"
                android:layout_width="match_parent"
                android:layout_height="0dp"
                android:layout_weight="1"
                android:gravity="center_vertical|end"
                android:text="2019/11/20 12:00:00" />

        </LinearLayout>

    </LinearLayout>
</androidx.cardview.widget.CardView>
```

## モデルの作成
`RealmObject`を継承したモデルを作成します。`@PrimaryKey`というアノテーションをつけると一意なプロパティとして定義することができ、その値を`UUID.randomUUID().toString()`とすると一意なIDを生成してくれます。(他にも`AutoIncrement`する方法もあります。)

参考: [Realmで一意なPrimaryKeyを設定する方法二つ](https://qiita.com/u_nation/items/b7b7257b46ed7cdf4f22)


```kotlin:Task.kt
open class Task(
    @PrimaryKey open var id: String = UUID.randomUUID().toString(),
    open var imageId: Int = 0,
    open var content: String = "",
    open var createdAt: Date = Date(System.currentTimeMillis())
) : RealmObject()
```

## CustomAdapterの作成
ここでRealmRecyclerViewAdapterを継承したCustomAdapterを作成します。
タスクのリストを表示するので`TaskAdapter.kt`としました。

```kotlin:TaskAdapter.kt
class TaskAdapter(
    private val context: Context,
    private var taskList: OrderedRealmCollection<Task>?,
    private val autoUpdate: Boolean
) :
    RealmRecyclerViewAdapter<Task, TaskAdapter.TaskViewHolder>(taskList, autoUpdate) {

    override fun getItemCount(): Int = taskList?.size ?: 0

    override fun onBindViewHolder(holder: TaskViewHolder, position: Int) {
        val task: Task = taskList?.get(position) ?: return

        holder.imageView.setImageResource(task.imageId)
        holder.contentTextView.text = task.content
        holder.dateTextView.text =
            SimpleDateFormat("yyyy/MM/dd HH:mm:ss", Locale.JAPANESE).format(task.createdAt)

    }

    override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): TaskViewHolder {
        val v = LayoutInflater.from(context).inflate(R.layout.list_item, viewGroup, false)
        return TaskViewHolder(v)
    }

    class TaskViewHolder(view: View) : RecyclerView.ViewHolder(view) {
        val imageView: ImageView = view.imageView
        val contentTextView: TextView = view.contentTextView
        val dateTextView: TextView = view.dateTextView
    }

}
```

`autoUpdate`というパラメータを`true`にして渡すと、DBを更新した際にRecyclerViewを自動で更新してくれるようになります。
また`SimpleDateFormat`を使うと、Date型を指定フォーマットでStringに変換できます。
参考: [SimpleDateFormat | Android Developers](https://developer.android.com/reference/java/text/SimpleDateFormat)

これでCustomAdapter周りも完了です。


# 3. データ作成と一覧表示
次に、Realmを用いたDBへの書き込みと読み込み、リストへの一覧表示を実装します。今回は一旦ダミーデータを10件作成し、そのリストを表示するような処理にしました。

```xml:activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<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">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recyclerView"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

```

```kotlin:MainActivity.kt
class MainActivity : AppCompatActivity() {

    private val realm: Realm by lazy {
        Realm.getDefaultInstance()
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val taskList = readAll()

        // タスクリストが空だったときにダミーデータを生成する
        if (taskList.isEmpty()) {
            createDummyData()
        }

        val adapter = TaskAdapter(this, taskList, true)

        recyclerView.setHasFixedSize(true)
        recyclerView.layoutManager = LinearLayoutManager(this)
        recyclerView.adapter = adapter

    }

    override fun onDestroy() {
        super.onDestroy()
        realm.close()
    }

    fun createDummyData() {
        for (i in 0..10) {
            create(R.drawable.ic_launcher_background, "やること $i")
        }
    }

    fun create(imageId: Int, content: String) {
        realm.executeTransaction {
            val task = it.createObject(Task::class.java, UUID.randomUUID().toString())
            task.imageId = imageId
            task.content = content
        }
    }

    fun readAll(): RealmResults<Task> {
        return realm.where(Task::class.java).findAll().sort("createdAt", Sort.ASCENDING)
    }

}
```
`realm`の変数ですが不変なオブジェクトとして扱いたかった(valで定義したかった)ので、`lazy`という委譲プロパティを使って遅延初期化をしています。これはこの変数が最初に参照されたときに`{}`内の値をsetして初期化します(2回目以降の参照では最初にsetした値を参照します)。
参考: [委譲プロパティ - Kotlin Programming Language](https://dogwood008.github.io/kotlin-web-site-ja/docs/reference/delegated-properties.html)

`readAll()`の中で`.sort("createdAt", Sort.ASCENDING)`をして作成したときの時刻を基準に昇順で取得しています。これは要素の削除をしたときに、リストの順番がバラバラになってしまう仕様を回避するためにこの実装にしました。
参考: [Realm: Order of records was changed - Stack Overflow](https://stackoverflow.com/questions/37480785/realm-order-of-records-was-changed)


また、`create()` 内の`createObject()`ですが、1つ目の引数にモデル、2つ目の引数に`@PrimaryKey`で設定したプロパティの値を代入して生成します。(これがないと落ちます)
`createObject()`以外にも`copyToRealm()`や`insert()`などがあります。
参考: [Creating objects | Realm Java 5.15.2](https://realm.io/docs/java/5.15.2/#creating-objects)


実行すると以下のようになります。(DB内に保存しているので、アプリを終了し再起動しても見えるようになっていると思います。)
<img src="https://i.gyazo.com/c2e33b8ad39ab96289e6a8ba39543df0.gif" height="500">


# 4. Itemをクリックしたときの処理を追加する
セルをタップしたときの処理を書いていきます。簡単な処理であればCustomAdapter側に直接記述すれば良いと思いますが、今回はRealmの処理がActivityに依存しているため、CustomAdapterからActivity内の処理(編集、削除など)を呼び出したいです。

そこで、今回はClickListenerとなるinterfaceを定義し、そのインスタンスをActivityからAdapterに渡すことで実現します。

```kotlin:TaskAdapter.kt
class TaskAdapter(
    private val context: Context,
    private var taskList: OrderedRealmCollection<Task>?,
    private var listener: OnItemClickListener, // ---------追加----------
    private val autoUpdate: Boolean
) :
    RealmRecyclerViewAdapter<Task, TaskAdapter.TaskViewHolder>(taskList, autoUpdate) {

    override fun getItemCount(): Int = taskList?.size ?: 0

    override fun onBindViewHolder(holder: TaskViewHolder, position: Int) {
        val task: Task = taskList?.get(position) ?: return

        // ----------------追加---------------
        holder.container.setOnClickListener{
            listener.onItemClick(task)
        }
        // -----------------------------------
        holder.imageView.setImageResource(task.imageId)
        holder.contentTextView.text = task.content
        holder.dateTextView.text =
            SimpleDateFormat("yyyy/MM/dd HH:mm:ss", Locale.JAPANESE).format(task.createdAt)

    }

    override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): TaskViewHolder {
        val v = LayoutInflater.from(context).inflate(R.layout.list_item, viewGroup, false)
        return TaskViewHolder(v)
    }

    class TaskViewHolder(view: View) : RecyclerView.ViewHolder(view) {
        val container : LinearLayout = view.container  // ---------追加----------
        val imageView: ImageView = view.imageView
        val contentTextView: TextView = view.contentTextView
        val dateTextView: TextView = view.dateTextView
    }

    // ----------------追加---------------
    interface OnItemClickListener {
        fun onItemClick(item: Task)
    }
    // -----------------------------------
}

```

```kotlin:MainActivity.kt
class MainActivity : AppCompatActivity() {

    /* 省略 */

    override fun onCreate(savedInstanceState: Bundle?) {
        /* 省略 */

        val adapter =
            TaskAdapter(this, taskList, object : TaskAdapter.OnItemClickListener {
                override fun onItemClick(item: Task) {
                    // クリック時の処理
                    Toast.makeText(applicationContext, item.content + "を削除しました", Toast.LENGTH_SHORT).show()
                    delete(item.id)
                }
            }, true)

        /* 省略 */

    }
    /* 省略 */

    fun update(id: String, content: String) {
        realm.executeTransaction {
            val task = realm.where(Task::class.java).equalTo("id", id).findFirst()
                ?: return@executeTransaction
            task.content = content
        }
    }

    fun update(task: Task, content: String) {
        realm.executeTransaction {
            task.content = content
        }
    }

    fun delete(id: String) {
        realm.executeTransaction {
            val task = realm.where(Task::class.java).equalTo("id", id).findFirst()
                ?: return@executeTransaction
            task.deleteFromRealm()
        }
    }

    fun delete(task: Task) {
        realm.executeTransaction {
            task.deleteFromRealm()
        }
    }

    fun deleteAll() {
        realm.executeTransaction {
            realm.deleteAll()
        }
    }

}
```

今回はセルがタップされたときにそれを削除し、それをToastで表示するようにしました。

これで一旦完成です。

# 終わりに
Realm周り結構ハマりどころがあるので、適宜調べながらやっていくと良いと思います。
また、SQLに慣れているのであればRoomを使うのもありなので、Realmが大変であれば別のものを採用するのもありだと思います。

参考: [Room | Android Developers](https://developer.android.com/jetpack/androidx/releases/room)