【Android】Kotlin + Firestore + RecyclerViewを使ってTodoアプリを作る

# Cloud Firestoreとは
Cloud Firestore（以下Firestore）とはFirebaseのサービスの1つです。
サーバー上にデータを保存できるデータベースです。
またデータの変更を**リアルタイムで**アプリに反映してくれます。

参考：[Cloud Firestore | Firebase Documentation](https://firebase.google.com/docs/firestore)

# 実際に使ってみる
それでは実際にFirestoreを使ってTodoアプリを作ってみましょう。
ソースコードは[こちら](https://github.com/Kaito-Dogi/android-sample-firestore)にあります。（GitHub）

![firestore-sample.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/933349/ed2a404c-7e61-fd9d-bcf8-5c95753196b1.gif)


## 手順
1. Firebaseの初期設定
1. Firestoreの初期設定
1. View Bindingの設定
1. data classの作成
1. カスタムAdapterの作成
1. データの保存と一覧表示

## 1. Firebaseの初期設定

まずはFirebaseのプロジェクトを作成します。
[公式ドキュメント](https://firebase.google.com/docs/android/setup)に則って進めます。

[Firebaseコンソール](https://console.firebase.google.com/u/0/)にログインし、`プロジェクトを作成`をクリックしましょう。
![Firebase_console.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/933349/965ec983-0321-1091-2f78-a26bc9d0ed37.png)

プロジェクトに名前を付けましょう。
![Firebase_console.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/933349/89c9808d-f5f7-7119-7b29-bb1d7bd247b7.png)

次はGoogleアナリティクスの設定です。
今回は使用しないので無効にし、`プロジェクトを作成`をクリックしましょう。
![Firebase_コンソール.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/933349/e8e4e682-f655-608c-e21e-f4970367943b.png)

`続行`をクリックしましょう。
![Firebase_コンソール.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/933349/b823b843-4e47-4c97-caa5-694afad9da54.png)

次に、作成したFirebaseのプロジェクトに自分のAndroidアプリを登録していきます。
Android ロボットのアイコンをクリックしましょう。
![Firestore_Sample_-_概要_-_Firebase_コンソール.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/933349/726561af-bb77-f111-3db8-6ed3eac42047.png)

自分のAndroidアプリのパッケージ名を入力し、`アプリを登録`をクリックしましょう。
![Firestore_Sample_-_概要_-_Firebase_コンソール.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/933349/9fe618c9-3f59-c428-4bbf-7a9beb16575d.png)

`google-services.json をダウンロード`をクリックし、`google-services.json`をダウンロードしましょう。
![Firestore_Sample_-_概要_-_Firebase_コンソール.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/933349/32082509-dcc2-af93-3b83-d0b48311179c.png)

ダウンロードできたら、自分のAndroidアプリプロジェクトの`app`フォルダに`google-services.json`を追加しましょう。
![FirestoreSample.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/933349/8039f810-9e61-2b77-a995-01075737d238.png)

次にFirebase SDKを追加します。
（Android Studio Bumblebee以降は[追記](https://qiita.com/Kaito-Dogi/items/b2da06db10a95fef84b3#%E8%BF%BD%E8%A8%98-20220213)を参照）

`build.gradle`の`dependencies`に以下のコードを追加しましょう。

```build.gradle
classpath 'com.google.gms:google-services:4.3.10'
```

`app/build.gradle`の`plugins`に以下のコードを追加しましょう。

```app/build.gradle
id 'com.google.gms.google-services'
```

`app/build.gradle`の`dependencies`に以下のコードを追加しましょう。

```app/build.gradle
implementation platform('com.google.firebase:firebase-bom:29.0.0')
```

`Sync Now`をクリックしましょう。
![FirestoreSample_–_build_gradle___app_.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/933349/aed7687e-d21e-2edd-a414-2bc1aba0d943.png)

ここまで完了したら`次へ`をクリックしましょう。
![Firestore_Sample_-_概要_-_Firebase_コンソール.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/933349/3fe54d5e-69e5-aeee-20b7-e0f5e971dd39.png)

最後に`コンソールに進む`をクリックしましょう。
![Firestore_Sample_-_概要_-_Firebase_コンソール.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/933349/9fc7de53-0f17-d4f7-5a2d-11d5f98e0fe7.png)

以上でFirebaseの初期設定は完了です！

### 追記： 2022/02/13

Android Studio Bumblebeeにバージョンアップされ、依存関係の追加の仕方が変わりました。

`build.gradle`の`plugins`に以下のコードを追加しましょう。

```build.gradle
id 'com.google.gms.google-services' version '4.3.10' apply false
```

`app/build.gradle`の`plugins`に以下のコードを追加しましょう。

```app/build.gradle
id 'com.google.gms.google-services'
```

`app/build.gradle`の`dependencies`に以下のコードを追加しましょう。

```app/build.gradle
implementation platform('com.google.firebase:firebase-bom:29.0.0')
```

## 2. Firestoreの初期設定

これからFirestoreを使えるように設定していきます。
公式ドキュメントの[スタートガイド](https://firebase.google.com/docs/firestore/quickstart)に則って進めます。

まずは左のメニューから`Firestore Database`を選択し、`データベースの作成`をクリックしましょう。
![Firestore_Sample_-_Cloud_Firestore_-_Firebase_コンソール.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/933349/f8f1486c-4940-2add-9bbb-896c579b40d9.png)

セキュリティ保護ルールを設定します。
今回はテストモードで開始します。
テストモードのデフォルトの設定で、30日間データの読み書きが有効となります。
このルールは後から変更できます。
**アプリをリリースする際は本番モードに変更し、ルールを変更しましょう。**

`テストモードを開始する`を選択して`次へ`をクリックしましょう。
![Firestore_Sample_-_Cloud_Firestore_-_Firebase_コンソール.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/933349/49c08177-a09b-6051-301d-b7dc903dcf06.png)

続いてロケーションを設定します。
東京は`asia-northeast1`、大阪は`asia-northeast2`です。
その他の地域は[こちら](https://firebase.google.com/docs/projects/locations?authuser=1#location-r)から確認してください。
**ロケーションは一度設定すると変更できません。**

ロケーションを選択したら`有効にする`をクリックしましょう。
![Firestore_Sample_-_Cloud_Firestore_-_Firebase_コンソール.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/933349/7e2f8e73-78e1-02ad-507e-997bafed1d39.png)

このような画面が表示されます。
![Firestore_Sample_-_Cloud_Firestore_-_Firebase_コンソール.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/933349/49692603-c964-81ca-7384-f7848d7be9cc.png)

最後に、`app/build.gradle`の`dependencies`に以下のコードを追加しましょう。

```app/build.gradle
implementation 'com.google.firebase:firebase-firestore-ktx'
```

以上でFirestoreの初期設定は完了です！

## 3. View Bindingの設定

これからアプリを作成していきます。
まずはView Bindingの設定です。
[公式ドキュメント](https://developer.android.com/topic/libraries/view-binding)に則って進めます。

まずは`build.gradle`の`android`に以下のコードを追加しましょう。

```build.gradle
android {
    ...
    buildFeatures {
        viewBinding true
    }
}
```

続いて`MainActivity.kt`で以下のように記述しましょう。

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

    // バインディングクラスの変数
    private lateinit var binding: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // レイアウトを読み込み
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)
}
```

以上でView Bindingの設定は完了です！

## 4. data classの作成

今回作成するTodoアプリではタスクを保存します。
保存するデータを扱うために、これからTaskクラスを作成していきます。

### Firestoreのデータ構造

実際にTaskクラスを作成する前にFirestoreのデータ構造を説明します。

Firestoreのデータ構造は、画像のようにコレクションとドキュメントから構成されます。
![Firestore_Sample_-_Cloud_Firestore_-_Firebase_コンソール.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/933349/9cdcef6a-e090-994c-c515-4b22e33ba348.png)

ドキュメントは保存されるデータです。
たくさんのドキュメントが集まってコレクションの中に入っているイメージです。
ドキュメントはKey-Valueペア形式のフィールドを持ちます。
フィールドは実際にデータの値が保存される部分です。
文字列や数値など様々なデータ型を使えます。

実際にデータが保存されると以下のようになります。
![Firestore_Sample_-_Cloud_Firestore_-_Firebase_コンソール.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/933349/b3e18134-aaf0-2b2a-a961-a47833e028d2.png)

この場合は`tasks`という名前のコレクションに3つのドキュメントが保存されています。
ドキュメントは`createdAt`と`title`というフィールドを持っており、値がそれぞれ保存されています。
ドキュメントの欄に表示されているランダムな英数字の文字列は自動で生成されるIDです。
これを`DocumentId`と呼びます。
`DocumentId`は後で登場するので頭の片隅に置いておいてください。

### Taskクラスの作成

それでは実際にTaskクラスを作成します。
タスクを識別するためのID、タイトル、作成した時間を扱います。

```Task.kt
data class Task(
    @DocumentId
    val id: String = "",
    val title: String = "",
    var createdAt: Date = Date(System.currentTimeMillis()),
)
```

プロパティに`@DocumentId`というアノテーションを付けると、先ほど登場した`DocumentId`が自動的に入力されます。
したがって、データの識別子として`DocumentId`を利用できます。

参考：[DocumentId | Firebase](https://firebase.google.com/docs/reference/kotlin/com/google/firebase/firestore/DocumentId)

## 5. カスタムAdapterの作成

### リストアイテムのレイアウトの作成 （追記：2022/01/30）

RecyclerViewの各アイテムのレイアウトを作成します。

```task_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="wrap_content"
        android:background="?android:attr/selectableItemBackground"
        android:clickable="true"
        android:focusable="true"
        android:gravity="center_vertical"
        android:minHeight="48dp"
        android:orientation="vertical"
        android:padding="8dp">

        <TextView
            android:id="@+id/title_text_view"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:gravity="center_vertical"
            android:text="やること"
            android:textSize="18sp" />

        <TextView
            android:id="@+id/date_text_view"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:gravity="center_vertical|end"
            android:text="2022/01/01" />

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

### カスタムAdapterの作成

RecyclerViewで使用するカスタムAdapterを作成します。
タスクをリスト表示するので、`TaskAdapter.kt`と名付けました。
`task_list_item.xml`で定義したViewを操作するため、ここでもView Bindingを使用しています。

参考：[［Android］RecyclerView の ListAdapter を viewBinding と組み合わせて使う方法](https://qiita.com/kaleidot725/items/63a900059e5e5c615c88#recyclerview-%E3%81%AB%E3%83%87%E3%83%BC%E3%82%BF%E3%82%92%E8%A1%A8%E7%A4%BA%E3%81%99%E3%82%8B)

```TaskAdapter.kt
class TaskAdapter : ListAdapter<Task, TaskViewHolder>(diffUtilItemCallback) {
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TaskViewHolder {
        val view = TaskListItemBinding.inflate(LayoutInflater.from(parent.context), parent, false)
        return TaskViewHolder(view)
    }

    override fun onBindViewHolder(holder: TaskViewHolder, position: Int) {
        holder.bind(getItem(position))
    }
}

class TaskViewHolder(
    private val binding: TaskListItemBinding
) : RecyclerView.ViewHolder(binding.root) {
    fun bind(task: Task) {
        binding.titleTextView.text = task.title
        binding.dateTextView.text =
            SimpleDateFormat("yyyy/MM/dd HH:mm:ss", Locale.JAPANESE).format(task.createdAt)
    }
}

private val diffUtilItemCallback = object : DiffUtil.ItemCallback<Task>() {
    override fun areContentsTheSame(oldItem: Task, newItem: Task): Boolean {
        return oldItem == newItem
    }

    override fun areItemsTheSame(oldItem: Task, newItem: Task): Boolean {
        return oldItem.id == newItem.id
    }
}
```

## 6. データの保存と一覧表示

### レイアウトの作成

まずはレイアウトを作成しましょう。

```activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

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

        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/recycler_view"
            android:layout_width="match_parent"
            android:layout_height="0dp"
            android:layout_weight="1" />

        <EditText
            android:id="@+id/title_edit_text"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginStart="8dp"
            android:layout_marginTop="8dp"
            android:layout_marginEnd="8dp"
            android:ems="10"
            android:hint="title"
            android:inputType="textPersonName"
            android:minHeight="48dp" />

        <Button
            android:id="@+id/add_button"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_margin="8dp"
            android:text="Add" />

    </LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
```

### データの保存

続いて`MainAtivity.kt`にコードを書いていきます。
EditTextにタスクを入力し、Buttonを押したらデータが保存されるようにします。

```MainActivity.kt
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        // Firestoreをインスタンス化
        val db = Firebase.firestore

        // ボタンを押したときの処理
        binding.addButton.setOnClickListener {

            // Taskをインスタンス化
            val task = Task(
                title = binding.titleEditText.text.toString(),
            )

            db.collection("tasks")
                .add(task)
                .addOnSuccessListener { documentReference ->
                    Log.d(ADD_TAG, "DocumentSnapshot added with ID: ${documentReference.id}")
                }
                .addOnFailureListener { e ->
                    Log.w(ADD_TAG, "Error adding document", e)
                }
        }
    }
```

`add()`メソッドを使用することでドキュメントのIDは自動的に生成されます。

参考：[Cloud Firestore にデータを追加する | Firebase Documentation](https://firebase.google.com/docs/firestore/manage-data/add-data#custom_objects)

適当な値を入力してデータが保存されることを確認しましょう。
Firebaseコンソールを確認して、データが表示されていたら成功です！
![Firestore_Sample_-_Cloud_Firestore_-_Firebase_コンソール.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/933349/62feb16c-bcea-6f8b-27bf-6027d58058a9.png)

### データの一覧表示

次に、先ほど保存したデータをアプリで取得して一覧表示してみましょう。
アプリ起動時にデータを取得するようにします。

```MainActivity.kt
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        // 省略

        // RecyclerViewの設定
        val taskAdapter = TaskAdapter()
        binding.recyclerView.adapter = taskAdapter
        binding.recyclerView.layoutManager =
            LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false)

        // アプリ起動時に、保存されているデータを取得する
        db.collection("tasks")
            .get()
            .addOnSuccessListener { tasks ->
                val taskList = ArrayList<Task>()
                tasks.forEach { taskList.add(it.toObject(Task::class.java)) }
                taskAdapter.submitList(taskList)
            }
            .addOnFailureListener { exception ->
                Log.d(READ_TAG, "Error getting documents: ", exception)
            }
    }
```

アプリを起動して、保存したデータがリスト表示されたら成功です！

### データの変更をリアルタイムに反映する

今までのコードでは、データを追加、削除、更新してもその変更がリアルタイムにアプリに反映されません。
したがって、データの変更をリアルタイムで検知するためのリスナーを設定します。

```MainActivity.kt
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        // 省略

        // データの変更をリアルタイムでアプリに反映する
        db.collection("tasks").addSnapshotListener { tasks, e ->
            if (e != null) {
                Log.w(READ_TAG, "Listen failed.", e)
                return@addSnapshotListener
            }

            if (tasks != null) {
                val taskList = ArrayList<Task>()
                tasks.forEach { taskList.add(it.toObject(Task::class.java)) }
                taskAdapter.submitList(taskList)
            } else {
                Log.d(READ_TAG, "Current data: null")
            }
        }
    }
```

データを追加したりして、リアルタイムでアプリに反映されたら成功です！

### データの並び替え
工事中

参考：[Cloud Firestore でデータを並べ替えたり制限する | Firebase Documentation](https://firebase.google.com/docs/firestore/query-data/order-limit-data)